From d02021aee103702583b27fcc5a528379a8aa1b25 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 17 Aug 2026 02:32:43 +0200 Subject: [PATCH] fix(eidos): dos animadores que no se hablaban y un cluster que no envolvia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dos hallazgos del contraste doctrinal del tier, ambos de CANON, ambos medidos antes y despues. El tercero (A-67) queda REGISTRADO y sin ejecutar: cae en el eje de sema. ── A-99 · Group no aplicaba NINGUNO de sus dos defaults ────────────────────── Su README los promete en tres sitios («defaults pensados para action rows — `align: center`, `wrap: wrap`»). No pasaba ninguno a `Flex`, que cae a `nowrap` + `stretch`. De ahi que las acciones de `hero` no apilaran a 375px (A-98): 315 de 327px en una linea, con la segunda etiqueta truncandose. ⚠️ CORRECCION A MI PROPIA FICHA. Habia escrito que «el `align` SI se cumple, lo que descarta que el recipe no cargue». Era FALSO: el `center` que medi salia de la demo, que lo pasa explicitamente en su control. `group.css` no declara ni `align-items` ni `flex-wrap` — no habia nada que cargar. Medir un default en una demo que lo pisa no es medir un default. Y de las tres salidas que la ficha proponia, «que lo declare el recipe» es IMPOSIBLE: `flex.svelte` tiene `wrap = 'nowrap'` como default de JS y siempre escribe `--flex-wrap` inline, asi que el fallback de `var(--flex-wrap, nowrap)` no se evalua nunca. Solo ganaria una declaracion dura, que congela el eje. El arreglo usa el mecanismo que el propio fichero ya tenia para `direction` y `effectiveGap`: `align = 'center'` destructurado (sobreescribible — `align` no esta omitido de `FlexProps`) y un `effectiveWrap` interno, `wrap` salvo `attached`, que lo invierte. Espejo exacto de «`attached` implica `gap=0`», y por la misma razon fisica: el recipe cuadra los radios interiores y tira de un margen negativo asumiendo UNA linea, asi que un segmentado envuelto abriria su segunda linea con una esquina plana y el borde recortado. EL CENSO DECIDIO LA FORMA. 77 de 91 usos de ` --- docs/process/AUDIT-blocks-ledger.md | 53 +++++++++++++++++-- .../blocks/stats-band/stats-band-value.svelte | 16 +++++- .../button-group/button-group.svelte | 1 + src/uix/eidos/components/group/README.md | 34 ++++++++---- src/uix/eidos/components/group/group.svelte | 14 ++++- src/uix/eidos/components/motion/README.md | 26 +++++++++ src/uix/eidos/components/motion/context.ts | 28 ++++++++++ src/uix/eidos/components/motion/index.ts | 2 + src/uix/eidos/components/motion/motion.svelte | 11 ++++ 9 files changed, 166 insertions(+), 19 deletions(-) create mode 100644 src/uix/eidos/components/motion/context.ts diff --git a/docs/process/AUDIT-blocks-ledger.md b/docs/process/AUDIT-blocks-ledger.md index 0835be7b6..762b990cd 100644 --- a/docs/process/AUDIT-blocks-ledger.md +++ b/docs/process/AUDIT-blocks-ledger.md @@ -111,7 +111,7 @@ Columna **or.** = de qué sección del documento viejo salió la fila (`C` confi | A-65 | `pricing` | percepcion | MEDIA | SV | Un solo gesto sobre el CTA de un plan produce DOS eventos sema sobre el MISMO elemento a 1 ms: el `commit-select` de la app pisa el… | `web/routes/blocks/pricing/PricingSite.svelte` | CONFIRMADO | | A-66 | `pricing` | percepcion | MEDIA | SV | El plan destacado se grada `fulfill` en lo visual pero su consecuencia suena `affirm`, exactamente igual que los otros dos planes: la lectura… | `web/routes/blocks/pricing/PricingSite.svelte` | REFUTADO | | A-67 | `faq` | percepcion | MEDIA | SV | Un solo clic que cambia de pregunta dispara DOS earcons emerge simultaneos y sin atenuar (collapse descendente + expand ascendente): un gesto, dos… | `src/uix/blocks/faq/faq-list.svelte:14` | CONFIRMADO | -| A-68 | `stats-band` | percepcion | MEDIA | SV | La coreografia de la banda esta desincronizada: la ENTRADA escalona, pero los contadores arrancan todos en el mismo frame, asi que una cifra ya va… | `src/uix/blocks/stats-band/stats-band-value.svelte:20` | CONFIRMADO | +| A-68 | `stats-band` | percepcion | MEDIA | SV | Los contadores corren detras de `opacity: 0` — dos observadores distintos (CountUp threshold 0 · Motion 0.1/-10%) y un IO no mira la opacidad… | `src/uix/blocks/stats-band/stats-band-value.svelte:20` | ARREGLADO | | A-69 | `stats-band` | percepcion | MEDIA | SV | Las cuatro cifras no aterrizan nunca juntas y `duration` no significa segundos: con el valor por defecto (2), la cifra grande tarda 7,9 s en… | `src/uix/eidos/components/count-up/count-up.svelte:169` | ARREGLADO | | A-70 | `feature-grid` | percepcion | BAJA | SV | DATO, NO DEFECTO: `feature-grid` no tiene ningun elemento interactivo — nada que emitir y nada que auditar perceptivamente en el. | `src/uix/blocks/feature-grid/feature-grid.svelte` | REFUTADO | | A-71 | `site-footer` | doctrina | BAJA | SV | La página de demo pinta el block ANTES de su propio `h1`, así que el documento arranca en h2/h3 y el h1 llega el último — jerarquía de encabezados… | `web/routes/blocks/_lib/BlockDemo.svelte` | CONFIRMADO | @@ -136,13 +136,13 @@ Columna **or.** = de qué sección del documento viejo salió la fila (`C` confi | A-90 | `contact` | — | — | R | Vocabulario paralelo: `ContactVerification` re-declara palabra por palabra el `ProofOfHumanStatus` del canon, y lo hace señalando a ese mismo… | — | ARREGLADO | | A-91 | `banner` | doctrina | BAJA | F7 | El block re-expone el eje `intent` de `Banner` —que el canon declara fuera del sistema de color abierto, en su propio typedoc— sin registrarlo en sus Gaps… | `src/uix/blocks/banner/README.md` | ARREGLADO | | A-92 | `pricing` | doctrina | BAJA | F7 | Una restricción estructural del modelo de cascada (sólo los presets CSS participan del stagger) vive en un comentario del block y falta en §D.13 de la doctrina… | `docs/theming/motion.md` | CONFIRMADO | -| A-93 | `stats-band` | doctrina | MEDIA | F7 | `Motion` no expone su momento «visto» (privado + `data-animation-pending`), así que un block no puede sincronizarse con el revelado sin violar B-6… | `src/uix/eidos/components/motion/motion.svelte` | CONFIRMADO | +| A-93 | `stats-band` | doctrina | MEDIA | F7 | `Motion` no expone su momento «visto» (privado + `data-animation-pending`), así que un block no puede sincronizarse con el revelado sin violar B-6… | `src/uix/eidos/components/motion/motion.svelte` | ARREGLADO | | A-94 | `feature-grid · team · testimonials` | doctrina | MEDIA | F7 | Los tres declaran `= AutoGridProps` y ponen `{...rest}` antes de props fijadas: el consumidor tipa `align`/`width` y se descartan en silencio (A-28 ×3)… | `src/uix/blocks/testimonials/types.ts` | ARREGLADO | | A-95 | `banner` | doctrina | MEDIA | F7 | Con `affix="top"` la tira fijada tapa la cabecera pegada y la navegación queda inalcanzable; la pieza que falta es la que posee las alturas de página (`app-shell`)… | `web/routes/blocks/banner/BannerSite.svelte` | CONFIRMADO | | A-96 | `banner` | doctrina | BAJA | F7 | `affixOffset` es prop público sin control vivo en la demo, contra la regla «cada prop público, un control»… | `web/routes/blocks/banner/+page.svelte` | CONFIRMADO | | A-97 | `feature-grid + 6 blocks` | percepcion | MEDIA | F7 | El texto `color="muted"` mide 3,70:1 en claro — bajo AA de cuerpo — en 8 sitios de 7 blocks; el hallazgo estaba enterrado en la ficha de A-70, que es REFUTADO… | `src/uix/blocks/feature-grid/feature-grid-item-text.svelte` | REFUTADO | -| A-98 | `hero` | percepcion | MEDIA | F7 | Las acciones no apilan en móvil: `Group` es row/nowrap y a 375px los dos botones ocupan 315 de 327px; `cta` ya lo resolvió con `Flex` responsive… | `src/uix/blocks/hero/hero.svelte` | CONFIRMADO | -| A-99 | `Group` (canon) | doctrina | MEDIA | F7 | `Group` no aplica el `wrap` que su README promete en tres sitios: no lo pasa a `Flex`, no lo declara el recipe, y `Omit` impide compensarlo… | `src/uix/eidos/components/group/group.svelte` | CONFIRMADO | +| A-98 | `hero` | percepcion | MEDIA | F7 | Las acciones no apilan en móvil: `Group` es row/nowrap y a 375px los dos botones ocupan 315 de 327px. Síntoma de [A-99]; cae sin tocar el block… | `src/uix/blocks/hero/hero.svelte` | ARREGLADO | +| A-99 | `Group` (canon) | doctrina | MEDIA | F7 | `Group` no aplica NINGUNO de los dos defaults de action row que su README promete (`wrap: wrap` y `align: center`): nunca se los pasa a `Flex`, y el recipe no puede suplirlos… | `src/uix/eidos/components/group/group.svelte` | ARREGLADO | ## Reparto @@ -417,6 +417,18 @@ Lo que su README afirma: «`wrap="wrap"` + `align="center"` por defecto», «**D **Disposición** — CANON. O `group.svelte` pasa el `wrap` con su default documentado, o el recipe lo declara, o la documentación deja de prometerlo. Nota de tipo: `Omit` fue deliberado (el wrap era una decisión del componente, no del consumidor) — coherente con un default fijo, incoherente con que ese default no exista. +**CORREGIDA (2026-08-17) — dos errores de esta ficha, y una de sus tres salidas es imposible.** + +1. ⚠️ **«El `align` SÍ se cumple, lo que descarta que el recipe no cargue» era FALSO.** El `align-items: center` que medí salía de la demo, que lo pasa explícitamente (`web/routes/uix/components/group/+page.svelte:15` inicializa su control a `'center'`); su propio generador de snippet trata `stretch` como el default real (línea 84). `group.css` no declara **ni** `align-items` **ni** `flex-wrap`: no había nada que cargar. La lección se repite — **medir un default en una demo que lo pisa no mide un default.** +2. **El hallazgo infracontaba.** Faltaban los DOS defaults que el README promete, no uno. Y el README estaba rancio en las dos direcciones: negaba `attached` como prop canónico y listaba `grow` como «No — gap conocido», cuando ambos llevan implementados desde hace tiempo. Describía la era del baseline `air`. +3. **La salida «que el recipe lo declare» es arquitectónicamente imposible.** `flex.svelte:21` tiene `wrap = 'nowrap'` como default de JS y SIEMPRE escribe `--flex-wrap` inline vía `pushStyleVar`, así que el fallback de `var(--flex-wrap, nowrap)` no se evalúa nunca. Un recipe sólo ganaría con una declaración dura, que congelaría el eje. (Para `align` sí sería viable — `pushStyleVar` ignora `undefined` — pero mezclar las dos vías por el mismo par de defaults sería incoherente.) + +**ARREGLADA (2026-08-17)** — en `group.svelte`, con el mecanismo que el propio fichero ya usaba para `direction` y `effectiveGap`: `align = 'center'` como default destructurado (sobreescribible: `FlexProps.align` no está omitido) y `effectiveWrap` interno, `'wrap'` salvo `attached`, que lo invierte a `'nowrap'` — el recipe cuadra radios interiores y tira de un margen negativo asumiendo UNA línea, así que un segmentado envuelto abriría su segunda línea con esquina plana. Espejo exacto de «`attached` implica `gap=0`». + +**El censo que decidió la forma**: 77 de 91 usos de `` estricto; (3) soma — src/uix/soma/components/accordion/accordion-provider.svelte.ts:119-120, emitir un único evento para el intercambio en modo `single` en lugar de collapse+expand (esta opción cambia el contrato del morfo, así que es la más cara). Severidad: la declarada MEDIA es razonable como problema de legibilidad, no de volumen (la suma 0.06+0.08 = 0.14 queda por debajo de un solo `emerge.strong` = 0.15). +**DISPOSICIÓN CORREGIDA (2026-08-17) — APLAZADA al eje de sema, con las tres puertas de abajo descartadas y una cuarta que ninguna nombraba.** + +Contrastadas contra `engine.ts`, `CANON.md` §8 y `sema.md`, las tres opciones originales fallan: + +1. **Tuñar el par en el pack** — inimplementable como está escrito. Una regla de cascada matchea los `data-event-*` del ítem estampado y **no puede distinguir** el `collapse` de un intercambio del `collapse` de un cierre a secas: son el mismo evento sobre el mismo tipo de nodo. Distinguirlos pediría un `:has()` sobre el hermano, manuscrito y dependiente del orden de emisión — dos violaciones (selector a mano + acoplamiento al orden). +2. **Resolver el empate en `applyDominance`** — malinterpreta al árbitro. El `>` estricto YA implementa la mitad del «on a tie, the most recent» que un filtro de LLEGADA puede implementar: el recién llegado no se mutea, o sea el más reciente gana. La otra mitad — atenuar al anterior — exige revocar una nota ya agendada, y `EngineSound` no tiene ducking ni handle de revocación. Tocaría la librería de sonido y cambiaría la conducta de TODO empate del ecosistema por el caso de un componente. +3. **Un solo evento para el intercambio** — rompe el modelo de commits: cada evento lleva su `commits` (`data-state` open/closed) sobre SU ítem, y un evento único no puede estampar dos estados en dos nodos. + +**La puerta correcta: un evento con NUANCE declarado — `emerge-collapse-swap`.** La forma es canónica (`{family}-{verb}[-{nuance}]`, ejemplo vivo `emerge-dismiss-outside`) y el provider lo sabe de forma determinista en `setValue`: `closing` y `opening` no vacíos a la vez sólo ocurre en el intercambio de `type='single'`. Con eso el pack lo NOMBRA (`SILENT`, o algo sub-audible) → un gesto, un sonido, que es justo lo que la dominancia del libro pediría; los dos estampados VISUALES se quedan (dos superficies distintas, sin conflicto — el aire es lo único que es uno); eidos no se entera, porque sus selectores por prefijo (`[data-event^='emerge-collapse']`) siguen matcheando el nuance; y el engine y `$sound` no se tocan. + +Coste: morfo + provider + pack. Cae en el eje de sema, así que **no se ejecuta aquí** — se ejecuta en una sesión suya, con esta puerta ya elegida. + +**Disposición original (superada)** — Canon, no block: en src/uix/blocks/faq no hay nada que tocar (solo elige `type='single'`, que es el default del Accordion canónico). Tres sitios posibles, por orden de preferencia: (1) pack sema del accordion — src/uix/sema/components/accordion.ts, dar al par swap una tuña propia (p. ej. que el `collapse` coincidente baje a `emerge.exit.soft`/gain ~0.02 o se calle, dejando el `expand` como único signo del gesto); (2) árbitro de dominancia — src/uix/sema/engine.ts:559-593, resolver el empate del mismo tick según docs/CANON.md:253-254 («on a tie, the most recent») atenuando la ocurrencia estructural anterior en vez de exigir `>` estricto; (3) soma — src/uix/soma/components/accordion/accordion-provider.svelte.ts:119-120, emitir un único evento para el intercambio en modo `single` en lugar de collapse+expand (esta opción cambia el contrato del morfo, así que es la más cara). Severidad: la declarada MEDIA es razonable como problema de legibilidad, no de volumen (la suma 0.06+0.08 = 0.14 queda por debajo de un solo `emerge.strong` = 0.15). ### A-68 — CONFIRMADO · `stats-band` · percepcion · MEDIA @@ -1438,6 +1466,21 @@ Emisión sema con LAS DOS sondas. (a) MutationObserver ligado a CADA nodo botón **Disposición** — La costura es del block (`src/uix/blocks/stats-band/stats-band-value.svelte:22` compone `CountUp` sin gate; `stats-band-stat.svelte:18` pone el `Motion`), pero el arreglo necesita canon: que `src/uix/eidos/components/motion/motion.svelte` exponga su momento de revelado (un `onEnter` o el `seen` como parámetro del snippet, hoy privado en la línea 42) y, si se quiere sincronía exacta, el retardo de stagger efectivo — que hoy sólo vive en CSS (`motion.css:41-43` + `render-css.ts:1118`). Con eso, `StatsBand.Value` alimenta el `startWhen`/`delay` que `CountUp` ya acepta (`count-up.svelte:31,34`) y la cifra empieza a contar cuando aterriza, no antes. Alternativa mínima en canon sin tocar el block: alinear el observador de `CountUp` con el de `Motion` (mismo threshold/rootMargin) — corrige el caso del scroll lento pero NO el desfase de 210 ms del stagger. +**⚠️ EL DILEMA ERA FALSO (2026-08-17), y lo planteé yo.** «Escalonar el arranque y aterrizar juntas son incompatibles» es cierto, pero **exponer el `seen` de `Motion` no implica escalonar nada**: el retardo del stagger vive en `animation-delay` (CSS), mientras que los observadores de los cuatro `Motion` de la banda disparan en el MISMO instante — los stats están en una fila, a la misma altura. Gatear los contadores en el REVELADO da arranque conjunto → duraciones iguales (λ derivada, A-69) → aterrizaje conjunto intacto. Se presentó como una decisión entre dos males y no lo era: la opción buena estaba fuera de la lista. + +Corolario sobre la alternativa «mínima» que llegué a recomendar (alinear el observador de `CountUp` con el de `Motion`): es **acoplamiento por copia** — duplica `threshold` + `rootMargin` en otro fichero, la clase exacta de deriva que el framework elimina con builders tipados, y cambia los defaults de `CountUp` para todos sus usos sueltos. Descartada. + +**ARREGLADA (2026-08-17)** — `stats-band-value.svelte` lee `getMotionContext()` (la costura de [A-93]) y pasa `startWhen={motion?.seen ?? true}` a `CountUp`, ANTES del spread de `countOptions`, para que el app pueda desactivar el gate explícitamente. Cero cambios en `CountUp`, cero observadores propios en el block, B-6 intacto. + +**Medido tras el arreglo** — Playwright headless, 1280×800, sonda `probe-A68-after.mjs`. ⚠️ **La primera corrida fue INVÁLIDA y se descarta**: insertó el espaciador DESPUÉS de cargar, y como `Motion` es `once: true` su observador ya había disparado con la banda en pantalla (`pending: 0`, opacidades a 1). El espaciador tiene que existir en el primer pintado — se instala por `addInitScript` con una hoja de estilo. Segunda corrida, con la banda nacida bajo el pliegue: + +- **Fuera de pantalla, 2,6 s**: `pending: 4`, opacidades `[0,0,0,0]`, contadores `0 / 0 / 0`, sin moverse. +- **La ventana que discrimina** — borde superior de la banda a **6 px dentro** del viewport, donde el IO de `CountUp` (threshold 0) dispara y el de `Motion` (0.1 / −10 %) no: contadores a `0 / 0 / 0` en la llegada **y** tras 2,6 s. Antes, en esa misma ventana, corrían (1144 / 31 / 4) y acababan prácticamente terminados (12.121 / 330 / 47). +- **Al revelarse**: la primera cifra aparece al **0 %** de su valor (antes: **97,8 %**). +- **A-69 intacta**: las tres aterrizan en t = 1781 ms, **dispersión 0 ms**. + +**Residuo, honesto y peor que mi estimación** — las cifras que el stagger revela más tarde ya llevan camino andado cuando se hacen visibles: 16,8 % la segunda (+70 ms) y **35,4 %** la tercera (+210 ms). Estimé «≤10 %» y la medición dice 35,4 %: el muelle es sobreamortiguado y cubre mucho recorrido al principio, así que 210 ms pesan bastante más que su fracción de los 2 s. Es el precio del aterrizaje conjunto que A-69 firmó, y no se puede bajar sin reabrirla — retrasar cada arranque por su índice devolvería el escalonado. Queda documentado, no escondido. + **Disposición corregida (2026-08-10, contraste doctrinal)** — **No es arreglable desde el block sin romper el contrato B.** `theming/motion.md` §D.11 dice que «the children's timing is per-component realization», así que sincronizar contador y revelado SÍ es trabajo del block; pero `Motion` guarda su `seen` en `$state` privado y lo único observable es el `data-animation-pending` del DOM, y leerlo exigiría que el block monte un observador propio — **exactamente lo que B-6 prohíbe** («needing to observe something is the admission rule firing»). La pieza que falta tiene nombre: `Motion` necesita exponer el momento visto, como `Cascade` ya lo expone por contexto. ⚠️ Y hay un conflicto que hay que FIRMAR antes de tocar nada: A-69 acaba de fijar en el canon que la banda **aterrice junta** («by construction», medido: 2005 ms × 3, desfase 0), y arrancar cada cifra con su retardo de entrada rompería ese aterrizaje. Escalonar el arranque y aterrizar juntas son incompatibles. diff --git a/src/uix/blocks/stats-band/stats-band-value.svelte b/src/uix/blocks/stats-band/stats-band-value.svelte index b5a06b73d..bc317953e 100644 --- a/src/uix/blocks/stats-band/stats-band-value.svelte +++ b/src/uix/blocks/stats-band/stats-band-value.svelte @@ -9,17 +9,31 @@ * * Without `count`, the app renders the value itself through `children` (already * formatted, or composed with `FormatNumber`). The block never formats. + * + * The count is GATED on the `Stat`'s reveal (`Motion`'s `seen`, read from + * context) because the two ride different observers: `CountUp` watches its own + * span at threshold 0, `Motion` its wrapper at 0.1 with a −10% bottom margin, + * and an IntersectionObserver does not care about opacity. Ungated, a figure + * entering by slow scroll counted its whole travel behind `opacity: 0` and + * landed at 97.8% of its value before anybody could see it move (A-68). + * Gating on the reveal — not on a per-stat delay — is what keeps A-69's joint + * landing intact: the counters still start together and still share a duration. */ import { Metrics } from '$uix/eidos/components/metrics'; import { CountUp } from '$uix/eidos/components/count-up'; + import { getMotionContext } from '$uix/eidos/components/motion'; import type { StatsBandValueProps } from './types'; let { count, countOptions, children, ...rest }: StatsBandValueProps = $props(); + + // `undefined` outside a `Motion` (a `Value` used on its own) — then there is + // no reveal to wait for and the count keeps its own viewport trigger. + const motion = getMotionContext(); {#if count !== undefined} - + {:else} {@render children?.()} {/if} diff --git a/src/uix/eidos/components/button-group/button-group.svelte b/src/uix/eidos/components/button-group/button-group.svelte index fdccdeca2..94185c5a5 100644 --- a/src/uix/eidos/components/button-group/button-group.svelte +++ b/src/uix/eidos/components/button-group/button-group.svelte @@ -67,6 +67,7 @@ {...rest} role="group" direction={orientation === 'vertical' ? 'column' : 'row'} + align={orientation === 'vertical' ? 'stretch' : undefined} {attached} grow={block} inline={!block} diff --git a/src/uix/eidos/components/group/README.md b/src/uix/eidos/components/group/README.md index f1eaa7996..0e6985654 100644 --- a/src/uix/eidos/components/group/README.md +++ b/src/uix/eidos/components/group/README.md @@ -18,8 +18,10 @@ acciones, listas de tags, conjuntos de chips. Origen: `air/components/layout/group`. Adaptación estándar. Composición: a través de Flex (`
`). -El recipe sólo aplica defaults distintos a los de Flex puro -(`align: center`, `flex-wrap: wrap`). +Los defaults distintos a los de Flex puro (`align: center`, `wrap: wrap`) los +pone el **componente**, no el recipe: `Flex` escribe `--flex-wrap` inline +siempre (su default JS es `nowrap`), así que el fallback CSS nunca se evalúa y +una regla del recipe no podría ganarle sin congelar el eje. ## Comparativa @@ -27,19 +29,28 @@ El recipe sólo aplica defaults distintos a los de Flex puro | --- | --- | --- | --- | --- | | Row con wrap por defecto | Sí | Sí | No (HStack no wrap) | Vía Flex | | `gap` / `align` / `justify` | Sí | Sí | Sí | Sí | -| `grow` boolean (hijos llenan ancho equitativamente) | **No** — gap conocido | Sí | No | — | +| `grow` boolean (hijos llenan ancho equitativamente) | Sí | Sí | No | — | | `preventGrowOverflow` | **No** — gap conocido | Sí | — | — | -| `attached` (cero gap + radios continuos) | Vía CSS local | No | No | — | +| `attached` (cero gap + radios continuos) | **Sí** (prop) | No | No | — | ## Decisiones - **Defaults pensados para action rows** — `align: center`, `wrap: wrap`, cero margen. Match Mantine Group. -- **Mantine `grow`** — útil para distribuir hijos equitativamente. Sin - añadirlo todavía; backlog. -- **`attached` no es un prop de Group canon** — el demo lo expone para - ilustrar el patrón "buttons attached" pero en producción se hace con - CSS local en el container que envuelve los buttons. +- **`wrap` no es un prop** (`Omit`) — envolver es la + decisión del componente, no del consumidor: un cluster que no envuelve deja + de ser un cluster. `attached` es la única excepción y la invierte a `nowrap`, + porque el recipe cuadra los radios interiores y solapa bordes asumiendo UNA + línea; un segmentado envuelto abriría su segunda línea con una esquina plana. +- **`grow` y `attached` son props canónicos** — ambos implementados + (`data-grow` → `flex: 1 1 0`; `data-attached` → radios interiores cuadrados + + solape de 1px, con `gap: 0` implícito y consciente del eje vía + `data-direction`). `ButtonGroup` los consume. +- **La columna no lleva los defaults de fila.** `direction="column"` convierte + el cluster en una pila, donde `align: center` dejaría los hijos en dientes de + sierra; el único consumidor vertical (`ButtonGroup orientation="vertical"`) + pasa `align="stretch"` en su llamada. Como es UN caso, la excepción vive ahí + y no como default condicional aquí. ## Eventos Sema @@ -49,10 +60,11 @@ El recipe sólo aplica defaults distintos a los de Flex puro | Gap | Disposición | Detalle | | --- | --- | --- | -| `grow` boolean (children fill equally) | **implementar** | Patrón Mantine. Backlog Layout fixes prioritario. | +| `grow` boolean (children fill equally) | ✅ **implementado** | Patrón Mantine. `data-grow` → `flex: 1 1 0` + `min-width: 0`. | | `preventGrowOverflow` boolean | **diferir** | Refinamiento de `grow`. Sólo aplica cuando `grow` está activo. | -| `attached` como prop canónico | **diferir** | Hoy se hace con CSS local. Si emerge un patrón estable, promover. | +| `attached` como prop canónico | ✅ **implementado** | Prop + recipe consciente del eje. Lo consume `ButtonGroup`. | | Slot `divider` | **diferir** | Mismo backlog que Stack. | +| `align` en columna | **por el call site** | Un solo consumidor vertical; ver Decisiones. | ## Referencias diff --git a/src/uix/eidos/components/group/group.svelte b/src/uix/eidos/components/group/group.svelte index ecfe4e048..429daac75 100644 --- a/src/uix/eidos/components/group/group.svelte +++ b/src/uix/eidos/components/group/group.svelte @@ -1,7 +1,7 @@ + import { getMotionContext } from '$uix/eidos/components/motion' + // `undefined` outside a — then there is no reveal to wait for. + const motion = getMotionContext() + + + +``` + +`seen` is `false` while a viewport animator waits and `true` from the start for every other +trigger. The precedent is ``, which already published `open` to its items; this is the +same seam on the single-element animator. + +**Why it is a seam and not an observer.** Its only other trace is `data-animation-pending` in the +DOM, so a consumer that needed the moment had to watch the wrapper from outside — which is what +the blocks contract (B-6) forbids, and what left `stats-band`'s counters running behind +`opacity: 0` (ledger A-68 / A-93). An IntersectionObserver does not look at opacity, so two +animators with different thresholds disagree silently; the context is what lets the inner one +defer to the outer one instead of guessing. + ## `` vs `motionAttrs` | | Use | Extra node | Exit | diff --git a/src/uix/eidos/components/motion/context.ts b/src/uix/eidos/components/motion/context.ts new file mode 100644 index 000000000..b2aa25c21 --- /dev/null +++ b/src/uix/eidos/components/motion/context.ts @@ -0,0 +1,28 @@ +import { getContext, setContext } from 'svelte'; + +const KEY = Symbol('motion'); + +/** + * What `` publishes to its subtree: whether its entrance has fired. A + * `trigger='viewport'` wrapper holds it `false` until the element is comfortably + * inside the viewport (its own `rootMargin` / `threshold`); every other trigger + * reports `true` from the start. + * + * It exists because the reveal is a MOMENT descendants need to synchronise with + * — a counter that must not run while its figure is still at `opacity: 0` — and + * the wrapper's only observable trace was `data-animation-pending` in the DOM, + * so a consumer had to mount an observer of its own. `` already + * published its equivalent (`open`); this is the same seam on the single-element + * animator. + */ +export type MotionContext = { + readonly seen: boolean; +}; + +export function setMotionContext(ctx: MotionContext): void { + setContext(KEY, ctx); +} + +export function getMotionContext(): MotionContext | undefined { + return getContext(KEY); +} diff --git a/src/uix/eidos/components/motion/index.ts b/src/uix/eidos/components/motion/index.ts index 32e231eb2..9fa1790ba 100644 --- a/src/uix/eidos/components/motion/index.ts +++ b/src/uix/eidos/components/motion/index.ts @@ -13,4 +13,6 @@ import Motion from './motion.svelte'; export { Motion }; export default Motion; +export { getMotionContext } from './context'; +export type { MotionContext } from './context'; export type { MotionProps } from './types'; diff --git a/src/uix/eidos/components/motion/motion.svelte b/src/uix/eidos/components/motion/motion.svelte index f2863076b..630f82597 100644 --- a/src/uix/eidos/components/motion/motion.svelte +++ b/src/uix/eidos/components/motion/motion.svelte @@ -18,6 +18,7 @@ import { tick } from 'svelte'; import { ActiveEidos, motionAttrs } from '$uix/eidos'; import { composeInlineStyle } from '$uix/eidos/lib/style'; + import { setMotionContext } from './context'; import type { MotionProps } from './types'; let { @@ -41,6 +42,16 @@ let node = $state(null); let seen = $state(trigger !== 'viewport'); + // Publish the reveal so the subtree can synchronise with it (a `CountUp` that + // must not run while its figure is still invisible). Read via + // `getMotionContext()`; the alternative was observing `data-animation-pending` + // from outside, which is the seam blocks are forbidden to grow themselves. + setMotionContext({ + get seen() { + return seen; + } + }); + $effect(() => { if (trigger !== 'viewport') return; const el = node;