Servicio de motion cross-layer: morfo declara superficies animables +
coordinación; soma coordina presencia/lifecycle; eidos posee lo visual;
arts/motion ejecuta por-nodo. RFC en eidos/MOTION_SERVICE_RFC.md.
- M1 — contrato MorfoPart.animation (types/schema/compile); children
como { enter?, exit? }.
- M2 — PresenceGroup (soma/layers/presence-group.ts), rune-free;
Presence.group descubre el coordinador por context.
- M3 — exit con retención de DOM (§8.1).
- M4 — interrupción/reversa (§8.3): token de generación + motion.cancel
en flip + toHandle resuelve finished en cancel (sin AbortError suelto).
- M5 — prop `animation` enrutada a las parts surface:true del morfo
compilado (routeAnimation); Panel/Item emiten data-animation-style.
- M6 — stagger auto-derivado del orden de registro (--motion-stagger-*,
inversa en exit) + presets coordinados en la librería de eidos
(MotionConfig.coordinated; cascade-slide/-fade/-scale) que reaccionan
a data-starting/ending-style, NO a data-state.
Helper Coordination (soma/layers/coordination.ts) extraído y validado
por DOS consumidores reales: Reveal (raíz virtual + Panel owner) y Rail
(raíz=owner, together). Demos en /temas/animations/{reveal,rail,
presence-group}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
parent
29729e27f5
commit
48b183672a
@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createEngineMotion } from './engine-motion';
|
||||
import type { MotionDom, MotionHandle, StatePreset } from './types';
|
||||
|
||||
// Minimal MotionDom. The JS presets below return a MotionHandle directly, so the
|
||||
// frame scheduler is never exercised; `dom` only needs to be non-null so `runJs`
|
||||
// proceeds instead of short-circuiting to the settled handle.
|
||||
const dom: MotionDom = {
|
||||
requestFrame: (cb) => (cb(0), 0),
|
||||
cancelFrame: () => {},
|
||||
prefersReducedMotion: { matches: false }
|
||||
};
|
||||
|
||||
// A node that names a preset via `data-animation-style` — all `run` reads from it.
|
||||
const el = (name: string) =>
|
||||
({
|
||||
getAttribute: (k: string) => (k === 'data-animation-style' ? name : null)
|
||||
}) as unknown as HTMLElement;
|
||||
|
||||
describe('EngineMotion — JS handle tracking + cancel semantics', () => {
|
||||
it('pending() stays unsettled until the JS preset finished resolves', async () => {
|
||||
let resolve!: () => void;
|
||||
const preset: StatePreset = {
|
||||
driver: 'spring',
|
||||
enter: (): MotionHandle => ({
|
||||
finished: new Promise<void>((r) => (resolve = r)),
|
||||
cancel() {}
|
||||
})
|
||||
};
|
||||
const motion = createEngineMotion({ dom, presets: { gated: preset } });
|
||||
const node = el('gated');
|
||||
|
||||
const handle = motion.run(node, 'enter');
|
||||
let settled = false;
|
||||
void handle.finished.then(() => (settled = true));
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
resolve();
|
||||
await handle.finished;
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it('a cancelled (rejecting) handle resolves finished + pending — no unhandled rejection (RFC §8.3)', async () => {
|
||||
// Simulate a WAAPI `Animation.cancel()` rejecting its `finished` with
|
||||
// AbortError. `toHandle` must swallow it so a reversal does not leak an
|
||||
// unhandled rejection through `track`/`pending`.
|
||||
const preset: StatePreset = {
|
||||
driver: 'waapi',
|
||||
enter: (): MotionHandle => ({ finished: Promise.reject(new Error('cancelled')), cancel() {} })
|
||||
};
|
||||
const motion = createEngineMotion({ dom, presets: { rejecting: preset } });
|
||||
const node = el('rejecting');
|
||||
|
||||
const handle = motion.run(node, 'enter');
|
||||
await expect(handle.finished).resolves.toBeUndefined();
|
||||
await expect(motion.pending(node)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('cancel(el) stops the tracked JS motion handle', () => {
|
||||
let cancelled = false;
|
||||
const preset: StatePreset = {
|
||||
driver: 'spring',
|
||||
enter: (): MotionHandle => ({
|
||||
finished: new Promise<void>(() => {}),
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
}
|
||||
})
|
||||
};
|
||||
const motion = createEngineMotion({ dom, presets: { c: preset } });
|
||||
const node = el('c');
|
||||
|
||||
motion.run(node, 'enter');
|
||||
motion.cancel(node);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,55 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* Rail — a horizontal strip of items that CASCADE in/out, parent-controlled via
|
||||
* `open` (no trigger, no events). The SECOND consumer of the coordinated-motion
|
||||
* helper (`soma/layers/coordination.ts`), built to prove it is reusable across a
|
||||
* DIFFERENT shape than `Reveal`:
|
||||
*
|
||||
* - the ROOT (`Provider`) IS the owner surface (vs Reveal's virtual root + separate
|
||||
* Panel owner);
|
||||
* - the relation is `{ enter: 'together', exit: 'together' }` (all parallel, the
|
||||
* visual stagger is CSS only) — vs Reveal's `{ before, after }` bracket;
|
||||
* - NO sema events — the parent owns `open`, the component is pure visual
|
||||
* coordination. So `scope` is just `['soma']` (no events ⇒ no 'sema' needed) and
|
||||
* there is no `channels: []`/`expression` to silence (nothing emits).
|
||||
*
|
||||
* RFC: eidos/MOTION_SERVICE_RFC.md.
|
||||
*/
|
||||
export const railMorfo = {
|
||||
name: 'Rail',
|
||||
kebab: 'rail',
|
||||
scope: ['soma'],
|
||||
texts: {
|
||||
label: '#?components.rail.label|Rail'
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'list',
|
||||
optional: false,
|
||||
// ROOT + owner animable surface. `together` = rail + items enter/exit in
|
||||
// parallel; the visual stagger is CSS only (data-starting/ending-style).
|
||||
animation: { surface: true, children: { enter: 'together', exit: 'together' } },
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Item',
|
||||
kebab: 'item',
|
||||
archetype: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'listitem',
|
||||
optional: false,
|
||||
animation: { surface: true },
|
||||
data: [],
|
||||
aria: []
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,133 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* Reveal — disclosure list (Trigger + Panel + Items). The FIRST real consumer of
|
||||
* the motion-coordination contract (RFC: eidos/MOTION_SERVICE_RFC.md). The Panel
|
||||
* is an animable OWNER surface that coordinates its Item CHILD surfaces:
|
||||
*
|
||||
* panel.animation.children = { enter: 'before', exit: 'after' }
|
||||
*
|
||||
* — the panel appears, THEN the items cascade in; on close the items leave first
|
||||
* while the panel RETAINS its DOM (exit-heavy, §8.1), then the panel goes. soma
|
||||
* reads these compiled `animation.children` to build the `PresenceGroup` (it is
|
||||
* the contract that drives the coordination, not hand-wiring). The visual stagger
|
||||
* is CSS (the demo / eidos) — soma only sequences WHEN each surface enters/exits.
|
||||
*
|
||||
* Disclosure semantics (Trigger aria-expanded/aria-controls, Panel region) mirror
|
||||
* Collapsible; the only addition is the parent↔children animation coordination.
|
||||
* `emerge` family (transitional, no intent) for open/close.
|
||||
*/
|
||||
export const revealMorfo = {
|
||||
name: 'Reveal',
|
||||
kebab: 'reveal',
|
||||
// 'sema' because the morfo declares events (open/close) — they participate in the
|
||||
// sema layer even though `channels: []` silences their perceptual surface.
|
||||
scope: ['soma', 'sema'],
|
||||
// The reveal's perception IS the coordinated cascade (eidos transitions on the
|
||||
// Presence's data-starting/ending-style) — NOT a generic sema signature. So the
|
||||
// open/close events carry `channels: []` (no perceptual surface) and expression is
|
||||
// 'none': otherwise the generic emerge `present-rise`/`dismiss-fade` signature
|
||||
// fires on the event and FIGHTS the coordinated motion (a real bug the demo hit).
|
||||
// Doctrine: a coordinated-motion component silences the generic sema signal.
|
||||
expression: 'none',
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/',
|
||||
texts: {
|
||||
label: '#?components.reveal.label|Reveal'
|
||||
},
|
||||
events: [
|
||||
{
|
||||
name: 'open',
|
||||
semantic: {
|
||||
family: 'emerge',
|
||||
verb: 'open',
|
||||
target: v.partRef('panel'),
|
||||
// State is set in the soma handler → `post`, so a (future) perceptual
|
||||
// hold never blocks the functional open (CLAUDE.md sequencing doctrine).
|
||||
sequence: 'post',
|
||||
// No perceptual surface — the coordinated cascade IS the feedback.
|
||||
channels: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'close',
|
||||
semantic: {
|
||||
family: 'emerge',
|
||||
verb: 'close',
|
||||
target: v.partRef('panel'),
|
||||
sequence: 'post',
|
||||
channels: []
|
||||
}
|
||||
}
|
||||
],
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'virtual',
|
||||
defaultElement: 'none',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Trigger',
|
||||
kebab: 'trigger',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: false,
|
||||
states: ['open', 'closed'],
|
||||
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
{
|
||||
attr: 'aria-controls',
|
||||
value: v.partRef('panel'),
|
||||
condition: { when: 'part-present', part: 'panel' },
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Panel',
|
||||
kebab: 'panel',
|
||||
archetype: 'content',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'region',
|
||||
optional: false,
|
||||
states: ['open', 'closed'],
|
||||
// OWNER animable surface — coordinates its Item children. `before` enter
|
||||
// = panel in, then items cascade; `after` exit = items out first while the
|
||||
// panel retains its DOM, then the panel (§8.1 exit-heavy). soma derives the
|
||||
// PresenceGroup `when` from exactly this.
|
||||
animation: { surface: true, children: { enter: 'before', exit: 'after' } },
|
||||
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.partRef('trigger'),
|
||||
condition: { when: 'part-present', part: 'trigger' },
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Item',
|
||||
kebab: 'item',
|
||||
archetype: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: false,
|
||||
// CHILD animable surface — the group releases it per the panel's
|
||||
// `children` relation. No state/aria of its own; it is a coordinated leaf.
|
||||
animation: { surface: true },
|
||||
data: [],
|
||||
aria: []
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { RailItemProvider } from '../rail-provider.svelte';
|
||||
import type { RailItemProps } from '../types';
|
||||
|
||||
let { ref = $bindable(null), children, ...restProps }: RailItemProps = $props();
|
||||
|
||||
// CHILD surface — same shape as Reveal's item: `data-rail-item` marker, a child
|
||||
// Presence (via the shared Coordination), and the auto `--motion-stagger-index`.
|
||||
const state = RailItemProvider.create({
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, { 'data-rail-item': '' }));
|
||||
</script>
|
||||
|
||||
{#if state.isPresent}
|
||||
<div
|
||||
bind:this={ref}
|
||||
{...mergedProps}
|
||||
{...state.transitionAttrs}
|
||||
data-animation-style={state.animationStyle}
|
||||
style:--motion-stagger-index={state.staggerIndex}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { RailProvider } from '../rail-provider.svelte';
|
||||
import type { RailProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'rail'),
|
||||
open = $bindable(false),
|
||||
onOpenChange = () => {},
|
||||
animation,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: RailProps = $props();
|
||||
|
||||
// Root AND owner surface: the rail element coordinates its Item children. The ref
|
||||
// is attached by the runtime part (in `state.props`); `state.isPresent` /
|
||||
// `transitionAttrs` come from the owner Presence.
|
||||
const state = RailProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
open: writableActive(
|
||||
() => open,
|
||||
(v) => {
|
||||
open = v;
|
||||
onOpenChange(v);
|
||||
}
|
||||
),
|
||||
animation: readableActive(() => animation)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if state.isPresent}
|
||||
{#if child}
|
||||
{@render child({
|
||||
props: {
|
||||
...mergedProps,
|
||||
...state.transitionAttrs,
|
||||
'data-animation-style': state.animationStyle
|
||||
}
|
||||
})}
|
||||
{:else}
|
||||
<div {...mergedProps} {...state.transitionAttrs} data-animation-style={state.animationStyle}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,4 @@
|
||||
export { default as Provider } from './components/rail.svelte';
|
||||
export { default as Item } from './components/rail-item.svelte';
|
||||
|
||||
export type { RailProps as ProviderProps, RailItemProps as ItemProps } from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import { compileMorfo, type Morfo } from '$uix/morfo';
|
||||
import { validateMorfo } from '$uix/morfo/schema';
|
||||
import { railMorfo } from '$uix/morfo/components/rail';
|
||||
import { createEngineMotion } from '$motion';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
import { PresenceGroup } from '$soma/layers/presence-group';
|
||||
|
||||
import { RailItemProvider, RailProvider } from './rail-provider.svelte';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const motion = createEngineMotion();
|
||||
const soma = {
|
||||
dom,
|
||||
motion,
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources })
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(RailProvider.ctx, 'set').mockImplementation((value) => value as never);
|
||||
vi.spyOn(PresenceGroup.ctx, 'set').mockImplementation((value) => value as never);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
describe('Rail — second consumer of the coordination helper (RFC: MOTION_SERVICE_RFC.md §7)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('the morfo is valid: the root Provider is the owner surface, Item is a child', () => {
|
||||
expect(() => validateMorfo(railMorfo)).not.toThrow();
|
||||
|
||||
const compiled = compileMorfo(railMorfo);
|
||||
// Provider (root) declares `children` → it is the owner; `together` relation.
|
||||
expect(compiled.parts.byKebab.get('provider')?.animation?.children).toEqual({
|
||||
enter: 'together',
|
||||
exit: 'together'
|
||||
});
|
||||
expect(compiled.parts.byKebab.get('item')?.animation).toEqual({
|
||||
surface: true,
|
||||
children: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('the root registers as owner; items as children; stagger + routing via the shared helper', () => {
|
||||
installSomaHarness();
|
||||
const railEl = document.createElement('div');
|
||||
const itemEls = [
|
||||
document.createElement('div'),
|
||||
document.createElement('div'),
|
||||
document.createElement('div')
|
||||
];
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = RailProvider.create({
|
||||
id: state('rail'),
|
||||
ref: state<HTMLElement | null>(railEl),
|
||||
open: state(true),
|
||||
animation: state<string | undefined>('cascade-slide')
|
||||
});
|
||||
vi.spyOn(RailProvider, 'require').mockReturnValue(provider);
|
||||
const items = itemEls.map((el) =>
|
||||
RailItemProvider.create({ ref: state<HTMLElement | null>(el) })
|
||||
);
|
||||
return { provider, items };
|
||||
});
|
||||
|
||||
// The root IS the owner → owner + 3 children = 4 members in ONE group.
|
||||
expect(result.provider.coord.group.size).toBe(4);
|
||||
// Items auto-derive their stagger (owner excluded from the child index).
|
||||
expect(result.items.map((it) => it.staggerIndex)).toEqual([0, 1, 2]);
|
||||
// Routing reaches both declared surfaces.
|
||||
expect(result.provider.coord.routeAnimation('provider')).toBe('cascade-slide');
|
||||
expect(result.provider.coord.routeAnimation('item')).toBe('cascade-slide');
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,121 @@
|
||||
import { context } from '../../provider';
|
||||
import { type Active, type State, type StateProps } from '$libs/reactive';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||
import { Coordination, type CoordinatedSurface } from '../../layers/coordination';
|
||||
import { railMorfo } from '../../../morfo/components/rail';
|
||||
|
||||
// ── Provider (root + owner surface) ─────────────────────────────────────────
|
||||
|
||||
interface RailOpts extends StateProps<{ open: boolean }> {
|
||||
id: Active<string>;
|
||||
ref: State<HTMLElement | null>;
|
||||
/** DX preset name routed to the declared surfaces (RFC §5). `undefined` = none. */
|
||||
animation: Active<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rail root — unlike Reveal's virtual root, the Provider IS the owner surface: it
|
||||
* holds the runtime, the open state, the `Coordination`, AND registers itself as the
|
||||
* group's `owner` (the rail element coordinates its Item children). Parent-controlled
|
||||
* via `open`; no trigger, no events.
|
||||
*/
|
||||
export class RailProvider {
|
||||
static readonly ctx = context<RailProvider>('Rail');
|
||||
static get(): RailProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as RailProvider | undefined;
|
||||
}
|
||||
static require(): RailProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
static create(opts: RailOpts) {
|
||||
return new RailProvider(opts);
|
||||
}
|
||||
|
||||
readonly opts: RailOpts;
|
||||
readonly soma: Soma;
|
||||
readonly runtime: SomaRuntime;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly coord: Coordination;
|
||||
/** OWNER member — the rail element; its presence triggers the coordinated enter/exit. */
|
||||
readonly surface: CoordinatedSurface;
|
||||
|
||||
private constructor(opts: RailOpts) {
|
||||
this.opts = opts;
|
||||
RailProvider.ctx.set(this);
|
||||
|
||||
this.soma = Soma.require();
|
||||
this.runtime = this.soma.runtime(railMorfo, {});
|
||||
this.runtimePart = this.runtime.part('provider', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
context: RailProvider.ctx,
|
||||
syncAttrs: true
|
||||
});
|
||||
|
||||
this.coord = new Coordination({
|
||||
morfo: railMorfo,
|
||||
dom: this.soma.dom,
|
||||
motion: this.soma.motion,
|
||||
open: opts.open,
|
||||
animation: opts.animation
|
||||
});
|
||||
this.surface = this.coord.surface('provider', opts.ref, 'owner');
|
||||
}
|
||||
|
||||
get isPresent(): boolean {
|
||||
return this.surface.isPresent;
|
||||
}
|
||||
|
||||
get transitionAttrs() {
|
||||
return this.surface.transitionAttrs;
|
||||
}
|
||||
|
||||
get animationStyle(): string | undefined {
|
||||
return this.surface.animationStyle;
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() => ({
|
||||
...this.runtimePart.props
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Item (child surface) ──────────────────────────────────────────────────────
|
||||
|
||||
interface RailItemOpts {
|
||||
ref: State<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export class RailItemProvider {
|
||||
static create(opts: RailItemOpts) {
|
||||
return new RailItemProvider(opts);
|
||||
}
|
||||
|
||||
readonly opts: RailItemOpts;
|
||||
readonly provider: RailProvider;
|
||||
/** CHILD member — mounts and waits for the group to release it (cascade). */
|
||||
readonly surface: CoordinatedSurface;
|
||||
|
||||
private constructor(opts: RailItemOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = RailProvider.require();
|
||||
this.surface = this.provider.coord.surface('item', opts.ref, 'child');
|
||||
}
|
||||
|
||||
get isPresent(): boolean {
|
||||
return this.surface.isPresent;
|
||||
}
|
||||
|
||||
get transitionAttrs() {
|
||||
return this.surface.transitionAttrs;
|
||||
}
|
||||
|
||||
get animationStyle(): string | undefined {
|
||||
return this.surface.animationStyle;
|
||||
}
|
||||
|
||||
get staggerIndex(): number {
|
||||
return this.surface.staggerIndex;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||
import type { PrimitiveDivAttributes } from '../../types';
|
||||
|
||||
export type RailProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Whether the rail is open. Bindable. @default false */
|
||||
open?: boolean;
|
||||
/** Callback fired when the open state changes. */
|
||||
onOpenChange?: OnChangeFn<boolean>;
|
||||
/** Motion preset name, routed to the declared surfaces (RFC §5). */
|
||||
animation?: string;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, { open: boolean }>;
|
||||
|
||||
export type RailItemProps = Without<PrimitiveDivAttributes, Record<never, never>> & {
|
||||
/** Element reference. Bindable — the child Presence animates this node. */
|
||||
ref?: HTMLElement | null;
|
||||
/** Inline style (e.g. `--motion-stagger-index`, set automatically by the item). */
|
||||
style?: string | Record<string, unknown> | null;
|
||||
children?: Snippet;
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { RevealItemProvider } from '../reveal-provider.svelte';
|
||||
import type { RevealItemProps } from '../types';
|
||||
|
||||
let { ref = $bindable(null), children, ...restProps }: RevealItemProps = $props();
|
||||
|
||||
// CHILD surface. No runtime part — it is a coordinated leaf; `data-reveal-item`
|
||||
// (the morfo's part marker) + the child Presence are all it needs. `bind:this`
|
||||
// feeds the ref the Presence animates; mergeProps normalises `style` for the div.
|
||||
const state = RevealItemProvider.create({
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, { 'data-reveal-item': '' }));
|
||||
</script>
|
||||
|
||||
{#if state.isPresent}
|
||||
<div
|
||||
bind:this={ref}
|
||||
{...mergedProps}
|
||||
{...state.transitionAttrs}
|
||||
data-animation-style={state.animationStyle}
|
||||
style:--motion-stagger-index={state.staggerIndex}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { RevealPanelProvider } from '../reveal-provider.svelte';
|
||||
import type { RevealPanelProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'reveal-panel'),
|
||||
children,
|
||||
...restProps
|
||||
}: RevealPanelProps = $props();
|
||||
|
||||
// OWNER surface. The ref is attached by the runtime part (in `state.props`);
|
||||
// `state.isPresent` / `state.transitionAttrs` come from the owner Presence, so
|
||||
// the panel mounts on open and RETAINS its DOM through the children's exit.
|
||||
const state = RevealPanelProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if state.isPresent}
|
||||
<div {...mergedProps} {...state.transitionAttrs} data-animation-style={state.animationStyle}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { RevealTriggerProvider } from '../reveal-provider.svelte';
|
||||
import type { RevealTriggerProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'reveal-trigger'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: RevealTriggerProps = $props();
|
||||
|
||||
const state = RevealTriggerProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>{@render children?.()}</button>
|
||||
{/if}
|
||||
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { RevealProvider } from '../reveal-provider.svelte';
|
||||
import type { RevealProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid, 'reveal'),
|
||||
open = $bindable(false),
|
||||
onOpenChange = () => {},
|
||||
animation,
|
||||
children
|
||||
}: RevealProps = $props();
|
||||
|
||||
// Virtual root — owns the runtime + the coordination group, renders no element.
|
||||
RevealProvider.create({
|
||||
id: readableActive(() => id),
|
||||
open: writableActive(
|
||||
() => open,
|
||||
(v) => {
|
||||
open = v;
|
||||
onOpenChange(v);
|
||||
}
|
||||
),
|
||||
animation: readableActive(() => animation)
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@ -0,0 +1,11 @@
|
||||
export { default as Provider } from './components/reveal.svelte';
|
||||
export { default as Trigger } from './components/reveal-trigger.svelte';
|
||||
export { default as Panel } from './components/reveal-panel.svelte';
|
||||
export { default as Item } from './components/reveal-item.svelte';
|
||||
|
||||
export type {
|
||||
RevealProps as ProviderProps,
|
||||
RevealTriggerProps as TriggerProps,
|
||||
RevealPanelProps as PanelProps,
|
||||
RevealItemProps as ItemProps
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,178 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import { compileMorfo, type Morfo } from '$uix/morfo';
|
||||
import { validateMorfo } from '$uix/morfo/schema';
|
||||
import { revealMorfo } from '$uix/morfo/components/reveal';
|
||||
import { createEngineMotion } from '$motion';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
import { PresenceGroup } from '$soma/layers/presence-group';
|
||||
|
||||
import { RevealItemProvider, RevealPanelProvider, RevealProvider } from './reveal-provider.svelte';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
async function flushRuntimeTrigger() {
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const motion = createEngineMotion();
|
||||
const soma = {
|
||||
dom,
|
||||
motion,
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
// No component context in a bare effect root — stub the context publication
|
||||
// (both the provider's own ctx and the group's, set by PresenceGroup.create).
|
||||
vi.spyOn(RevealProvider.ctx, 'set').mockImplementation((value) => value as never);
|
||||
vi.spyOn(PresenceGroup.ctx, 'set').mockImplementation((value) => value as never);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
describe('Reveal — contract → coordination wiring (RFC: MOTION_SERVICE_RFC.md §4)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('the morfo is valid and the Panel declares the bracket coordination', () => {
|
||||
expect(() => validateMorfo(revealMorfo)).not.toThrow();
|
||||
|
||||
const panel = compileMorfo(revealMorfo).parts.byKebab.get('panel');
|
||||
expect(panel?.animation?.surface).toBe(true);
|
||||
// `{ enter: 'before', exit: 'after' }` — what the provider feeds the group.
|
||||
expect(panel?.animation?.children).toEqual({ enter: 'before', exit: 'after' });
|
||||
|
||||
// The Item is a coordinated child surface with no relation of its own.
|
||||
const item = compileMorfo(revealMorfo).parts.byKebab.get('item');
|
||||
expect(item?.animation).toEqual({ surface: true, children: undefined });
|
||||
});
|
||||
|
||||
it('the provider builds a PresenceGroup that the panel (owner) + items (children) register into', () => {
|
||||
installSomaHarness();
|
||||
const panelEl = document.createElement('div');
|
||||
const itemEls = [document.createElement('div'), document.createElement('div')];
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = RevealProvider.create({
|
||||
id: state('reveal'),
|
||||
open: state(false),
|
||||
animation: state<string | undefined>(undefined)
|
||||
});
|
||||
vi.spyOn(RevealProvider, 'require').mockReturnValue(provider);
|
||||
const panel = RevealPanelProvider.create({
|
||||
id: state('reveal-panel'),
|
||||
ref: state<HTMLElement | null>(panelEl)
|
||||
});
|
||||
const items = itemEls.map((el) =>
|
||||
RevealItemProvider.create({ ref: state<HTMLElement | null>(el) })
|
||||
);
|
||||
return { provider, panel, items };
|
||||
});
|
||||
|
||||
expect(result.provider.group).toBeInstanceOf(PresenceGroup);
|
||||
// owner + 2 children all registered as group members.
|
||||
expect(result.provider.group.size).toBe(3);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('routes the `animation` DX prop ONLY to the declared surfaces (RFC §5)', () => {
|
||||
installSomaHarness();
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = RevealProvider.create({
|
||||
id: state('reveal'),
|
||||
open: state(false),
|
||||
animation: state<string | undefined>('slide')
|
||||
});
|
||||
return { provider };
|
||||
});
|
||||
|
||||
// Panel + Item are `surface: true` in the morfo → routed; Trigger is not.
|
||||
// Routing now lives on the shared `Coordination` helper.
|
||||
expect(result.provider.coord.routeAnimation('panel')).toBe('slide');
|
||||
expect(result.provider.coord.routeAnimation('item')).toBe('slide');
|
||||
expect(result.provider.coord.routeAnimation('trigger')).toBeUndefined();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('items auto-derive their stagger index from the group order (M6)', () => {
|
||||
installSomaHarness();
|
||||
const panelEl = document.createElement('div');
|
||||
const itemEls = [
|
||||
document.createElement('div'),
|
||||
document.createElement('div'),
|
||||
document.createElement('div')
|
||||
];
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = RevealProvider.create({
|
||||
id: state('reveal'),
|
||||
open: state(false),
|
||||
animation: state<string | undefined>(undefined)
|
||||
});
|
||||
vi.spyOn(RevealProvider, 'require').mockReturnValue(provider);
|
||||
RevealPanelProvider.create({
|
||||
id: state('reveal-panel'),
|
||||
ref: state<HTMLElement | null>(panelEl)
|
||||
});
|
||||
const items = itemEls.map((el) =>
|
||||
RevealItemProvider.create({ ref: state<HTMLElement | null>(el) })
|
||||
);
|
||||
return { items };
|
||||
});
|
||||
|
||||
// The dev numbers nothing — the cascade offsets itself from the registration order.
|
||||
expect(result.items.map((it) => it.staggerIndex)).toEqual([0, 1, 2]);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('toggle flips the open state through the open/close events', async () => {
|
||||
installSomaHarness();
|
||||
const open = state(false);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = RevealProvider.create({
|
||||
id: state('reveal'),
|
||||
open,
|
||||
animation: state<string | undefined>(undefined)
|
||||
});
|
||||
return { provider };
|
||||
});
|
||||
|
||||
result.provider.toggle();
|
||||
await flushRuntimeTrigger();
|
||||
expect(open.current).toBe(true);
|
||||
|
||||
result.provider.toggle();
|
||||
await flushRuntimeTrigger();
|
||||
expect(open.current).toBe(false);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,220 @@
|
||||
import { context } from '../../provider';
|
||||
import { state, type Active, type State, type StateProps } from '$libs/reactive';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||
import type { PresenceGroup } from '../../layers/presence-group';
|
||||
import { Coordination, type CoordinatedSurface } from '../../layers/coordination';
|
||||
import { revealMorfo } from '../../../morfo/components/reveal';
|
||||
|
||||
// ── Provider (virtual root) ────────────────────────────────────────────────
|
||||
|
||||
interface RevealOpts extends StateProps<{ open: boolean }> {
|
||||
id: Active<string>;
|
||||
/** DX preset name routed to the declared surfaces (RFC §5). `undefined` = none. */
|
||||
animation: Active<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal root — owns the morfo runtime, the open state, and the `Coordination`
|
||||
* (the shared coordinated-motion wiring: PresenceGroup + animation routing + auto
|
||||
* stagger, all derived from the morfo's `animation` contract). The Panel registers
|
||||
* as the `owner` surface, each Item as a `child` — see `soma/layers/coordination.ts`.
|
||||
*/
|
||||
export class RevealProvider {
|
||||
static readonly ctx = context<RevealProvider>('Reveal');
|
||||
static get(): RevealProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as RevealProvider | undefined;
|
||||
}
|
||||
static require(): RevealProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
static create(opts: RevealOpts) {
|
||||
return new RevealProvider(opts);
|
||||
}
|
||||
|
||||
readonly opts: RevealOpts;
|
||||
readonly soma: Soma;
|
||||
readonly runtime: SomaRuntime;
|
||||
/** Shared coordination wiring, built from the morfo contract (RFC §7 / M5 / M6). */
|
||||
readonly coord: Coordination;
|
||||
|
||||
// Cross-part id sources read by partRef('trigger') / partRef('panel') for the
|
||||
// Trigger's aria-controls and the Panel's aria-labelledby.
|
||||
triggerId = state('');
|
||||
panelId = state('');
|
||||
|
||||
private constructor(opts: RevealOpts) {
|
||||
this.opts = opts;
|
||||
RevealProvider.ctx.set(this);
|
||||
|
||||
this.soma = Soma.require();
|
||||
this.runtime = this.soma.runtime(revealMorfo, {
|
||||
states: {
|
||||
open: () => opts.open.current
|
||||
},
|
||||
parts: {
|
||||
trigger: () => this.triggerId.current,
|
||||
panel: () => this.panelId.current
|
||||
},
|
||||
events: {
|
||||
// State is set in the HANDLER, so both are `sequence: 'post'` in the
|
||||
// morfo (the perceptual hold must not block the functional change).
|
||||
open: () => {
|
||||
this.opts.open.current = true;
|
||||
},
|
||||
close: () => {
|
||||
this.opts.open.current = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.coord = new Coordination({
|
||||
morfo: revealMorfo,
|
||||
dom: this.soma.dom,
|
||||
motion: this.soma.motion,
|
||||
open: opts.open,
|
||||
animation: opts.animation
|
||||
});
|
||||
}
|
||||
|
||||
/** The coordination group (Panel owner + Item children). */
|
||||
get group(): PresenceGroup {
|
||||
return this.coord.group;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
void this.runtime.trigger(this.opts.open.current ? 'close' : 'open');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trigger ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RevealTriggerOpts {
|
||||
id: Active<string>;
|
||||
ref: State<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export class RevealTriggerProvider {
|
||||
static create(opts: RevealTriggerOpts) {
|
||||
return new RevealTriggerProvider(opts);
|
||||
}
|
||||
|
||||
readonly opts: RevealTriggerOpts;
|
||||
readonly provider: RevealProvider;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
|
||||
private constructor(opts: RevealTriggerOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = RevealProvider.require();
|
||||
this.provider.triggerId.current = opts.id.current;
|
||||
|
||||
this.runtimePart = this.provider.runtime.part('trigger', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
syncAttrs: true
|
||||
});
|
||||
}
|
||||
|
||||
readonly onclick = () => {
|
||||
this.provider.toggle();
|
||||
};
|
||||
|
||||
readonly props = $derived.by(() => ({
|
||||
...this.runtimePart.props,
|
||||
onclick: this.onclick
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Panel (owner surface) ─────────────────────────────────────────────────────
|
||||
|
||||
interface RevealPanelOpts {
|
||||
id: Active<string>;
|
||||
ref: State<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export class RevealPanelProvider {
|
||||
static create(opts: RevealPanelOpts) {
|
||||
return new RevealPanelProvider(opts);
|
||||
}
|
||||
|
||||
readonly opts: RevealPanelOpts;
|
||||
readonly provider: RevealProvider;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
/** OWNER member of the group — its presence triggers the coordinated enter/exit. */
|
||||
readonly surface: CoordinatedSurface;
|
||||
|
||||
private constructor(opts: RevealPanelOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = RevealProvider.require();
|
||||
this.provider.panelId.current = opts.id.current;
|
||||
|
||||
this.runtimePart = this.provider.runtime.part('panel', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
syncAttrs: true
|
||||
});
|
||||
|
||||
this.surface = this.provider.coord.surface('panel', opts.ref, 'owner');
|
||||
}
|
||||
|
||||
get isPresent(): boolean {
|
||||
return this.surface.isPresent;
|
||||
}
|
||||
|
||||
get transitionAttrs() {
|
||||
return this.surface.transitionAttrs;
|
||||
}
|
||||
|
||||
/** The routed `data-animation-style` for this surface (RFC §5). */
|
||||
get animationStyle(): string | undefined {
|
||||
return this.surface.animationStyle;
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() => ({
|
||||
...this.runtimePart.props
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Item (child surface) ──────────────────────────────────────────────────────
|
||||
|
||||
interface RevealItemOpts {
|
||||
ref: State<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export class RevealItemProvider {
|
||||
static create(opts: RevealItemOpts) {
|
||||
return new RevealItemProvider(opts);
|
||||
}
|
||||
|
||||
readonly opts: RevealItemOpts;
|
||||
readonly provider: RevealProvider;
|
||||
/** CHILD member — mounts and waits for the group to release it (cascade). */
|
||||
readonly surface: CoordinatedSurface;
|
||||
|
||||
private constructor(opts: RevealItemOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = RevealProvider.require();
|
||||
|
||||
this.surface = this.provider.coord.surface('item', opts.ref, 'child');
|
||||
}
|
||||
|
||||
get isPresent(): boolean {
|
||||
return this.surface.isPresent;
|
||||
}
|
||||
|
||||
get transitionAttrs() {
|
||||
return this.surface.transitionAttrs;
|
||||
}
|
||||
|
||||
/** The routed `data-animation-style` for this surface (RFC §5). */
|
||||
get animationStyle(): string | undefined {
|
||||
return this.surface.animationStyle;
|
||||
}
|
||||
|
||||
/** The auto `--motion-stagger-index`, derived from the group order (M6). */
|
||||
get staggerIndex(): number {
|
||||
return this.surface.staggerIndex;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
|
||||
|
||||
export type RevealProps = {
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Whether the panel is open. Bindable. @default false */
|
||||
open?: boolean;
|
||||
/** Callback fired when the open state changes. */
|
||||
onOpenChange?: OnChangeFn<boolean>;
|
||||
/**
|
||||
* Motion preset name, routed to the declared animable surfaces (Panel + Items)
|
||||
* as `data-animation-style` — name it once here, the system applies it where the
|
||||
* morfo says (RFC §5). `undefined` = no preset.
|
||||
*/
|
||||
animation?: string;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
export type RevealTriggerProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveButtonAttributes, Record<never, never>>;
|
||||
|
||||
export type RevealPanelProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, Record<never, never>>;
|
||||
|
||||
export type RevealItemProps = Without<PrimitiveDivAttributes, Record<never, never>> & {
|
||||
/** Element reference. Bindable — the child Presence animates this node. */
|
||||
ref?: HTMLElement | null;
|
||||
/** Inline style (e.g. `--i` for the CSS stagger index). */
|
||||
style?: string | Record<string, unknown> | null;
|
||||
children?: Snippet;
|
||||
};
|
||||
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Coordination — the shared soma wiring for a COORDINATED-motion component
|
||||
* (RFC: eidos/MOTION_SERVICE_RFC.md §7 + the M5/M6 increments). It reads a
|
||||
* component's morfo `animation` contract and does, ONCE, what every coordinated
|
||||
* component needs:
|
||||
*
|
||||
* - derives the parent↔children `when` (from the owner part's `animation.children`)
|
||||
* and the set of `surface: true` parts — the CONTRACT drives the coordination;
|
||||
* - creates the `PresenceGroup`;
|
||||
* - routes the DX `animation` prop to the declared surfaces (`routeAnimation`);
|
||||
* - mints per-surface members (`CoordinatedSurface`) — an owner/child `Presence`
|
||||
* that exposes the routed `data-animation-style` and the auto stagger index.
|
||||
*
|
||||
* A coordinated component's ROOT provider creates ONE `Coordination`; each animable
|
||||
* part provider creates a `CoordinatedSurface` via `coord.surface(...)`. This is
|
||||
* what makes a second coordinated component a handful of lines instead of the full
|
||||
* hand-wiring `Reveal` first did — both `Reveal` and `Rail` consume it.
|
||||
*
|
||||
* The opt-OUT doctrine (silence the generic sema signature, react to
|
||||
* `data-starting/ending-style` not `data-state`) lives in the morfo (`channels: []`,
|
||||
* `expression: 'none'`) + the component's CSS — NOT here. This helper only owns the
|
||||
* lifecycle coordination.
|
||||
*/
|
||||
|
||||
import { compileMorfo, type Morfo } from '$uix/morfo';
|
||||
import { readableActive, type Active } from '$libs/reactive';
|
||||
import type { ActiveDom } from '$adom';
|
||||
import type { EngineMotion } from '$motion';
|
||||
import { Presence } from './presence.svelte';
|
||||
import { PresenceGroup, type PresenceRole, type PresenceWhen } from './presence-group';
|
||||
|
||||
interface CoordinationStructure {
|
||||
readonly when: { readonly enter: PresenceWhen; readonly exit: PresenceWhen };
|
||||
readonly surfaces: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
// Cached by morfo identity (compileMorfo is itself cached) — the structure is a
|
||||
// pure function of the contract.
|
||||
const STRUCTURE_CACHE = new WeakMap<Morfo, CoordinationStructure>();
|
||||
|
||||
function structureOf(morfo: Morfo): CoordinationStructure {
|
||||
const cached = STRUCTURE_CACHE.get(morfo);
|
||||
if (cached) return cached;
|
||||
|
||||
let when: { enter: PresenceWhen; exit: PresenceWhen } = { enter: 'together', exit: 'together' };
|
||||
const surfaces = new Set<string>();
|
||||
for (const [kebab, part] of compileMorfo(morfo).parts.byKebab) {
|
||||
if (part.animation?.surface) surfaces.add(kebab);
|
||||
// The owner is the part that declares `children` — its `when` is the tree's.
|
||||
if (part.animation?.children) when = part.animation.children;
|
||||
}
|
||||
|
||||
const structure: CoordinationStructure = { when, surfaces };
|
||||
STRUCTURE_CACHE.set(morfo, structure);
|
||||
return structure;
|
||||
}
|
||||
|
||||
export interface CoordinationOptions {
|
||||
readonly morfo: Morfo;
|
||||
readonly dom: ActiveDom;
|
||||
readonly motion: EngineMotion;
|
||||
/** Shared open state — every surface (owner + children) observes the same flag. */
|
||||
readonly open: Active<boolean>;
|
||||
/** DX preset name routed to the declared surfaces. Omit for none. */
|
||||
readonly animation?: Active<string | undefined>;
|
||||
}
|
||||
|
||||
export class Coordination {
|
||||
readonly group: PresenceGroup;
|
||||
private readonly surfaces: ReadonlySet<string>;
|
||||
private readonly opts: CoordinationOptions;
|
||||
private readonly animation: Active<string | undefined>;
|
||||
|
||||
constructor(opts: CoordinationOptions) {
|
||||
this.opts = opts;
|
||||
const structure = structureOf(opts.morfo);
|
||||
this.surfaces = structure.surfaces;
|
||||
this.animation = opts.animation ?? readableActive((): string | undefined => undefined);
|
||||
this.group = PresenceGroup.create({ when: structure.when, dom: opts.dom });
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the DX `animation` to a part ONLY if the morfo declares it a surface
|
||||
* (RFC §5). A non-surface part gets `undefined`, so it can never be mis-targeted.
|
||||
*/
|
||||
routeAnimation(kebab: string): string | undefined {
|
||||
return this.surfaces.has(kebab) ? this.animation.current : undefined;
|
||||
}
|
||||
|
||||
/** Mint a coordinated member (an animable surface) for a part. */
|
||||
surface(kebab: string, ref: Active<HTMLElement | null>, role: PresenceRole): CoordinatedSurface {
|
||||
const presence = new Presence({
|
||||
dom: this.opts.dom,
|
||||
motion: this.opts.motion,
|
||||
open: this.opts.open,
|
||||
ref,
|
||||
group: this.group,
|
||||
groupRole: role
|
||||
});
|
||||
return new CoordinatedSurface(this, presence, kebab);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single animable surface of a `Coordination`: its registered `Presence`, the
|
||||
* routed `data-animation-style`, and the auto `--motion-stagger-index`. The part
|
||||
* provider exposes these to its svelte wrapper.
|
||||
*/
|
||||
export class CoordinatedSurface {
|
||||
constructor(
|
||||
private readonly coord: Coordination,
|
||||
readonly presence: Presence,
|
||||
private readonly kebab: string
|
||||
) {}
|
||||
|
||||
get isPresent(): boolean {
|
||||
return this.presence.isPresent;
|
||||
}
|
||||
|
||||
get transitionAttrs() {
|
||||
return this.presence.transitionAttrs;
|
||||
}
|
||||
|
||||
/** The routed `data-animation-style` for this surface (RFC §5). */
|
||||
get animationStyle(): string | undefined {
|
||||
return this.coord.routeAnimation(this.kebab);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical `--motion-stagger-index`, derived from this surface's position in
|
||||
* the group's registration order (M6) — the cascade offsets itself; the dev never
|
||||
* numbers the items. Owner surfaces are not children, so this is 0 for them.
|
||||
*/
|
||||
get staggerIndex(): number {
|
||||
return this.coord.group.childIndex(this.presence);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,326 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PresenceGroup, type PresenceMember, type PresencePhase } from './presence-group';
|
||||
|
||||
// Frame scheduler stub — runs the callback synchronously so the registration
|
||||
// window collapses to "now" in tests. The coordination core (playEnter) does
|
||||
// not touch the dom; this only feeds `requestEnter`.
|
||||
const syncDom = { requestFrame: (cb: () => void) => (cb(), 0) };
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** Records release/cancel calls; an optional gate controls when `release` resolves. */
|
||||
function member(
|
||||
role: 'owner' | 'child',
|
||||
id: string,
|
||||
calls: string[],
|
||||
gate?: { promise: Promise<void> }
|
||||
): PresenceMember {
|
||||
return {
|
||||
role,
|
||||
release(phase: PresencePhase) {
|
||||
calls.push(`${id}:${phase}`);
|
||||
return gate ? gate.promise : Promise.resolve();
|
||||
},
|
||||
unmount() {
|
||||
calls.push(`${id}:unmount`);
|
||||
},
|
||||
cancel() {
|
||||
calls.push(`${id}:cancel`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Frame scheduler that QUEUES callbacks (unlike syncDom which runs them inline),
|
||||
// so a test can interleave requestEnter/requestExit BEFORE the scheduled play runs
|
||||
// — the timing the interruption guard (RFC §8.3) actually defends against.
|
||||
function manualDom() {
|
||||
const cbs: Array<() => void> = [];
|
||||
return {
|
||||
dom: {
|
||||
requestFrame: (cb: () => void) => {
|
||||
cbs.push(cb);
|
||||
return cbs.length;
|
||||
}
|
||||
},
|
||||
flush() {
|
||||
for (const cb of cbs.splice(0)) cb();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('PresenceGroup — enter coordination (RFC: MOTION_SERVICE_RFC.md §7)', () => {
|
||||
it('together: releases every member in parallel', async () => {
|
||||
const calls: string[] = [];
|
||||
const group = new PresenceGroup({
|
||||
when: { enter: 'together', exit: 'together' },
|
||||
dom: syncDom
|
||||
});
|
||||
group.register(member('owner', 'owner', calls));
|
||||
group.register(member('child', 'a', calls));
|
||||
group.register(member('child', 'b', calls));
|
||||
|
||||
await group.playEnter();
|
||||
|
||||
expect([...calls].sort()).toEqual(['a:enter', 'b:enter', 'owner:enter']);
|
||||
});
|
||||
|
||||
it('before: owner releases, children only after the owner finishes', async () => {
|
||||
const calls: string[] = [];
|
||||
const ownerGate = deferred();
|
||||
const group = new PresenceGroup({ when: { enter: 'before', exit: 'together' }, dom: syncDom });
|
||||
group.register(member('owner', 'owner', calls, ownerGate));
|
||||
group.register(member('child', 'a', calls));
|
||||
|
||||
const done = group.playEnter();
|
||||
await Promise.resolve();
|
||||
|
||||
// Owner released; the child must wait for the owner's finished.
|
||||
expect(calls).toEqual(['owner:enter']);
|
||||
|
||||
ownerGate.resolve();
|
||||
await done;
|
||||
expect(calls).toEqual(['owner:enter', 'a:enter']);
|
||||
});
|
||||
|
||||
it('after: children release, owner only after they finish', async () => {
|
||||
const calls: string[] = [];
|
||||
const childGate = deferred();
|
||||
const group = new PresenceGroup({ when: { enter: 'after', exit: 'together' }, dom: syncDom });
|
||||
group.register(member('owner', 'owner', calls));
|
||||
group.register(member('child', 'a', calls, childGate));
|
||||
|
||||
const done = group.playEnter();
|
||||
await Promise.resolve();
|
||||
|
||||
// Child released; the owner must wait for the children's finished.
|
||||
expect(calls).toEqual(['a:enter']);
|
||||
|
||||
childGate.resolve();
|
||||
await done;
|
||||
expect(calls).toEqual(['a:enter', 'owner:enter']);
|
||||
});
|
||||
|
||||
it('playEnter resolves only after every member finishes (aggregation §9)', async () => {
|
||||
const calls: string[] = [];
|
||||
const gate = deferred();
|
||||
const group = new PresenceGroup({
|
||||
when: { enter: 'together', exit: 'together' },
|
||||
dom: syncDom
|
||||
});
|
||||
group.register(member('owner', 'owner', calls, gate));
|
||||
group.register(member('child', 'a', calls, gate));
|
||||
|
||||
let settled = false;
|
||||
const done = group.playEnter().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
gate.resolve();
|
||||
await done;
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it('register returns a deregister that removes the member', async () => {
|
||||
const calls: string[] = [];
|
||||
const group = new PresenceGroup({
|
||||
when: { enter: 'together', exit: 'together' },
|
||||
dom: syncDom
|
||||
});
|
||||
const off = group.register(member('child', 'a', calls));
|
||||
expect(group.size).toBe(1);
|
||||
off();
|
||||
expect(group.size).toBe(0);
|
||||
|
||||
await group.playEnter();
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('childIndex returns each child position in registration order (M6)', () => {
|
||||
const calls: string[] = [];
|
||||
const group = new PresenceGroup({
|
||||
when: { enter: 'together', exit: 'together' },
|
||||
dom: syncDom
|
||||
});
|
||||
const owner = member('owner', 'owner', calls);
|
||||
const a = member('child', 'a', calls);
|
||||
const b = member('child', 'b', calls);
|
||||
const c = member('child', 'c', calls);
|
||||
group.register(owner);
|
||||
group.register(a);
|
||||
group.register(b);
|
||||
group.register(c);
|
||||
|
||||
// Owner is skipped; children index from 0 in document (registration) order.
|
||||
expect(group.childIndex(a)).toBe(0);
|
||||
expect(group.childIndex(b)).toBe(1);
|
||||
expect(group.childIndex(c)).toBe(2);
|
||||
});
|
||||
|
||||
it('cancel propagates to every member (RFC §8.3)', () => {
|
||||
const calls: string[] = [];
|
||||
const group = new PresenceGroup({
|
||||
when: { enter: 'together', exit: 'together' },
|
||||
dom: syncDom
|
||||
});
|
||||
group.register(member('owner', 'owner', calls));
|
||||
group.register(member('child', 'a', calls));
|
||||
|
||||
group.cancel();
|
||||
expect([...calls].sort()).toEqual(['a:cancel', 'owner:cancel']);
|
||||
});
|
||||
|
||||
it('requestEnter coordinates after the registration-window frame', async () => {
|
||||
const calls: string[] = [];
|
||||
const group = new PresenceGroup({
|
||||
when: { enter: 'together', exit: 'together' },
|
||||
dom: syncDom
|
||||
});
|
||||
group.register(member('owner', 'owner', calls));
|
||||
|
||||
group.requestEnter();
|
||||
// syncDom runs the frame synchronously; a macrotask drains the microtask
|
||||
// queue so the triggered playEnter has fully settled.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(calls).toEqual(['owner:enter']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PresenceGroup — exit coordination + DOM retention (RFC: MOTION_SERVICE_RFC.md §8.1)', () => {
|
||||
it('after: children exit first while the owner is retained, then owner, then unmount', async () => {
|
||||
const calls: string[] = [];
|
||||
const childGate = deferred();
|
||||
const group = new PresenceGroup({ when: { enter: 'together', exit: 'after' }, dom: syncDom });
|
||||
group.register(member('owner', 'owner', calls));
|
||||
group.register(member('child', 'a', calls, childGate));
|
||||
|
||||
const done = group.playExit();
|
||||
await Promise.resolve();
|
||||
|
||||
// Child is exiting; the owner is RETAINED — it has neither exited nor
|
||||
// unmounted (no owner:exit, no unmount yet).
|
||||
expect(calls).toEqual(['a:exit']);
|
||||
|
||||
childGate.resolve();
|
||||
await done;
|
||||
// Owner exits only after the children, then the whole tree unmounts.
|
||||
expect(calls).toEqual(['a:exit', 'owner:exit', 'owner:unmount', 'a:unmount']);
|
||||
});
|
||||
|
||||
it('before: owner exits first, then children, then unmount', async () => {
|
||||
const calls: string[] = [];
|
||||
const group = new PresenceGroup({ when: { enter: 'together', exit: 'before' }, dom: syncDom });
|
||||
group.register(member('owner', 'owner', calls));
|
||||
group.register(member('child', 'a', calls));
|
||||
|
||||
await group.playExit();
|
||||
|
||||
expect(calls).toEqual(['owner:exit', 'a:exit', 'owner:unmount', 'a:unmount']);
|
||||
});
|
||||
|
||||
it('together: all exit in parallel; nobody unmounts before the exit aggregates', async () => {
|
||||
const calls: string[] = [];
|
||||
const gate = deferred();
|
||||
const group = new PresenceGroup({
|
||||
when: { enter: 'together', exit: 'together' },
|
||||
dom: syncDom
|
||||
});
|
||||
group.register(member('owner', 'owner', calls, gate));
|
||||
group.register(member('child', 'a', calls, gate));
|
||||
|
||||
const done = group.playExit();
|
||||
await Promise.resolve();
|
||||
// Both released; nobody unmounts until the exit aggregates (retention).
|
||||
expect([...calls].sort()).toEqual(['a:exit', 'owner:exit']);
|
||||
|
||||
gate.resolve();
|
||||
await done;
|
||||
// Unmounts come strictly after each surface's own exit.
|
||||
expect(calls.indexOf('owner:unmount')).toBeGreaterThan(calls.indexOf('owner:exit'));
|
||||
expect(calls.indexOf('a:unmount')).toBeGreaterThan(calls.indexOf('a:exit'));
|
||||
});
|
||||
|
||||
it('requestExit coordinates the exit after the next frame', async () => {
|
||||
const calls: string[] = [];
|
||||
const group = new PresenceGroup({
|
||||
when: { enter: 'together', exit: 'together' },
|
||||
dom: syncDom
|
||||
});
|
||||
group.register(member('owner', 'owner', calls));
|
||||
|
||||
group.requestExit();
|
||||
// Drain the microtask queue (macrotask) so playExit + the unmount sweep
|
||||
// have fully settled, not just the first await.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(calls).toEqual(['owner:exit', 'owner:unmount']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PresenceGroup — interruption / reversa (RFC: MOTION_SERVICE_RFC.md §8.3)', () => {
|
||||
it('re-open mid-exit cancels the exit and does NOT tear the tree down', async () => {
|
||||
const calls: string[] = [];
|
||||
const childGate = deferred();
|
||||
const md = manualDom();
|
||||
const group = new PresenceGroup({ when: { enter: 'together', exit: 'after' }, dom: md.dom });
|
||||
group.register(member('owner', 'owner', calls));
|
||||
group.register(member('child', 'a', calls, childGate));
|
||||
|
||||
// Start the exit; the child's release is gated → playExit is mid-flight,
|
||||
// the owner is retained.
|
||||
group.requestExit();
|
||||
md.flush();
|
||||
await Promise.resolve();
|
||||
expect(calls).toEqual(['a:exit']);
|
||||
|
||||
// Re-open mid-exit: a reversal cancels the in-flight members and supersedes
|
||||
// the exit's generation.
|
||||
group.requestEnter();
|
||||
expect(calls).toEqual(['a:exit', 'owner:cancel', 'a:cancel']);
|
||||
|
||||
// Resolve the gated child exit so the superseded playExit resumes — it must
|
||||
// bail at its interruption guard instead of running the unmount loop.
|
||||
childGate.resolve();
|
||||
md.flush();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(calls).not.toContain('owner:unmount');
|
||||
expect(calls).not.toContain('a:unmount');
|
||||
// The reversing enter ran instead.
|
||||
expect(calls).toContain('owner:enter');
|
||||
expect(calls).toContain('a:enter');
|
||||
});
|
||||
|
||||
it('close mid-enter cancels the enter and proceeds to the exit (final intent wins)', async () => {
|
||||
const calls: string[] = [];
|
||||
const gate = deferred();
|
||||
const md = manualDom();
|
||||
const group = new PresenceGroup({ when: { enter: 'together', exit: 'together' }, dom: md.dom });
|
||||
group.register(member('owner', 'owner', calls, gate));
|
||||
|
||||
group.requestEnter();
|
||||
md.flush();
|
||||
await Promise.resolve();
|
||||
expect(calls).toEqual(['owner:enter']);
|
||||
|
||||
// Close mid-enter: reversal cancels the enter, supersedes with an exit.
|
||||
group.requestExit();
|
||||
expect(calls).toEqual(['owner:enter', 'owner:cancel']);
|
||||
|
||||
// Resolve the gate; the superseded enter bails, the exit runs and tears down
|
||||
// (the user's final intent is closed).
|
||||
gate.resolve();
|
||||
md.flush();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(calls).toContain('owner:exit');
|
||||
expect(calls).toContain('owner:unmount');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,294 @@
|
||||
/**
|
||||
* PresenceGroup — coordinates the presence LIFECYCLE of a tree of animable
|
||||
* surfaces (RFC: eidos/MOTION_SERVICE_RFC.md §7). This is the soma half of the
|
||||
* motion service: pure state/lifecycle, NEVER visual. `Presence` manages one
|
||||
* surface; `PresenceGroup` generalizes it to a parent surface + its child
|
||||
* surfaces — sequencing WHEN each starts (per the morfo's
|
||||
* `animation.children.{enter,exit}`) and aggregating their `finished`. The visual
|
||||
* stagger/easing stays in eidos (CSS), the surface motion in `arts/motion`; the
|
||||
* group only owns the timing.
|
||||
*
|
||||
* Discovery is by Svelte context, NOT DOM proximity (RFC §7.1): the parent
|
||||
* surface's provider calls `PresenceGroup.create(...)`; child surfaces find it
|
||||
* with `PresenceGroup.get()` and register. Non-animable wrappers in between are
|
||||
* transparent — they neither create a group nor register, so the context flows
|
||||
* through them.
|
||||
*
|
||||
* No runes here on purpose: the coordination core is plain async + a plain
|
||||
* member set, so it is unit-testable without a component/DOM. The reactive
|
||||
* trigger (open → playEnter) lives in the owner `Presence` and the provider —
|
||||
* which is why this is a plain `.ts`, not a `.svelte.ts`.
|
||||
*
|
||||
* Scope — phases M2 + M3 + M4: ENTER coordination (M2) + EXIT with DOM retention
|
||||
* (M3, §8.1) + interruption/reversa (M4, §8.3: a generation token supersedes a
|
||||
* stale phase so a reversing enter never lets the exit tear down a surface it
|
||||
* wants to keep — no unmount→remount flash) + registration + context +
|
||||
* degradation (no group ⇒ each `Presence` is an island, exactly as before).
|
||||
* Count-based registration close-out (§8.2) is still deferred to the first
|
||||
* consumer that needs a fixed-cardinality barrier.
|
||||
*/
|
||||
|
||||
import { context } from '../provider/context';
|
||||
|
||||
/** Parent↔child lifecycle relation, compiled from the morfo (RFC §4). */
|
||||
export type PresenceWhen = 'before' | 'after' | 'together';
|
||||
|
||||
/** A member's place in the group: the coordinating surface vs a coordinated child. */
|
||||
export type PresenceRole = 'owner' | 'child';
|
||||
|
||||
export type PresencePhase = 'enter' | 'exit';
|
||||
|
||||
/**
|
||||
* A surface the group coordinates. The leaf is a `Presence`; a nested
|
||||
* `PresenceGroup` is also a member (a black box — RFC §9). The member owns the
|
||||
* "what/how" (its own motion); the group owns the "when".
|
||||
*/
|
||||
export interface PresenceMember {
|
||||
/** Coordinating surface (`owner`) or coordinated child (`child`). */
|
||||
readonly role: PresenceRole;
|
||||
/**
|
||||
* Start this surface's motion for the phase and resolve when ITS OWN
|
||||
* animation finishes. The group never reads visual values — it only awaits.
|
||||
*/
|
||||
release(phase: PresencePhase): Promise<void>;
|
||||
/**
|
||||
* Tear down after the group's coordinated exit settles (RFC §8.1). The group
|
||||
* calls this once the whole tree's exit has aggregated — never before, so a
|
||||
* retained owner survives its children's exit.
|
||||
*/
|
||||
unmount(): void;
|
||||
/** Cancel any in-flight motion on this surface (interruption — RFC §8.3, M4). */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal frame scheduler the group needs for its registration window. Declared
|
||||
* as a method (bivariant) so the real `ActiveDom` satisfies it structurally —
|
||||
* the group never passes a frame timestamp, so the callback takes no args. Keeps
|
||||
* this art-adjacent coordinator decoupled from the full `$adom` surface.
|
||||
*/
|
||||
export interface PresenceFrameScheduler {
|
||||
requestFrame(callback: () => void): unknown;
|
||||
}
|
||||
|
||||
export interface PresenceGroupOptions {
|
||||
/**
|
||||
* Parent↔child coordination per phase (from `morfo.animation.children`). Enter
|
||||
* and exit are independent so a container can BRACKET its children —
|
||||
* `{ enter: 'before', exit: 'after' }` appears before them and leaves after.
|
||||
*/
|
||||
readonly when: { readonly enter: PresenceWhen; readonly exit: PresenceWhen };
|
||||
/**
|
||||
* Frame scheduler for the registration window (RFC §8.2): children that mount
|
||||
* in the same pass as the owner register before the scheduled frame, so the
|
||||
* owner-triggered `requestEnter` sees the full member set. `ActiveDom`
|
||||
* satisfies this — the group only needs `requestFrame`.
|
||||
*/
|
||||
readonly dom: PresenceFrameScheduler;
|
||||
}
|
||||
|
||||
export class PresenceGroup {
|
||||
/** Discovered by child surfaces via Svelte context (RFC §7.1). */
|
||||
static readonly ctx = context<PresenceGroup>('PresenceGroup');
|
||||
|
||||
/** Optional read — `undefined` when there is no group ancestor (the common case). */
|
||||
static get(): PresenceGroup | undefined {
|
||||
return PresenceGroup.ctx.getOr(undefined) as PresenceGroup | undefined;
|
||||
}
|
||||
|
||||
/** Required read — throws when called outside a group scope. */
|
||||
static require(): PresenceGroup {
|
||||
return PresenceGroup.ctx.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the group and publish it in context. Call from the parent surface's
|
||||
* provider constructor (component init), so descendant surfaces discover it.
|
||||
*/
|
||||
static create(opts: PresenceGroupOptions): PresenceGroup {
|
||||
const group = new PresenceGroup(opts);
|
||||
PresenceGroup.ctx.set(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
private readonly opts: PresenceGroupOptions;
|
||||
private readonly members = new Set<PresenceMember>();
|
||||
|
||||
/**
|
||||
* Generation token (RFC §8.3) — the group-level analogue of `Presence`'s
|
||||
* `runId`. Every `requestEnter`/`requestExit`/`cancel` bumps it; a scheduled
|
||||
* `playEnter`/`playExit` captures the value at request time and bails at each
|
||||
* await boundary once it goes stale. This is what stops a superseded exit from
|
||||
* tearing down a surface the reversing enter wants to keep.
|
||||
*/
|
||||
private generation = 0;
|
||||
/** The phase currently mid-flight — lets `requestEnter`/`requestExit` detect a reversal. */
|
||||
private active: PresencePhase | undefined;
|
||||
|
||||
constructor(opts: PresenceGroupOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a member. Returns a deregister function — call it from the
|
||||
* member's cleanup (`$effect` teardown) so the set stays accurate as surfaces
|
||||
* mount/unmount (RFC §7.1: dynamic member set).
|
||||
*/
|
||||
register(member: PresenceMember): () => void {
|
||||
this.members.add(member);
|
||||
return () => {
|
||||
this.members.delete(member);
|
||||
};
|
||||
}
|
||||
|
||||
/** How many members are registered (the owner uses this to decide whether to coordinate). */
|
||||
get size(): number {
|
||||
return this.members.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 0-based position of a CHILD among the children, in registration (document)
|
||||
* order — the canonical stagger index. A coordinated child derives its
|
||||
* `--motion-stagger-index` from this, so the cascade offsets itself from the
|
||||
* coordination order and the dev never hand-numbers the items (RFC §15 / M6).
|
||||
*/
|
||||
childIndex(member: PresenceMember): number {
|
||||
let index = 0;
|
||||
for (const m of this.members) {
|
||||
if (m === member) return index;
|
||||
if (m.role === 'child') index++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a coordinated enter after a registration-window frame, so children
|
||||
* that registered in the owner's mount pass are included (RFC §8.2). The owner
|
||||
* `Presence` calls this when its `open` flips true.
|
||||
*/
|
||||
requestEnter(): void {
|
||||
// Reversal (RFC §8.3): an exit is in flight — cancel it so the two phases do
|
||||
// not stack motion on the same surfaces, then supersede it via a fresh `gen`.
|
||||
if (this.active === 'exit') this.cancel();
|
||||
const gen = ++this.generation;
|
||||
this.opts.dom.requestFrame(() => {
|
||||
void this.playEnter(gen);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a coordinated exit on the next frame (RFC §8.1). Members are already
|
||||
* registered (they mounted on enter), so no registration window is needed — the
|
||||
* frame defer just lets every member record its `ending` intent first.
|
||||
*/
|
||||
requestExit(): void {
|
||||
// Reversal (RFC §8.3): an enter is in flight — cancel it before superseding.
|
||||
if (this.active === 'enter') this.cancel();
|
||||
const gen = ++this.generation;
|
||||
this.opts.dom.requestFrame(() => {
|
||||
void this.playExit(gen);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinate the tree's ENTER (RFC §7, §9). Sequences each member's `release`
|
||||
* per `when` and aggregates their `finished`: the owner releases before the
|
||||
* children (`before`), after them (`after`), or all in parallel (`together` —
|
||||
* the CSS stagger does the visual offset). Resolves when the whole tree settles.
|
||||
*/
|
||||
async playEnter(gen: number = this.generation): Promise<void> {
|
||||
if (this.superseded(gen)) return;
|
||||
this.active = 'enter';
|
||||
const members = [...this.members];
|
||||
const owner = members.filter((m) => m.role === 'owner');
|
||||
const children = members.filter((m) => m.role === 'child');
|
||||
|
||||
switch (this.opts.when.enter) {
|
||||
case 'before':
|
||||
await releaseAll(owner, 'enter');
|
||||
if (this.superseded(gen)) return;
|
||||
await releaseAll(children, 'enter');
|
||||
break;
|
||||
case 'after':
|
||||
await releaseAll(children, 'enter');
|
||||
if (this.superseded(gen)) return;
|
||||
await releaseAll(owner, 'enter');
|
||||
break;
|
||||
case 'together':
|
||||
default:
|
||||
await releaseAll(members, 'enter');
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.superseded(gen)) return;
|
||||
this.active = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinate the tree's EXIT (RFC §8.1). Sequences each member's `release` per
|
||||
* `when` (the inverse intent of enter), then tears the tree down. For
|
||||
* `when: 'after'` the children leave first while the owner RETAINS its DOM,
|
||||
* then the owner runs its own exit — only after the whole exit aggregates does
|
||||
* anyone unmount. The visual stagger is CSS (eidos); soma only sequences,
|
||||
* awaits, and unmounts.
|
||||
*/
|
||||
async playExit(gen: number = this.generation): Promise<void> {
|
||||
if (this.superseded(gen)) return;
|
||||
this.active = 'exit';
|
||||
const members = [...this.members];
|
||||
const owner = members.filter((m) => m.role === 'owner');
|
||||
const children = members.filter((m) => m.role === 'child');
|
||||
|
||||
switch (this.opts.when.exit) {
|
||||
case 'before':
|
||||
await releaseAll(owner, 'exit');
|
||||
if (this.superseded(gen)) return;
|
||||
await releaseAll(children, 'exit');
|
||||
break;
|
||||
case 'after':
|
||||
// Children leave first; the owner RETAINS its DOM (stays mounted)
|
||||
// until their exit aggregates, then runs its own exit (RFC §8.1).
|
||||
await releaseAll(children, 'exit');
|
||||
if (this.superseded(gen)) return;
|
||||
await releaseAll(owner, 'exit');
|
||||
break;
|
||||
case 'together':
|
||||
default:
|
||||
await releaseAll(members, 'exit');
|
||||
break;
|
||||
}
|
||||
|
||||
// Interruption guard (RFC §8.3): a reversing `requestEnter` bumps
|
||||
// `generation`; if we were superseded mid-exit, DO NOT tear down — the new
|
||||
// enter keeps the surfaces mounted (no unmount→remount flash). Only when we
|
||||
// are still the current phase does the coordinated exit tear the tree down:
|
||||
// the owner's unmount drops the retained subtree, siblings unmount themselves.
|
||||
if (this.superseded(gen)) return;
|
||||
this.active = undefined;
|
||||
for (const member of members) member.unmount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate cancellation to every member (interruption — RFC §8.3). Bumps the
|
||||
* generation so any in-flight `playEnter`/`playExit` bails at its next guard (no
|
||||
* spurious unmount), clears the active phase, and stops each surface's motion via
|
||||
* `member.cancel()` (→ `motion.cancel(node)`). Called automatically on a reversal
|
||||
* by `requestEnter`/`requestExit`; also the public interruption entry point.
|
||||
*/
|
||||
cancel(): void {
|
||||
this.generation++;
|
||||
this.active = undefined;
|
||||
for (const member of this.members) member.cancel();
|
||||
}
|
||||
|
||||
/** Whether a newer request has superseded the coordination tagged `gen` (RFC §8.3). */
|
||||
private superseded(gen: number): boolean {
|
||||
return gen !== this.generation;
|
||||
}
|
||||
}
|
||||
|
||||
/** Release every member for a phase in parallel; resolve when all finish (RFC §9 aggregation). */
|
||||
function releaseAll(members: readonly PresenceMember[], phase: PresencePhase): Promise<void> {
|
||||
if (members.length === 0) return Promise.resolve();
|
||||
return Promise.all(members.map((m) => m.release(phase))).then(() => undefined);
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* One coordinated child surface. It DISCOVERS its group via Svelte context
|
||||
* (RFC §7.1) — the parent `CoordList` published it; this item never receives
|
||||
* the group explicitly. Pure CSS handles the visual (stagger via --i); the
|
||||
* group only decides WHEN this surface is released / unmounted.
|
||||
*/
|
||||
import { Presence } from '$soma/layers/presence.svelte'
|
||||
import { PresenceGroup } from '$soma/layers/presence-group'
|
||||
import { readableActive } from '$libs/reactive'
|
||||
import { ActiveEidos } from '$uix/eidos'
|
||||
|
||||
let { open, index }: { open: boolean; index: number } = $props()
|
||||
|
||||
const eidos = ActiveEidos.require()
|
||||
const group = PresenceGroup.get()
|
||||
let el = $state<HTMLElement | null>(null)
|
||||
const presence = new Presence({
|
||||
dom: eidos.dom,
|
||||
open: readableActive(() => open),
|
||||
ref: readableActive(() => el),
|
||||
group,
|
||||
groupRole: 'child'
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if presence.isPresent}
|
||||
<div class="coord-item" bind:this={el} style="--i: {index}" {...presence.transitionAttrs}>
|
||||
ítem {index + 1}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.coord-item {
|
||||
padding: 0.5rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
background: var(--color-primary-solid, #4f46e5);
|
||||
color: var(--color-primary-contrast, #fff);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 0.4s ease,
|
||||
transform 0.4s ease;
|
||||
transition-delay: calc(var(--i) * 0.08s);
|
||||
}
|
||||
.coord-item[data-starting-style] {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
.coord-item[data-ending-style] {
|
||||
animation: coord-item-out 0.4s ease forwards;
|
||||
animation-delay: calc(var(--i) * 0.1s);
|
||||
}
|
||||
@keyframes coord-item-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The owner surface of a coordinated list. It creates the `PresenceGroup`
|
||||
* (with the `when` that a real morfo would declare) and publishes it in
|
||||
* context, then renders its child items — which discover the group via
|
||||
* context (RFC §7.1) and register. The group sequences enter/exit per `when`
|
||||
* and, on exit, RETAINS this container's DOM until the items have left (§8.1).
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { Presence } from '$soma/layers/presence.svelte';
|
||||
import { PresenceGroup, type PresenceWhen } from '$soma/layers/presence-group';
|
||||
import { readableActive } from '$libs/reactive';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import CoordItem from './coord-item.svelte';
|
||||
|
||||
let {
|
||||
enter,
|
||||
exit,
|
||||
open,
|
||||
count = 5
|
||||
}: { enter: PresenceWhen; exit: PresenceWhen; open: boolean; count?: number } = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
// `enter`/`exit` are fixed per instance (a real morfo declares them once); read
|
||||
// them untracked so creating the group doesn't pretend to react to a changing prop.
|
||||
const group = untrack(() => PresenceGroup.create({ when: { enter, exit }, dom: eidos.dom }));
|
||||
let containerEl = $state<HTMLElement | null>(null);
|
||||
const container = new Presence({
|
||||
dom: eidos.dom,
|
||||
open: readableActive(() => open),
|
||||
ref: readableActive(() => containerEl),
|
||||
group,
|
||||
groupRole: 'owner'
|
||||
});
|
||||
|
||||
const items = $derived(Array.from({ length: count }, (_, i) => i));
|
||||
</script>
|
||||
|
||||
{#if container.isPresent}
|
||||
<div class="coord-container" bind:this={containerEl} {...container.transitionAttrs}>
|
||||
{#each items as i (i)}
|
||||
<CoordItem {open} index={i} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.coord-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
|
||||
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
.coord-container[data-starting-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.coord-container[data-ending-style] {
|
||||
animation: coord-container-out 0.35s ease forwards;
|
||||
}
|
||||
@keyframes coord-container-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,252 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Rail — the SECOND consumer of the coordinated-motion helper
|
||||
* (`soma/layers/coordination.ts`). Same coordination as `Reveal`, DIFFERENT shape:
|
||||
* the root IS the owner surface, the relation is `together` (all parallel, CSS
|
||||
* stagger), it is PARENT-CONTROLLED via `open` (no trigger), and has NO sema events.
|
||||
* Proves the helper is reusable — building this was a handful of lines.
|
||||
* Inherits the ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
|
||||
*/
|
||||
import * as Rail from '$soma/components/rail';
|
||||
|
||||
const ITEMS = ['Inicio', 'Buscar', 'Mensajes', 'Perfil', 'Ajustes'];
|
||||
const STYLES = [
|
||||
{ value: 'cascade-slide', label: 'slide' },
|
||||
{ value: 'cascade-fade', label: 'fade' },
|
||||
{ value: 'cascade-scale', label: 'scale' }
|
||||
];
|
||||
|
||||
let open = $state(false);
|
||||
let animStyle = $state('cascade-slide');
|
||||
let duration = $state(300); // ms — per-surface transition speed (exposed as a CSS var)
|
||||
let stagger = $state(45); // ms — the rhythm between items (`--motion-stagger-each`)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rail · 2º consumidor coordinado</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="root">
|
||||
<header>
|
||||
<a class="back" href="/temas/animations">← Motion</a>
|
||||
<h1>Rail <small>2º consumidor del helper coordinado</small></h1>
|
||||
<p class="lede">
|
||||
Misma coordinación que <a href="/temas/animations/reveal">Reveal</a>, otra forma: la raíz
|
||||
<strong>ES</strong> la superficie owner, la relación es <code>together</code> (todo en
|
||||
paralelo, el escalonado lo pone el CSS), es <strong>controlada por el padre</strong>
|
||||
(<code>bind:open</code>, sin trigger) y <strong>no tiene eventos sema</strong>. Todo el wiring
|
||||
—PresenceGroup + routing + auto-stagger— sale del helper compartido
|
||||
<code>soma/layers/coordination.ts</code>; construir este componente fueron cuatro líneas.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="button" onclick={() => (open = !open)}>
|
||||
{open ? 'Ocultar' : 'Mostrar'} rail
|
||||
</button>
|
||||
<div class="picker" role="group" aria-label="Estilo de animación">
|
||||
{#each STYLES as s (s.value)}
|
||||
<button
|
||||
class="chip-btn"
|
||||
class:on={animStyle === s.value}
|
||||
type="button"
|
||||
onclick={() => (animStyle = s.value)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sliders">
|
||||
<label>
|
||||
duración
|
||||
<input type="range" min="80" max="700" step="20" bind:value={duration} />
|
||||
<span class="val">{duration}ms</span>
|
||||
</label>
|
||||
<label>
|
||||
stagger
|
||||
<input type="range" min="0" max="120" step="5" bind:value={stagger} />
|
||||
<span class="val">{stagger}ms</span>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="stage"
|
||||
style="--cascade-duration: {duration}ms; --motion-stagger-each: {stagger}ms; --motion-stagger-count: {ITEMS.length}"
|
||||
>
|
||||
<Rail.Provider bind:open animation={animStyle}>
|
||||
{#each ITEMS as label (label)}
|
||||
<Rail.Item class="chip">{label}</Rail.Item>
|
||||
{/each}
|
||||
</Rail.Provider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
min-height: 100dvh;
|
||||
background: var(--color-surface-default, #fff);
|
||||
color: var(--color-content-primary, #111);
|
||||
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
header {
|
||||
max-width: 880px;
|
||||
margin-inline: auto;
|
||||
margin-block-end: 2rem;
|
||||
}
|
||||
.back {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-content-muted, #666);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
h1 {
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
font-size: 1.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
h1 small {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-content-muted, #666);
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
max-width: 64ch;
|
||||
line-height: 1.6;
|
||||
color: var(--color-content-secondary, #444);
|
||||
}
|
||||
.lede a {
|
||||
color: var(--color-primary-solid, #4f46e5);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-block-start: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.15));
|
||||
background: var(--color-surface-default, #fff);
|
||||
color: var(--color-content-primary, #111);
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--color-primary-solid, #4f46e5);
|
||||
color: var(--color-primary-contrast, #fff);
|
||||
border-color: transparent;
|
||||
}
|
||||
.picker {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem;
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
.chip-btn {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-content-secondary, #555);
|
||||
}
|
||||
.chip-btn.on {
|
||||
background: var(--color-primary-solid, #4f46e5);
|
||||
color: var(--color-primary-contrast, #fff);
|
||||
}
|
||||
.stage {
|
||||
max-width: 880px;
|
||||
margin-inline: auto;
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
|
||||
background: var(--color-surface-raised, #f7f7f8);
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.sliders {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-block-start: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sliders label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-content-secondary, #555);
|
||||
}
|
||||
.sliders input[type='range'] {
|
||||
accent-color: var(--color-primary-solid, #4f46e5);
|
||||
}
|
||||
.sliders .val {
|
||||
min-width: 3.5ch;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-content-muted, #888);
|
||||
}
|
||||
|
||||
/* The Rail surfaces — global (rendered by the soma component). HORIZONTAL row;
|
||||
soma stamps data-starting/ending-style, the CSS only reacts. */
|
||||
:global([data-rail]) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-overlay, rgba(99, 102, 241, 0.08));
|
||||
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
|
||||
transition:
|
||||
opacity var(--cascade-duration, 0.3s) ease,
|
||||
transform var(--cascade-duration, 0.3s) ease;
|
||||
}
|
||||
:global([data-rail-item]) {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-default, #fff);
|
||||
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
opacity var(--cascade-duration, 0.3s) ease,
|
||||
transform var(--cascade-duration, 0.3s) ease;
|
||||
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
|
||||
}
|
||||
/* Close = open reversed (last chip leaves first). */
|
||||
:global([data-rail-item][data-ending-style]) {
|
||||
transition-delay: calc(
|
||||
(var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) *
|
||||
var(--motion-stagger-each, 0ms)
|
||||
);
|
||||
}
|
||||
|
||||
/* `cascade-*` (non-eidos-preset names) — react to the Presence attrs, not data-state. */
|
||||
:global([data-animation-style='cascade-slide'][data-starting-style]),
|
||||
:global([data-animation-style='cascade-slide'][data-ending-style]) {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
:global([data-animation-style='cascade-fade'][data-starting-style]),
|
||||
:global([data-animation-style='cascade-fade'][data-ending-style]) {
|
||||
opacity: 0;
|
||||
}
|
||||
:global([data-animation-style='cascade-scale'][data-starting-style]),
|
||||
:global([data-animation-style='cascade-scale'][data-ending-style]) {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in new issue