|
|
|
|
|
<script lang="ts">
|
|
|
|
|
|
/**
|
|
|
|
|
|
* PresenceGroup — práctica.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The motion service's presence coordination (RFC: eidos/MOTION_SERVICE_RFC.md
|
|
|
|
|
|
* §7–§9), shown in isolation. Each card is a coordinated list (an owner
|
|
|
|
|
|
* container + its child items) wired through the real soma `PresenceGroup` +
|
|
|
|
|
|
* `Presence`, one per `when` mode. Discovery is by Svelte context (§7.1); the
|
|
|
|
|
|
* visual stagger is pure CSS; soma only sequences WHEN each surface enters /
|
|
|
|
|
|
* exits / unmounts — never the look. Inherits the `ActiveUix + Soma + Eidos`
|
|
|
|
|
|
* scope from `../+layout.svelte`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import CoordList from './coord-list.svelte';
|
|
|
|
|
|
|
|
|
|
|
|
type Phase = 'before' | 'after' | 'together';
|
|
|
|
|
|
type Mode = { id: string; enter: Phase; exit: Phase; badge: string; desc: string };
|
|
|
|
|
|
const MODES: Mode[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'bracket',
|
|
|
|
|
|
enter: 'before',
|
|
|
|
|
|
exit: 'after',
|
|
|
|
|
|
badge: "enter:'before' · exit:'after'",
|
|
|
|
|
|
desc: 'El contenedor BRACKETEA a sus hijos: entra antes que ellos y sale después (reteniendo su DOM hasta que terminan). El caso natural de un contenedor — justo lo que un when simétrico no podía expresar.'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'exit-heavy',
|
|
|
|
|
|
enter: 'together',
|
|
|
|
|
|
exit: 'after',
|
|
|
|
|
|
badge: "enter:'together' · exit:'after'",
|
|
|
|
|
|
desc: 'Entran a la vez; al cerrar, los hijos salen en stagger y el contenedor aguanta hasta que su salida agrega. La lista exit-heavy de la RFC §13.'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'together',
|
|
|
|
|
|
enter: 'together',
|
|
|
|
|
|
exit: 'together',
|
|
|
|
|
|
badge: "enter/exit:'together'",
|
|
|
|
|
|
desc: 'Todo en paralelo; el escalonado visual lo pone solo el CSS (animation-delay × índice). soma agrega el finished del árbol.'
|
|
|
|
|
|
}
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const open = $state<Record<string, boolean>>({
|
|
|
|
|
|
bracket: false,
|
|
|
|
|
|
'exit-heavy': false,
|
|
|
|
|
|
together: false
|
|
|
|
|
|
});
|
|
|
|
|
|
const toggleAll = () => {
|
|
|
|
|
|
const next = !MODES.every((m) => open[m.id]);
|
|
|
|
|
|
for (const m of MODES) open[m.id] = next;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Reversa demo (RFC §8.3): flip the card, then flip it BACK mid-animation (the
|
|
|
|
|
|
// transitions are ~300ms). The group's generation token must keep the surfaces
|
|
|
|
|
|
// mounted through the reversal — re-opening mid-exit must NOT unmount→remount.
|
|
|
|
|
|
const reversa = (id: string) => {
|
|
|
|
|
|
const back = open[id];
|
|
|
|
|
|
open[id] = !back;
|
|
|
|
|
|
setTimeout(() => (open[id] = back), 150);
|
|
|
|
|
|
};
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<svelte:head>
|
|
|
|
|
|
<title>PresenceGroup · práctica</title>
|
|
|
|
|
|
</svelte:head>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="root">
|
|
|
|
|
|
<header>
|
|
|
|
|
|
<a class="back" href="/temas/animations">← Motion</a>
|
|
|
|
|
|
<h1>PresenceGroup <small>coordinación de presencia</small></h1>
|
|
|
|
|
|
<p class="lede">
|
|
|
|
|
|
La capa de coordinación del servicio de motion, en aislado. Cada tarjeta es una lista
|
|
|
|
|
|
coordinada —un contenedor <strong>owner</strong> y sus ítems <strong>child</strong>— cableada
|
|
|
|
|
|
por el <code>PresenceGroup</code> + <code>Presence</code> reales de soma, uno por modo
|
|
|
|
|
|
<code>when</code>. Los ítems descubren el grupo por <strong>context</strong> (§7.1); el
|
|
|
|
|
|
stagger es CSS puro; soma solo decide <strong>cuándo</strong> entra / sale / se desmonta cada
|
|
|
|
|
|
superficie. Fíjate en <code>after</code>: al cerrar, los ítems salen y el contenedor
|
|
|
|
|
|
<strong>aguanta</strong> hasta que terminan. Y <strong>↻ Reversa</strong> reabre a media
|
|
|
|
|
|
salida (§8.3): el token de generación del grupo no desmonta lo que vuelve —
|
|
|
|
|
|
<strong>sin parpadeo</strong>.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div class="actions">
|
|
|
|
|
|
<button class="btn primary" type="button" onclick={toggleAll}>Abrir / cerrar todas</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>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="grid">
|
|
|
|
|
|
{#each MODES as m (m.id)}
|
|
|
|
|
|
<section class="card">
|
|
|
|
|
|
<div class="card-head">
|
|
|
|
|
|
<code class="badge">{m.badge}</code>
|
|
|
|
|
|
<div class="card-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn ghost"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="Reversa"
|
|
|
|
|
|
title="Reversa: flip + flip back mid-animation (§8.3)"
|
|
|
|
|
|
onclick={() => reversa(m.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
↻
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="btn" type="button" onclick={() => (open[m.id] = !open[m.id])}>
|
|
|
|
|
|
{open[m.id] ? 'Cerrar' : 'Abrir'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="desc">{m.desc}</p>
|
|
|
|
|
|
<div class="stage">
|
|
|
|
|
|
<CoordList enter={m.enter} exit={m.exit} open={open[m.id]} count={5} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</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);
|
|
|
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
.grid {
|
|
|
|
|
|
max-width: 880px;
|
|
|
|
|
|
margin-inline: auto;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
padding: 1.25rem;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
background: var(--color-surface-raised, #f7f7f8);
|
|
|
|
|
|
border: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.1));
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.btn.ghost {
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: var(--color-content-muted, #666);
|
|
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
padding: 0.4rem 0.55rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.badge {
|
|
|
|
|
|
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
padding: 0.15em 0.5em;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
background: color-mix(in srgb, var(--color-primary-solid, #4f46e5) 12%, transparent);
|
|
|
|
|
|
color: var(--color-primary-text, var(--color-primary-solid, #4f46e5));
|
|
|
|
|
|
}
|
|
|
|
|
|
.desc {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
color: var(--color-content-secondary, #555);
|
|
|
|
|
|
}
|
|
|
|
|
|
.stage {
|
|
|
|
|
|
min-height: 220px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
padding: 0.75rem;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
border: 1px dashed var(--color-border-subtle, rgba(0, 0, 0, 0.12));
|
|
|
|
|
|
background: var(--color-surface-default, #fff);
|
|
|
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
.btn:hover {
|
|
|
|
|
|
border-color: var(--color-primary-solid, #4f46e5);
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|