Remove the parallel motion-coordination service per the redesign: animation is a channel of the EVENT's firma (sema owns it across all channels), not a parallel axis. Code is gone; the RFC stays as historical record with a retirement banner. - morfo: drop MorfoPart.animation + its schema/compile/types/exports - arts/motion: drop CoordinatedPreset / MotionConfig.coordinated - eidos: drop BUILTIN_COORDINATED_PRESETS / renderCoordinatedPresetRules / the animation:none neutralization + registry block; regen generated/base.css - soma: Presence reduced to a single-surface island; delete presence-group / dom-cascade / coordination; dropdown-menu loses the cascade wiring - delete Reveal / Rail (morfo + soma + demos) Docs: MOTION_SERVICE_RFC gains the retirement banner + §D.8 (retirada) + §D.9 (verified model: morfo->soma->sema->eidos pipeline + the two hard rules — soma never writes a visual --var; data-event-* is a single-target stamp, not a bus) + §D.10 (Fase 1 prototype). Fix stale "5 canales" claim in GUIA §11; eidos-motion + dropdown README aligned. Fase 1 prototype (web/routes/temas/animations/panel-cascade): validates the model end to end — panel->cards cascade (enter/exit), dynamic removal with Svelte out: retention, nested cascade — all via :nth-child + custom-property inheritance, with zero JS visual writes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>active-uix
parent
b8637976a0
commit
6307d03055
@ -1,55 +0,0 @@
|
||||
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;
|
||||
@ -1,136 +0,0 @@
|
||||
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',
|
||||
scope: ['soma', 'sema'],
|
||||
// Modo (c) — composición sema + motion (RFC Apéndice B). `open` declares
|
||||
// `channels: ['sound']`: it PLAYS an `emerge` sound (sema) WHILE the coordinated
|
||||
// cascade runs (motion). They don't fight because eidos NEUTRALISES its own generic
|
||||
// VISUAL firma over coordinated surfaces (`render-css.ts`: `[data-animation-style][
|
||||
// data-event-phase='active'] { animation: none !important }`) — the firma's keyframe
|
||||
// dies, the cascade's `transition` survives. The opt-out lives in MOTION, never in
|
||||
// sema. Both `open` and `close` declare `channels: ['sound']` — emerge sounds on
|
||||
// appear AND on dismiss; eidos neutralises the visual firma either way (cascade survives).
|
||||
expression: 'family-default',
|
||||
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 the perceptual hold never
|
||||
// blocks the functional open (CLAUDE.md sequencing doctrine).
|
||||
sequence: 'post',
|
||||
// Modo (c): the sound plays WITH the cascade. eidos neutralises the generic
|
||||
// VISUAL firma over the coordinated surface, so only sound + cascade land.
|
||||
channels: ['sound']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'close',
|
||||
semantic: {
|
||||
family: 'emerge',
|
||||
verb: 'close',
|
||||
target: v.partRef('panel'),
|
||||
sequence: 'post',
|
||||
// Modo (c) también al cerrar — emerge sounds on dismiss too (symmetry); the
|
||||
// exit cascade (data-ending-style) runs while the visual firma is neutralised.
|
||||
channels: ['sound']
|
||||
}
|
||||
}
|
||||
],
|
||||
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;
|
||||
@ -1,31 +0,0 @@
|
||||
<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}
|
||||
@ -1,57 +0,0 @@
|
||||
<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}
|
||||
@ -1,4 +0,0 @@
|
||||
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';
|
||||
@ -1 +0,0 @@
|
||||
export * from './exports';
|
||||
@ -1,96 +0,0 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@ -1,121 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||
import type { PrimitiveDivAttributes } from '../../types';
|
||||
import type { CoordinatedPresetName } from '$motion';
|
||||
|
||||
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>;
|
||||
/** Coordinated-motion preset, routed to the declared surfaces (RFC §5). */
|
||||
animation?: CoordinatedPresetName;
|
||||
}> &
|
||||
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;
|
||||
};
|
||||
@ -1,32 +0,0 @@
|
||||
<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}
|
||||
@ -1,35 +0,0 @@
|
||||
<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}
|
||||
@ -1,33 +0,0 @@
|
||||
<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}
|
||||
@ -1,31 +0,0 @@
|
||||
<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?.()}
|
||||
@ -1,11 +0,0 @@
|
||||
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';
|
||||
@ -1 +0,0 @@
|
||||
export * from './exports';
|
||||
@ -1,178 +0,0 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@ -1,220 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
|
||||
import type { CoordinatedPresetName } from '$motion';
|
||||
|
||||
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?: CoordinatedPresetName;
|
||||
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;
|
||||
};
|
||||
@ -1,137 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@ -1,218 +0,0 @@
|
||||
import type { Active } from '$libs/reactive';
|
||||
import type { ActiveDom } from '$adom';
|
||||
|
||||
/**
|
||||
* DomCascade — the SECOND coordination mode of the motion service (RFC §M9): a
|
||||
* staggered cascade over an owner's STATIC DOM children, as opposed to the
|
||||
* `PresenceGroup` (which coordinates registered child `Presence` surfaces —
|
||||
* Reveal/Rail).
|
||||
*
|
||||
* Why it exists: a real menu/list has ONE `Presence` (the content/overlay that
|
||||
* mounts as a unit) and its items are plain DOM the runtime owns no wrapper for —
|
||||
* discovered by selector, arbitrary consumer markup. There is nothing to register
|
||||
* in a group, and no Svelte binding site to set `--motion-stagger-index` on.
|
||||
*
|
||||
* The trick (so eidos's coordinated preset is reused UNCHANGED): the owner
|
||||
* PROPAGATES its own lifecycle onto each DOM child. While the owner is
|
||||
* `data-starting/ending-style`, the cascade writes the SAME attr on every item
|
||||
* plus the routed `data-animation-style` and a per-DOM-order `--motion-stagger-
|
||||
* index` (+ `--motion-stagger-count` for the reversible exit). The owner
|
||||
* `Presence` already owns the TIMING (starting → next-frame → transition); this
|
||||
* just mirrors it. Items end up with exactly the attrs a child `Presence` would
|
||||
* have produced, so `[data-animation-style='X'][data-starting-style]` matches.
|
||||
*
|
||||
* The reactive mirror lives in the `$effect`; the imperative DOM writes live in
|
||||
* `sync` / `clear` (pure over an injected `ActiveDom`, so they're unit-testable
|
||||
* with a mock — no component/DOM needed).
|
||||
*/
|
||||
|
||||
const ANIMATION_STYLE = 'data-animation-style';
|
||||
const STAGGER_INDEX = '--motion-stagger-index';
|
||||
const STAGGER_COUNT = '--motion-stagger-count';
|
||||
/** The lifecycle attrs the owner produces and the cascade mirrors onto items. */
|
||||
const LIFECYCLE_ATTRS = ['data-starting-style', 'data-ending-style'] as const;
|
||||
|
||||
export interface DomCascadeOptions {
|
||||
readonly dom: ActiveDom;
|
||||
/** The routed coordinated preset name (the owner's `data-animation-style`). `undefined` ⇒ no cascade. */
|
||||
readonly animationStyle: Active<string | undefined>;
|
||||
/**
|
||||
* The owner surface's live transition attrs — `{ 'data-starting-style': '' }`,
|
||||
* `{ 'data-ending-style': '' }`, or `{}` once settled. This is exactly
|
||||
* `Presence.transitionAttrs`; the cascade mirrors it onto the DOM children.
|
||||
*/
|
||||
readonly ownerTransitionAttrs: Active<Readonly<Record<string, string | undefined>>>;
|
||||
/** Resolve the current DOM children to cascade, in document order. */
|
||||
readonly items: () => readonly HTMLElement[];
|
||||
}
|
||||
|
||||
export class DomCascade {
|
||||
private readonly opts: DomCascadeOptions;
|
||||
/** Items currently carrying the cascade attrs — cleared before each re-sync. */
|
||||
private applied: readonly HTMLElement[] = [];
|
||||
|
||||
constructor(opts: DomCascadeOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start mirroring the owner's lifecycle onto the DOM children. Call from the
|
||||
* owner provider's init (inside a component): it runs a reactive `$effect` that
|
||||
* re-syncs whenever the routed style or the owner's transition attrs change; the
|
||||
* cleanup strips the previous items so a changed set (or unmount) leaves no stale
|
||||
* attrs. Kept OUT of the constructor so `sync`/`clear` stay unit-testable without
|
||||
* a component (a server test drives them directly with a mock `dom`).
|
||||
*/
|
||||
watch(): void {
|
||||
let hadItems = false;
|
||||
// Logic effect: enter (beginEnter) / exit (mirror) / close (clear). It has NO
|
||||
// `$effect` cleanup — clearing the items' attrs on every re-run is what cut the
|
||||
// enter mid-transition (the owner's starting→settled re-run fires WHILE the enter
|
||||
// transition is playing). beginEnter is synchronous, so by the time that re-run
|
||||
// lands there is no phase to handle and the `else` branch leaves the items alone.
|
||||
$effect(() => {
|
||||
const items = this.opts.items();
|
||||
const style = this.opts.animationStyle.current;
|
||||
const ownerAttrs = this.opts.ownerTransitionAttrs.current;
|
||||
const ending = ownerAttrs['data-ending-style'] !== undefined;
|
||||
const appeared = items.length > 0 && !hadItems;
|
||||
hadItems = items.length > 0;
|
||||
|
||||
if (items.length === 0) {
|
||||
// Owner closed / unmounted.
|
||||
this.clear();
|
||||
} else if (ending) {
|
||||
// EXIT: ease the items on→off (reversed stagger). They are present and the
|
||||
// preset transition is active, so `sync` interpolates — correct for exit.
|
||||
// The owner awaits them via `pending` before unmounting (RFC §9).
|
||||
this.sync(items, style, ownerAttrs);
|
||||
} else if (style && appeared) {
|
||||
// ENTER: the items just mounted in the on-state. `beginEnter` jumps them to
|
||||
// the off-state with the transition SUPPRESSED, then releases → they ease in.
|
||||
this.beginEnter(items, style);
|
||||
}
|
||||
// else: settled / no phase change. Leave the attrs as beginEnter left them — a
|
||||
// re-sync would clear+rewrite and cut the in-flight enter transition (this
|
||||
// effect re-runs when the owner goes starting→settled). A mid-open item-set
|
||||
// change is not re-synced — acceptable for F2.
|
||||
});
|
||||
|
||||
// Teardown on dispose only (no reactive reads ⇒ never re-runs).
|
||||
$effect(() => () => this.clear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a just-mounted item set into the cascade. The items mount in the ON-state, so
|
||||
* we cannot just stamp the off-state: with the preset's `transition` already active
|
||||
* the change would EASE toward off (and, released a frame later, barely move — the
|
||||
* dropdown-menu enter bug; verified in-browser, opacity crept 1 → 0.95 → 1). Instead
|
||||
* stamp the off-state with the element transition SUPPRESSED so opacity/transform JUMP
|
||||
* to it, force a reflow to commit that, then restore the transition and drop
|
||||
* `data-starting-style` → the items ease IN with the preset's per-row stagger. (The
|
||||
* `Presence` model needs none of this: its `data-starting-style` is in the initial
|
||||
* render, i.e. the mount state, which never transitions.) Synchronous — the owner's
|
||||
* later starting→settled re-run finds nothing to do.
|
||||
*/
|
||||
private beginEnter(items: readonly HTMLElement[], style: string): void {
|
||||
const count = String(items.length);
|
||||
items.forEach((item, i) => {
|
||||
this.opts.dom.apply({
|
||||
target: item,
|
||||
attrs: { [ANIMATION_STYLE]: style, 'data-starting-style': '' }
|
||||
});
|
||||
this.opts.dom.writeProperty(item, STAGGER_INDEX, String(i));
|
||||
this.opts.dom.writeProperty(item, STAGGER_COUNT, count);
|
||||
this.opts.dom.writeProperty(item, 'transition', 'none');
|
||||
});
|
||||
this.applied = items;
|
||||
this.forceReflow(items[0]);
|
||||
for (const item of items) {
|
||||
this.opts.dom.removeProperty(item, 'transition');
|
||||
this.opts.dom.remove(item, ['data-starting-style']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a layout property to flush the transition-suppressed off-state before release. */
|
||||
private forceReflow(el: HTMLElement | undefined): void {
|
||||
if (el) void this.opts.dom.getWindow(el).getComputedStyle(el).opacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the cascade attrs onto each DOM child: the routed `data-animation-style`,
|
||||
* the mirrored owner lifecycle attrs, and the per-order stagger index + count.
|
||||
* Idempotent — clears the prior set first. Pure over `opts.dom`.
|
||||
*/
|
||||
sync(
|
||||
items: readonly HTMLElement[],
|
||||
style: string | undefined,
|
||||
ownerAttrs: Readonly<Record<string, string | undefined>>
|
||||
): void {
|
||||
this.clear();
|
||||
if (!style || items.length === 0) return;
|
||||
const count = String(items.length);
|
||||
items.forEach((item, i) => {
|
||||
this.opts.dom.apply({ target: item, attrs: { [ANIMATION_STYLE]: style, ...ownerAttrs } });
|
||||
this.opts.dom.writeProperty(item, STAGGER_INDEX, String(i));
|
||||
this.opts.dom.writeProperty(item, STAGGER_COUNT, count);
|
||||
});
|
||||
this.applied = items;
|
||||
}
|
||||
|
||||
/** Strip every cascade attr/property the last `sync` wrote. */
|
||||
clear(): void {
|
||||
for (const item of this.applied) {
|
||||
this.opts.dom.remove(item, [ANIMATION_STYLE, ...LIFECYCLE_ATTRS]);
|
||||
this.opts.dom.removeProperty(item, STAGGER_INDEX);
|
||||
this.opts.dom.removeProperty(item, STAGGER_COUNT);
|
||||
}
|
||||
this.applied = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the in-flight transitions of every cascaded item into ONE promise —
|
||||
* the cabo of RFC §9 (children-DOM mode, §M9). The owner's `getAnimations()` does
|
||||
* NOT see these: the items are DOM descendants, not the owner node, and the engine
|
||||
* stays hierarchy-agnostic (no `{subtree:true}`). The owner `Presence` awaits this
|
||||
* via `PresenceOptions.pending`, so on exit it holds the subtree until the whole
|
||||
* cascade settles instead of dropping it mid-stagger.
|
||||
*
|
||||
* Deferred a frame so the mirrored `data-(starting|ending)-style` has triggered the
|
||||
* items' CSS transitions before we read them (the same reason
|
||||
* `Presence.waitForAnimations` waits a frame). Reads the items stamped by the last
|
||||
* `sync` (stable during exit — the set does not change and the owner cannot unmount
|
||||
* until this resolves). Resolves immediately when nothing is animating, and resolves
|
||||
* (never rejects) if an item animation is cancelled — a cancelled exit must still
|
||||
* finalize the owner's lifecycle, never hang it.
|
||||
*/
|
||||
pending(): Promise<void> {
|
||||
const items = this.applied;
|
||||
return new Promise<void>((resolve) => {
|
||||
// TWO frames, not one: the exit transitions are written a microtask after
|
||||
// close (the owner's ending → our mirror `sync`), but they only surface in
|
||||
// getAnimations() a frame or two later — verified in-browser, the item's
|
||||
// animation count is 0 at +1ms and 2 at +29ms. Reading after a single frame
|
||||
// races ahead of them, so `getAnimations()` is empty and the owner unmounts
|
||||
// before the staggered exit plays ("closes in one go"). A second frame lets
|
||||
// every row's transition (including the long delay-phase ones) register.
|
||||
const read = () => {
|
||||
const finishers = items.flatMap((el) => el.getAnimations().map((a) => a.finished));
|
||||
if (finishers.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// settle (resolve OR reject) every finisher — a single cancelled transition
|
||||
// must not collapse the whole wait (Promise.all would reject early). We wait
|
||||
// for the LAST row to finish or be cancelled.
|
||||
Promise.all(
|
||||
finishers.map((f) =>
|
||||
f.then(
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
)
|
||||
).then(() => resolve());
|
||||
};
|
||||
this.opts.dom.requestFrame(() => this.opts.dom.requestFrame(read, items[0]), items[0]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,186 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { readableActive } from '$libs/reactive';
|
||||
import { DomCascade } from './dom-cascade.svelte';
|
||||
import type { ActiveDom } from '$adom';
|
||||
|
||||
type ApplyCall = { target: HTMLElement; attrs?: Record<string, unknown> };
|
||||
type PropCall = { target: HTMLElement; property: string; value: string };
|
||||
|
||||
// A mock that records the DOM-writing methods DomCascade.sync/clear use, and runs
|
||||
// `requestFrame` synchronously so `pending()` is deterministic in a server test.
|
||||
function mockDom() {
|
||||
const applied: ApplyCall[] = [];
|
||||
const removed: { target: HTMLElement; names: readonly string[] }[] = [];
|
||||
const props: PropCall[] = [];
|
||||
const removedProps: { target: HTMLElement; property: string }[] = [];
|
||||
const dom = {
|
||||
apply: (c: ApplyCall) => applied.push(c),
|
||||
remove: (target: HTMLElement, names: readonly string[]) => removed.push({ target, names }),
|
||||
writeProperty: (target: HTMLElement, property: string, value: string) =>
|
||||
props.push({ target, property, value }),
|
||||
removeProperty: (target: HTMLElement, property: string) =>
|
||||
removedProps.push({ target, property }),
|
||||
// Run the frame synchronously so `pending()` is deterministic in a server test.
|
||||
requestFrame: (cb: () => void) => {
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
// `beginEnter` reads a computed style to force a reflow between the suppressed
|
||||
// off-state and its release; the mock just needs to be callable.
|
||||
getWindow: () => ({ getComputedStyle: () => ({ opacity: '1' }) })
|
||||
} as unknown as ActiveDom;
|
||||
return { dom, applied, removed, props, removedProps };
|
||||
}
|
||||
|
||||
const el = () => ({}) as HTMLElement;
|
||||
|
||||
/** A fake item whose `getAnimations()` returns animations with the given finishers. */
|
||||
const animItem = (...finished: Promise<unknown>[]) =>
|
||||
({ getAnimations: () => finished.map((f) => ({ finished: f })) }) as unknown as HTMLElement;
|
||||
|
||||
function make(dom: ActiveDom, items: HTMLElement[]) {
|
||||
return new DomCascade({
|
||||
dom,
|
||||
animationStyle: readableActive(() => 'cascade-slide'),
|
||||
ownerTransitionAttrs: readableActive(() => ({})),
|
||||
items: () => items
|
||||
});
|
||||
}
|
||||
|
||||
describe('DomCascade — children-DOM stagger (RFC §M9)', () => {
|
||||
it('mirrors the routed style + owner lifecycle + per-order index/count onto each item', () => {
|
||||
const m = mockDom();
|
||||
const items = [el(), el(), el()];
|
||||
make(m.dom, items).sync(items, 'cascade-slide', { 'data-starting-style': '' });
|
||||
|
||||
expect(m.applied).toHaveLength(3);
|
||||
expect(m.applied[0].attrs).toEqual({
|
||||
'data-animation-style': 'cascade-slide',
|
||||
'data-starting-style': ''
|
||||
});
|
||||
const indices = m.props
|
||||
.filter((p) => p.property === '--motion-stagger-index')
|
||||
.map((p) => p.value);
|
||||
expect(indices).toEqual(['0', '1', '2']);
|
||||
expect(
|
||||
m.props.filter((p) => p.property === '--motion-stagger-count').every((p) => p.value === '3')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('mirrors data-ending-style on exit', () => {
|
||||
const m = mockDom();
|
||||
const items = [el()];
|
||||
make(m.dom, items).sync(items, 'cascade-slide', { 'data-ending-style': '' });
|
||||
expect(m.applied[0].attrs).toEqual({
|
||||
'data-animation-style': 'cascade-slide',
|
||||
'data-ending-style': ''
|
||||
});
|
||||
});
|
||||
|
||||
it('no-ops when no preset is routed (style undefined)', () => {
|
||||
const m = mockDom();
|
||||
const items = [el(), el()];
|
||||
make(m.dom, items).sync(items, undefined, { 'data-starting-style': '' });
|
||||
expect(m.applied).toHaveLength(0);
|
||||
expect(m.props).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clear() strips every attr/property a prior sync wrote', () => {
|
||||
const m = mockDom();
|
||||
const items = [el(), el()];
|
||||
const cascade = make(m.dom, items);
|
||||
cascade.sync(items, 'cascade-slide', {});
|
||||
cascade.clear();
|
||||
expect(m.removed).toHaveLength(2);
|
||||
expect(m.removed[0].names).toContain('data-animation-style');
|
||||
expect(m.removed[0].names).toContain('data-starting-style');
|
||||
expect(m.removed[0].names).toContain('data-ending-style');
|
||||
const removedProps = m.removedProps.map((p) => p.property);
|
||||
expect(removedProps).toContain('--motion-stagger-index');
|
||||
expect(removedProps).toContain('--motion-stagger-count');
|
||||
});
|
||||
|
||||
it('re-sync clears the prior set before writing (idempotent, owner settles)', () => {
|
||||
const m = mockDom();
|
||||
const items = [el()];
|
||||
const cascade = make(m.dom, items);
|
||||
cascade.sync(items, 'cascade-slide', { 'data-starting-style': '' });
|
||||
cascade.sync(items, 'cascade-slide', {}); // owner settled → drop starting-style
|
||||
expect(m.removed.length).toBeGreaterThan(0);
|
||||
const last = m.applied[m.applied.length - 1];
|
||||
expect(last.attrs).toEqual({ 'data-animation-style': 'cascade-slide' });
|
||||
});
|
||||
|
||||
// ── beginEnter() — items mount in the on-state, must ease IN (F2) ────────────
|
||||
it('beginEnter jumps to the off-state with transition suppressed, then releases', () => {
|
||||
const m = mockDom();
|
||||
const items = [el(), el()];
|
||||
const cascade = make(m.dom, items) as unknown as {
|
||||
beginEnter(i: readonly HTMLElement[], s: string): void;
|
||||
};
|
||||
cascade.beginEnter(items, 'cascade-slide');
|
||||
|
||||
// 1. off-state stamped on each item: data-animation-style + data-starting-style
|
||||
expect(m.applied).toHaveLength(2);
|
||||
expect(m.applied[0].attrs).toEqual({
|
||||
'data-animation-style': 'cascade-slide',
|
||||
'data-starting-style': ''
|
||||
});
|
||||
// 2. the element transition is suppressed inline so the off-state JUMPS (no ease)
|
||||
expect(m.props.some((p) => p.property === 'transition' && p.value === 'none')).toBe(true);
|
||||
// 3. release: transition restored + ONLY data-starting-style dropped (the routed
|
||||
// style + stagger vars stay), so the items ease to the on-state.
|
||||
expect(m.removedProps.some((p) => p.property === 'transition')).toBe(true);
|
||||
expect(m.removed).toHaveLength(2);
|
||||
expect(m.removed.every((r) => r.names.length === 1 && r.names[0] === 'data-starting-style')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
// ── pending() — the exit-heavy cabo (F1c, RFC §9 / §M9) ────────────────────
|
||||
it('pending() resolves only after every cascaded item animation finishes', async () => {
|
||||
const m = mockDom();
|
||||
let doneA!: () => void;
|
||||
let doneB!: () => void;
|
||||
const a = new Promise<void>((r) => (doneA = r));
|
||||
const b = new Promise<void>((r) => (doneB = r));
|
||||
const items = [animItem(a), animItem(b)];
|
||||
const cascade = make(m.dom, items);
|
||||
cascade.sync(items, 'cascade-slide', { 'data-ending-style': '' });
|
||||
|
||||
let settled = false;
|
||||
const p = cascade.pending().then(() => (settled = true));
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false); // both items still animating
|
||||
|
||||
doneA();
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false); // one still animating
|
||||
|
||||
doneB();
|
||||
await p;
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it('pending() resolves immediately when nothing is animating', async () => {
|
||||
const m = mockDom();
|
||||
const items = [animItem(), animItem()]; // getAnimations() → []
|
||||
const cascade = make(m.dom, items);
|
||||
cascade.sync(items, 'cascade-slide', {});
|
||||
let settled = false;
|
||||
await cascade.pending().then(() => (settled = true)); // resolves — must not hang
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it('pending() resolves (never rejects) when an item animation is cancelled', async () => {
|
||||
const m = mockDom();
|
||||
const items = [animItem(Promise.reject(new Error('cancelled')))];
|
||||
const cascade = make(m.dom, items);
|
||||
cascade.sync(items, 'cascade-slide', { 'data-ending-style': '' });
|
||||
// A cancelled exit must still finalize the owner's lifecycle, never hang it.
|
||||
let settled = false;
|
||||
await cascade.pending().then(() => (settled = true));
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -1,326 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@ -1,294 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@ -1,296 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Compuesto · presets de la LIBRERÍA de eidos.
|
||||
*
|
||||
* Unlike the `reveal` / `rail` demos — which hand-write the `cascade-*` CSS on
|
||||
* this page — this one uses the eidos BUILT-IN coordinated presets
|
||||
* (`BUILTIN_COORDINATED_PRESETS` → `generated/base.css`): there is ZERO
|
||||
* animation CSS here. The component just names `animation="cascade-slide"`; the
|
||||
* library supplies the off-state, the transition and the reversible stagger
|
||||
* (gated on the Presence's `data-starting/ending-style`, M6). The picker swaps
|
||||
* the prop live — the LOOK changes with no page CSS touched.
|
||||
*
|
||||
* It also COMPOSES the two orthogonal feedback systems (see /temas/animations
|
||||
* → the sema↔motion explainer): the Rail.Item surfaces cascade in (motion
|
||||
* coordinated, `data-animation-style`), while each <Button> inside fires its
|
||||
* own press firma (sema `contact-activate` → `press-squeeze` on
|
||||
* `data-event-family='contact'`). Two nodes, two axes, no conflict — pressing a
|
||||
* button squeezes it while the row stays put.
|
||||
*
|
||||
* Inherits the ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
|
||||
*/
|
||||
import * as Rail from '$soma/components/rail';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
|
||||
// The items are real <Button>s — one per variant, to show the prominence scale
|
||||
// (solid → soft → surface → outline → ghost). The variant is the VISUAL axis;
|
||||
// every button fires the SAME generic `contact` press (no intent), per book
|
||||
// cap. 22 §11 — the press is the gesture's reception, not its evaluation.
|
||||
const ITEMS = [
|
||||
{ label: 'Guardar', variant: 'solid' },
|
||||
{ label: 'Compartir', variant: 'soft' },
|
||||
{ label: 'Duplicar', variant: 'surface' },
|
||||
{ label: 'Archivar', variant: 'outline' },
|
||||
{ label: 'Eliminar', variant: 'ghost' }
|
||||
] as const;
|
||||
|
||||
// The THREE built-in coordinated presets of eidos — nothing page-local.
|
||||
const STYLES = [
|
||||
{ value: 'cascade-slide', label: 'slide' },
|
||||
{ value: 'cascade-fade', label: 'fade' },
|
||||
{ value: 'cascade-scale', label: 'scale' }
|
||||
];
|
||||
|
||||
let open = $state(true);
|
||||
let animStyle = $state('cascade-slide');
|
||||
let duration = $state(320); // ms → drives the preset's `--motion-cascade-duration`
|
||||
let stagger = $state(60); // ms → the rhythm between items (`--motion-stagger-each`)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Compuesto · presets de la librería de eidos</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="root">
|
||||
<header>
|
||||
<a class="back" href="/temas/animations">← Motion</a>
|
||||
<h1>Compuesto <small>presets predefinidos de eidos</small></h1>
|
||||
<p class="lede">
|
||||
A diferencia de <a href="/temas/animations/rail">rail</a> y
|
||||
<a href="/temas/animations/reveal">reveal</a> —que escriben el CSS de
|
||||
<code>cascade-*</code> a mano en la página— este compuesto usa los
|
||||
<strong>presets coordinados de la LIBRERÍA</strong> de eidos
|
||||
(<code>BUILTIN_COORDINATED_PRESETS</code> → <code>generated/base.css</code>). Esta página no
|
||||
tiene <strong>ni una línea</strong> de CSS de animación: el componente solo nombra
|
||||
<code>animation="cascade-slide"</code> y la librería aporta el off-state, la transición y el
|
||||
stagger reversible (sobre <code>data-starting/ending-style</code>, M6). El selector cambia la
|
||||
<strong>prop en vivo</strong> — cambia el look sin tocar la página.
|
||||
</p>
|
||||
<p class="lede compose">
|
||||
Y <strong>compone los dos ejes</strong>: las superficies <code>Rail.Item</code> entran en
|
||||
cascada (<em>motion coordinado</em>, <code>data-animation-style</code>) mientras cada
|
||||
<code><Button></code> dispara su propia firma de press
|
||||
(<em>sema</em>, <code>contact-activate</code> → <code>press-squeeze</code>). Dos nodos, dos
|
||||
sistemas, sin conflicto: al pulsar un botón se comprime él, la fila no se mueve.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="button" onclick={() => (open = !open)}>
|
||||
{open ? 'Ocultar' : 'Mostrar'} barra
|
||||
</button>
|
||||
<div class="picker" role="group" aria-label="Preset de animación (librería de eidos)">
|
||||
{#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>
|
||||
<span class="hint">
|
||||
Para la cascada fluida, abre esta página enfocada en el browser (en preview de fondo el rAF se
|
||||
throttlea). La animación NO está aquí — sale de la foundation generada.
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<!-- The stage feeds the library preset its knobs, which INHERIT to the surfaces:
|
||||
`--motion-cascade-duration` (speed), `--motion-stagger-each` (rhythm) and
|
||||
`--motion-stagger-count` (so the exit stagger can reverse). The per-item
|
||||
`--motion-stagger-index` is written by soma automatically (M6). -->
|
||||
<div
|
||||
class="stage"
|
||||
style="--motion-cascade-duration: {duration}ms; --motion-stagger-each: {stagger}ms; --motion-stagger-count: {ITEMS.length}"
|
||||
>
|
||||
<Rail.Provider bind:open animation={animStyle}>
|
||||
{#each ITEMS as item (item.label)}
|
||||
<Rail.Item>
|
||||
<Button variant={item.variant}>{item.label}</Button>
|
||||
</Rail.Item>
|
||||
{/each}
|
||||
</Rail.Provider>
|
||||
</div>
|
||||
|
||||
<pre class="code">{`<Rail.Provider animation="cascade-slide"> <!-- a library preset, no page CSS -->
|
||||
{#each items as item}
|
||||
<Rail.Item>
|
||||
<Button variant={item.variant}>{item.label}</Button>
|
||||
</Rail.Item>
|
||||
{/each}
|
||||
</Rail.Provider>`}</pre>
|
||||
</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 0 0.75rem;
|
||||
max-width: 66ch;
|
||||
line-height: 1.6;
|
||||
color: var(--color-content-secondary, #444);
|
||||
}
|
||||
.lede.compose {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
.lede a {
|
||||
color: var(--color-primary-solid, #4f46e5);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-block-start: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hint {
|
||||
display: inline-block;
|
||||
margin-block-start: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-content-muted, #888);
|
||||
max-width: 56ch;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* The Rail surfaces — STATIC layout only. NO transition, NO off-state: those
|
||||
come entirely from the eidos library preset (the whole point of this demo).
|
||||
soma stamps data-animation-style + data-starting/ending-style; the generated
|
||||
CSS reacts. The item is a transparent animable wrapper around the Button. */
|
||||
:global([data-rail]) {
|
||||
display: inline-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));
|
||||
}
|
||||
:global([data-rail-item]) {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.code {
|
||||
max-width: 880px;
|
||||
margin: 1.25rem auto 0;
|
||||
overflow-x: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-default, #fff);
|
||||
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.12));
|
||||
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-content-secondary, #555);
|
||||
}
|
||||
</style>
|
||||
@ -1,242 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* DomCascade — children-DOM stagger (RFC §M9), validated in ISOLATION before the
|
||||
* menu. Unlike Reveal/Rail (each item is a registered child `Presence`), here the
|
||||
* owner is ONE `Presence` (the container) and the items are STATIC DOM — exactly
|
||||
* the shape a real menu/list has. The `DomCascade` mirrors the owner's
|
||||
* `transitionAttrs` + a per-DOM-order `--motion-stagger-index` onto each item,
|
||||
* reusing eidos's coordinated preset UNCHANGED (the items end up carrying the same
|
||||
* attrs a child `Presence` would have produced).
|
||||
*
|
||||
* F1b validated the ENTER cascade (the items stagger in when the container opens).
|
||||
* F1c closes the EXIT: the owner `Presence` cannot see the items' transitions on its
|
||||
* own node (`getAnimations()`, no subtree), so the `DomCascade` exposes a `pending()`
|
||||
* that the owner aggregates via `PresenceOptions.pending` — it now HOLDS the subtree
|
||||
* until the cascade settles instead of dropping the items mid-stagger. Inherits the
|
||||
* ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { readableActive } from '$libs/reactive';
|
||||
import { Presence } from '$soma/layers/presence.svelte';
|
||||
import { DomCascade } from '$soma/layers/dom-cascade.svelte';
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
|
||||
const ITEMS = ['Perfil', 'Ajustes', 'Notificaciones', 'Equipo', 'Facturación', 'Salir'];
|
||||
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 containerEl = $state<HTMLElement | null>(null);
|
||||
|
||||
// ONE Presence for the owner (the container). It owns the lifecycle; the cascade
|
||||
// only mirrors it onto the DOM items. F1c: on exit the owner awaits the items'
|
||||
// cascade (`pending`) before unmounting — their transitions are invisible to the
|
||||
// owner's `getAnimations()` (no subtree), so without this the container would drop
|
||||
// mid-stagger on close. `cascade` is referenced lazily (only when `pending` fires).
|
||||
// Explicit annotations break the inference cycle: `presence.pending` reads
|
||||
// `cascade`, whose `ownerTransitionAttrs` reads `presence.transitionAttrs`.
|
||||
const presence: Presence = new Presence({
|
||||
dom: eidos.dom,
|
||||
open: readableActive(() => open),
|
||||
ref: readableActive(() => containerEl),
|
||||
pending: () => cascade.pending()
|
||||
});
|
||||
|
||||
// The propagator: route `animStyle` to each DOM child, mirror the owner's
|
||||
// transition attrs, number them by DOM order. `items()` is a plain selector read —
|
||||
// the same shape a menu's `getItems` uses.
|
||||
const cascade: DomCascade = new DomCascade({
|
||||
dom: eidos.dom,
|
||||
animationStyle: readableActive(() => animStyle),
|
||||
ownerTransitionAttrs: readableActive(() => presence.transitionAttrs),
|
||||
items: () =>
|
||||
containerEl ? Array.from(containerEl.querySelectorAll<HTMLElement>('[data-dc-item]')) : []
|
||||
});
|
||||
cascade.watch();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>DomCascade · children-DOM (M9 F1c)</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="root">
|
||||
<header>
|
||||
<a class="back" href="/temas/animations">← Motion</a>
|
||||
<h1>DomCascade <small>children-DOM · M9 F1c</small></h1>
|
||||
<p class="lede">
|
||||
La <strong>segunda coordinación</strong> del servicio (RFC §M9). A diferencia de
|
||||
<a href="/temas/animations/reveal">Reveal</a> / <a href="/temas/animations/rail">Rail</a>
|
||||
—donde cada ítem es un <code>Presence</code> registrado— aquí el owner es <strong>un solo</strong>
|
||||
<code>Presence</code> (el contenedor) y los ítems son <strong>DOM estático</strong>: justo la
|
||||
forma de un menú real. El propagador <code>DomCascade</code>
|
||||
<strong>espeja</strong> el <code>data-starting/ending-style</code> del owner + un
|
||||
<code>--motion-stagger-index</code> por orden DOM en cada ítem, <strong>reutilizando el preset
|
||||
coordinado de eidos sin cambiarlo</strong>. Es la grieta que el menú destapó, ya pavimentada.
|
||||
</p>
|
||||
<p class="lede note">
|
||||
F1b validó la <strong>entrada</strong>; <strong>F1c</strong> cierra la
|
||||
<strong>salida</strong>: el owner ahora <strong>espera</strong> a los ítems antes de
|
||||
desmontar. Su <code>getAnimations()</code> no ve las transiciones de los ítems —sin
|
||||
<code>subtree</code>— así que el <code>DomCascade</code> expone un <code>pending()</code> que el
|
||||
<code>Presence</code> agrega. Ciérrala: los ítems salen en cascada <strong>antes</strong> de que
|
||||
el contenedor desaparezca.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="button" onclick={() => (open = !open)}>
|
||||
{open ? 'Ocultar' : 'Mostrar'} lista
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<!-- The stage feeds the cascade its rhythm; the items inherit it. The container is
|
||||
the owner `Presence` (its transition attrs drive the cascade); the items are
|
||||
plain DOM the propagator writes onto. -->
|
||||
<div class="stage" style="--motion-cascade-duration: 320ms; --motion-stagger-each: 55ms">
|
||||
{#if presence.isPresent}
|
||||
<div class="container" bind:this={containerEl} {...presence.transitionAttrs}>
|
||||
{#each ITEMS as label (label)}
|
||||
<div class="dc-item" data-dc-item>{label}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</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 0 0.75rem;
|
||||
max-width: 66ch;
|
||||
line-height: 1.6;
|
||||
color: var(--color-content-secondary, #444);
|
||||
}
|
||||
.lede.note {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-content-muted, #777);
|
||||
}
|
||||
.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: 360px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
width: min(280px, 100%);
|
||||
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));
|
||||
}
|
||||
/* The item's base look. The off-state + transition + stagger come from eidos's
|
||||
coordinated preset (generated/base.css), applied via the attrs the DomCascade
|
||||
propagates — NO animation CSS here. */
|
||||
.dc-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-default, #fff);
|
||||
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@ -1,229 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Dropdown-menu item cascade (RFC §M9 F2) — the children-DOM coordination mode
|
||||
* on a REAL component. Unlike the `dom-cascade` isolation demo (static items in
|
||||
* a bare container), here the cascade rides the actual `<DropdownMenu>`: the
|
||||
* Content is the owner `Presence`, its rows are discovered by the provider
|
||||
* (`getCascadeRows`), and the cascade COEXISTS with the focus-trap, dismissal,
|
||||
* roving focus and a submenu. Opt-in via the `animation` prop; `undefined` ⇒ the
|
||||
* menu behaves exactly as before (its plain `dropdown-menu-enter` chrome).
|
||||
*
|
||||
* Decision C: the WHOLE menu staggers — items, separators and group headings,
|
||||
* disabled rows included. Decision A: focus returns to the trigger at close,
|
||||
* not after the exit cascade. Submenus (decision B) keep their own animation.
|
||||
* Inherits the ActiveUix + Soma + Eidos + sound scope from `../+layout.svelte`.
|
||||
*/
|
||||
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
|
||||
|
||||
type Preset = 'off' | 'cascade-slide' | 'cascade-fade' | 'cascade-scale';
|
||||
const PRESETS: { value: Preset; label: string }[] = [
|
||||
{ value: 'off', label: 'off' },
|
||||
{ value: 'cascade-slide', label: 'slide' },
|
||||
{ value: 'cascade-fade', label: 'fade' },
|
||||
{ value: 'cascade-scale', label: 'scale' }
|
||||
];
|
||||
|
||||
let open = $state(false);
|
||||
let preset = $state<Preset>('cascade-slide');
|
||||
let staggerEach = $state(45);
|
||||
let cascadeDuration = $state(320);
|
||||
|
||||
// Bound menu state
|
||||
let notifications = $state(true);
|
||||
let theme = $state<'light' | 'dark' | 'system'>('system');
|
||||
let lastSelected = $state<string | null>(null);
|
||||
|
||||
const animation = $derived(preset === 'off' ? undefined : preset);
|
||||
const contentStyle = $derived(
|
||||
`--motion-cascade-duration: ${cascadeDuration}ms; --motion-stagger-each: ${staggerEach}ms`
|
||||
);
|
||||
|
||||
const pick = (label: string) => () => (lastSelected = label);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>DropdownMenu cascade · children-DOM (M9 F2)</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="root">
|
||||
<header>
|
||||
<a class="back" href="/temas/animations">← Motion</a>
|
||||
<h1>DropdownMenu <small>item cascade · M9 F2</small></h1>
|
||||
<p class="lede">
|
||||
El modo <strong>children-DOM</strong> sobre un componente real. El
|
||||
<code><DropdownMenu></code> expone una prop <code>animation</code>: al activarla, las filas
|
||||
del menú <strong>entran y salen en cascada</strong> reusando el preset coordinado de eidos sin
|
||||
cambiarlo. El Content es el <code>Presence</code> dueño; el provider descubre las filas
|
||||
(<code>getCascadeRows</code>) y propaga el lifecycle. Convive con focus-trap, dismissal, roving
|
||||
focus y submenú.
|
||||
</p>
|
||||
<p class="lede note">
|
||||
<strong>Ábrelo y ciérralo</strong>: en salida, el panel <strong>espera</strong> a que las filas
|
||||
salgan (F1c · <code>pending</code>) antes de desmontar. El foco vuelve al botón al cerrar, no al
|
||||
terminar la animación. Cascadea <strong>todo</strong>: items, separadores y cabeceras,
|
||||
incluyendo deshabilitados.
|
||||
</p>
|
||||
|
||||
<div class="controls">
|
||||
<div class="picker" role="group" aria-label="Preset de animación">
|
||||
{#each PRESETS as p (p.value)}
|
||||
<button
|
||||
class="chip-btn"
|
||||
class:on={preset === p.value}
|
||||
type="button"
|
||||
onclick={() => (preset = p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="slider">
|
||||
<span>stagger {staggerEach}ms</span>
|
||||
<input type="range" min="10" max="120" step="5" bind:value={staggerEach} />
|
||||
</label>
|
||||
<label class="slider">
|
||||
<span>duración {cascadeDuration}ms</span>
|
||||
<input type="range" min="120" max="600" step="20" bind:value={cascadeDuration} />
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
<DropdownMenu bind:open {animation}>
|
||||
<DropdownMenu.Trigger>Cuenta ▾</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content side="bottom" align="start" sideOffset={6} style={contentStyle}>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>Cuenta</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Item onSelect={pick('Perfil')}>Perfil</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={pick('Ajustes')}>Ajustes</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled>Facturación (no disponible)</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.CheckboxItem bind:checked={notifications}>
|
||||
Notificaciones
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.RadioGroup bind:value={theme}>
|
||||
<DropdownMenu.GroupHeading>Tema</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.RadioItem value="light">Claro</DropdownMenu.RadioItem>
|
||||
<DropdownMenu.RadioItem value="dark">Oscuro</DropdownMenu.RadioItem>
|
||||
<DropdownMenu.RadioItem value="system">Sistema</DropdownMenu.RadioItem>
|
||||
</DropdownMenu.RadioGroup>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>Más opciones</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.Item onSelect={pick('Exportar')}>Exportar…</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={pick('Importar')}>Importar…</DropdownMenu.Item>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={pick('Salir')}>Salir</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
|
||||
<p class="echo">
|
||||
Última selección: <strong>{lastSelected ?? '—'}</strong>
|
||||
</p>
|
||||
</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 0 0.75rem;
|
||||
max-width: 66ch;
|
||||
line-height: 1.6;
|
||||
color: var(--color-content-secondary, #444);
|
||||
}
|
||||
.lede.note {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-content-muted, #777);
|
||||
}
|
||||
.lede code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
margin-block-start: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.slider {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-content-muted, #777);
|
||||
}
|
||||
.stage {
|
||||
max-width: 880px;
|
||||
margin-inline: auto;
|
||||
padding: 2rem;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
|
||||
background: var(--color-surface-raised, #f7f7f8);
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.echo {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-content-muted, #777);
|
||||
}
|
||||
</style>
|
||||
@ -1,216 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cubicOut, cubicIn } from 'svelte/easing';
|
||||
|
||||
/*
|
||||
* Fase 0 — Svelte-native menu cascade prototype (THROWAWAY).
|
||||
*
|
||||
* Close choreography (RFC `when: { exit: 'after' }` bracket):
|
||||
* 1. the items leave in a reversed cascade (opacity+transform) — you SEE them
|
||||
* go; they keep their space, so the cascade isn't masked;
|
||||
* 2. THEN the panel collapses like a blind (its OWN close firma — the size
|
||||
* channel of `emerge.close` of the panel), delayed so it "drags" the items.
|
||||
* The blind is CSS grid `1fr → 0fr` (no JS measuring; visual stays in eidos).
|
||||
* The panel's delay is tunable below so the drag feel can be dialled in.
|
||||
*/
|
||||
|
||||
let open = $state(false);
|
||||
let opening = $state(false);
|
||||
let closing = $state(false);
|
||||
let openTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
let items = $state(['Perfil', 'Facturación', 'Equipo', 'Integraciones', 'Ajustes', 'Cerrar sesión']);
|
||||
let added = 0;
|
||||
|
||||
// Panel-blind tuning. delay = how long after the items start leaving the panel
|
||||
// begins to collapse → the "drag" feel. Tune it live with the slider.
|
||||
let panelDelay = $state(250);
|
||||
const panelDur = 280;
|
||||
|
||||
function toggle() {
|
||||
if (open) {
|
||||
closing = true;
|
||||
open = false;
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = setTimeout(() => (closing = false), 1200);
|
||||
return;
|
||||
}
|
||||
closing = false;
|
||||
opening = true;
|
||||
open = true;
|
||||
clearTimeout(openTimer);
|
||||
openTimer = setTimeout(() => (opening = false), 800);
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
added += 1;
|
||||
items.push('Nuevo ' + added);
|
||||
}
|
||||
function removeLast() {
|
||||
if (items.length) items.pop();
|
||||
}
|
||||
|
||||
function readMs(node: Element, prop: string, fallback: number): number {
|
||||
const raw = getComputedStyle(node).getPropertyValue(prop).trim();
|
||||
if (!raw) return fallback;
|
||||
if (raw.endsWith('ms')) return parseFloat(raw);
|
||||
if (raw.endsWith('s')) return parseFloat(raw) * 1000;
|
||||
const n = parseFloat(raw);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function readPx(node: Element, prop: string, fallback: number): number {
|
||||
const n = parseFloat(getComputedStyle(node).getPropertyValue(prop));
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item lifecycle shim — opacity + transform only (items keep their space so the
|
||||
* cascade is visible). Stagger only during the open/close burst; an individual
|
||||
* add/remove enters/leaves with delay 0. Values from eidos tokens.
|
||||
*/
|
||||
function cascade(
|
||||
node: Element,
|
||||
{ index, count, dir }: { index: number; count: number; dir: 'in' | 'out' }
|
||||
) {
|
||||
const dur = readMs(node, '--motion-cascade-duration', 240);
|
||||
const each = readMs(node, '--motion-stagger-each', 55);
|
||||
const shift = readPx(node, '--proto-cascade-shift', -14);
|
||||
let delay = 0;
|
||||
if (dir === 'in' && opening) delay = index * each;
|
||||
else if (dir === 'out' && closing) delay = (count - 1 - index) * each;
|
||||
return {
|
||||
delay,
|
||||
duration: dur,
|
||||
easing: dir === 'out' ? cubicIn : cubicOut,
|
||||
css: (t: number, u: number) => `opacity:${t};transform:translateX(${u * shift}px)`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel blind — the panel's OWN close firma. CSS grid `1fr → 0fr` collapses the
|
||||
* height with NO JS measurement. Delayed so the items leave first and the panel
|
||||
* appears to drag them down.
|
||||
*/
|
||||
function blind(node: Element, { delay, duration }: { delay: number; duration: number }) {
|
||||
// Roll-up: collapse the panel's measured height to 0. `height` IS WAAPI-
|
||||
// animatable; `grid-template-rows` is NOT (Svelte produced no animation for
|
||||
// it). Real browsers compute layout, so getBoundingClientRect is reliable.
|
||||
const h = node.getBoundingClientRect().height;
|
||||
return {
|
||||
delay,
|
||||
duration,
|
||||
easing: cubicIn,
|
||||
css: (t: number) => `overflow:hidden;height:${t * h}px`
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>Fase 0 — cierre con arrastre (persiana del panel)</h1>
|
||||
<p class="note">
|
||||
Al cerrar: los ítems salen en cascada (se ven) y <strong>luego</strong> el panel colapsa como persiana
|
||||
(<code>grid 1fr→0fr</code>, sin medir en JS), con retardo → el panel los «arrastra». Es la firma de cierre
|
||||
propia del panel (<code>when: exit: 'after'</code>). Ajusta el retardo:
|
||||
</p>
|
||||
|
||||
<label class="slider">
|
||||
Retardo de la persiana: <strong>{panelDelay}ms</strong>
|
||||
<input type="range" min="0" max="700" step="10" bind:value={panelDelay} />
|
||||
</label>
|
||||
|
||||
<div class="controls">
|
||||
<button class="trigger" onclick={toggle} aria-expanded={open}>
|
||||
{open ? 'Cerrar' : 'Abrir'} menú
|
||||
</button>
|
||||
{#if open}
|
||||
<button class="add" onclick={addItem}>Añadir ítem</button>
|
||||
<button class="remove" onclick={removeLast}>Quitar último</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="panel" data-proto-panel out:blind={{ delay: panelDelay, duration: panelDur }}>
|
||||
{#each items as label, i (label)}
|
||||
<div
|
||||
class="item"
|
||||
data-proto-item
|
||||
data-index={i}
|
||||
in:cascade|global={{ index: i, count: items.length, dir: 'in' }}
|
||||
out:cascade|global={{ index: i, count: items.length, dir: 'out' }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4, 1rem);
|
||||
max-inline-size: 32rem;
|
||||
padding: var(--space-6, 2rem);
|
||||
color: var(--color-content-primary, #111);
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--color-content-muted, #666);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
line-height: var(--leading-ui, 1.5);
|
||||
}
|
||||
|
||||
.slider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1, 0.25rem);
|
||||
align-items: flex-start;
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
color: var(--color-content-muted, #666);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
border: 1px solid var(--color-border-default, #ccc);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
background: var(--color-surface-raised, #fff);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel {
|
||||
--motion-cascade-duration: var(--duration-moderate, 240ms);
|
||||
--motion-stagger-each: 55ms;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
inline-size: 14rem;
|
||||
padding: var(--space-1, 0.25rem);
|
||||
border: 1px solid var(--color-border-default, #ccc);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
background: var(--color-surface-overlay, #fff);
|
||||
box-shadow: var(--shadow-overlay, 0 8px 24px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.item {
|
||||
--proto-cascade-shift: -14px;
|
||||
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--color-primary-element, #eef);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,2 @@
|
||||
// Fase 1 prototype — panel→cards cascade on the verified model. Live, no prerender.
|
||||
export const prerender = false
|
||||
@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Fase 1 prototype — the cascade, as a real eidos recipe would ship it: PLAIN global
|
||||
* CSS in a .css file (no Svelte `<style>` scoping/pruning), selecting on data-attrs.
|
||||
* EIDOS owns 100% of this. The page (soma role) only flips data-state + retains the
|
||||
* leaving node (Svelte `out:`); nobody writes a per-card variable.
|
||||
*/
|
||||
|
||||
[data-cascade-panel] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-subtle, #e5e5e5);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
min-block-size: 8rem;
|
||||
/* rhythm tokens, set once on the container (inherited). The demo overrides the
|
||||
outer one inline. */
|
||||
--card-stagger-each: 70ms;
|
||||
--inner-stagger-each: 45ms;
|
||||
/* inner content waits for its card to SETTLE, then cascades — so the nesting
|
||||
reads as a distinct second phase instead of being masked by the card's fade. */
|
||||
--inner-after: var(--duration-moderate, 240ms);
|
||||
}
|
||||
|
||||
/* the wrapper IS the grid cell + the animated box; the Card fills it */
|
||||
[data-cascade-item] {
|
||||
display: flex;
|
||||
}
|
||||
[data-cascade-item] > [data-card] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@keyframes card-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes card-fall {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* EIDOS computes the per-card index FROM STRUCTURE — nobody writes it.
|
||||
nth-child → forward index (enter); nth-last-child → reverse (exit). */
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(1) { --row: 0; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(2) { --row: 1; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(3) { --row: 2; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(4) { --row: 3; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(5) { --row: 4; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(6) { --row: 5; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(7) { --row: 6; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(8) { --row: 7; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(9) { --row: 8; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(10) { --row: 9; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(11) { --row: 10; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(12) { --row: 11; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(13) { --row: 12; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(14) { --row: 13; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(15) { --row: 14; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-child(16) { --row: 15; }
|
||||
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(1) { --row-rev: 0; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(2) { --row-rev: 1; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(3) { --row-rev: 2; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(4) { --row-rev: 3; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(5) { --row-rev: 4; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(6) { --row-rev: 5; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(7) { --row-rev: 6; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(8) { --row-rev: 7; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(9) { --row-rev: 8; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(10) { --row-rev: 9; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(11) { --row-rev: 10; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(12) { --row-rev: 11; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(13) { --row-rev: 12; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(14) { --row-rev: 13; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(15) { --row-rev: 14; }
|
||||
[data-cascade-panel] > [data-cascade-item]:nth-last-child(16) { --row-rev: 15; }
|
||||
|
||||
/* ENTER: container open → each NON-leaving card rises, delayed by its structural index. */
|
||||
[data-cascade-panel][data-state='open'] > [data-cascade-item]:not([data-leaving]) {
|
||||
animation: card-rise var(--duration-moderate, 240ms) var(--ease-out, cubic-bezier(0.2, 0.8, 0.2, 1))
|
||||
backwards;
|
||||
animation-delay: calc(var(--row, 0) * var(--card-stagger-each));
|
||||
}
|
||||
|
||||
/* EXIT (panel close): all cards reverse-cascade out, held forwards. */
|
||||
[data-cascade-panel][data-state='closed'] > [data-cascade-item] {
|
||||
animation: card-fall var(--duration-fast, 120ms) var(--ease-in, cubic-bezier(0.4, 0, 1, 1)) forwards;
|
||||
animation-delay: calc(var(--row-rev, 0) * var(--card-stagger-each));
|
||||
}
|
||||
|
||||
/* EXIT (cards removed, panel still open): each retained item plays card-fall, staggered
|
||||
by its REVERSE structural index (--row-rev, from nth-last-child). A lone removal → the
|
||||
last card has --row-rev 0 → delay 0 (immediate); a bulk removal → reverse cascade (the
|
||||
last card leaves first). No `data-phase` needed — the structure gives both for free.
|
||||
Soma's `out:` retains each node for delay + duration, then unmounts. */
|
||||
[data-cascade-panel] > [data-cascade-item][data-leaving] {
|
||||
animation: card-fall var(--duration-fast, 120ms) var(--ease-in, cubic-bezier(0.4, 0, 1, 1)) forwards;
|
||||
animation-delay: calc(var(--row-rev, 0) * var(--card-stagger-each));
|
||||
}
|
||||
|
||||
/* ── NESTED cascade: inner content inside each card ──────────────────────────
|
||||
The nesting test. Each inner item composes TWO structural indices, both pure
|
||||
CSS:
|
||||
· `--row` — inherited from its ancestor [data-cascade-item] (the card's
|
||||
OUTER position in the panel cascade);
|
||||
· `--inner-row` — its OWN position among siblings (`:nth-child`, scoped to its
|
||||
own [data-inner-list] parent — nesting is automatic).
|
||||
delay = card_delay + inner_delay → the inner content enters AFTER its card.
|
||||
Zero JS writes; inheritance + nth-child do the composition. */
|
||||
|
||||
[data-inner-list] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-block-start: 0.5rem;
|
||||
}
|
||||
[data-inner-item] {
|
||||
block-size: 0.55rem;
|
||||
border-radius: 3px;
|
||||
background: var(--color-content-muted, #8a8a8a);
|
||||
}
|
||||
[data-inner-list] > [data-inner-item]:nth-child(1) { --inner-row: 0; inline-size: 90%; }
|
||||
[data-inner-list] > [data-inner-item]:nth-child(2) { --inner-row: 1; inline-size: 60%; }
|
||||
[data-inner-list] > [data-inner-item]:nth-child(3) { --inner-row: 2; inline-size: 78%; }
|
||||
|
||||
@keyframes inner-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-cascade-panel][data-state='open'] [data-inner-item] {
|
||||
animation: inner-rise var(--duration-moderate, 240ms) var(--ease-out, cubic-bezier(0.2, 0.8, 0.2, 1))
|
||||
backwards;
|
||||
/* composed delay: card's outer index + the card's settle time + own inner index */
|
||||
animation-delay: calc(
|
||||
var(--row, 0) * var(--card-stagger-each) + var(--inner-after) + var(--inner-row, 0) *
|
||||
var(--inner-stagger-each)
|
||||
);
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
<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>
|
||||
@ -1,75 +0,0 @@
|
||||
<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>
|
||||
@ -1,252 +0,0 @@
|
||||
<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