feat(blocks): el guard vigila la frontera, y siete filas del ledger caen medidas

FASE 5 DEL SANEAMIENTO — `blocks:check` deja de vigilar solo la forma del tier
y pasa a vigilar su frontera, su documentacion y su catalogo. Tres reglas, cada
una con su fixture negativo en el selfTest():

1. Lista blanca de importaciones (B-4). La frontera dura 1 era prosa; ahora es
   codigo: $uix, los arts publicos, $libs/forms, svelte y los relativos del
   propio block. Los arts se DERIVAN de src/arts/*, no se listan a mano — una
   lista escrita se queda atras el dia que aterriza un art. El tier ya cumplia:
   cero violaciones al encenderla.
2. README completo (B-9) + declaracion de landmark (B-8). Salieron 7 de 15 sin
   ella; escritas leyendo la fuente de cada block, no la plantilla.
3. Ficha en el catalogo de demos (B-9), en los DOS sentidos: un block que el
   catalogo no publica es invisible para el rail, y un slug publicado sin block
   detras es un enlace muerto.

De paso el escaner deja de leer los comentarios como codigo: los index.ts
documentan su uso con un `// import { Cta } from '$blocks/cta'`, y una lista
blanca que lee prosa habria empezado a acusar a los ejemplos.

Verificado EN ROJO sobre el arbol real, no solo contra los fixtures: un zod y un
$libs/days metidos en hero/types.ts salen con su linea exacta mientras el mismo
import dentro de un comentario NO salta; el catalogo falla en las dos
direcciones; y al romper isAllowedSpec a proposito el self-test aborta el guard
en vez de dar verde.

ARREGLOS DEL CONTRASTE DOCTRINAL — siete filas, cada una con medicion o gate:

- A-88 team: `height="100%"` en el Stack del Member. El div que pinta Motion es
  un bloque pelado, asi que el Stack se quedaba a altura de contenido y el
  `margin-top:auto` de los enlaces repartia CERO. Medido: la desviacion entre
  filas de iconos pasa de 20px a 0, y el margen reparte 20,297px.
- A-94/A-28 (feature-grid, team, testimonials): los tres declaraban su tipo como
  `= AutoGridProps` y ponian {...rest} ANTES de las props que fijan, asi que un
  consumidor podia escribir align="start" y no obtener nada. Cerrados con
  Pick<AutoGridProps, ...>, que conserva los tipos exactos del canon.
- A-84 feature-grid: el eje `align` llega a las partes por contexto, como en
  team, en vez de que el typedoc instruya al app a repetirlo. Medido: con
  align=center el align-items computado es center en cabecera Y celdas.
- A-19 faq: `marginX="auto"` en el Header — Box no centra solo. Medido: el Box
  de 768px resuelve margin-inline 248px/248px donde antes daba 0.
- A-27 testimonials y A-86 pricing: el typedoc decia `soft` donde el codigo hace
  `outline`, y el ejemplo de la puerta de entrada no compilaba.
- A-91 banner (fila nueva): el block hereda el eje intent/color que el canon
  declara fuera del sistema abierto en su propio typedoc, sin registrarlo.

LEDGER — dos lecciones de instrumento escritas dentro, porque ambas produjeron
hallazgos falsos publicados:

1. El dev server sirve codigo ANTERIOR a HEAD si reusa cache de Vite. Tres filas
   (A-36, A-69, A-85) se dieron por vivas estando arregladas. El delator fue
   aritmetico: las duraciones medidas eran EXACTAMENTE las que el comentario del
   arreglo cita como estado anterior.
2. Contar nodos WebAudio no es medir sonido: la sintesis crea dos osciladores
   por earcon, un sound pack de muestras cambia la aritmetica entera, y crear
   nodos no es sonar. El contador responde "hubo actividad" y nada mas.

Y el error de metodo del que ambas son sintoma, tambien registrado: se fue a
re-medir A-67 sin leer su ficha, que ya traia los gains del catalogo, el empate
de applyDominance y la regla de CANON.md incumplida — mejor razonado que la nota
que lo sustituia.

Gates: blocks:check verde (15 blocks / 115 ficheros) · svelte-check 69 errores /
57 avisos = linea base exacta, ninguno en lo tocado · docs:check 0/0 · prettier
limpio en todo lo del commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 998d72191e
commit 948f7cf7e1

@ -117,19 +117,19 @@ Group / Separator / AspectRatio / Surface`) and their props. A block ships
Guarded mechanically by `npm run blocks:check` (self-testing: the guard
asserts its own detectors against inline fixtures on every run).
| B | Obligation |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B-1 | No morfo, no sema pack, no audit row. Behavior with contract surface is promoted to canon BEFORE the block composes it. |
| B-2 | Every interactive element is a canon eidos component (`Button`, `Link`, `Field`, …). Raw interactive natives (`button/input/select/textarea/a`) are an error. (Single exception: the already-rendered HTML that `Prose` receives — that content belongs to the app.) |
| B-3 | Composed components are consumed AS-IS through their public props (`variant/size/color/…`). Re-styling their internals from the block (CSS or `style=`) is forbidden. Colors are always roles/tokens via props. |
| B-4 | One-way imports (hard boundary 1). Deleting the tier leaves `check` green. Blocks do not import each other (see B-10). |
| B-5 | Content enters by composition (children/snippets) — never `root={tree}` data-tree props (`items={...}` only where the composed canon component is already data-driven). A coordinating block MAY ship the default **shape** of the data it coordinates (a schema), which the app replaces by passing its own — a shape is not content. |
| B-6 | Responsive via the framework's mechanisms (responsive props of the layout components, canonical breakpoints). No `matchMedia`/listeners of its own — needing to observe something is the admission rule firing. |
| B-7 | Everything the APP says arrives as children/props — a block writes none of it. **Amended 2026-07-31**: a block DOES own the words of the states it coordinates (and only those), as idlangrefs with an English fallback resolved through the translator (`#?blocks.<block>.<key>\|…`). The reason a state names itself is the same reason it exists: the record that blocks the action writes the sentence, so it cannot go silent. |
| B-8 | Correct landmarks: sectioning element + `aria-label`/`aria-labelledby` where landmarks repeat; heading hierarchy coherent and documented in the block README (which level it emits, how to adjust). |
| B-9 | Every block ships `README.md` (**Function · Composition map** — which canon components, which props — **· Decisions · Gaps-with-disposition**) and a live demo page under `web/routes/blocks/{kebab}/`, built on the shared demo shell (`web/routes/blocks/_lib/BlockDemo.svelte`): the block is shown FULL-BLEED on the page — never inside a padded frame or a scroll box, which would change what it does — with the device widths served by its own `preview` route. Anatomy in [`src/uix/blocks/README.md`](../../src/uix/blocks/README.md). |
| B-10 | A block does not import another block. Shared structure is either a canon component or a conscious duplication (recorded in Gaps). Declared exception: the shells (`app-shell`, `docs-shell`) compose F1 pieces and blocks by design — allow-listed in `blocks:check`. |
| B-11 | Motion only through the composed components' `motion` props/presets or `Cascade` for entrance choreography. No `@keyframes`/transitions of its own. |
| B | Obligation |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B-1 | No morfo, no sema pack, no audit row. Behavior with contract surface is promoted to canon BEFORE the block composes it. |
| B-2 | Every interactive element is a canon eidos component (`Button`, `Link`, `Field`, …). Raw interactive natives (`button/input/select/textarea/a`) are an error. (Single exception: the already-rendered HTML that `Prose` receives — that content belongs to the app.) |
| B-3 | Composed components are consumed AS-IS through their public props (`variant/size/color/…`). Re-styling their internals from the block (CSS or `style=`) is forbidden. Colors are always roles/tokens via props. |
| B-4 | One-way imports (hard boundary 1). Deleting the tier leaves `check` green. Blocks do not import each other (see B-10). The allowlist is mechanical since 2026-08-09: `$uix`, the public arts (**derived from `src/arts/*`**, never a hand list), `$libs/forms`, `svelte` / `svelte/elements` and the block's own relatives. Anything else is either promoted to canon or the app's, and the guard says so by name. |
| B-5 | Content enters by composition (children/snippets) — never `root={tree}` data-tree props (`items={...}` only where the composed canon component is already data-driven). A coordinating block MAY ship the default **shape** of the data it coordinates (a schema), which the app replaces by passing its own — a shape is not content. |
| B-6 | Responsive via the framework's mechanisms (responsive props of the layout components, canonical breakpoints). No `matchMedia`/listeners of its own — needing to observe something is the admission rule firing. |
| B-7 | Everything the APP says arrives as children/props — a block writes none of it. **Amended 2026-07-31**: a block DOES own the words of the states it coordinates (and only those), as idlangrefs with an English fallback resolved through the translator (`#?blocks.<block>.<key>\|…`). The reason a state names itself is the same reason it exists: the record that blocks the action writes the sentence, so it cannot go silent. |
| B-8 | Correct landmarks: sectioning element + `aria-label`/`aria-labelledby` where landmarks repeat; heading hierarchy coherent and documented in the block README (which level it emits, how to adjust). The declaration is a `**Landmark + headings**` paragraph and `blocks:check` requires it — a block with no landmark of its own says THAT, which is the answer the app needs to name the region itself. |
| B-9 | Every block ships `README.md` (**Function · Composition map** — which canon components, which props — **· Decisions · Gaps-with-disposition**) and a live demo page under `web/routes/blocks/{kebab}/`, built on the shared demo shell (`web/routes/blocks/_lib/BlockDemo.svelte`): the block is shown FULL-BLEED on the page — never inside a padded frame or a scroll box, which would change what it does — with the device widths served by its own `preview` route. Anatomy in [`src/uix/blocks/README.md`](../../src/uix/blocks/README.md). `blocks:check` requires the README's four sections (Function · Composition map · Decisions · Gaps) and a `shipped` entry in the demo catalog (`web/routes/blocks/_lib/catalog.ts`), both ways: a block the catalog does not ship is invisible to the rail, and a slug it ships without a block behind it is a dead link. |
| B-10 | A block does not import another block. Shared structure is either a canon component or a conscious duplication (recorded in Gaps). Declared exception: the shells (`app-shell`, `docs-shell`) compose F1 pieces and blocks by design — allow-listed in `blocks:check`. |
| B-11 | Motion only through the composed components' `motion` props/presets or `Cascade` for entrance choreography. No `@keyframes`/transitions of its own. |
## Conventions

@ -50,19 +50,19 @@ Columna **or.** = de qué sección del documento viejo salió la fila (`C` confi
| A-04 | `contact` | doctrina | ALTA | C | `CONTACT_ACTION` («la acción nombra lo que está pasando», state.ts:86) solo se cumple a la vista: el nombre accesible del botón es siempre «Enviar»,… | `src/uix/blocks/contact/state.ts` | ARREGLADO |
| A-05 | `contact` | doctrina | ALTA | C | El block importa de `$libs/forms`, que no está en la lista de importaciones permitidas de la frontera dura 1 («may import `$uix`, `$adom` and the… | `src/uix/blocks/contact/contact.svelte` | ARREGLADO |
| A-06 | `site-footer` | percepcion | ALTA | C | El éxito del alta en la banda de sign-up del footer es PERCEPTIVAMENTE NULO: no suena, no estampa y no emite — el único momento que merecía… | `web/routes/blocks/site-footer/SiteFooterSite.svelte:105` | ARREGLADO |
| A-07 | `faq` | doctrina | MEDIA | C | La sección «Coordination» declara UNA sola costura (`value`) pero el block también expone `disabled`: una pregunta que no se puede abrir, sin nada… | `src/uix/blocks/faq/types.ts` | CONFIRMADO |
| A-07 | `faq` | doctrina | MEDIA | C | La sección «Coordination» declara UNA sola costura (`value`) pero el block también expone `disabled`: una pregunta que no se puede abrir, sin nada… | `src/uix/blocks/faq/types.ts` | ARREGLADO |
| A-08 | `cta` | doctrina | MEDIA | C | La medida de la descripción se construye a mano con un valor fuera de escala (`60ch`) en vez de usar el vocabulario de medida que el canon ya expone… | `src/uix/blocks/cta/cta.svelte` | ARREGLADO |
| A-09 | `stats-band` | doctrina | MEDIA | C | En modo `live` el anuncio a lectores de pantalla puede llevar la cifra CRUDA mientras en pantalla se ve la formateada por locale: el block pasa… | `src/uix/blocks/stats-band/stats-band-value.svelte` | CONFIRMADO |
| A-10 | `site-header` | doctrina | MEDIA | C | Si el cajón está abierto y el viewport cruza el `breakpoint`, el propio block esconde cajón + overlay + trigger con `display:none`, pero el Drawer… | `src/uix/blocks/site-header/site-header.svelte` | ARREGLADO |
| A-11 | `site-header` | doctrina | MEDIA | C | Las demos de site-header y hero redeclaran a mano uniones que ya existen en el canon, y ya han derivado: el `xxl` de `ContainerSize` es inalcanzable… | `web/routes/blocks/site-header/SiteHeaderSite.svelte` | CONFIRMADO |
| A-12 | `feature-grid` | doctrina | MEDIA | C | El tipo y la demo describen el chip del icono como `Surface` soft; el código lo pinta solid por defecto, y el README dice solid. Dos de las cuatro… | `src/uix/blocks/feature-grid/types.ts` | ARREGLADO |
| A-13 | `testimonials` | percepcion | MEDIA | C | Mismo defecto: `height="100%"` sobre `Card` es inerte, así que la tarjeta no rellena la altura de fila y las caras NO se alinean cuando las citas… | `src/uix/blocks/testimonials/testimonials-item.svelte` | ARREGLADO |
| A-14 | `feature-split · pricing · testimonials` | doctrina | MEDIA | C | Ninguno de los tres README declara el landmark ni la jerarquía de encabezados, que es la segunda mitad literal de B-8 y el único apartado a11y propio… | `src/uix/blocks/pricing/README.md` | CONFIRMADO |
| A-14 | `feature-split · pricing · testimonials` | doctrina | MEDIA | C | Ninguno de los tres README declara el landmark ni la jerarquía de encabezados, que es la segunda mitad literal de B-8 y el único apartado a11y propio… | `src/uix/blocks/pricing/README.md` | ARREGLADO |
| A-15 | `contact` | doctrina | MEDIA | C | La frase del estado no llega a la tecnología de apoyo: el submit va `disabled` nativo (no es alcanzable con teclado, así que un lector de pantalla… | `src/uix/blocks/contact/contact-reason.svelte` | ARREGLADO |
| A-16 | `contact` | doctrina | MEDIA | C | Deriva README↔código: el README afirma en negrita que el block «no valida y no pinta los errores de los campos», y `Contact.Fields` hace exactamente… | `src/uix/blocks/contact/README.md` | ARREGLADO |
| A-17 | `content-section` | doctrina | MEDIA | C | La pestaña API de la demo (superficie obligatoria por B-9) publica un default falso para `ContentSection.Media`: dice `width` «def. wide» cuando el… | `web/routes/blocks/content-section/+page.svelte` | ARREGLADO |
| A-18 | `team` | doctrina | MEDIA | C | Comentario nuevo en castellano dentro de la fuente de un block, contra la regla del proyecto (comentarios en inglés; los castellanos que quedan son… | `src/uix/blocks/team/team-member-links.svelte` | ARREGLADO |
| A-19 | `faq` | percepcion | BAJA | C | El typedoc de `Faq.Header` promete un Box «centered» que el propio root desmiente: la raíz apila con `align="stretch"`, así que la cabecera queda… | `src/uix/blocks/faq/types.ts` | CONFIRMADO |
| A-19 | `faq` | percepcion | BAJA | C | El typedoc de `Faq.Header` promete un Box «centered» que el propio root desmiente: la raíz apila con `align="stretch"`, así que la cabecera queda… | `src/uix/blocks/faq/types.ts` | ARREGLADO |
| A-20 | `faq` | doctrina | BAJA | C | B-8 incumplido: el block emite un encabezado por pregunta con `aria-level` FIJO en 3, no ofrece forma de ajustarlo y el README no documenta ni el… | `src/uix/blocks/faq/README.md` | ARREGLADO |
| A-21 | `stats-band` | doctrina | BAJA | C | El typedoc de `countOptions` anuncia una opción `style` que `CountUp` NO acepta — pasarla es error de tipos. | `src/uix/blocks/stats-band/types.ts` | ARREGLADO |
| A-22 | `stats-band` | doctrina | BAJA | C | El README no documenta landmark ni jerarquía de encabezados (B-8); el dato solo vive en la pestaña A11y de la demo. | `src/uix/blocks/stats-band/README.md` | ARREGLADO |
@ -70,20 +70,20 @@ Columna **or.** = de qué sección del documento viejo salió la fila (`C` confi
| A-24 | `pricing` | percepcion | BAJA | C | Las tarjetas de plan NO son de igual alto y los CTA NO se alinean: `height="100%"` sobre `Card` es un atributo HTML inerte, no una prop. El README y… | `src/uix/blocks/pricing/pricing-plan.svelte` | ARREGLADO |
| A-25 | `pricing` | doctrina | BAJA | C | La demo documenta una elevación que el código se niega explícitamente a hacer y que el propio README lista como brecha abierta del canon. | `web/routes/blocks/pricing/+page.svelte` | CONFIRMADO |
| A-26 | `pricing` | doctrina | BAJA | C | El block exige a la app tres cadenas obligatorias para nombrar el ÚNICO estado que coordina, y justifica esa exigencia citando una lectura de B-7 que… | `src/uix/blocks/pricing/pricing-switch.svelte` | REFUTADO |
| A-27 | `testimonials` | doctrina | BAJA | C | El tipo público documenta un default de `variant` que el componente no usa. | `src/uix/blocks/testimonials/types.ts` | CONFIRMADO |
| A-28 | `testimonials` | doctrina | BAJA | C | `.Items` tipa toda la superficie de `AutoGrid` pero descarta en silencio parte de ella: el spread del consumidor va ANTES de props fijadas a mano. | `src/uix/blocks/testimonials/testimonials-items.svelte` | CONFIRMADO |
| A-27 | `testimonials` | doctrina | BAJA | C | El tipo público documenta un default de `variant` que el componente no usa. | `src/uix/blocks/testimonials/types.ts` | ARREGLADO |
| A-28 | `testimonials` | doctrina | BAJA | C | `.Items` tipa toda la superficie de `AutoGrid` pero descarta en silencio parte de ella: el spread del consumidor va ANTES de props fijadas a mano. | `src/uix/blocks/testimonials/testimonials-items.svelte` | ARREGLADO |
| A-29 | `pricing · testimonials` | percepcion | BAJA | C | Las vistas de 375/768 de estas dos demos no heredan NINGUNO de los ejes de la sección: siempre salen en claro, español y ltr — justo la rama estrecha… | `web/routes/blocks/pricing/+page.svelte` | CONFIRMADO |
| A-30 | `newsletter` | doctrina | ALTA | SV | UNA sola pulsación de Enter dentro del campo de correo emite DOS `commit-submit` con intents contradictorios (neutral y fulfill) y suenan DOS earcons… | `src/uix/blocks/newsletter/newsletter.svelte:105` | CONFIRMADO |
| A-30 | `newsletter` | doctrina | ALTA | SV | UNA sola pulsación de Enter dentro del campo de correo emite DOS `commit-submit` con intents contradictorios (neutral y fulfill) y suenan DOS earcons… | `src/uix/blocks/newsletter/newsletter.svelte:105` | ARREGLADO |
| A-31 | `newsletter` | doctrina | ALTA | SV | `signal-warn-invalid` está declarado `persistence:'untilFix'` pero NUNCA se limpia al arreglar: corregido el correo, el form sigue estampado en… | `src/uix/soma/components/form/form-provider.svelte.ts:129` | REFUTADO |
| A-32 | `site-header` | percepcion | ALTA | SV | El pack de sema de NavigationMenu NUNCA casa: su regla de cascada apunta a la parte `item` y el estampado cae siempre en el `trigger`, asi que el nav… | `src/uix/sema/components/navigation-menu.ts` | ARREGLADO |
| A-33 | `site-header` | percepcion | ALTA | SV | El nav del header emite `commit-select` + `affirm` — con earcon audible — por el simple HOVER del puntero, sin un solo clic: un barrido del raton… | `src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts` | ARREGLADO |
| A-34 | `site-header` | percepcion | ALTA | SV | Inversion de dominancia atencional: pasar el raton por encima del nav suena mas largo y mas fuerte que pulsar el CTA principal del block, y con… | `web/routes/blocks/site-header/SiteHeaderSite.svelte` | ARREGLADO |
| A-35 | `site-header` | percepcion | ALTA | SV | MUDOS: 4 de los 7 elementos interactivos visibles del header no emiten NADA, y son precisamente los que navegan — el brand «Acme», los… | `web/routes/blocks/site-header/SiteHeaderSite.svelte` | ARREGLADO |
| A-36 | `site-header` | percepcion | ALTA | SV | Carrera de estampado en el trigger del drawer: `contact-activate` vive 3 ms antes de que `emerge.open` lo pise en el MISMO nodo, asi que el feedback… | `src/uix/blocks/site-header/site-header.svelte` | CONFIRMADO |
| A-36 | `site-header` | percepcion | ALTA | SV | Carrera de estampado en el trigger del drawer: `contact-activate` vive 3 ms antes de que `emerge.open` lo pise en el MISMO nodo, asi que el feedback… | `src/uix/blocks/site-header/site-header.svelte` | ARREGLADO |
| A-37 | `banner` | doctrina | ALTA | SV | El block cablea el descarte con `variant="ghost"` y sin tinta, así que la ✕ se pinta SIEMPRE con el ink `primary` del Button sobre el lienzo que el… | `src/uix/blocks/banner/banner.svelte` | ARREGLADO |
| A-38 | `banner` | percepcion | ALTA | SV | La pestaña «Notas» afirma que la acción en línea HEREDA la tinta de contraste de la tira «en todos los roles y en los dos modos». La medición lo… | `web/routes/blocks/banner/+page.svelte` | ARREGLADO |
| A-39 | `newsletter` | doctrina | ALTA | SV | El ejemplo de uso de la puerta de entrada del block anuncia `onValidSubmit` — exactamente el prop que el block decidió NO exponer porque es un no-op… | `src/uix/blocks/newsletter/index.ts` | ARREGLADO |
| A-40 | `contact` | percepcion | ALTA | SV | El clic sobre «Enviar mensaje» —la accion principal de la seccion— no estampa NINGUN evento sema en el momento del gesto; la unica emision llega 915… | `src/uix/blocks/contact/contact-submit.svelte` | CONFIRMADO |
| A-40 | `contact` | percepcion | ALTA | SV | El clic sobre «Enviar mensaje» —la accion principal de la seccion— no estampa NINGUN evento sema en el momento del gesto; la unica emision llega 915… | `src/uix/blocks/contact/contact-submit.svelte` | ARREGLADO |
| A-41 | `contact` | percepcion | ALTA | SV | Un solo Enter dentro de un campo estampa DOS commits con intents distintos; y cuando el envio esta bloqueado estampa un commit que no commitea nada. | `src/uix/morfo/components/field.ts` | ARREGLADO |
| A-42 | `contact` | percepcion | ALTA | SV | El nombre accesible del boton de envio queda congelado en «Enviar» en los cinco estados: las palabras de estado que el block posee por B-7 nunca… | `src/uix/blocks/contact/contact-submit.svelte` | ARREGLADO |
| A-43 | `contact` | percepcion | ALTA | SV | `Contact.Reason` —la frase que el block existe para que ningun estado bloqueado quede mudo— no es region viva ni esta enlazada al control: cambia sin… | `src/uix/blocks/contact/contact-reason.svelte` | ARREGLADO |
@ -94,7 +94,7 @@ Columna **or.** = de qué sección del documento viejo salió la fila (`C` confi
| A-48 | `banner` | percepcion | MEDIA | SV | El descarte —el ÚNICO gesto que el block cablea— sólo expresa `contact-activate` (una pulsación); la desaparición de la tira no la expresa nadie, y… | `src/uix/blocks/banner/banner.svelte:59` | CONFIRMADO |
| A-49 | `banner` | percepcion | MEDIA | SV | La única llamada a la acción del banner («Ver qué cambia →») es completamente muda: ni estampa ni suena, en clic, en Enter ni en hover. | `web/routes/blocks/banner/BannerSite.svelte:56` | REFUTADO |
| A-50 | `site-footer` | percepcion | MEDIA | SV | 18 de los 24 elementos interactivos del footer son `Link` y TODOS son mudos: el block cuya función entera es navegar no tiene canal perceptivo para… | `src/uix/blocks/site-footer/site-footer.svelte:77` | REFUTADO |
| A-51 | `newsletter` | doctrina | MEDIA | SV | La acción principal del block no tiene `contact` propio: pulsar «Suscribirme» no estampa NADA sobre el control — el único estampado es el `commit`… | `src/uix/morfo/components/form.ts:90` | CONFIRMADO |
| A-51 | `newsletter` | doctrina | MEDIA | SV | La acción principal del block no tiene `contact` propio: pulsar «Suscribirme» no estampa NADA sobre el control — el único estampado es el `commit`… | `src/uix/morfo/components/form.ts:90` | ARREGLADO |
| A-52 | `site-header` | percepcion | MEDIA | SV | La costura `mobileOpen` cierra el drawer en SILENCIO: el unico camino de cierre que el block documenta como suyo es el unico que no se anuncia,… | `src/uix/blocks/site-header/site-header.svelte` | REFUTADO |
| A-53 | `hero` | percepcion | MEDIA | SV | Las dos acciones del hero viven en el mismo `Group`, con la misma jerarquia de par primario/secundario, y responden de forma opuesta: el CTA emite… | `web/routes/blocks/hero/HeroSite.svelte` | CONFIRMADO |
| A-54 | `site-header` | doctrina | MEDIA | SV | El morfo de `NavigationMenu` carga el intent evaluable en el MARCO: declara un unico evento, `commit-select` + `affirm`, para lo que solo despliega… | `src/uix/morfo/components/navigation-menu.ts` | ARREGLADO |
@ -112,7 +112,7 @@ Columna **or.** = de qué sección del documento viejo salió la fila (`C` confi
| 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-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` | CONFIRMADO |
| 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 |
| A-72 | `contact` | percepcion | BAJA | SV | La ventana de vuelo (900 ms) no tiene canal perceptivo propio: ningun `sustain.*`, ningun spinner — solo el cambio de etiqueta visible. | `src/uix/blocks/contact/contact-submit.svelte` | REFUTADO |
@ -121,19 +121,23 @@ Columna **or.** = de qué sección del documento viejo salió la fila (`C` confi
| A-75 | `pricing` | percepcion | BAJA | SV | Volver a pulsar el CTA de un plan YA elegido re-emite un `commit-select`+`affirm` identico: no hay diferencia perceptiva entre «acabas de elegirlo» y… | `web/routes/blocks/pricing/PricingSite.svelte` | CONFIRMADO |
| A-76 | `testimonials` | percepcion | BAJA | SV | DATO, no defecto: el block no pone NINGUN elemento interactivo — no hay nada que pulsar y, por tanto, nada mudo; ademas sus Card no fingen afordancia. | `src/uix/blocks/testimonials/testimonials.svelte` | DATO |
| A-77 | `stats-band` | percepcion | BAJA | SV | DATO, no defecto: el block no pone NINGUN elemento interactivo — no hay nada que pulsar y por tanto nada que estampar. Toda su superficie perceptiva… | `src/uix/blocks/stats-band/stats-band.svelte` | DATO |
| A-78 | `stats-band` | doctrina | BAJA | SV | El <section> del block sale sin nombre accesible Y sin ningun encabezado dentro, asi que no es una region navegable ni por landmarks ni por… | `src/uix/blocks/stats-band/stats-band.svelte:26` | CONFIRMADO |
| A-78 | `stats-band` | doctrina | BAJA | SV | El <section> del block sale sin nombre accesible Y sin ningun encabezado dentro, asi que no es una region navegable ni por landmarks ni por… | `src/uix/blocks/stats-band/stats-band.svelte:26` | ARREGLADO |
| A-79 | `site-footer` | percepcion | SOSP | SV | Cambiar de idioma en el selector del footer cambia el régimen de toda la página (langs, formatos, dirección) pero suena y se estampa exactamente… | `web/routes/blocks/site-footer/SiteFooterSite.svelte:164` | CONFIRMADO |
| A-80 | `faq` | — | — | R | El block reimplementa la autogeneración del `value` que el canon ya hace, y README+index la venden como «la única conveniencia» de `.Item`. | — | ARREGLADO |
| A-81 | `cta` | — | — | R | El ejemplo del `index.ts` anuncia `variant="soft"`, una prop que se CORTÓ a propósito: copiarlo es un error de tipos. | — | ARREGLADO |
| A-82 | `cta` | — | — | R | La demo redeclara en app-land la unión que el block EXPORTA (`CtaLayout`), duplicando vocabulario. | — | CONFIRMADO |
| A-82 | `cta` | — | — | R | La demo redeclara en app-land la unión que el block EXPORTA (`CtaLayout`), duplicando vocabulario. | — | ARREGLADO |
| A-83 | `hero` | — | — | R | El block deriva el estado «la copia va sobre sólido» y lo aplica solo a dos de sus piezas, sin publicarlo: cada consumidor lo re-deriva en el punto… | — | ARREGLADO |
| A-84 | `feature-grid` | — | — | R | `align` está aplicado a medias: el block declara la prop y solo mueve la caja de la cabecera; el propio tipo instruye al app a repetir la alineación… | — | CONFIRMADO |
| A-85 | `site-header` | — | — | R | El `aria-label` que la demo escribe para el trigger del cajón nunca llega al DOM: el lector de pantalla anuncia la etiqueta genérica del canon. | — | CONFIRMADO |
| A-86 | `pricing` | — | — | R | El ejemplo de uso de la superficie pública del block no compila: omite dos props obligatorias. | — | CONFIRMADO |
| A-84 | `feature-grid` | — | — | R | `align` está aplicado a medias: el block declara la prop y solo mueve la caja de la cabecera; el propio tipo instruye al app a repetir la alineación… | — | ARREGLADO |
| A-85 | `site-header` | — | — | R | El `aria-label` que la demo escribe para el trigger del cajón nunca llega al DOM: el lector de pantalla anuncia la etiqueta genérica del canon. | — | ARREGLADO |
| A-86 | `pricing` | — | — | R | El ejemplo de uso de la superficie pública del block no compila: omite dos props obligatorias. | — | ARREGLADO |
| A-87 | `contact` | — | — | R | El propio gating del block deja INALCANZABLE el error por campo: con `progressive` los errores solo se escriben tras un envío inválido, y el envío… | — | ARREGLADO |
| A-88 | `team` | — | — | R | `marginTop: 'auto'` no ancla nada: el ítem de la retícula es el `<div>` del `Motion` (block, estirado), y el `Stack` de dentro mide su contenido, así… | — | CONFIRMADO |
| A-88 | `team` | — | — | R | `marginTop: 'auto'` no ancla nada: el ítem de la retícula es el `<div>` del `Motion` (block, estirado), y el `Stack` de dentro mide su contenido, así… | — | ARREGLADO |
| A-89 | `contact` | — | — | R | `sent` es un estado TERMINAL del que el block no sabe salir, y lo entra él solo: tras un envío correcto el formulario sigue editable pero la sección… | — | REFUTADO |
| 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-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 |
## Reparto
@ -187,6 +191,194 @@ y 30 de las confirmadas ya ARREGLADAS.
el CANON** (`NavigationMenu`, `Form.Submit`, morfo de `Field`, `Link`), no en los blocks. Los
blocks componen bien; lo que está roto es el arco perceptivo del canon.
## Fase 6 — re-medición de los confirmados (2026-08-10)
Las 33 filas que seguían CONFIRMADAS, medidas otra vez ANTES de tocar código. El
motivo: de las 12 ALTA de percepción, 8 tenían la causa raíz en el CANON, y el
canon se arregló el 2026-08-06 — cada fila de percepción viva era sospechosa de
estar ya cerrada. **Seis lo estaban** (A-07 · A-30 · A-40 · A-51 · A-78 · A-82),
y sus fichas llevan la evidencia. Quedan **27**.
Método: lectura dirigida para doctrina; para percepción, Playwright headless
contra un dev server propio (`:5201`), con la puerta de hidratación honesta
(`#uix-blocks-display` + ida y vuelta reactiva) y dos sondas — contador de nodos
WebAudio parcheado antes de cargar y `MutationObserver` de `data-event*` sobre
todo el documento.
**Lo que la re-medición corrige del método anterior**, y que vale para la
siguiente: el cero de la escala tiene que ser el GESTO. Anclado a la carga, un
`contact-activate` a +270 ms parece llegar tarde; con un CONTROL en el mismo
entorno (el `<Button>` canónico del `cta`, +299 ms) se ve que esos 270 ms son
latencia del montaje. **Sin control en el mismo entorno, una latencia no es un
veredicto.** Y para el anillo de foco, `.focus()` programático NO enciende
`:focus-visible` en Chromium: hay que TABULAR, o se mide el caso que no importa.
> ⚠️ **LECCIÓN DE INSTRUMENTO (2026-08-10), y la más cara de esta pasada.** Las
> mediciones en navegador del 2026-08-09 se hicieron contra un dev server cuyo
> grafo era ANTERIOR a los arreglos ya commiteados: `npm run dev` reutilizó una
> caché de Vite de una ejecución previa. El delator fue aritmético — las
> duraciones medidas (7867 / 3567 ms, ratio 2.21) son EXACTAMENTE las que el
> comentario del arreglo cita como estado anterior («12 500 took 7.9 s while 48
> took 3.6 s, a 2.21 ratio»). **Que el commit esté en HEAD no significa que el
> servidor lo sirva.** Antes de medir percepción: servidor recién arrancado, y
> si un número reproduce con demasiada exactitud el «antes» documentado,
> sospechar del instrumento antes que del código.
> ⚠️ **SEGUNDA LECCIÓN DE INSTRUMENTO (2026-08-10) — el contador de audio no mide
> lo que yo decía.** La sonda parchea `createOscillator` y `createBufferSource` y
> los suma en UN contador. Con eso escribí «N earcons», que es una etiqueta que no
> medí: (a) un earcon sintetizado del catálogo por defecto crea DOS osciladores
> (sine + el intervalo de la voz) y TRES si `roughness` supera el umbral, así que
> el número de nodos no divide por earcon; (b) si un **sound pack** sustituye
> nombres por muestras (`applySoundPack` / `sounds:` en el boot), lo que se crea es
> un `createBufferSource` por reproducción y la aritmética cambia entera; (c) crear
> nodos **no es sonar** — un earcon puede salir a gain 0, y en este proyecto
> «silenciar con gain 0 no es silencio» ya está escrito. Verificado para las rutas
> medidas: `SOUND_CATALOGUE` es síntesis y la galería de blocks **no aplica ningún
> sound pack** (lo que registra `_lib/sema-packs.ts` son packs de sema de
> COMPONENTE — reglas de cascada—, que es otra cosa). Regla: el contador responde
> «¿hubo actividad de audio?» y nada más. Toda fila sobre NIVEL o simultaneidad
> audible se mide offline o se deja sin verificar.
>
> ⚠️ Y el error mayor del que éste es síntoma: **fui a re-medir A-67 sin leer su
> ficha**, que ya traía los gains del catálogo, el empate de `applyDominance` y la
> regla del canon incumplida — todo mejor razonado que mi nota. Presenté como
> hallazgo lo que el registro ya tenía escrito. La primera regla de este fichero
> es unión por `id`: eso incluye LEER la fila antes de tocarla, no sólo escribir
> en la correcta.
Lo medido, por `id`:
| id | qué se midió | resultado |
| --- | --- | --- |
| A-36 | trigger del cajón a 375px | ⚠️ RETIRADA — medida contra código caducado (ver la nota de instrumento abajo). Re-medida con servidor limpio: **ARREGLADA**. |
| A-47 | anillo de foco del `cta`, tabulando de verdad | REPRODUCIDA y cuantificada: el anillo existe (2px, `:focus-visible`) pero el navegador lo compone en `rgb(165,110,212)` sobre `rgb(142,78,198)` = **1.43:1**, contra el mínimo 3:1 de WCAG 1.4.11. En los DOS elementos del block. |
| A-48 | descarte del `banner` | REPRODUCIDA: sólo `contact-activate`; la desaparición de la tira no la expresa nadie. |
| A-53 | las dos acciones del `hero` | REPRODUCIDA: la primaria estampa `contact-activate` (+2 nodos de audio), la secundaria **0 eventos y 0 audio**, en el mismo `Group` y con la misma jerarquía. |
| A-60 | `AutoGrid` del `site-footer` | CONFIRMADA por lectura: `auto-grid.svelte:26` emite `repeat(auto-fill, …)` y el README del canon documenta la elección — las pistas vacías son la consecuencia declarada. |
| A-61 | submit bloqueado de `contact` | REPRODUCIDA: `disabled=true`, `aria-disabled=true`, **no enfocable** (`tabIndex` 0 pero `focus()` no lo alcanza), con `aria-describedby` apuntando a una frase que quien navega con teclado nunca llega a oír. |
| A-62 · A-74 · A-29 | demos de `team`, `feature-split`, `pricing` | VIVAS por lectura: los `IconButton` siguen sin `onclick`/`href`; los `href` siguen apuntando a fragmentos inexistentes; `previewSrc` sigue sin propagar ningún eje. |
| A-65 | CTA de un plan | REPRODUCIDA: un gesto, DOS eventos sobre el mismo nodo — `contact-activate` +293 ms y `commit-select`+`affirm` **+296 ms**, 4 nodos de audio. |
| A-67 | cambiar de pregunta en `faq` | **SIGUE REPRODUCIÉNDOSE** tras los cambios del framework: el segundo gesto emite `emerge-collapse` y `emerge-expand` en el MISMO milisegundo (con un solo gesto la fila sale falsa por construcción — hay que encadenar dos). ⚠️ Lo único que aporta esta re-medición es ESO. El análisis está en la ficha desde la fase 2 y es más completo que esta nota: gains del catálogo, el empate de `applyDominance`, la regla `CANON.md:253-254` que no se aplica, y la precisión de que «sin atenuar» es impreciso (0.08 + 0.06 < 0.15 de un `emerge.strong`: no hay pico). **Leer la ficha, no esta fila.** |
| A-68 · A-69 | contadores de `stats-band` | ⚠️ RETIRADA — las duraciones 7867/5083/3567 ms son las del código ANTERIOR al arreglo. Re-medidas con servidor limpio: 2005 ms cada una, desfase 0 → **A-69 ARREGLADA**. A-68 queda como tensión doctrinal, no como defecto: el canon decidió en A-69 que la banda **aterrice junta**, y escalonar los arranques rompería esa decisión. Necesita firma. |
| A-75 | re-elegir el mismo plan | REPRODUCIDA: el segundo gesto sobre el plan YA elegido re-emite lo idéntico (`contact-activate` + `commit-select`/`affirm`, +4 nodos). |
| A-85 | `aria-label` del trigger del cajón | ARREGLADA 2026-08-11 — no con el `prop-truthy` prescrito sino con la regla de VOCABULARIO (dos clases de precedencia): `aria-label` es NAMING (`ARIA_NAMING_ATTRS`), el compilador marca el plan `consumerWins`, viaja en el bag (nunca `dom.apply`) y `mergeProps` resuelve consumer-first. El espejo manual de `drawer-provider` retirado. |
| A-09 · A-11 · A-19 · A-25 · A-27 · A-28 · A-55 · A-71 · A-84 · A-86 · A-88 | doctrina, por lectura de la fuente que cada una ancla | TODAS VIVAS, sin cambios desde su ficha. |
| A-79 | idioma del `site-footer` | VIVA por lectura (sigue SOSP): el `Select` cambia el régimen de toda la página y no expresa nada distinto de cualquier otro select. No se midió con sonda — el gesto pasa por un `Select` portaleado y la fila es de sospecha, no de defecto medido. |
**Cabo cerrado de paso — los hermanos del `Field`.** El handoff sospechaba que
`password-field`, `search-field` y `textarea` duplicaban el `commit-submit`
dentro de un `Form` copiando el patrón del `Field`. **No lo duplican, y por una
razón que la sospecha no contemplaba**: `search-field`, `number-field` y
`combobox` no declaran el evento en su provider, y `password-field`
(`password-field-provider.svelte.ts:353-356`) y `textarea`
(`textarea-provider.svelte.ts:353-356`) llaman a `e.preventDefault()` ANTES de
estampar, así que el envío implícito del navegador no ocurre y el form nunca
emite su segundo commit.
⚠️ Eso destapa una pregunta distinta, que se anota sin veredicto porque **no se
ha medido**: si `password-field` cancela el envío implícito, un Enter sobre la
contraseña dentro de un `Form` estampa `commit-submit` (intent `neutral`) y el
formulario **no se envía** — que es el segundo defecto descrito en el comentario
del `Field`, no el primero. Necesita un `Form` con `password-field` y una
medición antes de convertirse en fila.
## Fase 7 — contraste doctrinal de los 15 (2026-08-10, 8 de 15 con la rejilla completa)
Encargo del usuario: contrastar cada block con la documentación del sistema **a
nivel de cada componente que compone y en conjunto**, siguiendo la guía de
creación de blocks y **estableciendo la composición como estrategia válida**, y
comprobar que respeta las directrices de eidos, sema, morfo y soma — más
**motion**, que faltaba en la rejilla y detectó el usuario preguntando si había
leído su doctrina. No la había leído: el primer veredicto sobre coreografía se
emitió sin ella y hubo que retirarlo (abajo).
La vara es la documentación vigente, no la lista de hallazgos de 2026-08-01. Los
ids nuevos que salgan de esta pasada continúan la serie desde **A-91**; los
existentes se corrigen en su ficha, nunca se renumeran.
**Rejilla, siete ejes**: (1) componente a componente contra su propio contrato ·
(2) el conjunto contra B-1…B-11 + regla de forma + doctrina de coordinación ·
(3) morfo · (4) sema — incluido **no re-emitir sobre un control que ya emite** ·
(5) soma · (6) eidos · (7) **motion**: §D.11 (la cascada ES `[data-stagger]` +
preset + regla de fundación, modelo CERRADO), §D.13(1) hijos directos, §D.13(2)
ENTER-ONLY y `{#if}` crudo, §2 KNOWN-FRAGILE (firma y stagger sobre un mismo
nodo se pisan y decide el orden de emisión del CSS), B-11 sin keyframes propios.
⚠️ **Veredicto RETIRADO en esta misma pasada**: «los blocks se saltan `Cascade`»
es falso. `data-stagger` **es** el modelo canónico (§D.11: el eje coordinado se
retiró el 2026-06-19 y el modelo final es preset + stagger + la regla de
fundación), y envolver un `AutoGrid` en `<Cascade>` habría roto el índice, porque
§D.13(1) exige hijos DIRECTOS. Los comentarios de los blocks («must stay the
DIRECT child of the `[data-stagger]` grid») estaban citando esa restricción, que
es justo lo que se leyó como síntoma de atajo. Se registra el error, no se borra.
| block | veredicto | hallazgos |
| --- | --- | --- |
| `banner` | **conforme** | A-55 corregida (mecanismo falso, medido) · **A-91** nueva |
| `pricing` | **conforme** | A-86 vive (ejemplo que no compila) · **A-92** nueva |
| `faq` | **conforme** | A-19 vive (typedoc «centered») · A-67 explicada: rango idéntico, el `>` estricto de la dominancia es deliberado |
| `site-header` | **conforme** | sólo A-11, y es de la demo. B-6 ejemplar: `dom.isAtLeast`, nunca `matchMedia` propio |
| `site-footer` | **conforme** | A-60 aclarada: el arreglo es composición (`Grid` con `auto-fit`), camino que nombra el README del propio `AutoGrid` |
| `contact` | **conforme** — el listón del tier | A-61 se reduce: su segunda mitad ya está cerrada por `uix.announce`, y su disposición está medio obsoleta |
| `newsletter` | **conforme** (rejilla, sin medición en navegador) | gemelo de `contact`; sin filas vivas. Su comentario ya recoge el cambio del canon del 06 |
| `hero` · `cta` · `content-section` · `feature-split` | ⚠️ **BARRIDO PARCIAL, no conforme-verificado** | sólo se midió el eje del `{...rest}` y quién estampa `data-stagger`. Limpios EN ESE EJE (`content-section-body` cierra su tipo con `Omit`). Falta la rejilla: contratos de lo que componen · README · motion · sus filas vivas (A-25 · A-53 · A-74) |
| `team` | **conforme** (rejilla completa) | A-88 vive y es ADEMÁS deriva README↔código (el README ancla la fila de enlaces dos veces y el DOM no lo cumple) · A-94 le toca · A-62 es de la demo |
| `testimonials` · `feature-grid` | ⚠️ **BARRIDO PARCIAL** | **A-94** salió de ese barrido. Falta la rejilla y sus filas vivas: A-27 · A-84 |
| `stats-band` | **conforme** | A-09 y A-68 corregidas en su disposición · **A-93** nueva (la costura de `Motion`) |
Los trece restantes, por filas vivas primero: `pricing` · `faq` · `site-header` ·
`site-footer` · `contact` · `newsletter` · `hero` · `team` · `testimonials` ·
`feature-grid` · `feature-split` · `cta` · `content-section`.
### Precisiones de esta fase sobre fichas existentes (2026-08-10)
- **A-67** — la disposición «canon, no block» se confirma, con el mecanismo por fin nombrado: `applyDominance` (`engine.ts:885-892`) mutea una señal SÓLO si hay otra activa de rango **estrictamente mayor**, y `emerge-collapse` / `emerge-expand` tienen rango IDÉNTICO (ninguno evaluable, ninguno con intent). El comentario de `occurrenceRank` declara que el `>` estricto es deliberado, «so an equal-ranked newcomer stays audible». Que suenen los dos ES la conducta que la regla quiere. La pregunta abierta no es por qué no atenúa, sino si un `collapse`+`expand` del MISMO gesto debe contar como una ocurrencia — decisión de doctrina de sema, no defecto del tier.
- **A-88** — se confirma sin re-medir, y con un agravante de documentación: el README del block afirma DOS VECES el anclaje que no ocurre (mapa de composición, fila `MemberLinks`; y §Decisiones, «The links row is pinned to the bottom»). Las dos piezas del mecanismo verificadas por lectura el 2026-08-10: `team-member.svelte` no pasa `height` al `Stack`, y `motion.svelte:119-129` renderiza un div pelado sin `display` ni `height`, así que el `margin-top: auto` reparte cero. El contrafactual medido ya vive en la ficha.
- **A-62** — la atribución de la ficha (es de la DEMO, no del block) se confirma, y el contraste del conjunto añade un dato: la mini-página de `team` es la ÚNICA del corpus con cero anclas; sus ocho hermanas resuelven las navegaciones con `<Link href>`. El README del block justifica con «no stock faces», que no cubre el defecto: lo que falla no son las fotos, son dos navegaciones expresadas como `<button>` sin destino.
- **A-61** — la fila dice que quien navega con teclado «nunca llega al control NI SE TOPA CON LA FRASE que lo explica». La primera mitad sigue siendo cierta (medido: `disabled`, `aria-disabled=true`, no enfocable); **la segunda ya no**: `contact-reason.svelte` anuncia la frase por `uix.announce` en cada cambio de estado, así que la explicación SÍ llega a la tecnología asistiva aunque el control no se alcance. Esa costura entró el 2026-08-06 y la fila se midió el 05. Su disposición además está medio obsoleta: pedía dar `id` al `Text` y pasar `aria-describedby`, y ambas están ya en el código. Lo que queda es sólo la alcanzabilidad del control (`aria-disabled` en vez de `disabled` nativo, a cambio de bloquear el envío en el handler — el esquema no cubre `unverified`).
- **A-60** — la disposición «se arregla en el BLOCK» es correcta y ahora tiene camino: el README del canon `AutoGrid` documenta `auto-fill` como elección deliberada y nombra la salida — `<Grid templateColumns="repeat(auto-fit, minmax(…, 1fr))">`. Es composición con otro primitivo de layout del canon, sin tocar canon y sin CSS propio.
### A-94 — ARREGLADO · `feature-grid · team · testimonials` · doctrina · MEDIA
**Mecanismo** — A-28 no es de un block: es de TRES, y el barrido del tier lo mide. `feature-grid-items.svelte`, `team-members.svelte` y `testimonials-items.svelte` declaran su tipo público como `= AutoGridProps` —toda la superficie del primitivo— y colocan `{...rest}` ANTES de props fijadas a mano (`align`, `width`, y el propio `data-stagger`), así que un consumidor puede escribir `<Testimonials.Items align="start" width="60ch">`, tipar perfecto y no obtener nada. Prometer y descartar. El resto del tier está limpio: `content-section-body` cierra con `Omit<ProseProps,'measure'>` y los dos `Submit` con `Omit<…,'disabled'>`; en esos dos quedan `aria-label`/`aria-describedby` pisados, pero eso es deliberado (el block posee las palabras de sus estados, B-7) aunque el tipo siga exponiéndolos.
**Evidencia** — Barrido de los 114 ficheros del tier cruzando, por componente, el destructuring con los atributos escritos tras el `{...rest}` del tag raíz: 6 candidatos, 3 descartes reales. `feature-grid/types.ts:35` · `team/types.ts:36` · `testimonials/types.ts:24`, los tres `= AutoGridProps`.
**Disposición** — La solución ya está DENTRO del tier: `PricingPlansProps` (`pricing/types.ts:44-52`) declara un tipo cerrado sin rest, precisamente por esto. Cerrar los tres igual, o mover `{...rest}` al final y aceptar que `align`/`width` sean sobreescribibles. Lo único que no vale es prometer y descartar.
**Arreglo (2026-08-10)** — ARREGLADO 2026-08-10 — con una decisión de forma que NO tiene doctrina escrita detrás y se declara como tal. Los tres tipos quedan `Pick<AutoGridProps, 'minChildWidth' | 'columns' | 'gap' | 'children'>`, no un tipo cerrado escrito a mano como `PricingPlansProps`. Motivo: el `Pick` conserva los tipos EXACTOS del canon (incluido `ResponsiveProp`) y no puede quedarse atrás si el primitivo cambia. ⚠️ Y no es teórico: el primer intento los cerró a mano con `columns?: number` y rompió la demo de `team`, que pasa `columns={{ base: 2, md: N }}` — `svelte-check` lo cazó como +1 error sobre la línea base. Queda una tercera forma de tipar sub-parts en el tier (cerrado a mano · `Pick` · `= XProps` entero) sin regla que diga cuál; candidato a doctrina.
### A-91 — ARREGLADO · `banner` · doctrina · BAJA
**Mecanismo** — El canon avisa EN SU PROPIO TIPO (`eidos/components/banner/types.ts`, typedoc de `BannerIntent`) de que su eje `intent` «conflates two axes the doctrine separates»: `intent` es el eje EVALUATIVO (los 6 intents) mientras `primary`/`secondary`/`tertiary` son roles de jerarquía que pertenecen a `color`; y de que Banner, al no tener prop `color`, «is the one painted component outside the open colour system — resolving this is a pending decision, not an oversight». El block re-expone ese eje entero vía `Omit<BannerProps,'children'>` y su mapa de composición lo describe como «`intent` (the canonical color roles)», sin registrarlo. Su README registra CINCO huecos del canon (el `role="banner"` no anulable, la tinta de la ✕ con sus medidas, la persistencia del descarte, la animación diferida, el sticky) — la doctrina del tier es que «un hueco del canon se registra, no se falsea», y a éste le falta la fila.
**Evidencia** — `src/uix/eidos/components/banner/types.ts` (typedoc de `BannerIntent`) · `src/uix/blocks/banner/README.md` §Gaps · `src/uix/blocks/banner/types.ts:15`.
**Disposición** — Una fila en los Gaps del block, como candidato a canon: el eje de pintura de `Banner` está fuera del sistema de color abierto y el block lo hereda sin poder corregirlo. Cero código.
### A-92 — CONFIRMADO · `pricing` · doctrina · BAJA
**Mecanismo** — `pricing-plan.svelte` documenta en un comentario una restricción estructural del modelo de cascada que la doctrina de motion NO recoge: «el preset debe ser CSS: el retardo del stagger vive en el `animation-delay` del preset (índice × each), y un driver JS (`spring`) no lo lee — con `spring-pop` aquí todos los planes entraban a la vez». Es decir, **sólo los presets CSS participan del stagger**; un preset de driver JS ignora el índice estructural y colapsa la cascada en una entrada simultánea. `docs/theming/motion.md` §D.13 se titula «Structural constraints of the CSS model» y enumera TRES (estructura de hijos directos · salida ENTER-ONLY · depuración); ésta falta, y es la que más fácilmente se incumple al elegir un preset por su nombre.
**Evidencia** — `src/uix/blocks/pricing/pricing-plan.svelte:19-27` (la restricción, aprendida midiendo) · `docs/theming/motion.md` §2 RFC §D.13 (las tres que sí están).
**Disposición** — Un cuarto punto en §D.13 de `theming/motion.md`. Cero código: la restricción ya se respeta en los seis blocks que estampan `data-stagger`.
### A-93 — CONFIRMADO · `stats-band` · doctrina · BAJA
**Mecanismo** — Reservado: ver la ficha de A-68, cuya disposición corregida nombra la costura que falta en `Motion`. Se registra aquí para que el gap de canon tenga id propio y no viaje escondido dentro de una fila de percepción: **`Motion` no expone su momento «visto»**. Lo guarda en `$state` privado (`motion.svelte:42`) y sólo lo materializa como `data-animation-pending` en el DOM, así que un consumidor que necesite sincronizarse con el revelado tendría que montar un observador propio — exactamente lo que B-6 prohíbe. `Cascade` ya expone el equivalente por contexto (`cascade.svelte`, `effectiveOpen`), así que la pieza existe en el canon; le falta a `Motion`.
**Evidencia** — `src/uix/eidos/components/motion/motion.svelte:42,122-124` · `src/uix/eidos/components/cascade/cascade.svelte:38-40,60-66` · `docs/architecture/blocks.md` B-6.
**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.
## Veredictos re-medidos
_Una ficha por `id` verificado. La reclamación completa vive en el fichero del 2026-08-01._
@ -215,7 +407,7 @@ _Una ficha por `id` verificado. La reclamación completa vive en el fichero del
**Evidencia** — Hecho en 6 de los 8 READMEs sin declaración: feature-grid · feature-split · stats-band · team · faq · contact. Pendientes: pricing · testimonials.
**Disposición** — El resto se cierra con A-14; la regla mecánica llega en la fase 5.
**Disposición** — El resto se cierra con A-14; la regla mecánica llega en la fase 5. **Llegó** (2026-08-09): `blocks:check` exige `**Landmark + headings**` en todo README de block.
### A-04 — ARREGLADO · `contact` · doctrina · ALTA
@ -233,7 +425,7 @@ _Una ficha por `id` verificado. La reclamación completa vive en el fichero del
**Evidencia** — `docs/architecture/blocks.md` §Hard boundaries.
**Disposición** — La lista blanca mecánica entra en la fase 5 del guard.
**Disposición** — La lista blanca mecánica entra en la fase 5 del guard. **Entró** (2026-08-09): `blocks:check` rechaza todo especificador que la frontera dura 1 no nombre, y los arts públicos los DERIVA de `src/arts/*` en vez de listarlos a mano. El tier ya cumplía — cero violaciones al encenderla.
### A-06 — ARREGLADO · `site-footer` · percepcion · ALTA
@ -245,7 +437,7 @@ _Una ficha por `id` verificado. La reclamación completa vive en el fichero del
**Disposición** — Doctrina que merece escribirse: un `sequence:'post'` no sobrevive al desmontaje de su propio provider.
### A-07 — CONFIRMADO · `faq` · doctrina · MEDIA
### A-07 — ARREGLADO · `faq` · doctrina · MEDIA
**Mecanismo** — El README §Coordination enumera UNA costura («`value` is a bindable SEAM… A seam is not ownership») y el código expone DOS: `types.ts:28` declara `disabled` y `faq-item.svelte:16` lo reenvía a `Accordion.Item`. Y no es un passthrough genérico: `.Item` destructura exactamente `value, disabled, question, children` sin `...rest`, así que exponer `disabled` fue una elección deliberada que la doctrina del block no recoge.
@ -253,6 +445,9 @@ _Una ficha por `id` verificado. La reclamación completa vive en el fichero del
**Disposición** — Nombrar `disabled` en §Coordination (quién decide bloquear y por qué no necesita frase: la app pone el motivo en el texto de la pregunta). Fase 3, documental.
**Re-medición (2026-08-10)** — La costura que faltaba ya está escrita: `faq/README.md:43-45` nombra `disabled` como segunda costura reenviada y explica por qué no necesita frase propia (la app pone el motivo en el texto de la pregunta) — exactamente la disposición. Cerrada por lectura.
### A-08 — ARREGLADO · `cta` · doctrina · MEDIA
@ -267,7 +462,7 @@ _Una ficha por `id` verificado. La reclamación completa vive en el fichero del
**Evidencia** — `src/uix/blocks/stats-band/stats-band-value.svelte:20-22`, `src/uix/eidos/components/metrics/metrics-value.svelte:15-30`, `metrics.svelte:57-58` (`notifyUpdate` solo con `live`).
**Disposición** — Block-side: no pasar `value` cuando se compone `CountUp`. Fase 3.
**Disposición** — ⚠️ **CORREGIDA (2026-08-10, contraste doctrinal).** No pasar `value` **apagaría la función en vez de arreglarla**: el contrato de `Metrics.Value` dice que pasar `value` Y `children` es el uso previsto («when both are present, `children` renders and `value` still drives live detection») y que **`live` REQUIERE `value` para detectar el cambio**. Quitarlo deja la banda sin detección live. El defecto está en el CANON: `metrics-value.svelte:40` anuncia `String(next)` —la cifra cruda— teniendo el runtime de formato disponible, que es el mismo que `CountUp` usa para pintar. Arreglo: que el anuncio use el texto formateado.
### A-10 — ARREGLADO · `site-header` · doctrina · MEDIA
@ -302,13 +497,13 @@ _Una ficha por `id` verificado. La reclamación completa vive en el fichero del
**Disposición** — Cierra también A-24.
### A-14 — CONFIRMADO · `feature-split · pricing · testimonials` · doctrina · MEDIA
### A-14 — ARREGLADO · `feature-split · pricing · testimonials` · doctrina · MEDIA
**Mecanismo** — Ninguno de los tres README declara landmark ni jerarquía de encabezados. Es la misma falta que A-03, medida sobre el tier entero: 8 de los 15 READMEs no mencionan «landmark» ni una vez.
**Evidencia** — Grep sobre los 15 `src/uix/blocks/*/README.md`: 0 menciones en contact · faq · feature-grid · feature-split · pricing · stats-band · team · testimonials.
**Disposición** — Se arregla junto a A-03: párrafo «Landmark + headings» en los 8, y regla en `blocks:check` (fase 5).
**Disposición** — CERRADO en la fase 5 (2026-08-09), y con más alcance del que la fila pedía: la regla mecánica de `blocks:check` exige la declaración en los QUINCE, y al encenderla salieron **7** READMEs sin ella — los tres de esta fila más `banner`, `content-section`, `cta`, `newsletter` y `site-footer`, que mencionaban «landmark» de pasada en una celda de tabla pero no declaraban nada. Escritas las 7 leyendo la fuente de cada block, no la plantilla: `pricing` y `testimonials` son `<section>` pelados sin nombre (la app los nombra por `...rest`); `cta`, `newsletter` y `content-section` se nombran con su propio título por `aria-labelledby`; `site-footer` es un `<footer>` real (`contentinfo` mientras siga colgando de `<body>`); `banner` no puede ni poner ni quitar su landmark porque el canon estampa `role="banner"` después del rest — de ahí los dos banner de la página, que ya estaban en sus Gaps.
### A-15 — ARREGLADO · `contact` · doctrina · MEDIA
@ -341,7 +536,7 @@ _Una ficha por `id` verificado. La reclamación completa vive en el fichero del
**Evidencia** — `team/team-member-links.svelte`.
### A-19 — CONFIRMADO · `faq` · percepcion · BAJA
### A-19 — ARREGLADO · `faq` · percepcion · BAJA
**Mecanismo** — La promesa existe y el código no la cumple, pero SOLO cuando el cap muerde — la reclamación acierta en el mecanismo y exagera el alcance.
@ -368,6 +563,9 @@ Scripts: C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378
(b) o retirar la promesa de `src/uix/blocks/faq/types.ts:17` («measure-cap, centered» → «measure-cap»), y entonces conviene decir además que con `container='md'` el tope de 48rem es inerte.
Preferible (a): la asimetría con `container='xl'` es fea y el hermano ya la resuelve así. Severidad BAJA confirmada — nada visible en el block tal como se envía y se demuestra.
**Arreglo (2026-08-10)** — ARREGLADO 2026-08-10 y MEDIDO. `faq-header.svelte:12` pasa `marginX="auto"` — el mismo idioma que esta ficha ya nombraba en el block hermano (`content-section-media.svelte`). Medido en `/blocks/faq/preview`: el `Box` de `max-width: 768px` resuelve `margin-inline: 248px / 248px`, donde antes daba `0px / 0px` y la caja se anclaba en cross-start. Con los defaults del block el cap no muerde, así que nada se mueve hasta que un consumidor ensancha la sección — que es el caso para el que la promesa estaba escrita. El typedoc «centered» pasa a ser cierto en vez de tener que corregirse.
### A-20 — ARREGLADO · `faq` · doctrina · BAJA
@ -426,7 +624,7 @@ Preferible (a): la asimetría con `container='xl'` es fea y el hermano ya la res
**Disposición** — No se toca. Lo único cierto y menor es que el comentario cita B-7 en su forma anterior a la enmienda; si se roza el fichero por otro motivo, se acota la redacción.
### A-27 — CONFIRMADO · `testimonials` · doctrina · BAJA
### A-27 — ARREGLADO · `testimonials` · doctrina · BAJA
**Mecanismo** — El tipo público documenta un default que el componente no usa: `types.ts:26` dice «`variant` defaults to `soft`» y `testimonials-item.svelte:13` destructura `variant = 'outline'` (que además es la decisión razonada del README: el soft neutral es casi invisible en claro).
@ -435,7 +633,7 @@ Preferible (a): la asimetría con `container='xl'` es fea y el hermano ya la res
**Disposición** — Corregir el typedoc a `outline`. Fase 3, trivial.
### A-28 — CONFIRMADO · `testimonials` · doctrina · BAJA
### A-28 — ARREGLADO · `testimonials` · doctrina · BAJA
**Mecanismo** — `TestimonialsItemsProps = AutoGridProps`, así que el tipo promete toda la superficie de `AutoGrid`; pero el componente vuelca `{...rest}` PRIMERO y después fija `align="stretch"` y `width="100%"` a mano, y en Svelte el último gana. Un consumidor que pase `align` o `width` los ve descartados en silencio. `gap`, `columns` y `minChildWidth` sí funcionan porque están destructurados.
@ -494,7 +692,7 @@ No encaja en ningún falso positivo conocido (F15–F22), ni en «medir antes de
5. Candidato a guarda en `blocks:check`: que todo `previewSrc` de un demo pase por el constructor de URL del shell en vez de ser literal.
### A-30 — CONFIRMADO · `newsletter` · doctrina · ALTA
### A-30 — ARREGLADO · `newsletter` · doctrina · ALTA
**Mecanismo** — La duplicación del verbo es ESTRUCTURAL y está en el morfo, no en el block: `morfo/components/field.ts:29-34` declara `commit-submit` sobre `partRef('input')` con intent **neutral**, y `morfo/components/form.ts:17-22` declara `commit-submit` sobre `partRef('provider')` con intent **fulfill**. Un Enter dentro del input envía el form, así que la composición `Form > Field` dispara los dos: el mismo verbo, dos veces, con intents contradictorios.
@ -502,6 +700,9 @@ No encaja en ningún falso positivo conocido (F15–F22), ni en «medir antes de
**Disposición** — Deuda de canon (no es a11y): §6 de `PLAN-blocks-quality.md`. El block no puede evitarlo — no declara ninguno de los dos eventos.
**Re-medición (2026-08-10)** — La duplicación estructural está cortada en el origen: `soma/components/field/field-provider.svelte.ts:377` sale con `if (this.provider.form) return;` antes de estampar, y el comentario que lo precede documenta la medición que la fila reclamaba (dos commits a 11 ms con intents contradictorios). Un Enter dentro de un `Form` deja hoy UN solo `commit-submit`, el del form.
### A-31 — REFUTADO · `newsletter` · doctrina · ALTA
@ -540,7 +741,7 @@ No encaja en ningún falso positivo conocido (F15–F22), ni en «medir antes de
**Evidencia** — Sonda `G:/tmp/sonda-sema.mjs`, clic sobre `[data-navigation-menu-link]`: antes 0 eventos y 0 nodos de audio; ahora `commit-select` · familia=commit · intent=affirm sobre el propio link, 2 nodos de audio (un earcon).
### A-36 — CONFIRMADO · `site-header` · percepcion · ALTA
### A-36 — ARREGLADO · `site-header` · percepcion · ALTA
**Mecanismo** — La carrera existe y es peor que lo reclamado: el feedback de pulsación del hamburguesa no se trunca, NUNCA se pinta.
@ -585,6 +786,9 @@ Montaje del Content: antes del primer open `[data-drawer-content]` NO existe; ab
Colateral a arreglar en el mismo paso: `awaitExpression` (`src/uix/sema/chans/visual.ts:108-131`) mide animaciones ajenas del nodo compartido y alarga el hold de una señal por culpa de otra.
**Re-medición (2026-08-10, servidor limpio)** — YA NO SE REPRODUCE contra el código actual. Traza sobre el mismo nodo con servidor limpio: `contact-activate` estampado a +207,8 ms del clic y borrado a +410,2 — **202 ms de vida, por encima de su hold `glimpse` de 120 ms**, y el `emerge/open` NO aparece sobre el trigger. La carrera desapareció porque el evento ya no viaja a esa parte (`allowedTargets`, M1(i) `1c4303b10`). Queda vivo, pero como riesgo estructural sin manifestación conocida, que `unstampEventAttrs` borre ignorando la señal: si dos señales vuelven a coincidir en un nodo, el cleanup de una apagará a la otra.
### A-37 — ARREGLADO · `banner` · doctrina · ALTA
@ -611,7 +815,7 @@ Colateral a arreglar en el mismo paso: `awaitExpression` (`src/uix/sema/chans/vi
**Evidencia** — `newsletter/index.ts`.
### A-40 — CONFIRMADO · `contact` · percepcion · ALTA
### A-40 — ARREGLADO · `contact` · percepcion · ALTA
**Mecanismo** — El hecho es exacto y el defecto se sostiene, pero el origen es CANON, no el block. Cadena: `src/uix/blocks/contact/contact-submit.svelte:21` compone el canon `<Form.Submit>` de eidos. `src/uix/eidos/components/form/form-submit.svelte:27,38` es un wrapper PASSTHROUGH: no pasa el snippet `child` de soma, así que `src/uix/soma/components/form/components/form-submit.svelte:36` cae en su `<button>` nativo y pinta el cromo a mano en `form.css:85-190` en vez de componer el `<Button>` del framework — exactamente el bug de passthrough documentado (dialog-trigger 2026-06-19, card-group-title 2026-06-27) y contrario a la Regla 3 del patrón consumidor de Button. Consecuencia sema: el ÚNICO morfo que declara la familia `contact` para una acción es Button (`src/uix/morfo/components/button.ts:53-66`, `contact-activate` con `sequence:'pre'`, precisamente «para que la señal perceptual aterrice en el momento del clic, antes de cualquier trabajo asíncrono»). El morfo de Form (`src/uix/morfo/components/form.ts:16-25`) solo declara `commit-submit` con `sequence:'post'`, y el proveedor lo dispara DESPUÉS del await: `src/uix/soma/components/form/form-provider.svelte.ts:121-151` hace `await this.form.submit(e)` y solo entonces `runtime.trigger('commit-submit')`. Ese await encadena `onValidSubmit` del block (`src/uix/blocks/contact/contact.svelte:60`) con el `send()` de la demo (`web/routes/blocks/contact/ContactSite.svelte:37`, 900 ms). Resultado: el gesto sobre la acción principal no tiene tick de contacto en NINGÚN canal sema, ni micro-compresión `:active` (form.css solo trae `:hover`, `:focus-visible`, `:disabled`), mientras cualquier otro CTA de la misma galería —con sound/haptic ON por diseño, `web/routes/blocks/_lib/BootUix.svelte:62`— sí suena al pulsarse. Matiz honesto que la reclamación no dice: el botón NO queda mudo del todo — a ~45 ms del clic pasa a `disabled` + `data-pending` y reetiqueta a «Enviando…», o sea hay acuse de recibo en el canal de ESTADO; el agujero es el momento de contacto de sema (doctrina cap. 22 §8: «Contact inicia. Commit resuelve.»), y la asimetría con el resto de acciones de la página es perceptible.
@ -619,6 +823,9 @@ Colateral a arreglar en el mismo paso: `awaitExpression` (`src/uix/sema/chans/vi
**Disposición** — CANON, no el block. Arreglo en `src/uix/eidos/components/form/form-submit.svelte`: componer el `<Button>` del framework a través del snippet `child` de soma (Regla 3 del patrón consumidor de Button; `<Form.Submit>{#snippet child({props})}<Button {...props} {variant} {size} {color}>…</Button>{/snippet}</Form.Submit>`), con lo que el submit hereda `contact-activate` (sequence 'pre') en el gesto, el anillo de foco y la micro-compresión de press, y `form.css:85-190` suelta el cromo de acción duplicado. Mismo tratamiento para `form-reset.svelte` y los botones add/remove de auto-fields, que tienen el mismo passthrough. `src/uix/blocks/contact/contact-submit.svelte` no debe tocarse: compone el canon como toca.
**Re-medición (2026-08-10)** — MEDIDO de nuevo (Playwright headless, dev server propio en :5201, puerta `#uix-blocks-display` + ida y vuelta reactiva real tecleando los tres campos hasta que el submit se habilita). El submit estampa `data-event=contact-activate`, `data-event-family=contact` **sobre su propio nodo** (`[data-form-submit][data-archetype]`) a +270 ms del `mousedown`; el DOM confirma que compone el canon (`data-button`, `data-archetype=trigger`). CONTROL en el mismo entorno y misma sonda: el `<Button>` canónico de `/blocks/cta/preview` estampa a +299 ms. Es decir, el submit va incluso ANTES que el control — los 270 ms son latencia de mi montaje, no del block, y la asimetría que la fila describía ha desaparecido. Causa: `eidos/components/form/form-submit.svelte:30` compone `<Button>` por el snippet `child`, que es la disposición escrita en esta misma ficha.
### A-41 — ARREGLADO · `contact` · percepcion · ALTA
@ -844,7 +1051,7 @@ VISUALES del Button social, para comparar: reposo bg rgba(0,0,0,0) → hover bg
Scripts: C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-50.mjs, probe-A-50b.mjs, probe-A-50c.mjs.
### A-51 — CONFIRMADO · `newsletter` · doctrina · MEDIA
### A-51 — ARREGLADO · `newsletter` · doctrina · MEDIA
**Mecanismo** — El morfo de `Form` declara la parte `Submit` (archetype `action`, `type=submit`) pero sus TRES eventos —`commit-submit`, `signal-warn-invalid`, `commit-reset`— apuntan todos a `partRef('provider')`: ninguno estampa sobre el control. Como `Form.Submit` no es el `Button` del canon, tampoco hereda su `contact-activate`. Pulsar la acción principal no estampa contact propio y el arco perceptivo empieza directamente por el resultado.
@ -852,6 +1059,9 @@ Scripts: C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378
**Disposición** — Deuda de canon (morfo de `Form`), no del block y no a11y: §6 de `PLAN-blocks-quality.md`. Emparentado con A-30 y A-51 — el arco de `Form` es un hilo, no tres defectos sueltos.
**Re-medición (2026-08-10)** — Cerrada con A-40 y por la misma medición: la acción principal del formulario SÍ tiene contact propio en el gesto, porque `Form.Submit` compone el `Button` del canon y hereda su `contact-activate` (`sequence:'pre'`). Los tres eventos del morfo de `Form` siguen apuntando al provider —eso no ha cambiado ni tiene por qué—, pero la consecuencia que hacía de esto un defecto ya no se da.
### A-52 — REFUTADO · `site-header` · percepcion · MEDIA
@ -901,7 +1111,17 @@ Números (delta por camino):
**Evidencia** — `src/uix/blocks/banner/banner.svelte:41,55-60`, `src/uix/eidos/components/banner/banner.css:20,146-151`, `src/uix/eidos/components/container/container.svelte:26-30,57-58`.
**Disposición** — Block-side: `align="left"` en el Container (o `paddingX={0}` con márgenes explícitos) cuando hay descarte. Fase 3.
**Disposición** — ⚠️ **CORREGIDA (2026-08-10, contraste doctrinal).** El hecho es cierto y ahora está medido: a 1280px, con `onDismiss` el centro del contenedor cae en 619,5 frente al centro de la tira en 640 — **20,5 px de desvío**; sin descarte, 0. Pero **el mecanismo escrito es falso**: el `margin-inline` computado del Container es `0px / 0px`, así que los «tres márgenes automáticos» no existen. La causa es aritmética: el `Banner.Close` es un hermano flex que consume 41,1 px del ancho (1232 → 1190,9), y 41,1/2 = 20,55 = el desvío exacto. Por tanto `align="left"` **no arreglaría nada** — el contenedor ya no se centra a sí mismo. El arreglo real es que el descarte no compita por el ancho (fuera del flujo) o compensarlo con un hueco simétrico al inicio.
**Determinación (2026-08-10)** — **NO es arreglable desde el block con props públicas.** El canon pinta `[data-banner] [data-banner-close] { margin-inline-start: auto }` (`banner.css:150`) sobre una tira que es `display: flex` (`banner.css:20`), así que el `Close` es un hermano EN EL FLUJO y le resta su ancho al `Container` (medido: 41,1 px de 1232 → el mensaje se descentra 20,5 px, la mitad exacta). Sacarlo del flujo es CSS, y un `<style>` propio aquí no tendría justificación bajo D-BLK.2.
Dos salidas, y la primera es una DECISIÓN VISUAL que el block no debe tomar solo:
1. **Mover el `Close` dentro del `Container`** (composición pura, sin tocar canon): el `Container` recupera el ancho completo y el mensaje se centra respecto a la tira, pero el ✕ pasa a alinearse con la COLUMNA de contenido en vez de con el borde de la tira. En pantallas anchas se mete visiblemente hacia dentro. Es coherente con el «en columna por dentro» que el block promete, pero cambia el aspecto.
2. **Canon**: que `Banner` permita sacar el descarte del flujo, o que centre su contenido con independencia del `Close`.
Mientras no se decida, lo que hay que corregir es la PROMESA: el README del block afirma «full-bleed por fuera, en columna por dentro», y con `onDismiss` eso deja de ser cierto.
### A-56 — REFUTADO · `banner` · doctrina · MEDIA
@ -986,6 +1206,9 @@ Block (`src/uix/blocks/site-footer/site-footer.svelte:77`): la región de column
Canon (`src/uix/eidos/components/auto-grid/auto-grid.svelte:26`): `auto-fill` está cableado; no hay forma de pedir `auto-fit` desde fuera. Para una rejilla de tarjetas `auto-fill` es el default correcto (dos tarjetas no deben inflarse a media región), así que no lo cambiaría por defecto — pero le falta la palanca. Un `fit?: 'fill' | 'fit'` en `AutoGridProps` es la extensión mínima, y es lo que dejaría al site-footer resolverlo sin recetas propias (contrato B).
**Determinación (2026-08-10)** — el camino existe y es el que nombra el README del canon (`<Grid templateColumns="repeat(auto-fit, minmax(…, 1fr))">`, y `GridProps.templateColumns` es `ResponsiveProp<string>`), pero NO es la sustitución trivial que parecía: el prop del block es `minColumnWidth?: AutoGridProps['minChildWidth']`, o sea `ResponsiveProp<LayoutLengthValue>`, así que construir la pista exige resolverlo antes (precedente en el tier: `content-section` resuelve sus tres ejes con `eidos.resolve()`). Coste real: resolver el valor + reescribir el track + comprobar que no se pierde el comportamiento responsive. No se toca a medias.
### A-61 — CONFIRMADO · `contact` · percepcion · MEDIA
@ -1119,8 +1342,10 @@ 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.
**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.
### A-69 — CONFIRMADO · `stats-band` · percepcion · MEDIA
### A-69 — ARREGLADO · `stats-band` · percepcion · MEDIA
**Mecanismo** — El muelle de `count-up` está sobreamortiguado y su parada depende del TAMAÑO de la cifra, no de `duration`. En `src/uix/eidos/components/count-up/count-up.svelte:180-181` los parámetros son `damping = 20 + 40/duration` y `stiffness = 100/duration`; con el valor por defecto `duration = 2` → stiffness 50, damping 40 → w0 = 7.0711, zeta = 2.8284 (>1, sobreamortiguado). La forma cerrada de `runSpring` (líneas 108-133) se reduce, pasado el transitorio, a |y(t)| = 1.0345·|d0|·e^(−1.2917·t), con d0 = from − to. La condición de parada es `restThreshold = 0.5 * 10^-decimals` (línea 169), es decir 0.5 absoluto para enteros, luego el tiempo de asentamiento es t = ln(2.069·|d0|)/1.2917: crece con el LOGARITMO de la magnitud. `duration` sólo desplaza λ, nunca cancela el término log|d0| — medido, la razón settle(12500)/settle(48) = 2.21 constante para duration de 0.3 a 2, así que NINGÚN valor global de `duration` hace aterrizar juntas las cifras. `web/routes/blocks/stats-band/StatsBandSite.svelte:25-30` pone 12500 / 340 / 48 y `stats-band-value.svelte:22` monta `<CountUp to={count} />` sin `countOptions`, o sea con el default. Consecuencias reales, las tres verificadas en navegador: (1) los tres contadores arrancan en el MISMO frame y terminan a 3,57 / 5,09 / 7,88 s — 4,31 s de dispersión; (2) `duration = 2`, documentado en `types.ts:21` como «approximate count duration in seconds», da 3,9× para la cifra de cabecera; (3) `onEnd` se dispara en `delay + duration` (línea 189), 5,88 s ANTES de que la cifra grande deje de moverse. La cola no es imperceptible: el último dígito de 12.500 salta a 6,05 / 6,17 / 6,37 / 6,65 / 7,05 / 7,88 s, seis cambios visibles en los dos segundos finales.
@ -1128,6 +1353,9 @@ Emisión sema con LAS DOS sondas. (a) MutationObserver ligado a CADA nodo botón
**Disposición** — Se arregla en CANON, no en el block: `src/uix/eidos/components/count-up/count-up.svelte:169` (+180-181, 189). El umbral de reposo debe ser relativo al recorrido (p. ej. `restThreshold = max(0.5*10^-decimals, |to-from| * eps)`) o, mejor, `duration` debe cumplir su contrato: derivar λ de la magnitud para que el asentamiento real sea ≈ `duration` con cualquier cifra — y entonces `onEnd` (línea 189, hoy un temporizador ciego a `delay+duration`) debe dispararse desde la parada real del muelle, no desde un timer. Mientras el canon no cambie, `stats-band` sólo puede paliarlo con `countOptions.duration` por estadística, y eso serían números mágicos que ni siquiera igualan los aterrizajes (la razón 2,21 no depende de `duration`); mal parche. Documentar además en `types.ts:21` / `count-up/README.md:38` que `duration` NO es la duración observable mientras siga siendo un parámetro del muelle.
**Re-medición (2026-08-10, servidor limpio)** — ARREGLADA en `acda70f1e`/`89e42735e` y CONFIRMADO por medición contra código actual: las tres cifras de la banda (12.500 · 340 · 48) aterrizan en **2005 ms cada una, con 0 ms de desfase**. El contrato de `duration` («duración REAL del asentamiento para cualquier magnitud, λ derivada del recorrido») se cumple.
### A-70 — REFUTADO · `feature-grid` · percepcion · BAJA
@ -1343,7 +1571,7 @@ EMISIÓN DE SEMA, las DOS sondas exigidas: (a) contador WebAudio parcheado en `A
**Evidencia** — Sonda Playwright headless (scratchpad/probe-A-77.mjs) contra http://127.0.0.1:5201/blocks/stats-band/preview. Puerta de hidratación: esperado `#uix-blocks-display` (nodo cliente) y luego 0 `[data-animation-pending]`. Ida y vuelta real (no hay inputs en la página, así que usé el comportamiento sólo-cliente): los 3 `[data-count-up-value]` pasan de la semilla SSR "0","0","0" a "12.050","328","46" a los 2,6 s — hidratado y rAF vivo en primer plano. Mapa de la banda (25 nodos bajo el `<section>` raíz): elementos interactivos = 0 (selector a[href],button,input,select,textarea,summary,details,[tabindex],[role=button|link|tab|checkbox|menuitem],[onclick],[contenteditable]); focusables = 0; `cursor:pointer` = 0; `[data-metrics][data-live]` = 0 de 4 `[data-metrics]`. Atributos data presentes: sólo estructurales/visuales (data-section, data-container, data-box, data-auto-grid, data-grid, data-stagger, data-metrics(-value|-label), data-count-up(-value), data-animation-style, data-animation-trigger, data-state, data-align, data-layout, data-size) — ni un data-event. Interacción: hover + click + dblclick sobre la banda y sobre los 4 stats, sus values y sus labels; 12 pulsaciones Tab (document.activeElement se quedó en BODY las 12 veces, `inBand:false`), más Enter y Space. Resultado con las DOS sondas: (a) contador WebAudio parcheado en addInitScript ANTES de la carga → createOscillator = 0, createBufferSource = 0 (1 AudioContext construido, sin nodos); (b) MutationObserver atado AL NODO de la banda (+ uno al documento) → 0 mutaciones `data-event*`, y 0 elementos con `[data-event*]` al final. Contra-prueba de que la sonda no es ciega: el mismo observer en /blocks/stats-band, al pulsar un Button real, capturó 10 mutaciones — `data-event=commit-toggle`, `data-event-family=commit`, `data-event-intent=neutral`, `data-event-phase=active`, `data-event-id=sig-0` y su retirada. 0 errores de consola.
### A-78 — CONFIRMADO · `stats-band` · doctrina · BAJA
### A-78 — ARREGLADO · `stats-band` · doctrina · BAJA
**Mecanismo** — `stats-band.svelte:26` emite `<section {...rest}>` sin nombre accesible propio y el block no pone ningún encabezado dentro (no importa `Heading`). Un `<section>` sin nombre no se expone como landmark, así que la banda no es navegable ni por landmarks ni por titulares, y el README no dice nada de ninguna de las dos cosas.
@ -1351,6 +1579,9 @@ EMISIÓN DE SEMA, las DOS sondas exigidas: (a) contador WebAudio parcheado en `A
**Disposición** — Documentar la posición (una banda de cifras puede legítimamente no ser landmark) y, si se quiere región, que el app pueda nombrarla por `...rest`. Fase 3, junto a A-22.
**Re-medición (2026-08-10)** — Las dos mitades de la disposición están cumplidas: el README declara la posición en su párrafo `**Landmark + headings**` (una banda de cifras es una tira de apoyo, deliberadamente ausente del árbol de landmarks y del outline) y `stats-band.svelte:26` derrama `...rest` sobre el `<section>`, así que una app que quiera región la nombra con `aria-label`. Cerrada junto a A-22.
### A-79 — CONFIRMADO · `site-footer` · percepcion · SOSPECHA
@ -1375,7 +1606,7 @@ EMISIÓN DE SEMA, las DOS sondas exigidas: (a) contador WebAudio parcheado en `A
**Evidencia** — `cta/index.ts`.
### A-82 — CONFIRMADO · `cta` · sin dim · —
### A-82 — ARREGLADO · `cta` · sin dim · —
**Mecanismo** — El block EXPORTA `CtaLayout` (`index.ts:21`) y la demo lo redeclara a mano en tres sitios en vez de importarlo. Mismo patrón que A-11, agravado porque aquí el tipo canónico es del propio block.
@ -1383,6 +1614,9 @@ EMISIÓN DE SEMA, las DOS sondas exigidas: (a) contador WebAudio parcheado en `A
**Disposición** — Importar `CtaLayout`. Fase 3, junto a A-11.
**Re-medición (2026-08-10)** — La demo ya no redeclara nada: `web/routes/blocks/cta/+page.svelte:14` y `CtaSite.svelte:18` importan `CtaLayout` de `$blocks/cta`.
### A-83 — ARREGLADO · `hero` · sin dim · —
@ -1391,7 +1625,7 @@ EMISIÓN DE SEMA, las DOS sondas exigidas: (a) contador WebAudio parcheado en `A
**Evidencia** — Ver A-02.
### A-84 — CONFIRMADO · `feature-grid` · sin dim · —
### A-84 — ARREGLADO · `feature-grid` · sin dim · —
**Mecanismo** — El block declara un `align` de sección y solo lo aplica al `Stack` raíz (`feature-grid.svelte:30`); su propio tipo instruye al app a repetirlo: «Set the matching alignment on the header's own `Heading`/`Text` and on each `Item`» (`types.ts:22-25`), y la demo lo repite. No es «inventarle estado a un block de layout» —el eje YA está declarado—: es un eje declarado a medias, con cada consumidor re-derivándolo en el punto de uso. El propio tier tiene el precedente resuelto: `team` pasa su `align` a las partes por contexto.
@ -1399,8 +1633,11 @@ EMISIÓN DE SEMA, las DOS sondas exigidas: (a) contador WebAudio parcheado en `A
**Disposición** — Que `.Header` y `.Item` lean el `align` por contexto, como `team`. Fase 3.
**Arreglo (2026-08-10)** — ARREGLADO 2026-08-10 y MEDIDO. `feature-grid/context.ts` nuevo (mismo patrón y misma regla que `team`: es un DEFAULT, un `align` explícito en una parte gana), el root lo publica con un getter, y `.Header` y `.Item` lo leen. El typedoc que instruía al app a repetir el eje —«set the matching alignment on the header's own Heading/Text and on each Item»— se sustituye por la verdad: se pone una vez en la raíz. Medido en `/blocks/feature-grid/preview`: con `align=center` el `align-items` computado es `center` en la cabecera Y en las celdas; con `align=start`, `start` en ambas. `FeatureGridHeaderProps` gana el `align` (era `= BoxProps` y `svelte-check` cazó la falta como +1 error sobre base).
### A-85 — CONFIRMADO · `site-header` · sin dim · —
### A-85 — ARREGLADO · `site-header` · sin dim · —
**Mecanismo** — El `aria-label="Abrir navegación"` que la demo escribe en `web/routes/blocks/site-header/SiteHeaderSite.svelte:161` (dentro del `child` de `Drawer.Trigger`) SÍ llega al Button y SÍ se pinta en el DOM — y acto seguido lo pisa el canon.
@ -1428,7 +1665,26 @@ Resultado: el nombre accesible final es «Abrir cajón», la etiqueta genérica
Scripts: C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-85.mjs, probe-A-85b.mjs, probe-A-85c.mjs
**Disposición** — Se arregla en el CANON, no en el block (la demo compone correctamente y el block `site-header` sólo renderiza el snippet `mobileTrigger`). Dos puntos posibles, por orden de limpieza:
**RESOLUCIÓN (2026-08-11)** — Arreglado en el CANON, y no con el mecanismo que
esta disposición prescribía: replicar el `prop-truthy` de button era anotar 132
morfos y hacer dar al texto del consumidor una vuelta entera por el grafo
reactivo. La declaración del drawer era CORRECTA; la precedencia del runtime era
el defecto. Regla de VOCABULARIO (dos clases de precedencia, `morfo.md` Step 4):
`aria-label` ∈ `ARIA_NAMING_ATTRS` → el compilador marca el plan `consumerWins`,
el atributo viaja en el render bag de la parte (nunca por `dom.apply`, que era
la escritura post-render que pisaba) y `mergeProps` lo resuelve consumer-first.
Los espejos manuales del provider del drawer (líneas 440/1266) retirados.
Re-medido en `/blocks/site-header/preview` (2026-08-11): el SSR crudo YA trae
`aria-label="Abrir navegación"` (antes: sin atributo — los naming ships in the
bag también arreglan el hueco de SSR), y tras hidratación + click round-trip
(`data-state` closed→open→…) el label del consumidor sobrevive — la segunda y
tercera escritura del MutationObserver de la evidencia ya no existen. El caso
default (sin label del consumidor, `/uix/components/drawer`): «Abrir cajón» del
catálogo. Guard: `runtime.svelte.test.ts` («ships naming defaults in the bag»),
`props.test.ts` (política consumer-first), `compile.test.ts` (clasificación).
**Disposición ORIGINAL (superada)** — Se arregla en el CANON, no en el block (la demo compone correctamente y el block `site-header` sólo renderiza el snippet `mobileTrigger`). Dos puntos posibles, por orden de limpieza:
1. `src/uix/morfo/components/drawer.ts:181-185` + `src/uix/soma/components/drawer/drawer-provider.svelte.ts:444`: el `aria-label` del trigger debe ser un DEFAULT, no una imposición — condicionarlo a que el consumidor no haya aportado uno (patrón `prop-truthy` sobre un `aria-label` propio del trigger, como ya hace `src/uix/morfo/components/button.ts:164-169`), y en `drawer-trigger.svelte:26` dejar que el valor del consumidor gane sobre el del canon.
@ -1436,8 +1692,11 @@ Scripts: C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378
Paliativo si no se toca el canon: la demo no puede arreglarlo por sí sola (ninguna de las dos vías de llamada gana), así que documentar la limitación en `src/uix/blocks/site-header/README.md` sería lo único disponible.
**Re-medición (2026-08-10, servidor limpio)** — ARREGLADA. El trigger del cajón expone hoy `aria-label="Abrir navegación"` — el valor del CONSUMIDOR — donde antes el canon imponía «Abrir cajón». Medido en el DOM servido.
### A-86 — CONFIRMADO · `pricing` · sin dim · —
### A-86 — ARREGLADO · `pricing` · sin dim · —
**Mecanismo** — `PricingSwitchProps` declara las tres cadenas como REQUERIDAS (`monthlyLabel`, `annualLabel`, `'aria-label'`, sin `?`) y el ejemplo de la puerta de entrada monta `<Pricing.Switch annualLabel="Anual (−20%)" />`: faltan dos props obligatorias, no compila.
@ -1453,7 +1712,7 @@ Paliativo si no se toca el canon: la demo no puede arreglarlo por sí sola (ning
**Evidencia** — Ver A-44.
### A-88 — CONFIRMADO · `team` · sin dim · —
### A-88 — ARREGLADO · `team` · sin dim · —
**Mecanismo** — La cadena de cajas es: `AutoGrid` (grid, `align-items: stretch` por defecto) → el `<div>` que pinta `Motion` (`src/uix/eidos/components/motion/motion.svelte:119-129`, un div PELADO, sin `display` ni `height` propios) → el `Stack` del miembro (`src/uix/blocks/team/team-member.svelte:27-31`) → el `Group` de enlaces con `marginTop='auto'` (`src/uix/blocks/team/team-member-links.svelte:18`). El `stretch` de la retícula sí estira el div del `Motion` hasta la altura de la fila, pero ese div es `display: block`, así que el `Stack` de dentro —que es quien tiene el `display:flex; flex-direction:column` -- se queda en altura de contenido (`height: auto`). En una columna flex de altura automática el espacio libre es CERO, y `margin-top: auto` reparte cero. El token llega bien (`--box-margin-top: auto` vía `Box`, `box.css:242` + `formatLayoutSpace` que deja pasar la cadena `auto`), pero su valor usado se resuelve a `0px`. Resultado: la fila de enlaces cuelga justo detrás de la biografía y, con biografías de distinto largo —el caso EXACTO que el README declara como razón de ser de la regla (`src/uix/blocks/team/README.md:19` y `:69-71`)—, las filas de iconos quedan a alturas distintas dentro de la misma fila de la retícula. La reclamación acierta en el mecanismo Y en la consecuencia; también deja al README afirmando algo que el DOM no cumple.
@ -1465,6 +1724,9 @@ LÍNEA BASE sin biografía: todas las tarjetas 195px, desviación 0px — es dec
**Disposición** — Se arregla en el BLOCK, no en el canon: el defecto nace de la propia composición de `src/uix/blocks/team/team-member.svelte` (un `Stack` de altura automática dentro del div estirado del `Motion`). El arreglo mínimo verificado es que el `Stack` del miembro llene la celda —`height="100%"` en la línea 28 de `team-member.svelte`— o, equivalente, que el envoltorio del `Motion` sea columna flex. Nota de canon: `Motion` (`motion.svelte:119`) no expone ni `display` ni `height`, sólo derrama `...rest`, así que si se prefiere arreglarlo en el envoltorio hay que darle ese asa en el canon. Y corregir de paso el README del block (`README.md:19` y `:69-71`), que hoy documenta un anclaje que no ocurre.
**Arreglo (2026-08-10)** — ARREGLADO 2026-08-10 y MEDIDO. `team-member.svelte:28` pasa `height="100%"` al `Stack` (prop pública heredada de `BoxProps`, sobrevive el `Omit` de `StackProps` — comprobado en el contrato antes de dar el cambio por bueno). Medido en `/blocks/team/preview?align=center&columns=4&bio=true`: `linksTop` = [274,274,274,274], **desviación 0 px** (era 20), y `margin-top` reparte **20,297 px** donde antes daba `0px` en las seis tarjetas — el mismo valor que el contrafactual A de esta ficha, medido en su día por otra vía. El README del block, que afirmaba el anclaje dos veces, pasa a decir la verdad en vez de tener que corregirse.
### A-89 — REFUTADO · `contact` · sin dim · —

@ -1,22 +1,24 @@
# CONTINUE — tier blocks (handoff, act. 2026-08-06)
# CONTINUE — tier blocks (handoff, act. 2026-08-10)
**Estado: F1 CERRADA (8/8) · F2 CERRADA (15/15) · saneamiento de la auditoría
CERRADO · fase 4 del saneamiento (newsletter coordina) HECHA.**
CERRADO · fase 4 (newsletter coordina) HECHA · fase 5 (guard endurecido) HECHA
— el plan de saneamiento queda CERRADO ENTERO.**
Los 15: site-header · hero · feature-grid · feature-split · pricing ·
testimonials · faq · stats-band · cta · newsletter · site-footer · banner · team ·
contact · content-section.
## ⚠️ LO PRIMERO: la fuente viva es el LEDGER, no el documento del 2026-08-01
**`docs/process/AUDIT-blocks-ledger.md`** — 90 filas con id estable `A-01…A-90`.
**`docs/process/AUDIT-blocks-ledger.md`** — filas con id estable desde `A-01`.
El documento del 2026-08-01 se conserva (sus reclamaciones y evidencias son
válidas y el ledger las indexa) pero **su clasificación NO vale**: tenía los
veredictos desemparejados de sus hallazgos por un join por POSICIÓN, y el journal
del workflow era de sesión. Se reprodujo en vivo al re-verificar — el journal
devuelve los resultados en un orden distinto al de entrada.
Estado del ledger: **41 ARREGLADO · 34 CONFIRMADO · 12 REFUTADO · 3 DATO**. Cero
pendientes: los 90 están medidos. La tasa real de refutación fue del **13%**, no
Estado del ledger: **51 ARREGLADO · 27 CONFIRMADO · 12 REFUTADO · 3 DATO** (93
filas: las 90 originales + A-91…A-93, nacidas del contraste doctrinal). Cero
pendientes: todas están medidas. La tasa real de refutación fue del **13%**, no
del 28%.
**La regla que hay que respetar al escribir en él: unión por `id`, SIEMPRE.**
@ -57,16 +59,109 @@ COORDINA.
**segundo servicio, el anunciador** (`uix.announce`): un block que posee las
palabras de sus estados tiene que poder decirlas.
## Fase 7 — contraste doctrinal de los 15 (2026-08-10, 7 de 15)
Encargo del usuario: contrastar cada block con la documentación del sistema **a
nivel de cada componente que compone y en conjunto**, siguiendo la guía de
creación de blocks y **con la composición como estrategia válida**, comprobando
las directrices de eidos, sema, morfo y soma — más **motion**, que faltaba en la
rejilla y detectó él preguntando si había leído su doctrina. No la había leído;
el primer veredicto sobre coreografía salió sin ella y hubo que retirarlo.
**Detalle completo, rejilla de siete ejes y fichas: `AUDIT-blocks-ledger.md`
§Fase 7.** Aquí sólo el estado y cómo seguir.
### ⚠️ Lo primero: el instrumento mentía
Las mediciones en navegador del 2026-08-09 se hicieron contra un dev server con
caché de Vite **anterior a arreglos ya commiteados**. Al re-medir con servidor
limpio cayeron **A-36, A-69 y A-85**. El delator fue aritmético: las duraciones
medidas (7867/3567 ms, ratio 2.21) eran EXACTAMENTE las que el comentario del
arreglo cita como estado anterior. **Servidor recién arrancado antes de medir**,
y si un número reproduce el «antes» documentado con demasiada exactitud,
sospechar del instrumento antes que del código.
### Estado: 8 con la rejilla completa, 7 sólo barridos
**Rejilla completa (8)**: `banner` · `stats-band` · `pricing` · `faq` ·
`site-header` · `site-footer` · `contact` · `newsletter`. Ninguno tiene un
defecto de composición.
⚠️ **Barrido parcial (7)**: `hero` · `cta` · `content-section` ·
`feature-split` · `team` · `testimonials` · `feature-grid`. De estos sólo se
midió UN eje —dónde cae el `{...rest}` y quién estampa `data-stagger`—, que es
de donde salió A-94. **No están verificados**: falta contrastar los componentes
que componen contra sus contratos, sus READMEs, su eje motion y sus filas vivas
(A-25 · A-27 · A-53 · A-74 · A-84 · A-88). Llamarlos conformes sería afirmar más
de lo medido. Lo que el contraste está
produciendo son hallazgos sobre las FICHAS del ledger y sobre piezas de canon:
- **Cinco disposiciones corregidas** — A-55 (mecanismo falso: el desvío lo causa
el ancho que resta el `Close`, no unos márgenes auto que miden `0px`), A-09 (su
arreglo apagaría la detección live), A-68 (no es arreglable desde el block sin
romper B-6), A-60 (correcta, y con el camino que nombra el propio `AutoGrid`),
A-61 (su segunda mitad ya la cerró `uix.announce`).
- **Tres filas nuevas** — A-91 (`banner` hereda el eje `intent`/`color` sin
registrarlo), A-92 (una restricción del modelo de cascada que vive en un
comentario de `pricing` y falta en §D.13 de la doctrina de motion), A-93
(`Motion` no expone su momento «visto», que es lo que desbloquearía A-68).
### Quedan 7, en este orden
`team` (A-88 tiene contrafactual medido en su ficha) · `testimonials` (A-27) ·
`feature-grid` (A-84) · `hero` (A-53) · `cta` (A-25) · `feature-split` (A-74) ·
`content-section`. Son layout: la pasada es más corta, pero **la rejilla entera**,
no un grep.
⚠️ Al retomar: **la composición es estrategia válida** (norma del usuario). Un
block que sólo coloca canon no es defectuoso por serlo; sólo lo es si al componer
se salta un contrato o reinventa un primitivo. Y el eje motion se contrasta con
§D.11 (la cascada ES `[data-stagger]` + preset + regla de fundación — modelo
CERRADO, no un atajo), §D.13(1) hijos directos, §D.13(2) ENTER-ONLY, y §2
KNOWN-FRAGILE (firma y stagger sobre UN mismo nodo se pisan).
## Lo hecho en la sesión del 2026-08-09 — fase 5, el guard endurecido
`blocks:check` deja de vigilar sólo la forma del tier y pasa a vigilar su
frontera, su documentación y su catálogo. Tres reglas, cada una con fixture
negativo en el `selfTest()`:
1. **Lista blanca de importaciones (B-4)** — la frontera dura 1 era prosa; ahora
es código. Permitido: `$uix`, los arts públicos, `$libs/forms`, `svelte` /
`svelte/elements` y los relativos del propio block. **Los arts se DERIVAN de
`src/arts/*`**, no se listan a mano — una lista escrita se queda atrás el día
que aterriza un art (y este proyecto ya pagó dos veces esa lección). El tier
ya cumplía: cero violaciones al encenderla. Cierra la disposición de A-05.
2. **README completo (B-9) + declaración de landmark (B-8)** — las cuatro
secciones de la plantilla y un párrafo `**Landmark + headings**`. Al
encenderla salieron **7 de 15** sin declaración: los tres de A-14 más
`banner`, `content-section`, `cta`, `newsletter` y `site-footer`, que
nombraban «landmark» en una celda de tabla pero no declaraban nada. Escritas
leyendo la fuente de cada uno. **A-14 → ARREGLADO.**
3. **Ficha en `_lib/catalog.ts` (B-9)** — el árbol y el catálogo tienen que
coincidir en los DOS sentidos: un block que el catálogo no publica es
invisible para el raíl, y un slug publicado sin block detrás es un enlace
muerto.
De paso, **el escáner deja de leer los comentarios como código**: los `index.ts`
documentan su uso con un `// import { Cta } from '$blocks/cta'`, y una lista
blanca que lee prosa habría empezado a acusar a los ejemplos. (No era teórico:
esos 15 comentarios ya entraban en el escaneo de B-10 y sólo se salvaban porque
cada ejemplo cita su propio block.)
**Verificado en ROJO sobre el árbol real, no sólo contra los fixtures** — que es
la única prueba de que una regla nueva no es decorativa: un `zod` y un
`$libs/days` metidos en `hero/types.ts` salen con su línea exacta mientras el
mismo import dentro de un comentario NO salta; el catálogo falla en las dos
direcciones (`shipped: false` sobre un block vivo, slug fantasma); y al romper
`isAllowedSpec` a propósito el self-test aborta el guard en vez de dar verde.
## Qué queda, por orden
1. **Fase 5 del saneamiento — endurecer `blocks:check`** (lo único del plan sin
empezar): lista blanca de importaciones, README completo con declaración de
landmark, ficha en `catalog.ts`. Cada regla con su fixture negativo en el
`selfTest()`, que el guard ya corre en cada ejecución.
2. **Los 34 CONFIRMADOS sin arreglar** (4 ALTA · 15 MEDIA · 9 BAJA · resto):
1. **Los 33 CONFIRMADOS sin arreglar** (4 ALTA · 14 MEDIA · 9 BAJA · resto):
sobre todo `stats-band` (4), `pricing` (4), `faq`, `site-header`,
`site-footer` (3 cada uno). Cada fila lleva mecanismo y evidencia en el ledger.
3. **Tres cabos concretos**:
2. **Tres cabos concretos**:
- Un **desbordamiento de texto en las cards del card-group** que el usuario ve
y que NO se reprodujo a 1280 en claro ni en oscuro. Falta el ancho de ventana.
- Los **hermanos del `Field`** (`password-field`, `search-field`, `textarea`)
@ -74,7 +169,7 @@ palabras de sus estados tiene que poder decirlas.
dentro de un `Form`. Se comprueba en un comando con la sonda (abajo).
- **La página compuesta** (dimensión CRUZADA), que sigue siendo la condición de
cierre de F2 en el plan y que el usuario dejó fuera de alcance.
4. **F3 del plan original** (10 blocks de aplicación).
3. **F3 del plan original** (10 blocks de aplicación).
## ⚠️ El sonido CAMBIÓ DE MODELO el mismo día — léelo antes de tocar un pack
@ -420,18 +515,24 @@ navega»).
---
## Estado de gates al parar (2026-08-06, commit `866089a40`)
## Estado de gates al parar (2026-08-09, fase 5 — SIN COMMITEAR)
- `npm run blocks:check` **verde, 15 blocks / 114 ficheros**.
- `npm run blocks:check` **verde con las tres reglas nuevas, 15 blocks / 114
ficheros**.
- `npx vitest run src/uix/blocks/` — **20/20** (`contact/state.test.ts` 12 +
`newsletter/state.test.ts` 8).
- `npx vitest run src/uix/eidos src/uix/blocks` — **402/402**.
- `npm run docs:check` — **0 errores, 0 avisos** (617 docs).
- `svelte-check`: **75 errores / 54 avisos = LA LÍNEA BASE**, ninguno en `blocks/`
ni en lo que toqué del canon. ⚠️ Esa cifra SE MUEVE: con otras sesiones vivas en
el mismo árbol se midió 75, 76, 77 y 80 en el mismo día. Mídela justo antes y
justo después de tu cambio, nunca contra un número recordado.
- `prettier`: limpio en todo lo del commit.
- `npx vitest run src/uix/eidos src/uix/blocks` — **410/410** (eran 402 el
2026-08-06; los 8 nuevos son de eidos, de otra sesión).
- `npm run docs:check` — **0 errores, 0 avisos** (618 docs).
- `svelte-check`: **74 errores / 54 avisos**, medido justo antes y justo después
del cambio — misma cifra, cero regresión. ⚠️ Esa cifra SE MUEVE: con otras
sesiones vivas en el mismo árbol se midió 74, 75, 76, 77 y 80 en días
distintos. Mídela justo antes y justo después de tu cambio, nunca contra un
número recordado.
- `prettier`: limpio en todo lo tocado. `scripts/blocks-check.ts` **ya venía
sucio desde F0** y se formatea entero en este paso (el fichero se reescribe a
fondo aquí); `AUDIT-blocks-ledger.md` sigue sucio como estaba — sus tablas son
ajenas a este cambio.
- Fallos AJENOS vivos al parar (**no son del tier**): 7 en `contracts.test.ts` +
`soma-attr-audit` (barrel de `waveform`, MOR-4, escrituras DOM, data-attrs,
namespaces camelCase de `aura`) y un `__adv-verify-textarea-pack.test.ts` sin

@ -1680,3 +1680,35 @@ type="button">` es una pista de MIME falsa) — el morfo lo declara
sin verificar — el nav del header emitiendo `commit-select` + `affirm` con earcon
audible al pasar el RATÓN por encima, un `Enter` en `newsletter` emitiendo dos
`commit-submit` con intents contradictorios, y 4 de 7 elementos del header mudos.
- 2026-08-09 — **Fase 5 del saneamiento: `blocks:check` endurecido** (el último
punto del plan de saneamiento sin empezar; el detalle de las fases 1–4 vive en
`AUDIT-blocks-ledger.md`, que es la fuente viva desde el 2026-08-05). Tres
reglas nuevas, cada una con su fixture negativo en el `selfTest()` que el guard
corre antes de escanear:
1. **Lista blanca de importaciones (B-4)** — la frontera dura 1 deja de ser sólo
prosa: `$uix`, los arts públicos, `$libs/forms`, `svelte` y los relativos del
propio block; lo demás es error con el especificador por delante. **Los arts
se DERIVAN de `src/arts/*`**, no se listan a mano — una lista escrita se
queda atrás el día que aterriza un art. El tier ya cumplía: cero violaciones
al encenderla (cierra la disposición de A-05).
2. **README completo (B-9) + declaración de landmark (B-8)** — las cuatro
secciones de la plantilla y un párrafo `**Landmark + headings**`. Al
encenderla salieron **7 de 15** sin declaración; escritas leyendo la fuente
de cada block (cierra A-14, que sólo pedía tres).
3. **Ficha en el catálogo de demos (B-9)** — el árbol y
`web/routes/blocks/_lib/catalog.ts` tienen que coincidir en los DOS
sentidos: un block que el catálogo no publica es invisible para el raíl, y
un slug publicado sin block detrás es un enlace muerto.
De paso, el escáner **deja de leer los comentarios como código**: los `index.ts`
documentan su uso con un `// import { Cta } from '$blocks/cta'` y una lista
blanca que lee prosa habría empezado a acusar a los ejemplos.
Verificado **en rojo sobre el árbol real**, no sólo contra los fixtures: un
`zod` y un `$libs/days` metidos en `hero/types.ts` salen señalados con su línea
exacta mientras el mismo import dentro de un comentario NO salta; el catálogo
falla en las dos direcciones; y al romper `isAllowedSpec` a propósito, el
self-test aborta el guard en vez de dar verde. Gates: `blocks:check` verde
(15 blocks / 114 ficheros) · `vitest src/uix/blocks` 20/20 · `docs:check` 0/0 ·
`svelte-check` 74E/54W = la línea base medida justo antes.

@ -11,12 +11,20 @@
* nothing in the canon (`src/uix/{morfo,soma,sema,eidos,
* active-uix,langs}`) nor in `src/{arts,libs,packs}` imports the
* blocks tier.
* B-4 import allowlist (hard boundary 1): a block imports only `$uix`,
* the public arts, `$libs/forms`, `svelte` and its own relatives.
* Anything else is the boundary being crossed, not a new need.
* B-10 a block never imports another block (declared exception: the
* shells allowlist).
* D-BLK.2 no `.css` files under the tier; a scoped `<style>` requires a
* `justified:` comment marker (layout-components-first).
* B-9 every block folder ships `README.md` and a demo route at
* `web/routes/blocks/{kebab}/+page.svelte`.
* B-9 the README carries the template's four sections, and the block
* has a shipped entry in the demo catalog (`_lib/catalog.ts`) —
* the single list the rail and the gallery read.
* B-8 the README declares its landmark + heading hierarchy: which
* sectioning element and which levels the block emits.
*
* Self-testing: the detectors are asserted against inline fixtures on every
* run (a raw `<button>` fixture MUST be flagged, a `<Button>` fixture must
@ -29,10 +37,23 @@ import { dirname, join, relative, resolve } from 'node:path';
const REPO = process.cwd();
const BLOCKS_DIR = join(REPO, 'src', 'uix', 'blocks');
const DEMO_DIR = join(REPO, 'web', 'routes', 'blocks');
const CATALOG_FILE = join(DEMO_DIR, '_lib', 'catalog.ts');
const ARTS_DIR = join(REPO, 'src', 'arts');
// B-10 declared exception: the shells compose F1 pieces and blocks by design.
const SHELL_ALLOWLIST = new Set(['app-shell', 'docs-shell']);
// The public arts a block may import, DERIVED from the tree (`$name` →
// `src/arts/name`) — a hand-written list falls behind the day an art lands.
const PUBLIC_ARTS = new Set(
existsSync(ARTS_DIR)
? readdirSync(ARTS_DIR).filter((entry) => statSync(join(ARTS_DIR, entry)).isDirectory())
: []
);
// The four sections the B-9 template requires of every block README.
const README_SECTIONS = ['## Function', '## Composition map', '## Decisions', '## Gaps'];
// Trees that must never import the blocks tier (B-4).
const OUTSIDE_ROOTS = [
join(REPO, 'src', 'arts'),
@ -60,6 +81,34 @@ function norm(path: string): string {
return path.replace(/\\/g, '/');
}
/**
* Blank out comments, keeping every line break so line numbers survive. The
* entry points document their own usage with an `// import { Cta } from
* '$blocks/cta'` example, and an allowlist that reads prose as code flags it.
*/
function stripComments(src: string): string {
const blank = (match: string): string => match.replace(/[^\n]/g, ' ');
return src
.replace(/\/\*[\s\S]*?\*\//g, blank)
.replace(/<!--[\s\S]*?-->/g, blank)
.replace(/(?<!:)\/\/[^\n]*/g, blank);
}
/**
* Hard boundary 1 (B-4): what a block is allowed to import. `$uix/sema`,
* `$packs` and cross-block imports are rejected earlier with their own rule,
* so this is the catch-all for everything the boundary never named.
*/
function isAllowedSpec(spec: string): boolean {
if (spec.startsWith('./') || spec.startsWith('../')) return true;
if (spec === 'svelte' || spec === 'svelte/elements') return true;
if (spec === '$uix' || spec.startsWith('$uix/')) return true;
if (spec === '$blocks' || spec.startsWith('$blocks/')) return true;
if (spec === '$libs/forms' || spec.startsWith('$libs/forms/')) return true;
const art = /^\$([a-z0-9-]+)(?:\/|$)/.exec(spec);
return art !== null && PUBLIC_ARTS.has(art[1]);
}
function isUnder(abs: string, root: string): boolean {
return norm(abs).startsWith(norm(root) + '/');
}
@ -110,12 +159,18 @@ export function scanBlockFile(fileAbs: string, rel: string, src: string): Violat
}
}
for (const match of src.matchAll(IMPORT_RE)) {
const withoutComments = stripComments(src);
for (const match of withoutComments.matchAll(IMPORT_RE)) {
const spec = match[1];
const line = src.slice(0, match.index).split('\n').length;
const line = withoutComments.slice(0, match.index).split('\n').length;
const target = resolveSpec(fileAbs, spec);
if (spec.startsWith('$packs') || (target && isUnder(target, join(REPO, 'src', 'packs')))) {
out.push({ rule: 'B-4', file: rel, line, detail: `a block must not import the packs tier (${spec})` });
out.push({
rule: 'B-4',
file: rel,
line,
detail: `a block must not import the packs tier (${spec})`
});
continue;
}
if (
@ -139,8 +194,87 @@ export function scanBlockFile(fileAbs: string, rel: string, src: string): Violat
line,
detail: `block "${ownBlock}" imports block "${targetBlock}" — share via canon or duplicate consciously`
});
continue;
}
}
if (!isAllowedSpec(spec)) {
out.push({
rule: 'B-4',
file: rel,
line,
detail: `import outside hard boundary 1 (${spec}) — a block imports $uix, the public arts, $libs/forms and svelte; anything else is promoted or the app's`
});
}
}
return out;
}
/** Scan a block README against B-9's template and B-8's landmark duty. Pure. */
export function scanReadme(rel: string, src: string): Violation[] {
const out: Violation[] = [];
for (const section of README_SECTIONS) {
if (!src.split('\n').some((line) => line.trim() === section)) {
out.push({
rule: 'B-9',
file: rel,
line: 1,
detail: `README without the "${section}" section of the B-9 template`
});
}
}
if (!/^\*\*Landmark\b/m.test(src)) {
out.push({
rule: 'B-8',
file: rel,
line: 1,
detail:
'README without a `**Landmark + headings**` declaration — which sectioning element and which heading levels the block emits, and how the app adjusts them'
});
}
return out;
}
/** slug → shipped, read from the demo catalog. Pure. */
export function parseCatalog(src: string): Map<string, boolean> {
const out = new Map<string, boolean>();
for (const match of src.matchAll(/\{\s*slug:\s*'([^']+)'[^}]*?shipped:\s*(true|false)\s*\}/g)) {
out.set(match[1], match[2] === 'true');
}
return out;
}
/**
* Cross-check the live tree against the demo catalog, BOTH ways: a block the
* catalog does not ship is invisible to the rail and the gallery; a slug it
* ships without a block behind it is a dead link. Pure.
*/
export function scanCatalog(
blocks: string[],
catalog: Map<string, boolean>,
rel: string
): Violation[] {
const out: Violation[] = [];
const live = new Set(blocks);
for (const block of blocks) {
if (catalog.get(block) === true) continue;
out.push({
rule: 'B-9',
file: rel,
line: 1,
detail: catalog.has(block)
? `block "${block}" ships but its catalog entry says shipped: false`
: `block "${block}" has no entry in the demo catalog`
});
}
for (const [slug, shipped] of catalog) {
if (shipped && !live.has(slug)) {
out.push({
rule: 'B-9',
file: rel,
line: 1,
detail: `the catalog ships "${slug}" but there is no block at src/uix/blocks/${slug}`
});
}
}
return out;
}
@ -229,7 +363,11 @@ function selfTest(): string[] {
);
expectRules(
'canon importing $blocks flagged',
scanOutsideFile(join(REPO, 'src', 'uix', 'eidos', 'x.ts'), 'fixture', "import H from '$blocks/hero';"),
scanOutsideFile(
join(REPO, 'src', 'uix', 'eidos', 'x.ts'),
'fixture',
"import H from '$blocks/hero';"
),
['B-4']
);
expectRules(
@ -241,6 +379,96 @@ function selfTest(): string[] {
),
['B-4']
);
expectRules(
'import outside the boundary flagged',
scanBlockFile(inHero, 'fixture', "import { z } from 'zod';"),
['B-4']
);
expectRules(
'a lib that is not $libs/forms flagged',
scanBlockFile(inHero, 'fixture', "import { toDate } from '$libs/days';"),
['B-4']
);
expectRules(
'the sanctioned imports clean',
scanBlockFile(
inHero,
'fixture',
"import { Stack } from '$uix/eidos/components/stack';\n" +
"import { createForm } from '$libs/forms';\n" +
"import { getWindow } from '$adom';\n" +
"import { object } from '$sium/core';\n" +
"import type { Snippet } from 'svelte';\n" +
"import type { HTMLAttributes } from 'svelte/elements';\n" +
"import type { HeroProps } from './types';"
),
[]
);
expectRules(
'an example import inside a comment is prose, not code',
scanBlockFile(inHero, 'fixture', "// import { Pricing } from '$blocks/pricing';"),
[]
);
expectRules(
'README without the landmark declaration flagged',
scanReadme(
'fixture',
'# Hero\n\n## Function\n\n## Composition map\n\n## Decisions\n\n## Gaps\n'
),
['B-8']
);
expectRules(
'README missing a template section flagged',
scanReadme(
'fixture',
'# Hero\n\n## Function\n\n**Landmark + headings**: a `<section>`.\n\n## Gaps\n'
),
['B-9', 'B-9']
);
expectRules(
'a complete README clean',
scanReadme(
'fixture',
'# Hero\n\n## Function\n\n## Composition map\n\n**Landmark + headings**: a `<section>`.\n\n## Decisions\n\n## Gaps\n'
),
[]
);
const catalog = parseCatalog(
"[{ slug: 'hero', label: 'Hero', shipped: true }, { slug: 'kanban', label: 'Kanban', shipped: false }]"
);
if (catalog.get('hero') !== true || catalog.get('kanban') !== false || catalog.size !== 2)
failures.push(`catalog parse: expected hero=true kanban=false, got ${[...catalog]}`);
if (parseCatalog("[{ label: 'Hero', shipped: true }]").size !== 0)
failures.push('catalog parse: an entry without a slug must not become a row');
expectRules(
'block without a catalog entry flagged',
scanCatalog(['hero'], new Map(), 'fixture'),
['B-9']
);
expectRules(
'block whose entry says shipped: false flagged',
scanCatalog(['hero'], new Map([['hero', false]]), 'fixture'),
['B-9']
);
expectRules(
'catalog shipping a block that does not exist flagged',
scanCatalog([], new Map([['ghost', true]]), 'fixture'),
['B-9']
);
expectRules(
'tree and catalog in agreement clean',
scanCatalog(
['hero'],
new Map([
['hero', true],
['kanban', false]
]),
'fixture'
),
[]
);
return failures;
}
@ -257,9 +485,7 @@ function main(): void {
let blockDirs: string[] = [];
if (existsSync(BLOCKS_DIR)) {
blockFiles = walk(BLOCKS_DIR, /\.(ts|svelte|css)$/);
blockDirs = readdirSync(BLOCKS_DIR).filter((e) =>
statSync(join(BLOCKS_DIR, e)).isDirectory()
);
blockDirs = readdirSync(BLOCKS_DIR).filter((e) => statSync(join(BLOCKS_DIR, e)).isDirectory());
}
for (const file of blockFiles) {
@ -277,16 +503,33 @@ function main(): void {
violations.push(...scanBlockFile(file, rel, readFileSync(file, 'utf8')));
}
const catalog = existsSync(CATALOG_FILE)
? parseCatalog(readFileSync(CATALOG_FILE, 'utf8'))
: null;
if (catalog === null) {
violations.push({
rule: 'B-9',
file: relative(REPO, CATALOG_FILE),
line: 1,
detail: 'the demo catalog is missing — the rail and the gallery read it as their single list'
});
}
const shippedBlocks = new Set<string>();
for (const dir of blockDirs) {
const hasCode = walk(join(BLOCKS_DIR, dir), /\.(ts|svelte)$/).length > 0;
if (!hasCode) continue;
if (!existsSync(join(BLOCKS_DIR, dir, 'README.md'))) {
shippedBlocks.add(dir);
const readme = join(BLOCKS_DIR, dir, 'README.md');
if (!existsSync(readme)) {
violations.push({
rule: 'B-9',
file: `src/uix/blocks/${dir}`,
line: 1,
detail: 'block folder without README.md (Function · Composition map · Decisions · Gaps)'
});
} else {
violations.push(...scanReadme(relative(REPO, readme), readFileSync(readme, 'utf8')));
}
if (!existsSync(join(DEMO_DIR, dir, '+page.svelte'))) {
violations.push({
@ -298,6 +541,10 @@ function main(): void {
}
}
if (catalog !== null) {
violations.push(...scanCatalog([...shippedBlocks], catalog, relative(REPO, CATALOG_FILE)));
}
let outsideCount = 0;
for (const root of OUTSIDE_ROOTS) {
if (!existsSync(root)) continue;
@ -311,7 +558,9 @@ function main(): void {
for (const v of violations) {
console.error(`ERROR [${v.rule}] ${v.file}:${v.line} — ${v.detail}`);
}
console.error(`\nblocks-check: ${violations.length} error(s) across ${blockDirs.length} block(s)`);
console.error(
`\nblocks-check: ${violations.length} error(s) across ${blockDirs.length} block(s)`
);
process.exit(1);
}
console.log(

@ -13,7 +13,9 @@ nothing is the opposite mistake. Worked example: `contact`. Open candidate:
- **Doctrine** (what a block is, the admission rule, the B contract):
[`docs/architecture/blocks.md`](../../../docs/architecture/blocks.md)
- **Guard**: `npm run blocks:check` (B contract, self-testing)
- **Guard**: `npm run blocks:check` (B contract, self-testing) — it also decides
what a block may import, that its README carries the template's four sections
plus the landmark declaration, and that the demo catalog and this tree agree
- **Execution plan / state**:
[`docs/process/PLAN-blocks.md`](../../../docs/process/PLAN-blocks.md)
- **Demos**: `web/routes/blocks/{kebab}/+page.svelte` + gallery at
@ -75,8 +77,11 @@ One paragraph: the page function this block performs.
| ------ | ---------------- | --------- |
| `.Nav` | `NavigationMenu` | … |
Landmark + heading hierarchy: which sectioning element / heading level the
block emits and how the app adjusts it.
**Landmark + headings**: which sectioning element and which heading levels the
block emits, and how the app adjusts them. Required by B-8 and checked by
`blocks:check` — a block with no landmark of its own declares THAT (a bare
`<section>` with no accessible name is not an exposed landmark; the app names
it through `...rest`).
## Decisions

@ -60,6 +60,11 @@ state**: the caption of a broken-out figure needs the section's reading column,
and telling it again at the call site is where it would drift from the grid.
Same use `team` makes of its `align`.
**Landmark + headings**: one `<section>` named by its own title — the block
renders the `Heading` and points `aria-labelledby` at it. The title is an
`<h{level}>`, default `h2`. Inside `Body` the copy is the app's: the headings of
an article's run belong to it and the block neither emits nor renumbers them.
**`Prose`'s own measure is OFF inside `Body`.** The grid owns the width, and two
owners of the same measure would fight — the narrower wins silently, so a `wide`
body would quietly not be wide.

@ -21,6 +21,12 @@ beat of a page (`center`) or a mid-page nudge that must not stop the reading
| `description` | `Text` `as="p"` (`measure="narrow"`) | on-solid ink. A `span` would ignore `align` — `text-align` is inert on an inline box |
| `actions` | `Flex` (column → row at `sm`) | the app drops `Button`s / `Link`s |
**Landmark + headings**: one `<section>` named by its own title — the block
renders the `Heading` and points `aria-labelledby` at it, so the region has a
name without the app wiring ids. The title is an `<h{level}>`, default `h2`; with
no `title` slot there is neither name nor exposed landmark, and nothing else in
the block is a heading.
## Form: snippet slots, not sub-components
The tier's rule is that a compound API is earned when parts **repeat**

@ -9,7 +9,17 @@
let { maxWidth = '48rem', children, ...rest }: FaqHeaderProps = $props();
</script>
<Box {maxWidth} width="100%" {...rest}>
<!--
`marginX="auto"` is what makes the typedoc's «centered» true (A-19). `Box` does
not centre on its own — `maxWidth` rides `--box-max-width` and the margin is a
separate channel — and the root stacks with `align="stretch"`, so once the cap
bites the header anchored at cross-start instead. Same idiom the sibling block
already uses (`content-section-media.svelte`). With this block's own defaults
the cap never bites (a `md` container is narrower than `48rem`), so nothing
moves until a consumer widens the section — which is exactly the case the
promise was written for.
-->
<Box {maxWidth} width="100%" marginX="auto" {...rest}>
<Motion trigger="viewport">
<Stack gap={3}>
{@render children?.()}

@ -0,0 +1,31 @@
import { getContext, setContext } from 'svelte';
/**
* What `FeatureGrid` shares with its parts: the section's cross-axis alignment.
*
* The axis was already declared on the root — what it was not doing is reaching
* the parts (A-84). The type used to instruct the app to repeat it («set the
* matching alignment on the header's own `Heading`/`Text` and on each `Item`»),
* so every consumer re-derived at the point of use the thing the section had
* already decided. That is the same hole the coordination doctrine exists to
* close, in its mildest form: not invented state, a declared axis applied
* halfway.
*
* Same shape and same rule as `team`'s: it is a DEFAULT, not a law — an explicit
* `align` on a part always wins.
*/
export interface FeatureGridContext {
/** `center` (the arrangement the references lead with) or `start`. A getter,
* so reads track the reactive source. */
readonly align: 'start' | 'center';
}
const KEY = Symbol('uix.feature-grid');
export function setFeatureGridContext(ctx: FeatureGridContext): void {
setContext(KEY, ctx);
}
export function getFeatureGridContext(): FeatureGridContext | undefined {
return getContext<FeatureGridContext | undefined>(KEY);
}

@ -7,14 +7,23 @@
import { Box } from '$uix/eidos/components/box';
import { Stack } from '$uix/eidos/components/stack';
import { Motion } from '$uix/eidos/components/motion';
import { getFeatureGridContext } from './context';
import type { FeatureGridHeaderProps } from './types';
let { maxWidth = '48rem', children, ...rest }: FeatureGridHeaderProps = $props();
let { align, maxWidth = '48rem', children, ...rest }: FeatureGridHeaderProps = $props();
const grid = getFeatureGridContext();
const resolvedAlign = $derived(align ?? grid?.align ?? 'center');
</script>
<!--
The section's axis reaches the header's own content (A-84): it used to align
only the header BOX, and the type told the app to repeat the alignment on the
`Heading` and the `Text` it drops in here.
-->
<Box {maxWidth} width="100%" {...rest}>
<Motion trigger="viewport">
<Stack gap={3}>
<Stack gap={3} align={resolvedAlign}>
{@render children?.()}
</Stack>
</Motion>

@ -9,8 +9,12 @@
import { Surface } from '$uix/eidos/components/surface';
import type { FeatureGridItemIconProps } from './types';
let { color = 'primary', variant = 'solid', children, ...rest }: FeatureGridItemIconProps =
$props();
let {
color = 'primary',
variant = 'solid',
children,
...rest
}: FeatureGridItemIconProps = $props();
</script>
<Surface {variant} {color} rounded padding={3} {...rest}>

@ -7,9 +7,16 @@
*/
import { Stack } from '$uix/eidos/components/stack';
import { Motion } from '$uix/eidos/components/motion';
import { getFeatureGridContext } from './context';
import type { FeatureGridItemProps } from './types';
let { align = 'start', gap = 4, children, ...rest }: FeatureGridItemProps = $props();
let { align, gap = 4, children, ...rest }: FeatureGridItemProps = $props();
// The section's axis, unless this cell overrides it (A-84). `start` stays the
// fallback when there is no section around — the arrangement that reads when a
// cell carries a paragraph.
const grid = getFeatureGridContext();
const resolvedAlign = $derived(align ?? grid?.align ?? 'start');
</script>
<!--
@ -19,7 +26,7 @@
millisecond.
-->
<Motion trigger="viewport">
<Stack {gap} {align} {...rest}>
<Stack {gap} align={resolvedAlign} {...rest}>
{@render children?.()}
</Stack>
</Motion>

@ -11,6 +11,7 @@
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import { setFeatureGridContext } from './context';
import type { FeatureGridProps } from './types';
let {
@ -20,6 +21,15 @@
children,
...rest
}: FeatureGridProps = $props();
// The axis reaches the parts instead of being repeated at every call site
// (A-84). A getter, so the parts track the reactive source; `team` shares its
// `align` the same way, and the tier's rule is applied part by part.
setFeatureGridContext({
get align() {
return align;
}
});
</script>
<section {...rest}>

@ -1,4 +1,5 @@
import type { Snippet } from 'svelte';
import type { AutoGridProps } from '$uix/eidos/components/auto-grid';
import type { HTMLAttributes } from 'svelte/elements';
import type { ContainerSize } from '$uix/eidos/components/container';
import type { SectionSize } from '$uix/eidos/components/section';
@ -7,7 +8,6 @@ import type { StackProps } from '$uix/eidos/components/stack';
import type { SurfaceProps } from '$uix/eidos/components/surface';
import type { HeadingProps } from '$uix/eidos/components/heading';
import type { TextProps } from '$uix/eidos/components/text';
import type { AutoGridProps } from '$uix/eidos/components/auto-grid';
// The root renders a native `<section>`, so it carries the raw HTML attributes.
// Every OTHER part wraps a canon component and extends THAT component's props
@ -19,9 +19,11 @@ export type FeatureGridProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/** Block-axis padding. Passed straight to `Section`. @default 'lg' */
size?: SectionSize;
/**
* Cross-axis placement of the header block — `center` sits it in the middle,
* `start` runs it flush with the item column. Set the matching alignment on
* the header's own `Heading`/`Text` and on each `Item`. @default 'center'
* Cross-axis placement of the section — `center` sits the header in the middle
* and centres each cell's content; `start` runs both flush with the item
* column. It **travels to the parts through context** (A-84), so it is set
* once here; an explicit `align` on a `Header` or an `Item` still wins.
* @default 'center'
*/
align?: 'start' | 'center';
/** `Header` and `Items` in composition order. */
@ -29,10 +31,26 @@ export type FeatureGridProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
};
/** Wraps a `Box` (measure-cap). `maxWidth` defaults to `48rem`. */
export type FeatureGridHeaderProps = BoxProps;
export type FeatureGridHeaderProps = BoxProps & {
/**
* Cross-axis alignment of the header's own content. Defaults to the section's
* (through context), so it is normally set once on the root.
*/
align?: 'start' | 'center';
};
/** Wraps an `AutoGrid`. Fluid via `minChildWidth` (default `16rem`) or fixed `columns`. */
export type FeatureGridItemsProps = AutoGridProps;
/**
* Wraps an `AutoGrid`. Fluid via `minChildWidth` (default `16rem`) or fixed
* `columns`.
*
* CLOSED on purpose (A-94), like `PricingPlansProps`: the wrapper spreads
* `{...rest}` BEFORE the props it fixes, so a consumer's `align` / `width`
* type-checked and was discarded in silence.
*/
export type FeatureGridItemsProps = Pick<
AutoGridProps,
'minChildWidth' | 'columns' | 'gap' | 'children'
>;
/** Wraps a `Stack`. `align` defaults to `start`, `gap` to `4`. */
export type FeatureGridItemProps = StackProps;

@ -27,6 +27,12 @@ and a handler here.
| `note` | `Text` `as="p"` `size="sm"` | quieter by SIZE, same ink as the description — `on-solid` on the panel, `secondary` off it |
| measure of the form | `Container size="sm"` (center only) | a field line as wide as the prose is unusable |
**Landmark + headings**: one `<section>` named by its own title — the block
renders the `Heading` and points `aria-labelledby` at it. The title is an
`<h{level}>`, default `h2`; with no `title` slot there is neither name nor
exposed landmark. The description, the note and the reason are `Text`, so the
section contributes exactly one line to the outline.
## What it does NOT do
**It does not validate.** `Form` owns the runtime, the Standard Schema / SIUM

@ -21,6 +21,12 @@ monthly ↔ annual with the prices updating in step.
| `.PlanFeatures` / `.PlanFeature` | `Stack` / `Group` + `Icon.Check` + `Text` | the checklist; the check is decorative (aria-hidden) |
| `.PlanAction` | `Box` (`margin-block-start: auto`) | the CTA, pinned to the card's bottom so a row lines up |
**Landmark + headings**: a bare `<section>` with NO accessible name of its own,
so it is not an exposed landmark — the app names it by passing `aria-label` /
`aria-labelledby` through `...rest`. The section heading is the app's (`h2`, in
`.Header`); `.PlanName` emits `h3` and takes a `level` prop to move under a
different parent.
## Why compound WITH a context
`feature-grid` and `feature-split` are compound because their parts **repeat**

@ -9,7 +9,12 @@
// <Pricing.Header>
// <Heading level={2} align="center">Precios que crecen contigo</Heading>
// </Pricing.Header>
// <Pricing.Switch annualLabel="Anual (−20%)" />
// <!-- The three words are REQUIRED: a block ships none of its own (B-7). -->
// <Pricing.Switch
// monthlyLabel="Mensual"
// annualLabel="Anual (−20%)"
// aria-label="Periodo de facturación"
// />
// <Pricing.Plans>
// <Pricing.Plan featured>
// {#snippet badge()}<Badge color="primary">Más popular</Badge>{/snippet}

@ -22,6 +22,12 @@ there (language / theme / region pickers).
| `Column` | `Stack` (gap 3) | children are the app's `Link`s |
| `ColumnTitle` | `Heading` `level={3}` `size="xs"` | |
**Landmark + headings**: a real `<footer>`, so the page gets `contentinfo` for
free and there is no ARIA to add — as long as it stays a child of `<body>`;
nested inside a `<section>` or an `<article>` it is scoped away and stops being
the landmark. `.ColumnTitle` emits `h3` and takes a `level` prop: a footer group
HAS a name, and the visual step down is `size`, never a smaller level.
## Form: compound where it repeats, slots where it does not
The tier's admission rule, applied part by part. `Column` **repeats** (the app maps

@ -23,9 +23,17 @@
<!--
The Member IS the viewport `Motion`, so it must be the DIRECT child of the
`[data-stagger]` grid for the foundation to hand it its structural index.
`height="100%"` is what makes `MemberLinks`' `margin-top: auto` mean anything
(A-88). The grid stretches the cell, but the div `Motion` renders is a plain
block with no height of its own, so the Stack inside stayed at content height
— and in a flex column of automatic height the free space is ZERO, so `auto`
distributed nothing. Measured with uneven bios: the icon rows sat 20px apart
inside the same row, the exact case this block's README gives as the reason
for the rule.
-->
<Motion trigger="viewport">
<Stack {gap} align={resolvedAlign} {...rest}>
<Stack {gap} align={resolvedAlign} height="100%" {...rest}>
{@render children?.()}
</Stack>
</Motion>

@ -1,4 +1,5 @@
import type { Snippet } from 'svelte';
import type { AutoGridProps } from '$uix/eidos/components/auto-grid';
import type { HTMLAttributes } from 'svelte/elements';
import type { ContainerSize } from '$uix/eidos/components/container';
import type { SectionSize } from '$uix/eidos/components/section';
@ -7,7 +8,6 @@ import type { StackProps } from '$uix/eidos/components/stack';
import type { GroupProps } from '$uix/eidos/components/group';
import type { HeadingProps } from '$uix/eidos/components/heading';
import type { TextProps } from '$uix/eidos/components/text';
import type { AutoGridProps } from '$uix/eidos/components/auto-grid';
// The root renders a native `<section>`, so it carries the raw HTML attributes.
// Every OTHER part wraps a canon component and extends THAT component's props
@ -32,8 +32,18 @@ export type TeamProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/** Wraps a `Box` (measure-cap). `maxWidth` defaults to `48rem`. */
export type TeamHeaderProps = BoxProps;
/** Wraps an `AutoGrid`. Fluid via `minChildWidth` (default `12rem`) or fixed `columns`. */
export type TeamMembersProps = AutoGridProps;
/**
* Wraps an `AutoGrid`. Fluid via `minChildWidth` (default `12rem`) or fixed
* `columns`.
*
* CLOSED on purpose (A-94), like `PricingPlansProps`: the wrapper spreads
* `{...rest}` BEFORE the props it fixes, so a consumer's `align` / `width`
* type-checked and was discarded in silence.
*/
export type TeamMembersProps = Pick<
AutoGridProps,
'minChildWidth' | 'columns' | 'gap' | 'children'
>;
/**
* Wraps a `Stack`. Its `align` follows the section's unless overridden, so a

@ -19,6 +19,12 @@ section, usually below features or pricing.
| `.AuthorName` | `Text` (medium, sm) | |
| `.AuthorRole` | `Text` (muted, sm) | role / company |
**Landmark + headings**: a bare `<section>` with NO accessible name of its own,
so it is not an exposed landmark — the app names it by passing `aria-label` /
`aria-labelledby` through `...rest`. The section heading is the app's (`h2`, in
`.Header`); the block emits NO heading of its own — an author's name is a `Text`,
not a title.
## Why compound
Its `Item` **repeats** — the app maps over N quotes — so it earns a compound

@ -1,4 +1,5 @@
import type { Snippet } from 'svelte';
import type { AutoGridProps } from '$uix/eidos/components/auto-grid';
import type { HTMLAttributes } from 'svelte/elements';
import type { ContainerSize } from '$uix/eidos/components/container';
import type { SectionSize } from '$uix/eidos/components/section';
@ -6,7 +7,6 @@ import type { BoxProps } from '$uix/eidos/components/box';
import type { CardProps } from '$uix/eidos/components/card';
import type { TextProps } from '$uix/eidos/components/text';
import type { GroupProps } from '$uix/eidos/components/group';
import type { AutoGridProps } from '$uix/eidos/components/auto-grid';
export type TestimonialsProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/** Content measure. Passed straight to `Container`. @default 'lg' */
@ -20,10 +20,26 @@ export type TestimonialsProps = Omit<HTMLAttributes<HTMLElement>, 'children'> &
/** Wraps a `Box` (measure-cap, centered). `maxWidth` defaults to `48rem`. */
export type TestimonialsHeaderProps = BoxProps;
/** Wraps an `AutoGrid`. Fluid via `minChildWidth` (default `20rem`) or fixed `columns`. */
export type TestimonialsItemsProps = AutoGridProps;
/** Wraps a `Card` (the quote card). `variant` defaults to `soft`. */
/**
* Wraps an `AutoGrid`. Fluid via `minChildWidth` (default `20rem`) or fixed
* `columns`.
*
* CLOSED on purpose (A-94), like `PricingPlansProps`. It used to be the whole
* `AutoGridProps`, but the wrapper spreads `{...rest}` BEFORE the props it fixes
* (`align`, `width`, `data-stagger`), so `<Testimonials.Items align="start">`
* type-checked and did nothing. Promising a surface and discarding it is the one
* thing that does not hold: the type now says exactly what the part honours.
*/
export type TestimonialsItemsProps = Pick<
AutoGridProps,
'minChildWidth' | 'columns' | 'gap' | 'children'
>;
/**
* Wraps a `Card` (the quote card). `variant` defaults to **`outline`** (A-27):
* `soft` neutral is all but invisible on a light canvas, which is why the block
* picked the outline card — the typedoc said `soft` and the code never did.
*/
export type TestimonialsItemProps = CardProps;
/** Wraps a `Text` — the quotation. `size` defaults to `lg`. */

Loading…
Cancel
Save

Powered by TurnKey Linux.