|
|
<script lang="ts">
|
|
|
/**
|
|
|
* Reveal — the FIRST REAL consumer of the motion-coordination contract.
|
|
|
*
|
|
|
* Unlike the synthetic `/presence-group` demo (which hand-constructs `Presence`
|
|
|
* + `PresenceGroup`), this drives the REAL soma `<Reveal>` component: its morfo
|
|
|
* declares `panel.animation.children = { enter: 'before', exit: 'after' }`, and
|
|
|
* the provider reads that COMPILED contract to build the group (RFC §4). The
|
|
|
* panel enters, 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. The visual
|
|
|
* stagger is pure CSS here (transition-delay × index); soma only sequences WHEN.
|
|
|
*
|
|
|
* M5 (RFC §5): the `animation` prop is named ONCE on the root and routed to the
|
|
|
* declared surfaces (Panel + Items) as `data-animation-style` — "el sistema sabe
|
|
|
* dónde". The picker changes it live; the CSS below reacts to the attr.
|
|
|
* Inherits the ActiveUix + Soma + Eidos scope from `../+layout.svelte`.
|
|
|
*/
|
|
|
import * as Reveal from '$soma/components/reveal';
|
|
|
|
|
|
const ITEMS = ['Perfil', 'Ajustes', 'Notificaciones', 'Equipo', 'Facturación', 'Salir'];
|
|
|
|
|
|
// Coordinated-surface styles — deliberately NOT the eidos built-in preset names
|
|
|
// (`fade`, `slide-fade`, `scale-fade`…). Those presets generate
|
|
|
// `[data-animation-style][data-state]` CSS that fires on MOUNT (keyframe animation),
|
|
|
// which collides with and fights the coordinated cascade — the §5 finding. These
|
|
|
// `cascade-*` names route to NO eidos preset, so only this page's own
|
|
|
// data-starting-style-gated transitions apply (the correct gating for coordination).
|
|
|
const STYLES = [
|
|
|
{ value: 'cascade-slide', label: 'slide' },
|
|
|
{ value: 'cascade-fade', label: 'fade' },
|
|
|
{ value: 'cascade-scale', label: 'scale' },
|
|
|
{ value: 'cascade-drop', label: 'drop' }, // pure transform — 1 CSS rule, nothing else
|
|
|
{ value: 'cascade-blur', label: 'blur' } // adds `filter` → also extend the base transition
|
|
|
];
|
|
|
|
|
|
let open = $state(false);
|
|
|
let animStyle = $state('cascade-slide');
|
|
|
let duration = $state(300); // ms — the per-surface transition speed (exposed as a CSS var)
|
|
|
let stagger = $state(55); // ms — the rhythm between items (`--motion-stagger-each`)
|
|
|
|
|
|
// Reversa (RFC §8.3): re-open mid-exit — the group's generation token keeps the
|
|
|
// surfaces mounted (no unmount→remount flash). Flip, then flip back mid-animation.
|
|
|
const reversa = () => {
|
|
|
const back = open;
|
|
|
open = !back;
|
|
|
setTimeout(() => (open = back), 150);
|
|
|
};
|
|
|
</script>
|
|
|
|
|
|
<svelte:head>
|
|
|
<title>Reveal · primer consumidor real</title>
|
|
|
</svelte:head>
|
|
|
|
|
|
<div class="root">
|
|
|
<header>
|
|
|
<a class="back" href="/temas/animations">← Motion</a>
|
|
|
<h1>Reveal <small>primer consumidor real</small></h1>
|
|
|
<p class="lede">
|
|
|
A diferencia del demo <a href="/temas/animations/presence-group">presence-group</a> —que
|
|
|
construye <code>Presence</code> + <code>PresenceGroup</code> a mano— esto usa el componente
|
|
|
<strong>real</strong> <code><Reveal></code>: su <strong>morfo</strong> declara
|
|
|
<code>panel.animation.children = { enter: 'before', exit: 'after' }</code>, y el
|
|
|
<strong>provider lee ese contrato compilado</strong> para construir el grupo (RFC §4). El
|
|
|
panel entra, luego los ítems <strong>entran en cascada</strong>; al cerrar los ítems salen
|
|
|
primero mientras el panel <strong>retiene su DOM</strong> (exit-heavy §8.1), y solo después se
|
|
|
va el panel. El escalonado es CSS puro; soma solo decide el <strong>cuándo</strong>. El
|
|
|
selector cambia el prop <code>animation</code>, que el componente
|
|
|
<strong>enruta a las superficies declaradas</strong>
|
|
|
(Panel + Items) como <code>data-animation-style</code> — se nombra una vez, el sistema sabe dónde
|
|
|
(RFC §5).
|
|
|
</p>
|
|
|
<div class="actions">
|
|
|
<div class="picker" role="group" aria-label="Estilo de animación">
|
|
|
{#each STYLES as s (s.value)}
|
|
|
<button
|
|
|
class="chip"
|
|
|
class:on={animStyle === s.value}
|
|
|
type="button"
|
|
|
onclick={() => (animStyle = s.value)}
|
|
|
>
|
|
|
{s.label}
|
|
|
</button>
|
|
|
{/each}
|
|
|
</div>
|
|
|
<button class="btn ghost" type="button" onclick={reversa}>↻ Reversa</button>
|
|
|
<span class="hint"
|
|
|
>Para la animación fluida, abre esta página enfocada en el browser (en preview de fondo el
|
|
|
rAF se throttlea).</span
|
|
|
>
|
|
|
</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>
|
|
|
|
|
|
<!-- The slider-controlled vars live on the stage and INHERIT to the surfaces:
|
|
|
`--cascade-duration` (the transition speed) + `--motion-stagger-each` (rhythm)
|
|
|
+ `--motion-stagger-count` (for the reversed exit). Each item still auto-derives
|
|
|
its own `--motion-stagger-index` (M6). -->
|
|
|
<div
|
|
|
class="stage"
|
|
|
style="--cascade-duration: {duration}ms; --motion-stagger-each: {stagger}ms; --motion-stagger-count: {ITEMS.length}"
|
|
|
>
|
|
|
<Reveal.Provider bind:open animation={animStyle}>
|
|
|
<Reveal.Trigger>
|
|
|
{open ? '▾' : '▸'} Menú de cuenta
|
|
|
</Reveal.Trigger>
|
|
|
<Reveal.Panel class="panel">
|
|
|
{#each ITEMS as label (label)}
|
|
|
<Reveal.Item class="item">{label}</Reveal.Item>
|
|
|
{/each}
|
|
|
</Reveal.Panel>
|
|
|
</Reveal.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;
|
|
|
}
|
|
|
.hint {
|
|
|
font-size: 0.78rem;
|
|
|
color: var(--color-content-muted, #888);
|
|
|
max-width: 42ch;
|
|
|
}
|
|
|
.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.ghost {
|
|
|
background: transparent;
|
|
|
color: var(--color-content-muted, #666);
|
|
|
}
|
|
|
.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 {
|
|
|
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.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: 360px;
|
|
|
}
|
|
|
|
|
|
/* The Reveal surfaces — global because the elements are rendered by the soma
|
|
|
component, not this page's own markup. soma stamps data-starting-style /
|
|
|
data-ending-style; the CSS only reacts (it never decides WHEN). */
|
|
|
:global([data-reveal-trigger]) {
|
|
|
appearance: none;
|
|
|
cursor: pointer;
|
|
|
font: inherit;
|
|
|
font-weight: 600;
|
|
|
padding: 0.5rem 0.9rem;
|
|
|
border-radius: 10px;
|
|
|
border: 1px solid var(--color-border-default, rgba(0, 0, 0, 0.18));
|
|
|
background: var(--color-surface-default, #fff);
|
|
|
color: var(--color-content-primary, #111);
|
|
|
margin-block-end: 0.6rem;
|
|
|
}
|
|
|
|
|
|
:global([data-reveal-panel]) {
|
|
|
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));
|
|
|
transition:
|
|
|
opacity var(--cascade-duration, 0.3s) ease,
|
|
|
transform var(--cascade-duration, 0.3s) ease,
|
|
|
filter var(--cascade-duration, 0.3s) ease;
|
|
|
}
|
|
|
:global([data-reveal-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;
|
|
|
transition:
|
|
|
opacity var(--cascade-duration, 0.3s) ease,
|
|
|
transform var(--cascade-duration, 0.3s) ease,
|
|
|
filter var(--cascade-duration, 0.3s) ease;
|
|
|
/* Canonical eidos stagger tokens (M6): index (per item, auto-derived from the
|
|
|
group order) × each (the rhythm, inherited from the panel). Enter counts UP. */
|
|
|
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
|
|
|
}
|
|
|
/* The CLOSE is the OPEN played in reverse: the exit stagger counts DOWN
|
|
|
(count − 1 − index), so the last item leaves first and the choreography mirrors
|
|
|
the entrance — the inverse the user expects. */
|
|
|
:global([data-reveal-item][data-ending-style]) {
|
|
|
transition-delay: calc(
|
|
|
(var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) *
|
|
|
var(--motion-stagger-each, 0ms)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/* The DX `animation` prop → `data-animation-style` on the surfaces drives the
|
|
|
LOOK; the Presence's data-starting-style / data-ending-style gate WHEN (so the
|
|
|
coordinated cascade is preserved — unlike the built-in data-state presets,
|
|
|
which would fire on mount and break the sequencing). */
|
|
|
:global([data-animation-style='cascade-slide'][data-starting-style]),
|
|
|
:global([data-animation-style='cascade-slide'][data-ending-style]) {
|
|
|
opacity: 0;
|
|
|
transform: translateX(-16px);
|
|
|
}
|
|
|
: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.9);
|
|
|
}
|
|
|
/* `drop` — vertical, distinct from slide's horizontal. Pure transform: nothing
|
|
|
else to wire (stagger + reverse-on-exit are already generic). */
|
|
|
:global([data-animation-style='cascade-drop'][data-starting-style]),
|
|
|
:global([data-animation-style='cascade-drop'][data-ending-style]) {
|
|
|
opacity: 0;
|
|
|
transform: translateY(-14px);
|
|
|
}
|
|
|
/* `blur` — uses `filter`, a property beyond opacity/transform, so it ALSO needs
|
|
|
`filter` in the base transition above (panel + item). */
|
|
|
:global([data-animation-style='cascade-blur'][data-starting-style]),
|
|
|
:global([data-animation-style='cascade-blur'][data-ending-style]) {
|
|
|
opacity: 0;
|
|
|
transform: scale(1.03);
|
|
|
filter: blur(6px);
|
|
|
}
|
|
|
</style>
|