You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/temas/animations/reveal/+page.svelte

335 lines
11 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>&lt;Reveal&gt;</code>: su <strong>morfo</strong> declara
<code>panel.animation.children = &#123; enter: 'before', exit: 'after' &#125;</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>

Powered by TurnKey Linux.