fix(eidos): dos animadores que no se hablaban y un cluster que no envolvia

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 `<Group` ya pasan `align`; de los
que no, tres blocks lo reenvian con default propio y **`ButtonGroup` es el
unico que voltea `Group` a columna en todo el ecosistema** — donde `center`
habria dejado los botones verticales en dientes de sierra. Un solo caso no
justifica un default condicional, asi que la excepcion vive en su call site.

Medido: `hero` a 375x800 pasa a DOS lineas (tops 416,3 / 472,3) y la accion
secundaria de 124px truncados a 156,9px, su etiqueta entera. ButtonGroup
horizontal `attached` sigue `nowrap`; el vertical, `stretch` con los tres hijos
a 301px. El README se corrige entero: negaba `attached` como prop canonico y
listaba `grow` como «gap conocido» cuando ambos llevan tiempo implementados —
describia la era del baseline `air`.

── A-68 + A-93 · dos observadores que no se hablaban ─────────────────────────

`CountUp` trae su propio IntersectionObserver (threshold 0) y `Motion` usa otro
(threshold 0.1, rootMargin −10%). Un IO no mira la opacidad, asi que las cifras
contaban su recorrido entero detras de `opacity: 0`: por scroll lento la
primera aparecia ya al 97,8% de su valor.

⚠️ EL DILEMA QUE PLANTEE ERA FALSO. Dije que escalonar el arranque y aterrizar
juntas eran incompatibles — cierto — y de ahi que exponer el `seen` de `Motion`
rompiera A-69. No se sigue: el retardo del stagger vive en `animation-delay`
(CSS) y los observadores de los cuatro `Motion` disparan en el MISMO instante,
porque los stats estan en una fila, a la misma altura. Gatear en el REVELADO da
arranque conjunto, duraciones iguales y aterrizaje conjunto intacto. Presente
como decision entre dos males algo que tenia una tercera opcion mejor.

Tambien descarto la «alternativa minima» que llegue a recomendar (alinear el
observador de `CountUp` con el de `Motion`): es acoplamiento por copia —
duplica threshold + rootMargin en otro fichero, la deriva que el framework
elimina con builders tipados — y cambia los defaults de `CountUp` para todos
sus usos sueltos.

`Motion` publica ahora su momento visto por contexto (`motion/context.ts`,
calcado de `cascade/context.ts`, que ya exponia `open`), y `stats-band-value`
lo pasa a `startWhen` ANTES del spread de `countOptions`, para que el app pueda
desactivarlo. Cero cambios en `CountUp`, cero observadores propios en el block,
B-6 intacto — la costura era justamente lo que faltaba en canon (A-93).

Medido con Playwright headless, 1280x800. ⚠️ La primera corrida fue INVALIDA y
se descarta: inserto el espaciador DESPUES de cargar, y como `Motion` es
`once: true` su observador ya habia disparado con la banda en pantalla. El
espaciador tiene que existir en el primer pintado (`addInitScript`). 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
- ventana que discrimina (banda a 6px dentro, donde el IO de CountUp dispara y
  el de Motion no): 0/0/0 en la llegada Y tras 2,6 s. Antes, ahi mismo, corrian
  (1144/31/4) y acababan practicamente terminados (12.121/330/47)
- al revelarse, la primera cifra al 0% — antes 97,8%
- A-69 INTACTA: las tres aterrizan a 1781 ms, dispersion 0 ms

RESIDUO, y es peor que mi estimacion: las cifras que el stagger revela despues
llegan al 16,8% (+70 ms) y 35,4% (+210 ms). Estime «≤10%» y la medicion dice
35,4% — el muelle es sobreamortiguado y cubre mucho recorrido al principio. Es
el precio del aterrizaje conjunto que A-69 firmo y no baja sin reabrirla.
Queda documentado, no escondido.

── A-67 · registrada, no ejecutada ──────────────────────────────────────────

Contrastadas contra `engine.ts` y el canon, las tres puertas de su ficha fallan:
el pack no puede distinguir el `collapse` de un intercambio del de un cierre a
secas (mismo evento, mismo tipo de nodo; pediria un `:has()` a mano sobre el
hermano); el arbitro ya implementa la mitad del «on a tie, the most recent» que
un filtro de LLEGADA permite, y la otra mitad exigiria revocar una nota ya
agendada, que `EngineSound` no sabe hacer; y un evento unico rompe los
`commits`, porque cada uno estampa `data-state` sobre SU item.

La puerta correcta no estaba en la lista: un nuance declarado,
`emerge-collapse-swap`. La forma es canonica y el provider lo sabe de forma
determinista en `setValue`. Cae en el eje de sema, asi que NO se ejecuta aqui —
se deja elegida para su sesion.

Puertas: svelte-check 72 errores / 59 avisos = linea base exacta · blocks:check
0 sobre 15 blocks · docs:check 0/0 sobre 629 docs. La suite completa deja 8
rojos en 3 ficheros, todos nombrando waveform / skin-media-player / radio-group
mas un timeout de orca — ninguno en lo tocado aqui. Prettier solo se queja de
ficheros CRLF preexistentes (el diff seria el fichero entero), asi que no se
reformatean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 4caf1100a1
commit d02021aee1

@ -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<FlexProps,'wrap'>` 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<FlexProps,'wrap'>` 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 `<Group` ya pasan `align` explícito; de los que no, los tres blocks lo reenvían con default propio y sólo **`ButtonGroup` voltea `Group` a columna** en todo el ecosistema — donde `align: center` habría dejado los botones verticales en dientes de sierra. Por eso la excepción vive en su call site (`align="stretch"` cuando `orientation="vertical"`) y no como default condicional en `Group`: un caso no justifica una abstracción.
**Medido tras el arreglo** (Chromium, `/uix/components/button-group`): grupo horizontal `attached` → `flex-wrap: nowrap` (el segmentado no se parte); grupo vertical → `align-items: stretch`, los tres hijos a 301px = ancho del grupo, sin dientes de sierra.
### A-98 — CONFIRMADO · `hero` · percepcion · MEDIA
@ -430,6 +442,8 @@ Lo que su README afirma: «`wrap="wrap"` + `align="center"` por defecto», «**D
Alcance del síntoma, medido: `hero` (dos acciones, 315 de 327px a 375px, la segunda etiqueta truncándose — contenido REAL de la demo) y `feature-split` (`.Actions` también compone `Group`; su escenario pone una sola acción, así que el caso no se ejercita — al forzar una segunda, sigue en una línea. ⚠️ el «desborda el viewport» que se afirmó primero era artefacto: el ancho lo puso el texto inventado para el clon).
**ARREGLADA (2026-08-17) — sin tocar `hero`**, como predecía la reescritura: cayó con [A-99]. Medido en `/blocks/hero/preview` a 375×800 tras la puerta de hidratación, seleccionando el `[data-group]` con ≥2 hijos interactivos (el selector bueno, no el que invalidó la primera medición): `flex-wrap: wrap` · `align-items: center` · **2 líneas** (tops 416,3 y 472,3) · sin desbordamiento. Y el dato que cierra el defecto: la acción secundaria pasa de **124px truncados a 156,9px**, su etiqueta entera.
`cta` se salva por accidente: usa `Flex` a mano, no porque `Group` fuera inadecuado.
**Disposición corregida** — sin arreglo block-side. En cuanto `Group` envuelva, los dos blocks se curan solos. Si se quisiera además APILAR en móvil (que no es lo mismo que envolver), eso sí sería block-side y por props: `direction={{ base: 'column', sm: 'row' }}` sobre el propio `Group`.
@ -462,6 +476,8 @@ Alcance del síntoma, medido: `hero` (dos acciones, 315 de 327px a 375px, la seg
**Disposición** — CANON: dar a `Motion` una costura para su momento visto (contexto o callback), espejando `Cascade`. Desbloquea A-68 sin que el block viole B-6.
**ARREGLADA (2026-08-17)** — `src/uix/eidos/components/motion/context.ts`, calcado de `cascade/context.ts`: `Motion` publica `{ readonly seen }` con un getter sobre su `$state`, y `getMotionContext()` sale por el barrel. Devuelve `undefined` fuera de un `Motion`, así que un consumidor suelto no paga nada. La pieza que faltaba en el canon existe ya en las dos formas de animador — el contenedor (`Cascade.open`) y el de un solo elemento.
## Veredictos re-medidos
@ -1427,7 +1443,19 @@ Emisión sema con LAS DOS sondas. (a) MutationObserver ligado a CADA nodo botón
**Evidencia** — Playwright headless sobre http://127.0.0.1:5201/blocks/faq/preview (scripts probe-A-67.mjs y probe-A-67b.mjs en el scratchpad). Puerta de hidratación: espera a `document.getElementById('uix-blocks-display')`, luego a 0 nodos `[data-animation-pending]`, y un ida y vuelta reactivo real (clic en el trigger 0 → `aria-expanded` pasa a `true`). Sonda (a) contadores WebAudio parcheando `AudioContext.prototype.createOscillator/createBufferSource` y `OscillatorNode.prototype.start` ANTES de interactuar; sonda (b) MutationObserver atado a los NODOS `[data-accordion-item]` concretos. Gesto medido = un solo clic en el trigger 1 con el ítem 0 abierto. Números: 4 osciladores arrancados, 0 bufferSources — voz A fundamental 480 Hz + quinta 720 Hz, voz B 600 Hz + 900 Hz; los CUATRO con el mismo `when` de AudioContext (1.4628571428571429 en la corrida b; 1.401904761904762 en la a) y el mismo fin (1.6128571428571428) ⇒ solape del 100 % durante 150 ms. Envolventes: pico 0.06 (voz 480, la de collapse) y 0.08 (voz 600, expand), ambas con rampa de caída al mismo instante. Contornos opuestos confirmados en el detune: owner de 480 Hz `setValueAtTime(+400)` → `linearRampToValueAtTime(-400)` (DESCENDENTE); owner de 600 Hz `setValueAtTime(-400)` → `linearRampToValueAtTime(+400)` (ASCENDENTE). Estampas visuales del MutationObserver en el mismo milisegundo: ítem 0 `data-event=collapse` / `data-event-family=emerge` / `sig-1`, ítem 1 `data-event=expand` / `emerge` / `sig-2`. Control (segundo clic sobre el mismo ítem abierto, un solo cambio de estado): solo 2 osciladores (480 + 720), una rampa 0.06, una estampa `collapse` ⇒ el doblete es específico del cambio de pregunta. Sonido activo por configuración real de la página: `events: { sound: true, haptic: true, components: semaPacks }` en web/routes/blocks/\_lib/BootUix.svelte:62 (y en web/routes/blocks/+layout@.svelte:61), con `dominance` en su default `true` (src/uix/sema/engine.ts:257).
**Disposición** — 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).
**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.

@ -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();
</script>
<Metrics.Value value={count} {...rest}>
{#if count !== undefined}
<CountUp to={count} {...countOptions} />
<CountUp to={count} startWhen={motion?.seen ?? true} {...countOptions} />
{:else}
{@render children?.()}
{/if}

@ -67,6 +67,7 @@
{...rest}
role="group"
direction={orientation === 'vertical' ? 'column' : 'row'}
align={orientation === 'vertical' ? 'stretch' : undefined}
{attached}
grow={block}
inline={!block}

@ -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 (`<div data-box data-flex data-group>`).
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<FlexProps, 'wrap'>`) — 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

@ -1,7 +1,7 @@
<script lang="ts">
/**
* Eidos `<Group>` — inline cluster. Flex row by default, with two
* cluster-specific behaviors:
* Eidos `<Group>` — inline cluster. Flex row with the ACTION-ROW defaults
* (`align: center`, `wrap: wrap`), plus two cluster-specific behaviors:
*
* `grow` — every child stretches to equal width (`flex: 1 1 0`)
* `attached` — adjacent children share an edge: the inner radii are
@ -19,6 +19,7 @@
grow = false,
attached = false,
direction = 'row',
align = 'center',
gap,
class: className,
children,
@ -27,12 +28,21 @@
// `attached` implies `gap=0`; an explicit `gap` prop still wins.
const effectiveGap = $derived(gap !== undefined ? gap : attached ? 0 : undefined);
// A cluster WRAPS: an action row that outgrows its measure breaks a line
// instead of crushing its labels. `wrap` is not a prop (`Omit<FlexProps,'wrap'>`)
// because it is the component's decision, not the consumer's — and `attached`
// is the one case that inverts it: the recipe squares the inner radii and
// pulls a negative margin assuming ONE line, so a wrapped segmented control
// would open its second line with a flat corner and a clipped border.
const effectiveWrap = $derived<'wrap' | 'nowrap'>(attached ? 'nowrap' : 'wrap');
const toBoolAttr = (value: boolean) => (value ? '' : undefined);
</script>
<Flex
{...restProps}
{direction}
{align}
wrap={effectiveWrap}
gap={effectiveGap}
class={className}
data-group=""

@ -59,6 +59,32 @@ themselves). `scope: ['eidos']`, `events: []` in the morfo.
The wrapper writes **no visual var** and runs **no JS animation** — it only sets the state attr
and holds the node during exit. Eidos owns the keyframes.
## The reveal, published to the subtree
`trigger="viewport"` makes the entrance a **moment**, and descendants sometimes have to
synchronise with it. `Motion` publishes it by context:
```svelte
<script>
import { getMotionContext } from '$uix/eidos/components/motion'
// `undefined` outside a <Motion> — then there is no reveal to wait for.
const motion = getMotionContext()
</script>
<CountUp to={12500} startWhen={motion?.seen ?? true} />
```
`seen` is `false` while a viewport animator waits and `true` from the start for every other
trigger. The precedent is `<Cascade>`, 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.
## `<Motion>` vs `motionAttrs`
| | Use | Extra node | Exit |

@ -0,0 +1,28 @@
import { getContext, setContext } from 'svelte';
const KEY = Symbol('motion');
/**
* What `<Motion>` 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. `<Cascade>` 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);
}

@ -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';

@ -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<HTMLElement | null>(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;

Loading…
Cancel
Save

Powered by TurnKey Linux.