feat(eidos): `Reveal` + `Backdrop` + cascada por viewport — la capa de acabado
Auditoría del tier (docs/process/PLAN-blocks-quality.md): los 7 blocks de F2
componían EXCLUSIVAMENTE la capa de layout (Box/Stack/Grid/Section/Text/Card).
Uso de la capa expresiva del framework: cero. Ni un Motion, ni un Cascade, ni un
Display, ni un TextGradient, ni un CountUp.
La causa es estructural, no de gusto: el contrato B prohíbe que un block traiga
`.css`, así que **un block solo puede ser tan expresivo como los componentes del
canon que compone** — y faltaban dos primitivas. El arreglo no es maquillar 14
blocks uno a uno, es construir lo que falta en el canon y componerlo.
- **`Reveal`** — reproduce la ENTRADA de un preset al entrar en viewport. No
posee keyframes ni timing: estampa `data-animation-style` + `data-state='open'`
cuando dispara el observador, así que reduced-motion degrada DENTRO del preset.
Mientras espera, el recipe lo mantiene invisible bajo `@media (scripting:
enabled)`: sin JS la regla no aplica y el contenido se ve siempre — el fallo de
una animación de scroll debe ser «sin animación», nunca «página en blanco».
- **Escalonado gratis**: el foundation ya escribe `--motion-stagger-index` en
cada hijo de `[data-stagger]` (por `:nth-child`, sin JS) y los presets retrasan
por `índice × --motion-stagger-each`. El recipe de Reveal le da ritmo por
defecto, así que una lista se escalona poniendo `data-stagger` en SU
contenedor: cero delays a mano, cero números en el consumidor.
- **`Cascade trigger="viewport"`** — la cascada existente esperaba a `open`
(disclosure); ahora puede esperar también a ser vista.
- **`Backdrop`** — la capa de profundidad que no existía: `glow` · `mesh` ·
`grid` · `dots`, pintada en un `::before` desde tokens (acento por el forward
de paleta, mesh desde `--gradient-aurora`), con `fade` para no cortar en seco.
**El hero como prueba del nuevo listón**: `Display` (el primitivo de tipografía
de hero que existía y no usaba — el titular pasa de `Heading` a 80px reales),
entrada escalonada de eyebrow/título/subtítulo/acciones, media con `scale-fade`
y `decor` (glow por defecto) expuesto como control vivo en la demo.
Verificado con Playwright: glow pintado desde el token de paleta, 4 Reveal con
índices estructurales 0·1·2·3 a 70ms, titular H1 a 80px, y el patrón `grid` con
su máscara. `blocks:check` verde (7 blocks) · `vitest src/uix/eidos` 361/361 ·
`svelte-check` sin errores propios.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
# PLAN — Subir el listón de los blocks (análisis comparativo + plan de mejora)
> Encargo del usuario (2026-07-24): *«todos los bloques hechos hasta ahora son
> simplones… no hay animaciones, no hay disposiciones o diseños que los hagan
> sobresalir… la peor versión de todos los que se analizaron»*. Este documento
> audita los 7 blocks hechos contra las referencias del dossier
> (`RESEARCH-blocks-references.md`) **y contra el propio framework**, y fija el
> plan para que cada block gane la comparación en vez de empatarla.
---
## 1. Diagnóstico: qué compone realmente cada block (dato, no opinión)
Auditoría de imports reales en `src/uix/blocks/*/*.svelte` :
| Block | Componentes que compone |
|---|---|
| site-header | box · container · drawer · group · sticky |
| hero | box · container · grid · group · **heading** · section · stack · text |
| feature-grid | auto-grid · box · container · heading · section · stack · surface · text |
| feature-split | box · container · grid · group · heading · icon · section · stack · text |
| pricing | auto-grid · box · card · container · group · heading · icon · section · stack · text · toggle-group |
| testimonials | auto-grid · box · card · container · group · section · stack · text |
| faq | accordion · box · container · section · stack |
**Los 7 componen EXCLUSIVAMENTE la capa de layout** (`Box`/`Stack`/`Grid`/
`Group` /`Section`/`Container`) + tipografía base (`Text`/`Heading`) + `Card` /
`Surface` , más el componente de comportamiento que cada uno necesita
(`Drawer`, `Sticky` , `Accordion` , `ToggleGroup` ).
**Uso de la capa expresiva del framework: CERO.** Ni un `Motion` , ni un
`Cascade` , ni un `Display` , ni un `TextGradient` , ni un `CountUp` , ni un `Aura` .
### El framework SÍ tiene esa capa — y no la usamos
| Componente del canon | Qué hace | Dónde debería estar |
|---|---|---|
| ** `Display` ** | *«eidos single-component * *hero typography** primitive»* | **el hero** (usé `Heading` ) |
| ** `Cascade` ** | escalona (stagger) la entrada/salida de sus hijos | feature-grid, testimonials, pricing, stats |
| ** `Motion` ** | animador de un elemento con presets (`scale-fade`, `slide-fade` , `spring-pop` …) | entradas de cualquier pieza |
| ** `TextGradient` ** | degradado animado sobre texto real; acepta `colors="aurora"` | titulares de hero/CTA |
| ** `CountUp` ** | cuenta con spring **al entrar en viewport** , locale-aware, respeta reduced-motion | stats-band, pricing, métricas |
| ** `Mockup` ** | cromo de navegador/teléfono (lo construí en F2.3b) | hero, feature-split, testimonials |
| ** `Aura` ** | escena ambiental | fondos de sección |
| `TextBlur` · `TextFocus` · `TextScramble` · `TextCircular` | familia de efectos de texto | acentos puntuales |
| `Carousel` · `Timeline` · `Metrics` · `Image` | — | testimonials, stats, content |
### Por qué salieron planos: la causa es ESTRUCTURAL, no de gusto
El contrato B prohíbe que un block traiga `.css` propio (D-BLK.2). Es una regla
buena — mantiene el tier componiendo en vez de pintando. Pero implica una cosa
que no vi a tiempo:
> **Un block solo puede ser tan expresivo como los componentes del canon que
> compone.**
Con solo primitivas de layout disponibles y **sin primitiva de scroll-reveal ni
de fondo decorativo**, el resultado plano era el único posible. Mi error no fue
"no decorar los blocks": fue **no detectar que faltaban primitivas en el canon y
construirlas** (como sí hice con `Mockup` ), y dar por hecho un block que compone
bien pero no gana la comparación.
**Corolario que dirige el plan**: la mejora NO es maquillar 14 blocks uno a uno.
Es **construir 2– 3 primitivas que faltan y aplicarlas sistemáticamente** — sube
todo el tier a la vez y queda arquitectónicamente limpio.
---
## 2. Los dos huecos del canon que bloquean el acabado
### Hueco 1 — No hay scroll-reveal
`Cascade` escalona, pero se dispara por `open` (disclosure), no por viewport.
Toda la maquinaria existe ya a nivel de art:
- `eidos.dom.observeIntersection(el, cb)` (adom)
- `src/arts/adom/is-in-viewport.svelte.ts` (primitiva reactiva)
- `eidos.dom.prefersReducedMotion`
`CountUp` la usa **en privado** . Nada la expone como composable. Sin esto, cada
sección aparece de golpe: el rasgo nº1 que separa a Linear/Vercel/Stripe de un
dump estático.
fix(blocks): lo que la auditoría encontró DENTRO del tier
Una auditoría multi-agente (7 dimensiones doctrinales + refutación adversarial)
confirmó 32 hallazgos sobre la pasada de calidad. Esto corrige los que caen
dentro de `blocks/`; la deuda de fuera queda REGISTRADA, no tocada
(`PLAN-blocks-quality.md` §6).
- **B-7 roto en `pricing`**: el block traía tres strings castellanas propias como
defaults del toggle — dos visibles y un nombre accesible. Hardcodeaba un idioma
DENTRO del framework y esquivaba `langs`, en el mismo block cuyo README predica
«cero strings propias». Ahora los tres son props REQUERIDAS y las pone la app.
- **El block pintaba**: `pricing.Plan` metía `box-shadow` por `style=` porque
`Card` no expone elevación. Retirado; el hueco queda registrado como candidato
a canon en vez de falseado.
- **El escalonado estaba roto y el comentario mentía**: puse `spring-pop` (driver
JS) en los planes, y un driver JS no lee el `animation-delay` donde vive el
stagger — entraban TODOS a la vez mientras el comentario afirmaba lo contrario.
Vuelto al preset CSS: verificado, índices 0,1,2 a 70ms.
- **Comentario falso en la demo del hero**: atribuía al `intent` visual una
consecuencia sonora/háptica que ningún camino produce (el morfo del Button dice
explícitamente que `contact-activate` NO lleva intent). Reescrito.
- **Composition maps mentían**: los 6 blocks que ahora componen `Motion` no lo
decían; el hero seguía diciendo `Heading` (es `Display`), sin `Backdrop` ni
`decor`, y declaraba DIFERIDO un preset de cromo de media que ya está shipeado
(`Mockup`). Al día, sin filas duplicadas ni datos rancios (el chip de
feature-grid es `solid`, no `soft`).
- **Handoff y plan**: el plan seguía prescribiendo un `<Reveal>` que esta misma
sesión creó y retiró; el handoff daba `feature-split` por «sin decidir» estando
hecho, y los gates con números falsos.
Verificado: `blocks:check` verde (7) · `svelte-check` sin errores propios · en el
navegador, pricing escalona de nuevo, las etiquetas vienen de la app y ninguna
tarjeta pinta sombra.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
→ **Resuelto** : `Motion` gana `trigger="viewport"` (NO un componente nuevo — `Reveal` se creó y se retiró por ser un fork) y `Cascade` gana el mismo disparo.
feat(eidos): `Reveal` + `Backdrop` + cascada por viewport — la capa de acabado
Auditoría del tier (docs/process/PLAN-blocks-quality.md): los 7 blocks de F2
componían EXCLUSIVAMENTE la capa de layout (Box/Stack/Grid/Section/Text/Card).
Uso de la capa expresiva del framework: cero. Ni un Motion, ni un Cascade, ni un
Display, ni un TextGradient, ni un CountUp.
La causa es estructural, no de gusto: el contrato B prohíbe que un block traiga
`.css`, así que **un block solo puede ser tan expresivo como los componentes del
canon que compone** — y faltaban dos primitivas. El arreglo no es maquillar 14
blocks uno a uno, es construir lo que falta en el canon y componerlo.
- **`Reveal`** — reproduce la ENTRADA de un preset al entrar en viewport. No
posee keyframes ni timing: estampa `data-animation-style` + `data-state='open'`
cuando dispara el observador, así que reduced-motion degrada DENTRO del preset.
Mientras espera, el recipe lo mantiene invisible bajo `@media (scripting:
enabled)`: sin JS la regla no aplica y el contenido se ve siempre — el fallo de
una animación de scroll debe ser «sin animación», nunca «página en blanco».
- **Escalonado gratis**: el foundation ya escribe `--motion-stagger-index` en
cada hijo de `[data-stagger]` (por `:nth-child`, sin JS) y los presets retrasan
por `índice × --motion-stagger-each`. El recipe de Reveal le da ritmo por
defecto, así que una lista se escalona poniendo `data-stagger` en SU
contenedor: cero delays a mano, cero números en el consumidor.
- **`Cascade trigger="viewport"`** — la cascada existente esperaba a `open`
(disclosure); ahora puede esperar también a ser vista.
- **`Backdrop`** — la capa de profundidad que no existía: `glow` · `mesh` ·
`grid` · `dots`, pintada en un `::before` desde tokens (acento por el forward
de paleta, mesh desde `--gradient-aurora`), con `fade` para no cortar en seco.
**El hero como prueba del nuevo listón**: `Display` (el primitivo de tipografía
de hero que existía y no usaba — el titular pasa de `Heading` a 80px reales),
entrada escalonada de eyebrow/título/subtítulo/acciones, media con `scale-fade`
y `decor` (glow por defecto) expuesto como control vivo en la demo.
Verificado con Playwright: glow pintado desde el token de paleta, 4 Reveal con
índices estructurales 0·1·2·3 a 70ms, titular H1 a 80px, y el patrón `grid` con
su máscara. `blocks:check` verde (7 blocks) · `vitest src/uix/eidos` 361/361 ·
`svelte-check` sin errores propios.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
### Hueco 2 — No hay fondo decorativo de sección
No existe `backdrop` /`pattern`/`glow`/`mesh`/`noise`/`spotlight` en el canon.
Solo hay **un** gradiente nombrado (`--gradient-aurora`) y los acabados de
`Surface` . Las referencias viven de esto (glow radial tras el hero, rejilla de
puntos, mesh, viñeta).
→ **Candidato a canon: `<Backdrop>`** (patrones + glow + mesh sobre tokens).
---
## 3. Comparativa por block: anatomía (dossier) + acabado (nuevo)
**A** = brecha de anatomía ya registrada en el dossier §P1.
**B** = brecha de acabado (nueva, la que motivó este plan).
**C** = superación que podemos meter y ninguna referencia puede.
| Block | A · anatomía que falta | B · acabado que falta | C · superación disponible |
|---|---|---|---|
| **hero** | presets de media ✔(Mockup) · **form-in-hero** · logo-strip como slot · video | `Display` en vez de `Heading` · `TextGradient` en el titular · `Backdrop` (glow/mesh) · reveal escalonado · parallax suave del mockup | tipografía fluida por tokens + dark/RTL/density gratis |
| **feature-grid** | variante en `Card` · numerada | `Cascade` al entrar · hover con elevación/acento en el item · iconos con más presencia | stagger con reduced-motion correcto |
uix(background): el velo tenía una escala que no ordenaba, y una tinta que le pone techo
Cierra el eje salvo dos firmas. La escala `strength` dejaba de crecer a mitad de
camino: tomaba prestada `--opacity-*`, que nombra cuán opaco es un ELEMENTO, y
leída como pesos de velo salía `ghost` 0,30 · `scrim` 0,45 · `overlay` 0,65 ·
`muted` 0,65 · `subtle` 0,80 — es decir, un consumidor que pedía `subtle` recibía
el velo MÁS pesado del conjunto, y `overlay` y `muted` eran el mismo número con
dos nombres. Ahora el velo tiene su propia escala de cinco pasos
(`--background-scrim-strength-xs`…`-xl`) y la unión pasa a `xs|sm|md|lg|xl`,
ordenada por construcción: medido en Chrome, α 0,084 · 0,126 · 0,189 · 0,273 ·
0,420, estrictamente creciente. `md` sostiene el 0,45 que shipeó el hero, así que
el default pinta exactamente lo que pintaba. Era cambio de API y se hizo mientras
el único consumidor era la demo: ningún block nombra `strength`.
Y midiendo eso apareció que la tabla de contraste del README era falsa por la
mitad, en la dirección incómoda. Estaba calculada sobre una tinta de α 0,66;
`--color-overlay` resuelve a `rgba(28, 25, 23, 0.42)`. Los valores reales, ahora
compuestos en un canvas y leídos por píxel en vez de calculados: el default da
1,48:1 sobre foto blanca —no 2,10— y el paso más fuerte 2,14 —no 4,42—. Lo que
eso destapa no es un número peor sino un TECHO: `xl` gasta la tinta entera y
llega a 2,66:1, de modo que ningún paso nuevo puede alcanzar AA, porque el límite
es el alpha del token y no la escala. Con tinta opaca los mismos pesos dan 5,45:1
a 0,65 y 9,22:1 a 0,80. Queda como decisión del autor, reescrita con estas
cifras, porque es sobre qué ES un scrim y no sobre subir un valor.
La rama `@supports not (animation-timeline: view())` queda ejercitada, que era el
último hueco de verificación. Chromium ya no puede desactivar scroll-driven
—estable, flag de runtime retirado: seis candidatos probados, los seis siguen
reportando soporte—, pero el Firefox de Playwright NO lo soporta por sí mismo, así
que la rama está viva ahí sin emulación ninguna. Con el fichero de receta real:
`--background-progress` 0 → −64px, 0,5 → 0, 1 → +64px, con `animation-name: none`
y cero animaciones. Son los mismos extremos que el camino CSS medido en Chrome, o
sea que los dos caminos concuerdan; y en Chromium la rama no aplica y escribir la
var no mueve nada, que es la exclusión mutua que el README afirmaba sin haberla
medido. Sin ejercitar queda un eslabón —que `ScrollProgress` escriba la var
extremo a extremo—: desde el shell de este entorno no hay ruta a localhost, y
Firefox no alcanza el dev server.
El paseo de la checklist A–H, que estaba listado como lectura de F4 y nunca
registrado, encontró tres cosas. Faltaba la excepción `A2.3` con su ID (las tres
partes llevan `data: []` porque sus attrs son de wrapper y su único estado vive en
el Button que compone) y faltaba `## Subset` (color: el conjunto completo, porque
en decoración restringir la paleta sería arbitrario; intent: no se acepta). Y una
deriva documental: el README decía en TRES sitios que el control de pausa «IS the
canonical Toggle» y que dispara `commit-toggle`, cuando D-BG.18 lo cambió a
`IconButton` con etiqueta que cambia y sin `aria-pressed` — el evento es
`contact-activate` de `button.ts`. Vestigios de la era D-BG.4 que la enmienda no
barrió; corregidos, incluido el comentario del propio morfo.
La banda de `feature-split` queda firmada POR SECCIÓN, enmendando el plan en sus
tres menciones y `PLAN-blocks-quality` §3 columna B, que la pedía por fila: por
fila obliga a cada `Row` a poseer el estado de alternancia —su índice y el de sus
hermanas— y un block que coordina deja de ser un block que no posee nada.
Gates: audit PASS 0/0 · morfo:check (6 de 160 fallan, background no) · eidos-lint
invalid 0 y class-hooks 0 · rtl:check 0/180 · smoke del componente PASS · vitest
eidos 434/435 (el rojo es `skin-media-player`, el de siempre) · `check` con los
mismos 72 errores preexistentes y ninguno en ficheros propios.
Tres fantasmas costaron tiempo y son el mismo patrón: leer las vars tras un
screenshot devuelve el `write(0,0)` de un `pointerleave`; dar por muerto el
`strength` leyendo `backgroundColor` de un scrim GRADUADO, que pinta por
`background-image`; y juzgar `fade` y `pause` sin la precondición que necesitan.
El instrumento miente antes que el código, y aquí mintió tres veces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
| **feature-split** | ~~banda de fondo por fila~~ → **por SECCIÓN** (enmienda firmada 2026-08-18, ver nota abajo) · media pegajosa | reveal por fila (media y copy con desfase) · ~~`Backdrop` alterno~~ → `Background` en la sección | `Mockup` real (ninguna ref lo compone, lo dibujan a mano) |
feat(eidos): `Reveal` + `Backdrop` + cascada por viewport — la capa de acabado
Auditoría del tier (docs/process/PLAN-blocks-quality.md): los 7 blocks de F2
componían EXCLUSIVAMENTE la capa de layout (Box/Stack/Grid/Section/Text/Card).
Uso de la capa expresiva del framework: cero. Ni un Motion, ni un Cascade, ni un
Display, ni un TextGradient, ni un CountUp.
La causa es estructural, no de gusto: el contrato B prohíbe que un block traiga
`.css`, así que **un block solo puede ser tan expresivo como los componentes del
canon que compone** — y faltaban dos primitivas. El arreglo no es maquillar 14
blocks uno a uno, es construir lo que falta en el canon y componerlo.
- **`Reveal`** — reproduce la ENTRADA de un preset al entrar en viewport. No
posee keyframes ni timing: estampa `data-animation-style` + `data-state='open'`
cuando dispara el observador, así que reduced-motion degrada DENTRO del preset.
Mientras espera, el recipe lo mantiene invisible bajo `@media (scripting:
enabled)`: sin JS la regla no aplica y el contenido se ve siempre — el fallo de
una animación de scroll debe ser «sin animación», nunca «página en blanco».
- **Escalonado gratis**: el foundation ya escribe `--motion-stagger-index` en
cada hijo de `[data-stagger]` (por `:nth-child`, sin JS) y los presets retrasan
por `índice × --motion-stagger-each`. El recipe de Reveal le da ritmo por
defecto, así que una lista se escalona poniendo `data-stagger` en SU
contenedor: cero delays a mano, cero números en el consumidor.
- **`Cascade trigger="viewport"`** — la cascada existente esperaba a `open`
(disclosure); ahora puede esperar también a ser vista.
- **`Backdrop`** — la capa de profundidad que no existía: `glow` · `mesh` ·
`grid` · `dots`, pintada en un `::before` desde tokens (acento por el forward
de paleta, mesh desde `--gradient-aurora`), con `fade` para no cortar en seco.
**El hero como prueba del nuevo listón**: `Display` (el primitivo de tipografía
de hero que existía y no usaba — el titular pasa de `Heading` a 80px reales),
entrada escalonada de eyebrow/título/subtítulo/acciones, media con `scale-fade`
y `decor` (glow por defecto) expuesto como control vivo en la demo.
Verificado con Playwright: glow pintado desde el token de paleta, 4 Reveal con
índices estructurales 0·1·2·3 a 70ms, titular H1 a 80px, y el patrón `grid` con
su máscara. `blocks:check` verde (7 blocks) · `vitest src/uix/eidos` 361/361 ·
`svelte-check` sin errores propios.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
| **pricing** | **tabla de comparación** · single-price | transición del precio al cambiar periodo (`Motion`) · destaque del featured (glow/escala) · `CountUp` en el importe | precio locale-aware (`FormatNumber`) + toggle con estado real |
| **testimonials** | **cita en spotlight** (la variante modal de TODAS las refs) | `Cascade` · logos de empresa · comillas decorativas · avatar con anillo | comillas locale-aware (territorio sin reclamar) |
| **faq** | **lista estática 2/3 col** (6 de 7 en TW) · cola de soporte como slot | transición de apertura con carácter · numeración/iconografía | teclado+ARIA del `Accordion` del canon |
| **site-header** | flyout ✔ · banner hermano | fondo con blur al pegarse (`[data-stuck]` ya lo da) · transición de la sombra | `data-stuck` = polyfill del `scroll-state()` futuro |
| **stats-band** *(pendiente)* | split-with-image · timeline | reveal + `Backdrop` | ** `CountUp` : ninguna ref puede shippearlo** (markup estático) — la superación literal |
| **cta** *(pendiente)* | arreglo **justified** · split-with-media | `Backdrop` + gradiente de acabado | — |
| **newsletter** *(pendiente)* | nota de privacidad | estados (enviando/éxito) con `Motion` | validación real del `Form` del canon |
| **site-footer** *(pendiente)* | newsletter en footer · **selector de idioma** | — | el selector de idioma encaja con `langs` (ninguna ref lo tiene funcional) |
| **banner · team · contact · content-section** *(pendientes)* | — | al nivel nuevo desde el día 1 | — |
uix(background): el velo tenía una escala que no ordenaba, y una tinta que le pone techo
Cierra el eje salvo dos firmas. La escala `strength` dejaba de crecer a mitad de
camino: tomaba prestada `--opacity-*`, que nombra cuán opaco es un ELEMENTO, y
leída como pesos de velo salía `ghost` 0,30 · `scrim` 0,45 · `overlay` 0,65 ·
`muted` 0,65 · `subtle` 0,80 — es decir, un consumidor que pedía `subtle` recibía
el velo MÁS pesado del conjunto, y `overlay` y `muted` eran el mismo número con
dos nombres. Ahora el velo tiene su propia escala de cinco pasos
(`--background-scrim-strength-xs`…`-xl`) y la unión pasa a `xs|sm|md|lg|xl`,
ordenada por construcción: medido en Chrome, α 0,084 · 0,126 · 0,189 · 0,273 ·
0,420, estrictamente creciente. `md` sostiene el 0,45 que shipeó el hero, así que
el default pinta exactamente lo que pintaba. Era cambio de API y se hizo mientras
el único consumidor era la demo: ningún block nombra `strength`.
Y midiendo eso apareció que la tabla de contraste del README era falsa por la
mitad, en la dirección incómoda. Estaba calculada sobre una tinta de α 0,66;
`--color-overlay` resuelve a `rgba(28, 25, 23, 0.42)`. Los valores reales, ahora
compuestos en un canvas y leídos por píxel en vez de calculados: el default da
1,48:1 sobre foto blanca —no 2,10— y el paso más fuerte 2,14 —no 4,42—. Lo que
eso destapa no es un número peor sino un TECHO: `xl` gasta la tinta entera y
llega a 2,66:1, de modo que ningún paso nuevo puede alcanzar AA, porque el límite
es el alpha del token y no la escala. Con tinta opaca los mismos pesos dan 5,45:1
a 0,65 y 9,22:1 a 0,80. Queda como decisión del autor, reescrita con estas
cifras, porque es sobre qué ES un scrim y no sobre subir un valor.
La rama `@supports not (animation-timeline: view())` queda ejercitada, que era el
último hueco de verificación. Chromium ya no puede desactivar scroll-driven
—estable, flag de runtime retirado: seis candidatos probados, los seis siguen
reportando soporte—, pero el Firefox de Playwright NO lo soporta por sí mismo, así
que la rama está viva ahí sin emulación ninguna. Con el fichero de receta real:
`--background-progress` 0 → −64px, 0,5 → 0, 1 → +64px, con `animation-name: none`
y cero animaciones. Son los mismos extremos que el camino CSS medido en Chrome, o
sea que los dos caminos concuerdan; y en Chromium la rama no aplica y escribir la
var no mueve nada, que es la exclusión mutua que el README afirmaba sin haberla
medido. Sin ejercitar queda un eslabón —que `ScrollProgress` escriba la var
extremo a extremo—: desde el shell de este entorno no hay ruta a localhost, y
Firefox no alcanza el dev server.
El paseo de la checklist A–H, que estaba listado como lectura de F4 y nunca
registrado, encontró tres cosas. Faltaba la excepción `A2.3` con su ID (las tres
partes llevan `data: []` porque sus attrs son de wrapper y su único estado vive en
el Button que compone) y faltaba `## Subset` (color: el conjunto completo, porque
en decoración restringir la paleta sería arbitrario; intent: no se acepta). Y una
deriva documental: el README decía en TRES sitios que el control de pausa «IS the
canonical Toggle» y que dispara `commit-toggle`, cuando D-BG.18 lo cambió a
`IconButton` con etiqueta que cambia y sin `aria-pressed` — el evento es
`contact-activate` de `button.ts`. Vestigios de la era D-BG.4 que la enmienda no
barrió; corregidos, incluido el comentario del propio morfo.
La banda de `feature-split` queda firmada POR SECCIÓN, enmendando el plan en sus
tres menciones y `PLAN-blocks-quality` §3 columna B, que la pedía por fila: por
fila obliga a cada `Row` a poseer el estado de alternancia —su índice y el de sus
hermanas— y un block que coordina deja de ser un block que no posee nada.
Gates: audit PASS 0/0 · morfo:check (6 de 160 fallan, background no) · eidos-lint
invalid 0 y class-hooks 0 · rtl:check 0/180 · smoke del componente PASS · vitest
eidos 434/435 (el rojo es `skin-media-player`, el de siempre) · `check` con los
mismos 72 errores preexistentes y ninguno en ficheros propios.
Tres fantasmas costaron tiempo y son el mismo patrón: leer las vars tras un
screenshot devuelve el `write(0,0)` de un `pointerleave`; dar por muerto el
`strength` leyendo `backgroundColor` de un scrim GRADUADO, que pinta por
`background-image`; y juzgar `fade` y `pause` sin la precondición que necesitan.
El instrumento miente antes que el código, y aquí mintió tres veces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
> **Enmienda 2026-08-18 · la banda de `feature-split` va por SECCIÓN.** La columna
> B pedía `Backdrop` alterno POR FILA. Ejecutado en F5 del eje Background quedó en
> la sección, y la enmienda confirma esa forma: una banda por fila obliga a cada
> `Row` a poseer el estado de alternancia —saber su índice y el de sus hermanas—, y
> eso es coordinación entre partes. Un block que coordina deja de ser un block que
> no posee nada (doctrina de `blocks.md`). Si la alternancia se quisiera de todos
> modos, el lugar correcto no es el block: es un selector de la receta
> (`:nth-child`), y entonces es una decisión de eidos, no de composición.
feat(eidos): `Reveal` + `Backdrop` + cascada por viewport — la capa de acabado
Auditoría del tier (docs/process/PLAN-blocks-quality.md): los 7 blocks de F2
componían EXCLUSIVAMENTE la capa de layout (Box/Stack/Grid/Section/Text/Card).
Uso de la capa expresiva del framework: cero. Ni un Motion, ni un Cascade, ni un
Display, ni un TextGradient, ni un CountUp.
La causa es estructural, no de gusto: el contrato B prohíbe que un block traiga
`.css`, así que **un block solo puede ser tan expresivo como los componentes del
canon que compone** — y faltaban dos primitivas. El arreglo no es maquillar 14
blocks uno a uno, es construir lo que falta en el canon y componerlo.
- **`Reveal`** — reproduce la ENTRADA de un preset al entrar en viewport. No
posee keyframes ni timing: estampa `data-animation-style` + `data-state='open'`
cuando dispara el observador, así que reduced-motion degrada DENTRO del preset.
Mientras espera, el recipe lo mantiene invisible bajo `@media (scripting:
enabled)`: sin JS la regla no aplica y el contenido se ve siempre — el fallo de
una animación de scroll debe ser «sin animación», nunca «página en blanco».
- **Escalonado gratis**: el foundation ya escribe `--motion-stagger-index` en
cada hijo de `[data-stagger]` (por `:nth-child`, sin JS) y los presets retrasan
por `índice × --motion-stagger-each`. El recipe de Reveal le da ritmo por
defecto, así que una lista se escalona poniendo `data-stagger` en SU
contenedor: cero delays a mano, cero números en el consumidor.
- **`Cascade trigger="viewport"`** — la cascada existente esperaba a `open`
(disclosure); ahora puede esperar también a ser vista.
- **`Backdrop`** — la capa de profundidad que no existía: `glow` · `mesh` ·
`grid` · `dots`, pintada en un `::before` desde tokens (acento por el forward
de paleta, mesh desde `--gradient-aurora`), con `fade` para no cortar en seco.
**El hero como prueba del nuevo listón**: `Display` (el primitivo de tipografía
de hero que existía y no usaba — el titular pasa de `Heading` a 80px reales),
entrada escalonada de eyebrow/título/subtítulo/acciones, media con `scale-fade`
y `decor` (glow por defecto) expuesto como control vivo en la demo.
Verificado con Playwright: glow pintado desde el token de paleta, 4 Reveal con
índices estructurales 0·1·2·3 a 70ms, titular H1 a 80px, y el patrón `grid` con
su máscara. `blocks:check` verde (7 blocks) · `vitest src/uix/eidos` 361/361 ·
`svelte-check` sin errores propios.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
---
## 4. Plan
### F-Q0 · Primitivas que faltan en el canon *(la palanca)*
| # | Pieza | Por qué |
|---|---|---|
fix(blocks): lo que la auditoría encontró DENTRO del tier
Una auditoría multi-agente (7 dimensiones doctrinales + refutación adversarial)
confirmó 32 hallazgos sobre la pasada de calidad. Esto corrige los que caen
dentro de `blocks/`; la deuda de fuera queda REGISTRADA, no tocada
(`PLAN-blocks-quality.md` §6).
- **B-7 roto en `pricing`**: el block traía tres strings castellanas propias como
defaults del toggle — dos visibles y un nombre accesible. Hardcodeaba un idioma
DENTRO del framework y esquivaba `langs`, en el mismo block cuyo README predica
«cero strings propias». Ahora los tres son props REQUERIDAS y las pone la app.
- **El block pintaba**: `pricing.Plan` metía `box-shadow` por `style=` porque
`Card` no expone elevación. Retirado; el hueco queda registrado como candidato
a canon en vez de falseado.
- **El escalonado estaba roto y el comentario mentía**: puse `spring-pop` (driver
JS) en los planes, y un driver JS no lee el `animation-delay` donde vive el
stagger — entraban TODOS a la vez mientras el comentario afirmaba lo contrario.
Vuelto al preset CSS: verificado, índices 0,1,2 a 70ms.
- **Comentario falso en la demo del hero**: atribuía al `intent` visual una
consecuencia sonora/háptica que ningún camino produce (el morfo del Button dice
explícitamente que `contact-activate` NO lleva intent). Reescrito.
- **Composition maps mentían**: los 6 blocks que ahora componen `Motion` no lo
decían; el hero seguía diciendo `Heading` (es `Display`), sin `Backdrop` ni
`decor`, y declaraba DIFERIDO un preset de cromo de media que ya está shipeado
(`Mockup`). Al día, sin filas duplicadas ni datos rancios (el chip de
feature-grid es `solid`, no `soft`).
- **Handoff y plan**: el plan seguía prescribiendo un `<Reveal>` que esta misma
sesión creó y retiró; el handoff daba `feature-split` por «sin decidir» estando
hecho, y los gates con números falsos.
Verificado: `blocks:check` verde (7) · `svelte-check` sin errores propios · en el
navegador, pricing escalona de nuevo, las etiquetas vienen de la app y ninguna
tarjeta pinta sombra.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
| Q0.1 | ~~`Reveal`~~ → ** `Motion trigger="viewport"` ** (+ `rootMargin` , `threshold` , `once` , `delay` ) | HECHO. Se creó un `Reveal` aparte y se consolidó: era un fork de `Motion` , que ya era el animador de un elemento — solo le faltaba el CUÁNDO |
| Q0.2 | ** `Cascade` por viewport** — `trigger="viewport"` además de `open` | HECHO |
uix(background): el fondo se mueve con el scroll, y con lo que el lector no pidió no
F3 (parallax) + F4 (demo y registro documental) + las correcciones de sus dos
auditorías, en un commit porque viven en los mismos ficheros: la demo enseña los
ejes que F3 añade, y separarlas dejaría un estado que nunca se probó.
Cinco maneras de que una capa deje de estarse quieta: `speed` (cuánto del token
de travel cubre mientras el anfitrión cruza el viewport), `bleed` (crece más
allá del anfitrión para que el viaje no arrastre su propio borde), `depth` (la
deriva contra el puntero), `spotlight` y `attach='fixed'`.
El travel es CSS: `animation-timeline: view()` lo gobierna desde la posición de
scroll, sin listener ni rAF. Sólo donde el motor no lo trae, la pila arranca
`ScrollProgress` y escribe `--background-progress`, que la rama `@supports not`
mete en la MISMA declaración; los dos caminos no pueden estar vivos a la vez
porque el JS comprueba la condición idéntica con `CSS.supports`, y ambos se
paran bajo `prefers-reduced-motion` — el parallax es movimiento atado al scroll
del propio lector, que es justo la clase que provoca síntomas vestibulares.
**La decisión que no estaba prevista.** El scroll y el puntero quieren mover la
MISMA capa, y una animación sobre `translate` gana a cualquier declaración
estática: el puntero habría dejado de existir sin más. Así que el scroll anima
una custom property REGISTRADA (`@property`, o interpolaría a saltos) y un único
`translate` compone los dos términos. Medido: parallax solo → `0px 30px`; con el
puntero arriba-derecha y `depth: 20px` → `20px 10px`. Es `translate` y nunca el
shorthand `transform`, la misma ley que sigue el lift del draggable con `scale`.
El precio, dicho porque en la primera redacción escribí lo contrario tres veces:
una custom property NO se puede compositar, así que el navegador recalcula
estilo cada frame. Para una decoración es el intercambio correcto —una property
por capa que viaja, ninguna bajo reduced motion— pero «va en el compositor» era
falso y ahora el código dice lo que ocurre.
**Dos footguns cerrados por forma, no por disciplina:**
- `attach='fixed'` se DECLARA desde la capa y la pila se recorta sola. Antes
había que escribirlo también en la pila, y olvidarlo dejaba la capa
`position: fixed` pintando a sangre por todo el viewport, detrás de todo y sin
error (un hijo fijo se escapa de `overflow: clip`; sólo un `clip-path` lo trae
de vuelta). La prop de la pila desaparece: no hay nada que olvidar.
- Un `speed` negativo —una capa que se mueve contra el scroll— invertía el
bleed: la capa ENCOGÍA y enseñaba justo los bordes que el bleed tapa. Ahora
usa la magnitud.
**Lo que costó medición**: el shorthand `animation` pone `duration: 0s` y una
línea de tiempo de progreso necesita el `auto` inicial, así que con el shorthand
la capa no se movía nunca (van longhands, con el porqué escrito) · mi listener
de puntero pedía un frame y no lo liberaba si el rect salía degenerado, matando
el puntero para el resto de la sesión (reescrito sin frame, con el rect cacheado
e invalidado por `pointerenter` y `observeResize`) · las cuatro registraciones
—`animated`, `pointer`, `scroll`, `fixed`— comparten un solo sitio,
`declare.svelte.ts`, donde vive la regla A30 y su segunda mitad: registrar desde
el init, y seguir el prop sin escribir en la primera pasada.
**La demo** (`/uix/components/background`, v2, nueve pestañas) monta un
ANFITRIÓN de verdad en el escenario, porque este componente es invisible por sí
solo y sin padre no se puede enseñar lo único que importa: que el padre se
adopta y el layout no se mueve. Los chips son uniones completas verificadas por
el TIPO (`Record<Union, 0>`): un miembro que falte es error de compilación.
Y fue la demo la que destapó que, con A30, encender `animate` en caliente no
hacía aparecer el control de pausa — el registro era un hecho de montaje.
Invisible en una sonda, obvio con un interruptor.
Registro documental (D-BG.11): `next-features.md` §11 · la frase en
`design-text-effects.md` (el mismo corte canon/pack leído desde el otro lado) ·
`PLAN-blocks-quality.md` Q0.3 → sucesor · `surface/README.md` §Gaps «scrim de
autoría» CERRADO por `Background.Scrim` · glosario con entrada `Background` y
`Aura` corregida (decía «Not built yet» y está construido) · y en
`motion-guide.md` §8 + el RFC: el travel ligado al scroll no es un preset —un
preset nombra una transición discreta CON duración, y esto es modulación
continua sin ninguna— y sólo se replantea como dominio con un segundo consumidor.
Verificado en Chrome real: el puntero mueve `depth` y `spotlight` con los
valores exactos y vuelven al centro al salir · `attach='fixed'` estampa y
retira el recorte de la pila · el bleed aguanta el speed negativo · RTL: el
`translate` del puntero se mantiene FÍSICO y el bleed en el eje de bloque · cada
control de la demo cambia algo (los de `spotlight` y `depth` no llegaban a tres
de las cuatro clases de capa hasta la segunda auditoría).
⚠️ SIN VERIFICAR, y no lo doy por bueno: el travel real al hacer scroll, los 60
fps y el detector de reflow. El panel del navegador va oculto con viewport 0×0 y
ahí las animaciones scroll-driven declaradas en CSS no se activan — comprobado
que es del ENTORNO con un caso mínimo inyectado (un `div` pelado con
`animation-timeline: view()` sale inactivo mientras una `ViewTimeline` creada
por API sobre el mismo sujeto marca 68%). Necesita una pasada con Chrome
visible.
Gates: audit `--only background` PASS 0 errores · eidos-lint invalid 0 ·
`rtl:check` 0/180 · `docs:check` 0/0 en 634 docs · `blocks:check` 0/18 ·
`morfo:check` PASS · smoke PASS · 441/442 (el fallo es el `skin-media-player` de
siempre) · `check` 0 errores propios.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
| Q0.3 | ** `Backdrop` ** — fondo de sección: `glow` · `mesh` · `grid` · `dots` , todo sobre tokens | HECHO — y **superado el 2026-08-18** por [`Background` ](../../src/uix/eidos/components/background/README.md ), que lo absorbe: las cuatro tramas viajan valor por valor a `Background.Pattern` (más `lines` · `noise` · `rings` · `vignette` ), y donde `Backdrop` envolvía a su contenido y pintaba UNA capa en un `::before` , la pila se cuelga del anfitrión como hija y apila cuantas capas haga falta — imagen, vídeo, gradiente, velo. El plan y las decisiones firmadas: [`PLAN-background.md` ](./PLAN-background.md ) |
feat(eidos): `Reveal` + `Backdrop` + cascada por viewport — la capa de acabado
Auditoría del tier (docs/process/PLAN-blocks-quality.md): los 7 blocks de F2
componían EXCLUSIVAMENTE la capa de layout (Box/Stack/Grid/Section/Text/Card).
Uso de la capa expresiva del framework: cero. Ni un Motion, ni un Cascade, ni un
Display, ni un TextGradient, ni un CountUp.
La causa es estructural, no de gusto: el contrato B prohíbe que un block traiga
`.css`, así que **un block solo puede ser tan expresivo como los componentes del
canon que compone** — y faltaban dos primitivas. El arreglo no es maquillar 14
blocks uno a uno, es construir lo que falta en el canon y componerlo.
- **`Reveal`** — reproduce la ENTRADA de un preset al entrar en viewport. No
posee keyframes ni timing: estampa `data-animation-style` + `data-state='open'`
cuando dispara el observador, así que reduced-motion degrada DENTRO del preset.
Mientras espera, el recipe lo mantiene invisible bajo `@media (scripting:
enabled)`: sin JS la regla no aplica y el contenido se ve siempre — el fallo de
una animación de scroll debe ser «sin animación», nunca «página en blanco».
- **Escalonado gratis**: el foundation ya escribe `--motion-stagger-index` en
cada hijo de `[data-stagger]` (por `:nth-child`, sin JS) y los presets retrasan
por `índice × --motion-stagger-each`. El recipe de Reveal le da ritmo por
defecto, así que una lista se escalona poniendo `data-stagger` en SU
contenedor: cero delays a mano, cero números en el consumidor.
- **`Cascade trigger="viewport"`** — la cascada existente esperaba a `open`
(disclosure); ahora puede esperar también a ser vista.
- **`Backdrop`** — la capa de profundidad que no existía: `glow` · `mesh` ·
`grid` · `dots`, pintada en un `::before` desde tokens (acento por el forward
de paleta, mesh desde `--gradient-aurora`), con `fade` para no cortar en seco.
**El hero como prueba del nuevo listón**: `Display` (el primitivo de tipografía
de hero que existía y no usaba — el titular pasa de `Heading` a 80px reales),
entrada escalonada de eyebrow/título/subtítulo/acciones, media con `scale-fade`
y `decor` (glow por defecto) expuesto como control vivo en la demo.
Verificado con Playwright: glow pintado desde el token de paleta, 4 Reveal con
índices estructurales 0·1·2·3 a 70ms, titular H1 a 80px, y el patrón `grid` con
su máscara. `blocks:check` verde (7 blocks) · `vitest src/uix/eidos` 361/361 ·
`svelte-check` sin errores propios.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
Los tres siguen el patrón `Mockup` : componente eidos + morfo mínimo, recipe
sobre tokens, cero color a mano.
### F-Q1 · Pasada de acabado a los 7 hechos
Por block: aplicar A (anatomía) + B (acabado) de la tabla §3. Orden sugerido por
impacto: **hero → pricing → feature-split → testimonials → feature-grid → faq →
site-header**. El hero primero como **prueba del nuevo listón** : si no gana
al lado de la referencia, se itera antes de replicar.
### F-Q2 · Los 7 pendientes, ya al nivel nuevo
stats-band (con `CountUp` — la superación literal) → cta → newsletter →
site-footer → banner → team → contact → content-section.
### F-Q3 · Página compuesta como prueba de aceptación
Todas las refs shippean páginas compuestas (TW 10 · Untitled 105). La nuestra
—los 14 blocks en una landing real— es el test de que el conjunto respira.
### Cambio en la definición de "hecho"
Un block **no** está hecho cuando compone bien y pasa `blocks:check` . Está hecho
cuando, **puesto al lado del mejor ejemplo de las referencias, gana** . Añadir al
checklist del contrato B: ¿tiene movimiento? ¿profundidad/fondo que lo
distinga? ¿una disposición que no sea la rejilla obvia? ¿tipografía con
presencia?
---
## 5. Riesgos
- **Sobre-animar**: el movimiento debe respetar `prefers-reduced-motion` (la
maquinaria ya lo hace) y no retrasar la lectura. Presets sobrios, no circo.
- **Romper el contrato B**: nada de `.css` en blocks — todo el acabado entra por
primitivas del canon. Si algo no se puede, es que falta una primitiva.
- **Regresiones**: `Reveal` /`Backdrop` tocan el canon → suite eidos + barrido de
demos, como en los fixes de `Grid` y `Accordion` .
fix(blocks): lo que la auditoría encontró DENTRO del tier
Una auditoría multi-agente (7 dimensiones doctrinales + refutación adversarial)
confirmó 32 hallazgos sobre la pasada de calidad. Esto corrige los que caen
dentro de `blocks/`; la deuda de fuera queda REGISTRADA, no tocada
(`PLAN-blocks-quality.md` §6).
- **B-7 roto en `pricing`**: el block traía tres strings castellanas propias como
defaults del toggle — dos visibles y un nombre accesible. Hardcodeaba un idioma
DENTRO del framework y esquivaba `langs`, en el mismo block cuyo README predica
«cero strings propias». Ahora los tres son props REQUERIDAS y las pone la app.
- **El block pintaba**: `pricing.Plan` metía `box-shadow` por `style=` porque
`Card` no expone elevación. Retirado; el hueco queda registrado como candidato
a canon en vez de falseado.
- **El escalonado estaba roto y el comentario mentía**: puse `spring-pop` (driver
JS) en los planes, y un driver JS no lee el `animation-delay` donde vive el
stagger — entraban TODOS a la vez mientras el comentario afirmaba lo contrario.
Vuelto al preset CSS: verificado, índices 0,1,2 a 70ms.
- **Comentario falso en la demo del hero**: atribuía al `intent` visual una
consecuencia sonora/háptica que ningún camino produce (el morfo del Button dice
explícitamente que `contact-activate` NO lleva intent). Reescrito.
- **Composition maps mentían**: los 6 blocks que ahora componen `Motion` no lo
decían; el hero seguía diciendo `Heading` (es `Display`), sin `Backdrop` ni
`decor`, y declaraba DIFERIDO un preset de cromo de media que ya está shipeado
(`Mockup`). Al día, sin filas duplicadas ni datos rancios (el chip de
feature-grid es `solid`, no `soft`).
- **Handoff y plan**: el plan seguía prescribiendo un `<Reveal>` que esta misma
sesión creó y retiró; el handoff daba `feature-split` por «sin decidir» estando
hecho, y los gates con números falsos.
Verificado: `blocks:check` verde (7) · `svelte-check` sin errores propios · en el
navegador, pricing escalona de nuevo, las etiquetas vienen de la app y ninguna
tarjeta pinta sombra.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
---
## 6. Auditoría posterior (2026-07-29) — deuda que dejó esta pasada
Una auditoría multi-agente (7 dimensiones doctrinales + refutación adversarial)
confirmó **32 hallazgos** sobre el trabajo de la pasada. Lo corregido dentro del
tier queda en los README de cada block; lo que cae **fuera de `blocks/`** queda
registrado aquí como deuda, sin tocar:
| # | Dónde | Qué |
|---|---|---|
| A1 | `eidos/components/motion/motion.svelte` | con `trigger='viewport'` no estampa `data-state` hasta que dispara el observador, y su morfo lo declara `always` +`required`. `Cascade` —construido en la misma pasada— sí lo hace bien: estampa el estado y deja la espera en un attr aparte |
| A2 | `motion.css` · `cascade.css` | el `opacity: 0` de espera solo se levanta con la media query del SO; la preferencia de app proyectada como `data-motion='reduce'` NO lo levanta |
| A4 | `motion.svelte` (`leave`) | la salida no pasa por el motor: un preset JS no anima al salir y el nodo se retiene 200 ms |
| C7 | `motion.css` + su README | crear el recipe activó la regla de auditoría R-1.1 (sin selector raíz `[data-motion]` ) y el README declara la excepción CONTRARIA («ships no CSS») y sigue diciendo «no JS engine» |
| C8 | `eidos/components/{mockup,backdrop}` | sin README (162 de 165 componentes lo tienen) |
| C10 | `docs/theming/motion.md` | no documenta el `trigger` nuevo ni la llamada al motor |
| D12 | `morfo/components/backdrop.ts` · `cascade.css` | citan `reveal.ts` , borrado; y `Cascade` emite `data-reveal-pending` — nombre de un componente que ya no existe — para el mismo concepto que `Motion` llama `data-animation-pending` |
| E14 | `motion.css:42` | `--motion-stagger-each-default` no existe en ninguna parte: el knob es ficción y siempre vale el literal 70ms. Ya existe el canónico `--motion-stagger` |
feat(blocks): `cta` — el panel que pide el siguiente paso, y tres mentiras que destapó al medirlo
F2.8. Slots de snippet (eyebrow · title · description · actions · children),
misma forma que `hero`: las partes de un CTA no repiten ni coordinan, así que un
compound no se gana nada. Dos disposiciones: `center` para cerrar una página y
`justified` —copia al inicio, acciones al final— que es el hueco que nombraba el
dossier. Un solo `Motion trigger="viewport"`: el panel llega ENTERO, porque un
CTA es una sola afirmación y repartir sus tres partes se leería como duda.
Lo que salió al verificarlo en navegador, medido y no a ojo:
- `variant='soft'` **fuera de la API**. Su track queda a `oklch(0.9932)` contra
un `--color-surface-default` de `oklch(0.9911)`: 0.002 de luminancia, o sea
ningún panel en claro. Y `Surface` no tiene borde al que caer. Cortar el prop
es más barato que shipear un estado que se esfuma.
- La ranura `contrast` de la paleta es blanco en TODO escalón sólido, así que un
lienzo de luminancia media deja el cuerpo por debajo de AA: `primary` 5.18 ·
`indigo` 5.21 · `plum` 4.75 pasan en ambos modos; `neutral` 3.32 · `teal` 3.07
fallan en claro. El block reenvía cualquier `color`; la demo solo ofrece los
que pasan.
- `Text align` es inerte por defecto: renderiza un `span`, y `text-align` no hace
nada sobre una caja inline. `align="center"` dejaba la copia a la izquierda
dentro del layout centrado, sin avisar. Rodeado con `as="p"`.
Y `Group` no apila: a 420px la etiqueta de la acción secundaria se parte contra
el botón primario, así que las acciones van en `Flex direction={{ base:
'column', sm: 'row' }}`. `hero` compone las suyas con `Group` — anotado.
Los tres hallazgos de canon quedan en los gaps del README del block y en
`PLAN-blocks-quality.md` §6 (F15/F16/F17), sin tocar nada fuera del tier.
Verificado: `center` y `justified` en claro/oscuro/RTL y a 420px, tres colores,
entrada disparada, descripción en `<p>` centrada, cero errores de página.
Gates: `blocks:check` verde (9 blocks) · `svelte-check` sin errores propios.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
### Añadidos al construir `cta` (2026-07-30) — medidos en navegador
Tres cosas que salieron al componer F2.8, todas verificadas con sonda de
luminancia / `getComputedStyle` , no a ojo. Caen fuera de `blocks/` , así que
tampoco se han tocado:
| # | Dónde | Qué |
|---|---|---|
| F15 | `eidos/components/surface` | `variant='soft'` no puede acotar un panel: el track de `primary` mide `oklch(0.9932 0.0034 325.6)` contra un `--color-surface-default` de `oklch(0.9911 0 0)` — **0.002 L** — y `Surface` no tiene prop de borde. `Card outline` sí acota pero no acepta `gradient` , así que «panel sosegado con borde» no tiene primitivo. Por eso `cta` **eliminó** su prop `variant` |
| F16 | paleta (`contrast` slot) | la ranura de contraste es `#ffffff` en **todo** escalón sólido, así que un lienzo de luminancia media deja el cuerpo de texto por debajo de AA. Medido sobre el panel: `primary` 5.18 · `indigo` 5.21 · `plum` 4.75 (pasan en ambos modos) vs `neutral` 3.32 · `secondary` 3.30 · `slate` 3.30 · `teal` 3.07 (fallan en claro). La garantía de emparejamiento solo se cumple en los lienzos oscuros |
| F17 | `eidos/components/text` | `align` es **inerte** por defecto: el componente renderiza un `span` y `text-align` no hace nada sobre una caja inline. `align="center"` dejó la copia alineada a la izquierda dentro de un layout centrado sin avisar de nada. Se rodea con `as="p"` , pero el prop anuncia un efecto que no tiene hasta que el consumidor cambia el elemento |
feat(blocks): `newsletter` — el alta al boletín, y el reset que el framework daba por hecho
F2.9. Slots de snippet (eyebrow · title · description · field · submit · note ·
children), `center` y `justified`, y `panel` como interruptor real: el panel de
marca es la cara de las referencias, pero el control, la ayuda y el error están
calibrados para la superficie de página, así que apagarlo es un estado de primera
y no un fallback.
La fila es `Grid templateColumns={{ base: '1fr', sm: '1fr auto' }}`: el campo se
queda la pista libre y la acción abraza su contenido —eso es lo que hace que un
alta se lea como UN gesto— y bajo `sm` pasa a una columna, porque un botón al lado
de un campo de correo deja inservibles a los dos.
El block NO valida y NO emite sema. `Form` posee el runtime, el esquema, dirty/
touched, la agregación de errores y el foco al primer error; `Field` posee el
cableado ARIA; el morfo del `Form` ya declara `commit-submit` y `signal-invalid`.
El app posee esquema, valores y handler. No hay ni un `if` sobre un correo aquí.
## Superación del dossier: la etiqueta
El único hueco que el dossier nombraba era la nota de privacidad, y está. Pero la
diferencia de verdad es la etiqueta: las referencias shipean la fila escondiéndola
con `sr-only` o dejando solo un placeholder. El canon tiene `Field floatingLabel`
—arranca dentro del control y sube al borde al enfocar o rellenar—, así que la fila
queda alineada CON etiqueta real y asociada. Verificado con pulsaciones de teclado
de verdad: 10px dentro en reposo → −11px sobre el borde al enfocar y al rellenar.
## Tres hallazgos de canon más, medidos
- **La fundación de eidos no trae reset de modelo de caja y lo asume del app.**
`[data-field-control]` declara `inline-size: 100%` + padding, así que bajo
`content-box` el control mide 30px más que su contenedor: el campo se metía por
debajo del botón de envío. Campo 480 / control 510 en la galería frente a
502 / 502 en los docs de componentes, que sí resetean (igual que
`web/routes/active/styles.css`). Arreglado en app-land con
`web/routes/blocks/_lib/reset.css`, con A/B sobre los 10 previews y 5 páginas de
shell: cambia el newsletter y NADA más. Hay que importarlo dos veces porque la
galería arranca UIX en línea en vez de pasar por `BootUix` — deuda del arnés,
anotada en el handoff.
- **`onValidSubmit` es un no-op silencioso** cuando se pasa un `form` ya
construido: el componente solo lo reenvía al `createForm` que hace él mismo. El
envío validaba, limpiaba el error y no anunciaba nada. Por eso el block no
expone el prop: el handler va en el `createForm` del app.
- **Los mensajes de SIUM son idlangref.** La vía correcta es
`uix.langs.t(issue.message, issue.params)` —verificado, sale «Debe ser una
dirección de correo válida»—, pero la demo de docs del propio `Form` parte la
cadena a mano tras el `|`, así que el único ejemplo del repo enseña el patrón
equivocado y siempre muestra inglés.
Los tres quedan en los gaps del README y en `PLAN-blocks-quality.md` §6
(F18/F19/F20), sin tocar nada fuera del tier.
Verificado en navegador el arco completo: correo inválido → error traducido con
`role="alert"`, `aria-invalid`, `aria-describedby` y foco al primer error; correo
válido → confirmación del app (`Callout` afirmativo con la dirección) y error
limpio. Claro/oscuro/RTL, tres colores, `panel` sí/no, 420px. Cero errores de
página. Gates: `blocks:check` verde (10 blocks) · `svelte-check` sin errores
propios · prettier limpio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
### Añadidos al construir `newsletter` (2026-07-30) — el tier de formulario
| # | Dónde | Qué |
|---|---|---|
| F18 | fundación de `eidos` (`index.css`) | **No hay reset de modelo de caja.** Recetas como `[data-field-control]` declaran `inline-size: 100%` + `padding-inline` , así que bajo `content-box` el control mide **30px más que su contenedor** : en la fila `1fr auto` del newsletter el campo se metía por debajo del botón de envío. Medido: campo 480 / control 510 en la galería de blocks frente a 502 / 502 en los docs de componentes, cuyo `uix.css` resetea `box-sizing` bajo `[data-uix-docs]` (y `web/routes/active/styles.css` hace lo mismo). O sea: el framework **asume** que el app pone `border-box` y esa asunción solo está documentada porque app-land la cumple dos veces. Arreglado en mi lado con `web/routes/blocks/_lib/reset.css` (A/B sobre los 10 previews y 5 páginas de shell: cambia el newsletter y NADA más). La pregunta de canon es si la fundación debería poseerlo en vez de asumirlo |
| F19 | `soma/components/form` (`form.svelte` + `types.ts` ) | ** `onValidSubmit` / `onInvalidSubmit` son no-op silenciosos cuando se pasa un `form` ya construido.** El componente solo los reenvía al `createForm` que hace él mismo (la rama `defaults` ); con un handle externo se ignoran sin aviso, aunque el tipo los documenta como «Called when validation passes». El envío validaba, limpiaba el error y no anunciaba nada. El block los quitó de su API: el handler va en `createForm` |
| F20 | `libs/forms` + `web/routes/uix/components/form` | Los mensajes de SIUM llegan como **idlangref** (`#?sium.errors.email|Must be a valid email address`). La vía soportada es `uix.langs.t(issue.message, issue.params)` — verificado, resuelve el catálogo («Debe ser una dirección de correo válida»), no el fallback inglés. Pero la demo de docs del propio `Form` **parte la cadena a mano** tras el `|` con un helper local `fallbackMessage` , así que el único ejemplo del repo enseña el patrón equivocado y siempre muestra inglés |
feat(blocks): `site-footer` — el pie de la página, con el selector de idioma vivo
F2.10. Es el block donde la regla de forma del tier se ve mejor: `Column` SE
REPITE (el app mapea sobre N grupos), así que es parte compound; la marca, la
banda de alta, lo social, lo legal y el `extra` no se repiten ni coordinan, así
que son slots. El landmark es un `<footer>` de verdad: `contentinfo` sin pedir
nada.
Las columnas van en `AutoGrid minChildWidth`, **sin un solo breakpoint**: el mismo
código sirve para 2 grupos o para 5, y en móvil caen a dos. La entrada es un
`Motion trigger="viewport"` solo en la región superior y **sin escalonado** — una
línea de copyright que aparece con fundido es teatro, y nadie lee un pie columna
por columna.
## Los dos huecos que nombraba el dossier, cerrados
- **La banda de alta al boletín EN el pie** (3 de 7 en Tailwind Plus) como slot
`signup`, con su propia fila a lo ancho: en la columna de la marca (~230px) la
fila de correo tendría que apilarse. El formulario es del app; este block NO
importa el block `newsletter` (B-10) ni inventa validación.
- **El selector de idioma** como `extra` (el slot libre de D-BLK.6), y está VIVO:
escribe `uix.prefs.setIntent('language', …)`, el eje real del ecosistema.
Verificado — al elegir «English», `document.documentElement.lang` pasa a `en`.
## Tres números que salieron de medir, no de suponer
- El `container` bajó de `xl` a **`lg`**: con `xl` el pie no se alineaba con
ninguna sección de la página compuesta encima.
- La rejilla superior pasó de `1fr 2fr` a **`1fr 3fr`**: con `2fr`, un pie de
cuatro grupos se partía en 3+1.
- El gap entre columnas es **6, no 8**: con 8, cinco grupos no comparten fila al
ancho `lg` (728px justos). Horizontal más apretado que vertical, porque el gap
horizontal es el que decide cuántos grupos caben.
## Dos hallazgos de canon (F21/F22 en `PLAN-blocks-quality.md` §6)
- **Los primitivos de layout no pueden cambiar de elemento.** `Text` y `Heading`
aceptan `as`; `Box` —y por tanto `Stack`, `Flex`, `Grid`, `Group`, `Wrap`,
`Container`, `Section`— renderiza un `<div>` fijo. Consecuencia: una columna de
enlaces no puede ser `<ul>/<li>`, que es como la marcan las referencias. Un
block solo puede elegir entre divs o escribir markup que luego no puede estilar.
- **Un `Select` controlado muestra el VALOR crudo hasta que se abre una vez.**
`getDisplayText()` resuelve contra un registro de etiquetas que llenan los
`Select.Item` al montarse, y con el `Content` en un portal cerrado no hay ninguno
montado: `value=['es']` pintaba «es» en vez de «Español». Rodeado con el `child`
de `Select.Value`.
Verificado en navegador: 2/3/4/5 columnas, banda de alta sí/no, claro/oscuro/RTL
y 420px (2×2 columnas, fila de correo apilada), cero desbordamiento horizontal,
separador `aria-hidden`, los tres `IconButton` con nombre obligatorio, y el
idioma cambiando de verdad. Cero errores de página. Gates: `blocks:check` verde
(11 blocks) · `svelte-check` sin errores propios · prettier limpio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
### Añadidos al construir `site-footer` (2026-07-30)
| # | Dónde | Qué |
|---|---|---|
uix(section): la sección de una página es un componente, no un esqueleto que cada block reescribe
Los 19 blocks del tier repetían el mismo chasis: un <section {...rest}> escrito a
mano, dentro Section (el aire), dentro Container (la medida) y a veces
Background (la decoración). Medido antes de tocar: containerSize recableado en
16 blocks, sectionSize en 15, decor en 9, y OCHO copias del mismo Header —
Box maxWidth 48rem + Motion viewport + Stack gap 3—, una de las cuales ya había
derivado en silencio: la de article-grid perdió su Motion.
La regla del propio tier dice que una parte se gana el compound cuando SE
REPITE. Aquí lo que se repite son los blocks, así que la repetición se gana un
componente. Y no uno nuevo: ese componente ya existía a medias y se llama
Section. Crece en vez de nacer un hermano — un segundo componente obligaría a
explicar en cada README en qué se diferencia de Section, y la explicación sería
«Section con tres props más».
Section gana, todo opcional: containerSize (compone Container DENTRO, y sin él
los hijos se renderizan igual que siempre), decor (pinta Background.Pattern
como HIJO, para que la banda sangre a todo el ancho por detrás de la medida sin
ganar caja) y la parte Section.Header.
Y renderiza un <section> real. Renderizaba un <div> a propósito, con la nota
«si un consumidor necesita un <section> de verdad para el landmark, que lo
envuelva» — y nueve blocks hacían exactamente eso, pagando un nodo extra para
poder nombrarse. Un <section> sin nombre NO es landmark (se expone como region
sólo al llevar nombre accesible, que es lo que A-109 dejó medido esta misma
semana), así que el tag no ensucia el árbol de nadie y la raíz duplicada
desaparece.
Para eso Box gana `as`, que cierra F21 («los primitivos de layout no pueden
cambiar de elemento»): lista CERRADA de elementos contenedores, nunca void —
es una escotilla semántica, no un prop de tag libre—, default 'div', y el
modelo de caja, las vars y la receta idénticos rinda lo que rinda. Los ≥2 casos
reales que la decisión original pedía para reconsiderarlo llegaron hace tiempo:
la columna de enlaces del pie que debía ser ul/li, y estos dieciséis blocks.
Migrados DOS como prueba, uno por clase: feature-grid (medida) y stats-band
(medida + decoración). Cada uno pierde dos niveles de anidamiento y su
<section> duplicado — medido en navegador: [data-section] ES el <section>, el
patrón 'grid' sigue siendo su hijo, 1280 de ancho, 64px de aire, 1024 de
medida, y el documento pasa de 3 elementos <section> a 2. Los otros 17 van en
tandas aparte.
⚠️ El paso de tipos que costó los únicos 5 errores de esta rama, y que lo
explica una regla ya escrita en el propio types.ts de stats-band: «sub-parts
extend the props of the canon component they wrap, never HTMLAttributes: the
raw attribute surface clashes with the canon's refined style/class when spread
through (the feature-grid lesson)». Ahora la RAÍZ también envuelve un
componente del canon, así que sigue la misma regla: FeatureGridProps y
StatsBandProps extienden SectionProps.
Gates: svelte-check 72/62 antes y después · vitest eidos+blocks 470/471 (el
rojo es skin-media-player, ajeno y documentado) · blocks:check 0/19 ·
docs:check 0/0. Prosa al día: README de Box y de Section, la demo de Section
(que anunciaba la limitación en cuatro sitios) y F21 cerrado en
PLAN-blocks-quality §6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
| ~~F21~~ **CERRADO 2026-08-19** — `Box` gana `as` (lista cerrada de elementos contenedores, default `div` ), así que `Stack` /`Flex`/`Grid`/`Group`/`Wrap`/`Container`/`Section` heredan el polimorfismo; `Section` lo usa para renderizar `<section>` . La columna de enlaces del pie ya puede ser `ul` /`li`. | `eidos/components/box` (y todo lo construido sobre él) | **Los primitivos de layout no pueden cambiar de elemento.** `Text` y `Heading` aceptan `as` ; `Box` —y por tanto `Stack` , `Flex` , `Grid` , `Group` , `Wrap` , `Container` , `Section` — renderiza un `<div>` fijo. Consecuencia concreta: una columna de enlaces de pie no puede ser `<ul>/<li>` , que es como la marcan las referencias. Un block solo puede elegir entre divs o escribir markup que luego no puede estilar (contrato B). La asimetría (tipográficos sí, layout no) ES el hallazgo |
feat(blocks): `site-footer` — el pie de la página, con el selector de idioma vivo
F2.10. Es el block donde la regla de forma del tier se ve mejor: `Column` SE
REPITE (el app mapea sobre N grupos), así que es parte compound; la marca, la
banda de alta, lo social, lo legal y el `extra` no se repiten ni coordinan, así
que son slots. El landmark es un `<footer>` de verdad: `contentinfo` sin pedir
nada.
Las columnas van en `AutoGrid minChildWidth`, **sin un solo breakpoint**: el mismo
código sirve para 2 grupos o para 5, y en móvil caen a dos. La entrada es un
`Motion trigger="viewport"` solo en la región superior y **sin escalonado** — una
línea de copyright que aparece con fundido es teatro, y nadie lee un pie columna
por columna.
## Los dos huecos que nombraba el dossier, cerrados
- **La banda de alta al boletín EN el pie** (3 de 7 en Tailwind Plus) como slot
`signup`, con su propia fila a lo ancho: en la columna de la marca (~230px) la
fila de correo tendría que apilarse. El formulario es del app; este block NO
importa el block `newsletter` (B-10) ni inventa validación.
- **El selector de idioma** como `extra` (el slot libre de D-BLK.6), y está VIVO:
escribe `uix.prefs.setIntent('language', …)`, el eje real del ecosistema.
Verificado — al elegir «English», `document.documentElement.lang` pasa a `en`.
## Tres números que salieron de medir, no de suponer
- El `container` bajó de `xl` a **`lg`**: con `xl` el pie no se alineaba con
ninguna sección de la página compuesta encima.
- La rejilla superior pasó de `1fr 2fr` a **`1fr 3fr`**: con `2fr`, un pie de
cuatro grupos se partía en 3+1.
- El gap entre columnas es **6, no 8**: con 8, cinco grupos no comparten fila al
ancho `lg` (728px justos). Horizontal más apretado que vertical, porque el gap
horizontal es el que decide cuántos grupos caben.
## Dos hallazgos de canon (F21/F22 en `PLAN-blocks-quality.md` §6)
- **Los primitivos de layout no pueden cambiar de elemento.** `Text` y `Heading`
aceptan `as`; `Box` —y por tanto `Stack`, `Flex`, `Grid`, `Group`, `Wrap`,
`Container`, `Section`— renderiza un `<div>` fijo. Consecuencia: una columna de
enlaces no puede ser `<ul>/<li>`, que es como la marcan las referencias. Un
block solo puede elegir entre divs o escribir markup que luego no puede estilar.
- **Un `Select` controlado muestra el VALOR crudo hasta que se abre una vez.**
`getDisplayText()` resuelve contra un registro de etiquetas que llenan los
`Select.Item` al montarse, y con el `Content` en un portal cerrado no hay ninguno
montado: `value=['es']` pintaba «es» en vez de «Español». Rodeado con el `child`
de `Select.Value`.
Verificado en navegador: 2/3/4/5 columnas, banda de alta sí/no, claro/oscuro/RTL
y 420px (2×2 columnas, fila de correo apilada), cero desbordamiento horizontal,
separador `aria-hidden`, los tres `IconButton` con nombre obligatorio, y el
idioma cambiando de verdad. Cero errores de página. Gates: `blocks:check` verde
(11 blocks) · `svelte-check` sin errores propios · prettier limpio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
| F22 | `soma/components/select` | **Un `Select` controlado muestra el VALOR crudo hasta que se abre una vez.** `getDisplayText()` resuelve contra un registro de etiquetas que llenan los `Select.Item` AL MONTARSE; con el `Content` dentro de `Select.Portal` y cerrado no hay ninguno montado, así que `value=['es']` pinta «es» en vez de «Español». Se rodea con el `child` de `Select.Value` , pero el caso «select controlado que aún no se ha abierto» es el más común de todos |