You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/process/AUDIT-blocks-ledger.md

273 KiB

AUDIT — tier blocks: registro de hallazgos (ledger)

Fuente única del estado de calidad del tier desde 2026-08-05. Sustituye a la clasificación de AUDIT-blocks-2026-08-01.md, que se conserva intacta: sus reclamaciones, evidencias y sugerencias son válidas y esta tabla las indexa. Lo que no sirve de aquel documento es el veredicto.

Por qué existe este fichero

En el documento del 2026-08-01 los campos «Veredicto» están desemparejados de su hallazgo: el de cta/Container lleva un veredicto sobre Card/BoxProps; el del contraste de hero, uno sobre el drawer de site-header; el del import de $libs/forms, uno sobre --_link-palette-text. La lista de refutados arrastra el mismo desplazamiento: su primera justificación refuta el hallazgo de feature-grid que la sección de confirmados declara CONFIRMADO.

La reclamación, la evidencia y la sugerencia sí casan entre sí dentro de cada entrada. Lo que viajó movido es el veredicto — y con él la etiqueta CONFIRMADO/REFUTADO. La causa más probable es un join por posición entre el array de veredictos y el de hallazgos: un agente caído desplaza la lista entera. El journal del workflow era de sesión y ya no existe, así que el emparejamiento original no se puede recuperar: hay que volver a medir.

Las reglas de este registro

  1. Toda escritura es por id. Ningún proceso vuelve a unir dos listas por posición. Un verificador que no devuelve su id no escribe fila.
  2. Las 90 filas parten de PENDIENTE, incluidas las 29 «confirmadas» y las 11 «refutadas»: su etiqueta viajó con el veredicto movido y no es evidencia de nada.
  3. El id es re-derivable: es el orden documental del fichero del 2026-08-01 (A-01…A-29 confirmados · A-30…A-79 sin verificar · A-80…A-90 refutados). Se extrae mecánicamente; no se renumera nunca.
  4. El veredicto se escribe con su mecanismo y su evidencia, en la ficha de abajo. Un REFUTADO sin motivo escrito es exactamente el fallo que estamos arreglando.

Estados: PENDIENTE · CONFIRMADO · REFUTADO · DATO (cierto y sin defecto) · ARREGLADO · DIFERIDO (confirmado, disposición firmada, no se toca ahora).

Columna or. = de qué sección del documento viejo salió la fila (C confirmado · SV sin verificar · R refutado). Es trazabilidad, no un veredicto.

Índice de hallazgos

id block dim sev or. afirmación ancla estado
A-01 cta doctrina ALTA C La disposición «Panel a sangre → app-land, la app pone container="full"» es falsa: Container siempre aplica padding inline y el block no ofrece… src/uix/blocks/cta/README.md ARREGLADO
A-02 hero percepcion ALTA C En layout="background" la CTA secundaria mide 1.78:1 contra su fondo — muy por debajo de AA — y el token que la demo escribe a mano para arreglarlo… web/routes/blocks/hero/HeroSite.svelte ARREGLADO
A-03 feature-grid doctrina ALTA C El README no declara landmark ni cómo ajustar la jerarquía de encabezados, que es obligación explícita de B-8 y de la plantilla; sus dos hermanos sí… src/uix/blocks/feature-grid/README.md ARREGLADO
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 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 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 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
A-23 pricing doctrina BAJA C Pulsar el segmento de periodo YA ACTIVO devuelve la sección entera al periodo contrario: el block posee el estado y su propio control lo mueve a un… src/uix/blocks/pricing/pricing-switch.svelte ARREGLADO
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 ARREGLADO
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 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 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 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 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
A-44 contact doctrina ALTA SV Con los tres campos rellenos y el correo mal formado, la seccion afirma «Completa todos los campos para enviar.» y no marca el campo: el camino de… src/uix/blocks/contact/contact-fields.svelte ARREGLADO
A-45 pricing percepcion ALTA SV Un solo clic sobre el periodo YA activo VACIA el segmented control: la seccion queda sin ningun periodo marcado mientras los precios siguen siendo… src/uix/blocks/pricing/pricing-switch.svelte ARREGLADO
A-46 cta percepcion ALTA SV La accion secundaria del cluster («Hablar con ventas») es MUDA: no emite ningun evento sema, y en reposo es tipograficamente identica al parrafo de… web/routes/blocks/cta/CtaSite.svelte:72 REFUTADO
A-47 cta percepcion ALTA SV El anillo de foco es practicamente invisible sobre el panel solido: los DOS unicos elementos interactivos del block quedan sin indicador de foco por… src/uix/blocks/cta/cta.svelte:111 CONFIRMADO
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 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
A-55 banner doctrina MEDIA SV «full-bleed por fuera, en columna por dentro» deja de ser cierto en cuanto se pasa onDismiss: el margin-inline-start:auto del Close es un TERCER… src/uix/blocks/banner/banner.svelte CONFIRMADO
A-56 banner doctrina MEDIA SV B-9 exige el block A SANGRE en la página de demo, «nunca dentro de un marco con padding». En la galería el block se pinta dentro del raíl del… web/routes/blocks/_lib/BlockDemo.svelte REFUTADO
A-57 newsletter percepcion MEDIA SV Con panel={false} la nota de privacidad se pinta con MÁS fuerza que la línea de apoyo: la descripción cae a muted y la nota se queda con la tinta… src/uix/blocks/newsletter/newsletter.svelte ARREGLADO
A-58 site-footer doctrina MEDIA SV La demo construye tintas a mano en app-land (color="var(--color-content-*)") y son INERTES: con variant="subtle" el recipe del Link fuerza… web/routes/blocks/site-footer/SiteFooterSite.svelte ARREGLADO
A-59 site-footer percepcion MEDIA SV El título del grupo se pinta MÁS PEQUEÑO que los enlaces que nombra y con la misma tinta: la única señal de jerarquía que queda es el peso. src/uix/blocks/site-footer/site-footer-column-title.svelte ARREGLADO
A-60 site-footer percepcion MEDIA SV «Tantas columnas como caben, el mismo código para 2 o para 5 grupos» describe el código, no el resultado: AutoGrid usa auto-fill, que crea las… src/uix/blocks/site-footer/site-footer.svelte CONFIRMADO
A-61 contact percepcion MEDIA SV El submit bloqueado sale del orden de tabulacion, asi que quien navega con teclado nunca llega al control ni se topa con la frase que lo explica. src/uix/blocks/contact/contact-submit.svelte CONFIRMADO
A-62 team percepcion MEDIA SV Los 12 IconButton que el block pone en escena estampan contact-activate y no hacen absolutamente nada: el contacto inicia y nada resuelve, doce veces. web/routes/blocks/team/TeamSite.svelte CONFIRMADO
A-63 contact percepcion MEDIA SV El Link «hola@vicen.dev» dentro de Contact.Detail es mudo: es el unico elemento interactivo de la columna de datos y no emite nada. web/routes/blocks/contact/ContactSite.svelte REFUTADO
A-64 pricing percepcion MEDIA SV Ese mismo clic muerto emite commit-toggle + intent neutral: el sistema anuncia que algo quedo fijado cuando la seccion no cambio de estado (los… src/uix/blocks/pricing/pricing-switch.svelte ARREGLADO
A-65 pricing percepcion MEDIA SV Un solo gesto sobre el CTA de un plan produce DOS eventos sema sobre el MISMO elemento a 1 ms: el commit-select de la app pisa el… web/routes/blocks/pricing/PricingSite.svelte CONFIRMADO
A-66 pricing percepcion MEDIA SV El plan destacado se grada fulfill en lo visual pero su consecuencia suena affirm, exactamente igual que los otros dos planes: la lectura… web/routes/blocks/pricing/PricingSite.svelte REFUTADO
A-67 faq percepcion MEDIA SV Un solo clic que cambia de pregunta dispara DOS earcons emerge simultaneos y sin atenuar (collapse descendente + expand ascendente): un gesto, dos… src/uix/blocks/faq/faq-list.svelte:14 CONFIRMADO
A-68 stats-band percepcion MEDIA SV La coreografia de la banda esta desincronizada: la ENTRADA escalona, pero los contadores arrancan todos en el mismo frame, asi que una cifra ya va… src/uix/blocks/stats-band/stats-band-value.svelte:20 CONFIRMADO
A-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
A-73 content-section percepcion BAJA SV DATO, no defecto: el block no pone NINGUN elemento interactivo — cero en toda su region — y nada en el estampa sema. web/routes/blocks/content-section/ContentSectionSite.svelte DATO
A-74 feature-split percepcion BAJA SV Los 3 unicos elementos interactivos del block emiten su contact correctamente, pero no llevan a ningun sitio: los href apuntan a fragmentos que no… web/routes/blocks/feature-split/FeatureSplitSite.svelte CONFIRMADO
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
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. — 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… — 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í… — 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
A-95 banner doctrina MEDIA F7 Con affix="top" la tira fijada tapa la cabecera pegada y la navegación queda inalcanzable; la pieza que falta es la que posee las alturas de página (app-shell)… web/routes/blocks/banner/BannerSite.svelte CONFIRMADO
A-96 banner doctrina BAJA F7 affixOffset es prop público sin control vivo en la demo, contra la regla «cada prop público, un control»… web/routes/blocks/banner/+page.svelte CONFIRMADO
A-97 feature-grid + 6 blocks percepcion MEDIA F7 El texto color="muted" mide 3,70:1 en claro — bajo AA de cuerpo — en 8 sitios de 7 blocks; el hallazgo estaba enterrado en la ficha de A-70, que es REFUTADO… src/uix/blocks/feature-grid/feature-grid-item-text.svelte REFUTADO
A-98 hero percepcion MEDIA F7 Las acciones no apilan en móvil: Group es row/nowrap y a 375px los dos botones ocupan 315 de 327px; cta ya lo resolvió con Flex responsive… src/uix/blocks/hero/hero.svelte CONFIRMADO
A-99 Group (canon) doctrina MEDIA F7 Group no aplica el wrap que su README promete en tres sitios: no lo pasa a Flex, no lo declara el recipe, y Omit<FlexProps,'wrap'> impide compensarlo… src/uix/eidos/components/group/group.svelte CONFIRMADO

Reparto

Dimensión Filas Cómo se verifica
doctrina 35 lectura dirigida de la fuente que ancla la fila, contra architecture/blocks.md
percepcion 44 navegador con sondas (audio + MutationObserver al nodo), tras la puerta de hidratación real
sin dimensión (los 11 refutados) 11 se clasifica al verificar

Severidad ALTA declarada en origen: 24 filas.

Fase 1 — doctrina re-verificada por lectura (2026-08-05)

43 filas cerradas leyendo la fuente que cada una ancla: 39 CONFIRMADO · 4 REFUTADO. Lo que la re-verificación cambió respecto de lo que decía el documento viejo:

  • A-04 (contact, ALTA) — el hecho se sostiene, la CAUSA no. El canon SÍ tiene escotilla: soma form-submit acepta aria-label y form-provider.svelte.ts:187-189 hace ganar al valor explícito del consumidor. El nombre accesible está congelado porque contact-submit.svelte:21 no lo pasa, no porque el canon lo imponga. La sugerencia original mandaba a tocar soma sin necesidad.
  • Cuatro refutados con motivo escrito: A-26 (la doctrina acota la propiedad de las palabras a las secciones que pueden bloquear; pricing no bloquea nada), A-31 (form-provider.svelte.ts:129 SÍ limpia el signal-warn-invalid, en la línea que la propia reclamación anclaba), A-56 (B-9 prohíbe marco con padding o caja con scroll, y la región de contenido no aporta ninguno de los dos), A-89 (sent es un prop $bindable: la vuelta existe y es del app).
  • A-87 es duplicado de A-44 — la MISMA reclamación aparecía como «sin verificar» y como «refutada» en el documento viejo. Por sí solo prueba que la clasificación estaba movida.
  • A-83 no era doctrinal, era la causa de A-02: hero deriva onDark y solo lo aplica a dos piezas, así que la tinta on-solid no llega a lo que el app pone en actions — de ahí el 1.78:1.
  • B-8 es más grave de lo que decía el handoff: 8 de 15 READMEs sin declaración de landmark, no 6.
  • Tres puertas de entrada del tier tienen ejemplos que no compilan: A-39 (newsletter), A-81 (cta), A-86 (pricing).

Fase 2 — percepción re-verificada en navegador (2026-08-05)

47 filas (44 de percepción + 3 que exigían medir) por workflow adversarial, en cuatro tandas, un agente por id. Resultado del registro completo: 75 CONFIRMADO · 12 REFUTADO · 3 DATO, y 30 de las confirmadas ya ARREGLADAS.

  • La causa raíz del desastre anterior, reproducida y neutralizada: el journal del workflow devuelve los resultados en un orden distinto al de entrada (en la primera tanda salió A-42 primero y A-02 penúltimo). Unir por posición habría vuelto a cruzar cada veredicto con la reclamación equivocada. Aquí la unión es por id y el desorden es irrelevante.
  • La tasa de refutación real es del 13% (12 de 90), no el 28% que declaraba el documento viejo — y las refutaciones se concentraron en las tandas 2–4, así que no es complacencia: A-46, A-49, A-50, A-52, A-70, A-72 cayeron con motivo escrito.
  • Los verificadores corrigieron reclamaciones además de juzgarlas: A-02 mide 1.61:1, no 1.78:1; A-42 afecta a los SIETE estados, no a cinco; A-36 no trunca el feedback, NUNCA lo pinta; A-60 mide el 62% de región muerta con 2 grupos.
  • El retrato que sale del conjunto: de las 12 ALTA de percepción, 8 tienen la causa raíz en 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-95 — CONFIRMADO · banner · doctrina · MEDIA

Mecanismo — Con affix="top" la tira fijada TAPA la cabecera pegada, y la navegación del sitio queda inalcanzable mientras el aviso siga visible. Medido en /blocks/banner/preview?affix=top a 1280×700 con la página desplazada 800px: la tira ocupa 0..54 y el <header> del site-header 0..31 —se solapan—, y document.elementFromPoint en el centro del header devuelve LA TIRA. El apilamiento en sí es correcto y está firmado: Affix monta en el peldaño --viewport-placement-z = 150, «encima de un sticky header, debajo de menús y diálogos», y el escenario lo dice («las dos compiten por el mismo borde — por eso el aviso va por encima del cromo»). El escenario ya compensa con padding-block-start: 3.5rem en la página, pero eso empuja el CONTENIDO: un sticky con top: 0 se sigue anclando donde está la tira.

La solución NO está en el banner, y ahí estaba el error de análisis. La primera propuesta fue pasar offset al site-header (que lo expone, en px), y se cayó con una pregunta del usuario: «¿y si tenemos banners a diferentes alturas?». Cualquier número acordado se rompe con size="lg", con el mensaje envolviendo a dos líneas en móvil, o con dos avisos.

Cómo lo resuelve la referencia (verificado en su documentación, 2026-08-15): Mantine NO usa Affix para esto —su Affix es «renders children inside portal at fixed position», para elementos flotantes puntuales tipo scroll-to-top—. Lo resuelve en AppShell: todas las secciones son position: fixed, las alturas se DECLARAN una vez en el shell (header={{ height: 60 }}, aceptando objeto con breakpoints) y AppShell.Main es «statically positioned and offset by the other sections». Su configuración lleva además collapsed —«the section is hidden from the viewport and doesn't affect the Main offset»— y offset?: boolean, así que un aviso descartado deja de contar para el desplazamiento sin que nadie recalcule nada.

Evidencia — web/routes/blocks/banner/BannerSite.svelte:39-49 (el padding compensatorio) · src/uix/eidos/components/affix/types.ts (el peldaño 150) · https://mantine.dev/core/affix/ y https://mantine.dev/core/app-shell/ (leídas).

Disposición — NO se arregla en banner ni en site-header: la pieza que falta es la que POSEE las alturas de página y propaga los offsets, y en el plan del tier es el block app-shell (F3.1, sin construir). Hasta que exista, cualquier solución en el banner es un número acordado con otro nombre. Lo que sí procede ya, y es documental: que el README del block diga que affix="top" no está pensado para convivir con una cabecera pegada — para eso, hasta que haya shell, el app agrupa aviso y cabecera en un solo Sticky (⚠️ esa alternativa está RAZONADA, no medida).

A-96 — CONFIRMADO · banner · doctrina · BAJA

Mecanismo — affixOffset es prop público y no tiene control vivo en la demo; sólo lo tiene affix. La regla del tier es «cada prop público, un control vivo» (B-9 + la doctrina de demos del handoff).

Evidencia — web/routes/blocks/banner/+page.svelte — controles: intent · variant · descartable · affix · dir.

Disposición — Un control más, o declararlo en la ficha de la API como omitido a propósito (podría serlo: un selector de longitud CSS es ruido en una fila de chips).

A-97 — REFUTADO · feature-grid + 6 blocks · percepcion · MEDIA

Mecanismo — El texto que los blocks pintan con color="muted" mide 3,70:1 sobre el lienzo de página en claro, por debajo del suelo AA de cuerpo (4,5:1). Medido hoy en /blocks/feature-grid/preview: la descripción de cada celda (feature-grid-item-text.svelte:12, Text color="muted", 16px) resuelve tinta rgb(131,131,131) sobre fondo rgb(252,252,252) = 3,70:1. Es EXACTAMENTE el mismo número que midió el verificador de la fase 2, y ahí está el problema de proceso: ese hallazgo vive enterrado dentro de la ficha de A-70, que está marcada REFUTADO. La reclamación de A-70 («feature-grid no tiene nada que auditar perceptivamente») se refutó correctamente, pero el verificador encontró de paso ESTO y nunca se le dio fila propia, así que el recuento del ledger lo daba por inexistente.

No es de un block: es del tier. Censo de color="muted" en src/uix/blocks/: 8 sitios en 7 blocks — feature-grid-item-text (16px, cuerpo) · feature-split-text (lg) · content-section lede (lg) y meta (sm) · content-section-media caption (sm) · pricing-plan-description (sm) · pricing-plan-price suffix · testimonials-author-role (sm). El ratio es idéntico en todos —mismo token, mismo lienzo—; lo que cambia es el umbral aplicable: a 16px y a sm es 4,5:1, así que fallan; sólo los lg podrían salvarse si superan los 18,66px que bajan el umbral a 3:1.

Evidencia — Medición con composición hecha por el navegador (canvas) en /blocks/feature-grid/preview, 1280×900, tema claro. ⚠️ La medición en oscuro NO vale: dio el mismo valor, señal de que la preview no conmuta con prefers-color-scheme, así que sólo se declara el modo claro.

Disposición — El valor sale del token del canon (content-muted), no del block, pero es cada block quien ELIGE el rol muted para su cuerpo. Dos salidas y no son excluyentes: (a) CANON — que content-muted alcance AA sobre el lienzo de página, que es donde se arregla para todos; (b) block-side — no usar muted para el CUERPO de un párrafo, reservándolo a metadato. Antes de tocar nada hay que medir los lg por separado, porque su umbral es otro.

REFUTADO el mismo día (2026-08-15) — la vara era ajena. El proyecto NO mide el contraste con el 4,5:1 genérico de WCAG: su suelo es DOBLE y está escrito en theming/reference.md:1719 — «floors APCA ≥ 60 ∧ WCAG ≥ 3» — con guard propio (scripts/contrast-audit.ts, «WCAG 2 gate + APCA Lc, over the 33 scales × 2 modes»). Y sobre esta tinta en concreto, reference.md:1780-1781: «text·11 is the secondary / low-contrast ink (≈APCA 60) — marginal sub-4.5:1 on the muddy light-mode scales… by design».

Re-medido con las funciones del propio repo (src/arts/color/apca.ts, en el formato que declara — gamma sRGB 0..1, no 0..255): Lc APCA = 63,7 (suelo 60 ✅) y WCAG = 3,70:1 (suelo ≥3 ✅). La tinta CUMPLE los dos suelos del sistema.

⚠️ Dos lecciones, y la segunda es de instrumento: (1) antes de medir contraste hay que usar la vara del proyecto, que aquí es APCA + un WCAG rebajado para la tinta secundaria, no el 4,5 de manual; (2) el primer intento con apcaLc devolvió 102666.5 — Lc va de −108 a +106 — porque le pasé el color en 0..255. Un valor fuera de rango no es un hallazgo raro: es un formato mal pasado.

Lo que SÍ sobrevive de esta ficha, como dato y no como defecto: el censo de dónde se pinta cuerpo con muted — 8 sitios en 7 blocks —, útil el día que se retoque ese rol.

A-99 — CONFIRMADO · Group (canon) · doctrina · MEDIA

Mecanismo — Group no envuelve, y nadie puede hacer que envuelva, mientras su documentación promete lo contrario en tres sitios. La cadena, leída entera:

group.svelte  →  <Flex {...restProps} {direction} gap class data-*>   ← NUNCA pasa `wrap`
flex.svelte   →  wrap = 'nowrap'  →  pushStyleVar('--flex-wrap', …)
flex.css:51   →  flex-wrap: var(--flex-wrap, nowrap)
types.ts:4    →  GroupProps = Omit<FlexProps, 'wrap'>                 ← el consumidor tampoco puede
group.css     →  sólo `--group-attached-overlap`, `data-grow` y las reglas de `attached`

Lo que su README afirma: «wrap="wrap" + align="center" por defecto», «Defaults pensados para action rows — align: center, wrap: wrap», y en la comparativa «Row con wrap por defecto | UIX: Sí | Chakra HStack: No». Medido en la demo del propio componente (/uix/components/group): flex-wrap: **nowrap**, flex-direction: row, align-items: center. El align SÍ se cumple, lo que descarta que el recipe no cargue: falta específicamente el wrap.

Evidencia — Lectura de los cuatro ficheros de la cadena + medición del componente aislado en su propia demo, no en un block.

Disposición — CANON. O group.svelte pasa el wrap con su default documentado, o el recipe lo declara, o la documentación deja de prometerlo. Nota de tipo: Omit<FlexProps,'wrap'> fue deliberado (el wrap era una decisión del componente, no del consumidor) — coherente con un default fijo, incoherente con que ese default no exista.

A-98 — CONFIRMADO · hero · percepcion · MEDIA

Mecanismo — El cluster de acciones del hero no apila en móvil: hero.svelte:127 lo compone con <Group gap={3} align="center">, y Group es flex-direction: row / flex-wrap: nowrap. Medido a 375×800 en /blocks/hero/preview: el grupo mide 327px y sus dos hijos 191 + 124 = 315px sin contar el gap, en una sola fila, con la etiqueta de la acción secundaria ya truncándose. El block hermano resolvió exactamente esto: cta.svelte:98-99 usa <Flex direction={{ base: 'column', sm: 'row' }}>, y sus dos acciones SÍ apilan a la misma anchura (medido: 279 y 191px, una debajo de otra).

Evidencia — Playwright headless, 375×800, tras la puerta de hidratación. ⚠️ La primera medición de esta fila fue INVÁLIDA y se descarta: el selector cogió el primer contenedor con dos botones, que en hero es el Stack de la copia (anchos 100/327/327/327 = eyebrow · título · descripción · acciones), no el Group. El dato bueno viene de seleccionar [data-group] con ≥2 hijos interactivos.

Disposición — Block-side y por composición pura, con el patrón que el tier ya usa: cambiar el Group por Flex direction={{ base: 'column', sm: 'row' }}, como cta. Estaba anotado en el handoff desde julio («Group no apila… hero todavía compone sus acciones con Group») sin medición; ahora la tiene.

REESCRITA (2026-08-17) — la causa está una capa más abajo: [A-99]. La disposición anterior («cambiar el Group por Flex direction responsive, como cta») da por hecho que el block eligió mal el componente, y es falso: Group ES el componente para una fila de acciones —su README lo llama «defaults pensados para action rows»— y además expone direction responsive, así que ni siquiera hacía falta Flex. Lo que falla es que Group no aplica el wrap que documenta (A-99), y por eso las acciones ni envuelven ni apilan.

Alcance del síntoma, medido: hero (dos acciones, 315 de 327px a 375px, la segunda etiqueta truncándose — contenido REAL de la demo) y feature-split (.Actions también compone Group; su escenario pone una sola acción, así que el caso no se ejercita — al forzar una segunda, sigue en una línea. ⚠️ el «desborda el viewport» que se afirmó primero era artefacto: el ancho lo puso el texto inventado para el clon).

cta se salva por accidente: usa Flex a mano, no porque Group fuera inadecuado.

Disposición corregida — sin arreglo block-side. En cuanto Group envuelva, los dos blocks se curan solos. Si se quisiera además APILAR en móvil (que no es lo mismo que envolver), eso sí sería block-side y por props: direction={{ base: 'column', sm: 'row' }} sobre el propio Group.

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.

A-01 — ARREGLADO · cta · doctrina · ALTA

Mecanismo — cta.svelte suelta el canalón cuando container === 'full', así que la disposición del README pasa a ser cierta.

Evidencia — Medido en /blocks/cta/preview: el panel pasa de 992px@x144 (idéntico con lg y con full) a 1264px@x8 = todo el ancho del documento. De paso, la ruta preview y la demo ganan el control vivo de container, que no existía.

Disposición — README y pestaña Gaps actualizados: la fila pasa de «app-land» a SHIPPED con los números.

A-02 — ARREGLADO · hero · percepcion · ALTA

Mecanismo — El block estampa el contexto de inversión (data-on='dark') MÁS la propiedad color sobre el racimo de copia, así que lo que la app slotea en actions hereda la tinta on-solid. La demo deja de pasar el token a mano, que además era inerte.

Evidencia — Contraste de la CTA secundaria sobre el fondo compuesto: 1.61:1 → 6.61:1. Título y descripción también a 6.61:1. Verificado sin regresión en split y center (tintas originales intactas) y en modo oscuro. Captura mirada.

Disposición — Dos huecos de canon DESCUBIERTOS al arreglarlo y registrados en el README del hero: [data-on] no arrastra la propiedad color, y Display no lee --color-content-primary (por eso el título conserva su prop). Además, muted invertido = 3.73:1 → la descripción se queda en on-solid pleno.

A-03 — ARREGLADO · feature-grid · doctrina · ALTA

Mecanismo — Párrafo «Landmark + headings» añadido, con el elemento que emite, si tiene nombre accesible y qué nivel emite cada parte.

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. Llegó (2026-08-09): blocks:check exige **Landmark + headings** en todo README de block.

A-04 — ARREGLADO · contact · doctrina · ALTA

Mecanismo — contact-submit pasa aria-label con la palabra de estado. NO hizo falta tocar soma: el canon ya honra un aria-label explícito — la sugerencia original mandaba a un cambio de canon innecesario.

Evidencia — Medido en /blocks/contact/preview: aria-label pasa de «Enviar» fijo a seguir el estado («Enviar mensaje»), y el botón gana aria-describedby apuntando a la razón.

Disposición — Queda vivo el hilo de canon de A-42: el aria-label incondicional del canon rompe WCAG 2.5.3 para CUALQUIER consumidor con texto propio.

A-05 — ARREGLADO · contact · doctrina · ALTA

Mecanismo — Enmendada la frontera dura 1 de architecture/blocks.md: $libs/forms queda nombrada como puerta sancionada, con el motivo (el canon Form no re-exporta createForm y soma lo consume igual).

Evidencia — docs/architecture/blocks.md §Hard boundaries.

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.

Mecanismo — La demo deja el <Form> MONTADO y pone la confirmación como hermana, en vez de sustituirlo. El swap desmontaba el provider dentro del mismo await que corría el handler, así que el commit-submit (sequence:'post') resolvía su target a un nodo desprendido y moría en silencio.

Evidencia — Sonda de audio + MutationObserver al nodo del form: antes 0 osciladores y 0 mutaciones data-event; ahora 2 osciladores y data-event=commit-submit · family=commit · intent=fulfill, con el form montado y la confirmación visible.

Disposición — Doctrina que merece escribirse: un sequence:'post' no sobrevive al desmontaje de su propio provider.

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.

Evidencia — src/uix/blocks/faq/types.ts:27-28, faq-item.svelte:10,16, faq/README.md §Coordination.

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

Mecanismo — La descripción usa measure="narrow" (54ch tokenizado) en vez del Box maxWidth="60ch" fuera de escala.

Evidencia — Medido: max-inline-size = 719.28px = 54ch con esa tipografía a 20px.

A-09 — CONFIRMADO · stats-band · doctrina · MEDIA

Mecanismo — El block pasa los DOS caminos a la vez: <Metrics.Value value={count} …> y, dentro, <CountUp to={count}>. metrics-value.svelte:22-30 anuncia String(next) — crudo — cuando value cambia de verdad, y CountUp pinta con uix.format.numbers. Con live y un count que el app mueva, la pantalla dice «12.500» y el anuncio «12500». PRECISIÓN sobre la reclamación: no se manifiesta con un count constante (el memo de metrics-value solo notifica en cambios reales, no en el init), así que la demo no lo enseña; y el formateo del anuncio es del canon Metrics. Lo que es del block es elegir pasar value teniendo children.

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 — ⚠️ 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

Mecanismo — El block cierra mobileOpen cuando la variante ancha toma el relevo, leyendo el breakpoint reactivo del framework (dom.isAtLeast), no un matchMedia propio (B-6).

Evidencia — Medido abriendo el cajón a 500px y redimensionando a 1200px: antes {drawerState:'open', drawerVisible:false, bodyOverflow:'hidden'} — página congelada sin salida visible; ahora el cajón se cierra y bodyOverflow vuelve a visible.

A-11 — CONFIRMADO · site-header · doctrina · MEDIA

Mecanismo — Las demos redeclaran a mano uniones que el canon ya exporta, y ya han derivado: ContainerSize se copia sin 'xxl' (el canon lo tiene en container/types.ts:4, con token real) y Breakpoint se copia como tres valores frente a los seis de libs/dom/responsive.ts:1. Consecuencia: el control vivo de la demo no puede alcanzar xxl aunque el block lo acepte, y nada rompe a nivel de tipo cuando el canon crece.

Evidencia — web/routes/blocks/site-header/SiteHeaderSite.svelte:25-26 y web/routes/blocks/hero/HeroSite.svelte:30-31 (cuatro uniones copiadas). El block hace lo correcto: site-header/types.ts importa los tipos del canon.

Disposición — Importar los tipos del canon en las demos. Fase 3.

A-12 — ARREGLADO · feature-grid · doctrina · MEDIA

Mecanismo — Typedoc y demo corregidos a solid, que es lo que el código hace y lo que el README razona.

Evidencia — feature-grid/types.ts y web/routes/blocks/feature-grid/+page.svelte.

A-13 — ARREGLADO · testimonials · percepcion · MEDIA

Mecanismo — CANON. Card prometía toda la superficie de Box (CardProps = Omit<BoxProps,'display'>) y no componía Box: lo no destructurado se derramaba como atributo HTML crudo, así que height="100%" era INERTE. No puede componer Box (es un <div> fijo y una Card interactiva es un <button>), así que ahora el tipo dice la verdad —solo el eje de TAMAÑO, que es lo único que se le pedía en todo el repo— y card.svelte lo aplica de verdad con los mismos helpers de layout.

Evidencia — Medido: las 5 tarjetas de testimonials pasan a 169px iguales y las 3 de pricing a 505px iguales, con block-size estampado. Un grep del repo entero encontró UN solo consumidor pasando props de Box a Card, así que acotar el tipo no rompe a nadie: ahora un layout inerte es error de compilación.

Disposición — Cierra también A-24.

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 — 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

Mecanismo — Contact.Reason es ahora un nodo ESTABLE con role="status" e id, y el submit lo referencia con aria-describedby. Antes el nodo se destruía y se creaba con su propio texto, que es justo lo que una región viva no puede anunciar.

Evidencia — Medido: role=status, id=s1-reason, aria-describedby=s1-reason en el botón, y el texto cambia sin recrear el nodo.

Disposición — Cierra también A-43.

A-16 — ARREGLADO · contact · doctrina · MEDIA

Mecanismo — El README deja de afirmar que el block «no pinta los errores de los campos» — los pinta, y ahora además son alcanzables (A-44).

Evidencia — contact/README.md §«What it does NOT do», reescrita.

A-17 — ARREGLADO · content-section · doctrina · MEDIA

Mecanismo — La pestaña API de la demo dice measure, que es el default real.

Evidencia — web/routes/blocks/content-section/+page.svelte.

A-18 — ARREGLADO · team · doctrina · MEDIA

Mecanismo — Comentario traducido al inglés.

Evidencia — team/team-member-links.svelte.

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.

  1. src/uix/blocks/faq/types.ts:17 promete sin condiciones: «Wraps a Box (measure-cap, centered)».
  2. src/uix/blocks/faq/faq-header.svelte:12 renderiza <Box {maxWidth} width="100%" {...rest}> — sin marginX="auto". El idioma canónico para centrar un Box con tope existe y lo usa un block hermano: src/uix/blocks/content-section/content-section-media.svelte:48 (<Box maxWidth={capWidth} marginX="auto">). Box no centra por sí solo: maxWidth viaja por --box-max-width (src/uix/eidos/components/box/box.svelte:89) y el margen es un canal aparte (--box-margin-inline, box.svelte:103 / box.css:245).
  3. La raíz src/uix/blocks/faq/faq.svelte:23 apila con <Stack gap={8} align="stretch"> → align-items: stretch. Con width:100% explícito y un max-width que muerde, el ítem flex se ancla en cross-start (izquierda en LTR, derecha en RTL), no al centro.

DÓNDE FALLA LA RECLAMACIÓN (matiz que baja la severidad, no la anula): con los defaults del propio block NO se desplaza nada. container='md' (faq.svelte:17) = --container-width-md = --breakpoint-md = 768px (src/uix/eidos/generated/base.css:79,140) e incluye 16px de padding a cada lado (border-box), así que la columna útil es 736px y el tope de 48rem = 768px NUNCA muerde. Medido: cabecera y acordeón con rectángulo idéntico. El defecto es LATENTE y se materializa por dos vías de API documentadas: container a lg|xl|xxl|full (prop de primera clase en types.ts:10), o un maxWidth más estrecho en Faq.Header (FaqHeaderProps = BoxProps, types.ts:18 — el propio typedoc invita a cambiarlo). En cuanto una de las dos ocurre, el Box queda anclado al borde y el typedoc miente.

Evidencia — Playwright headless (1440x900) sobre http://127.0.0.1:5201/blocks/faq/preview. Puerta de hidratación: esperado style#uix-blocks-display, luego 0 [data-animation-pending], luego ida y vuelta reactivo real (clic en [data-accordion-trigger]: aria-expanded false→true). Simulaciones hechas por los MISMOS canales que usan las props (--box-max-width en [data-container] = container='xl'; --box-max-width en el Box de la cabecera = Faq.Header maxWidth), nunca DOM fabricado. Style inline que Svelte escribe en la cabecera: --box-width:100%;--box-max-width:48rem (sin margen).

Rectángulos (left/width/centro-x):

  • DEFAULT (container='md'): cabecera 352/736/cx=720 · acordeón 352/736/cx=720 → IDÉNTICOS, cap de 768px inalcanzable (columna útil 736px). Captura: la cabecera se ve centrada sobre el acordeón. Sin defecto visible.
  • container='xl': cabecera 96/768/cx=480 · acordeón 96/1248/cx=720 → 240px descentrada; en captura el h2 y la entradilla cuelgan a la izquierda del acordeón.
  • Faq.Header maxWidth=32rem con container='md': cabecera 352/512/cx=608 vs acordeón cx=720 → 112px descentrada.
  • RTL (?dir=rtl) + container='xl': cabecera 576/768/cx=960 → se ancla al borde derecho (anclaje lógico, no un bug físico de dirección; sigue descentrada).
  • Hipótesis de arreglo, --box-margin-inline:auto sobre la cabecera con container='xl': 336/768/cx=720 → centrada, coincide con el acordeón. El arreglo canónico funciona.

Scripts: C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-19.mjs y probe-A-19c.mjs (probe-A-19b.mjs dio una medida inválida del caso xl porque removeProperty borró el --box-max-width que escribe el propio Box; descartada y rehecha en la c).

Disposición — En el BLOCK, no en el canon (Box y Stack se comportan exactamente como especifican). Dos salidas, una línea cada una: (a) cumplir el typedoc: src/uix/blocks/faq/faq-header.svelte:12 → <Box {maxWidth} width="100%" marginX="auto" {...rest}>, el mismo idioma que src/uix/blocks/content-section/content-section-media.svelte:48. Verificado en navegador que restaura cx=720 con container='xl'; con el default no cambia nada (el tope no muerde). (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

Mecanismo — El README de faq declara landmark y jerarquía, y dice explícitamente que el nivel de las preguntas está FIJO en 3 porque .Item no reenvía level.

Evidencia — faq/README.md.

Disposición — Reenviar level queda como gap declarado, no silenciado.

A-21 — ARREGLADO · stats-band · doctrina · BAJA

Mecanismo — El typedoc dice formatStyle, que es el nombre real; style está excluido de CountUpProps.

Evidencia — stats-band/types.ts.

A-22 — ARREGLADO · stats-band · doctrina · BAJA

Mecanismo — README de stats-band con su párrafo B-8, declarando que la banda NO es landmark ni entra en el esquema de titulares, y cómo hacerlo si se quiere.

Evidencia — stats-band/README.md. Cierra también A-78.

A-23 — ARREGLADO · pricing · doctrina · BAJA

Mecanismo — Pricing.Switch pasa deselectable={false} y además ignora una salida vacía, así que una segunda pulsación sobre el segmento activo ya no escribe un periodo que el usuario no eligió.

Evidencia — Medido en /blocks/pricing/preview: pulsar «anual» dos veces mantiene anual; pulsar «mensual» dos veces mantiene mensual. Antes la deselección caía en el ?? 'monthly'.

Disposición — Cierra también A-45 y A-64.

A-24 — ARREGLADO · pricing · percepcion · BAJA

Mecanismo — Ver A-13.

Evidencia — Ver A-13.

A-25 — ARREGLADO · pricing · doctrina · BAJA

Mecanismo — La demo documenta una elevación que el código se niega explícitamente a hacer: «featured le da acento y elevación» frente al comentario de implementación «The featured tier is NOT elevated: Card exposes no elevation prop… Registered as a gap in the README instead of faked with an inline box-shadow».

Evidencia — web/routes/blocks/pricing/+page.svelte:53 frente a src/uix/blocks/pricing/pricing-plan.svelte:5-11 y pricing/README.md:74.

Disposición — Corregir la demo (acento cromático, sin elevación). Fase 3, trivial.

Re-verificación (2026-08-17) — ya no se reproduce: no queda ninguna mención de «elevación» ni en web/routes/blocks/cta/+page.svelte ni en el README del block.

A-26 — REFUTADO · pricing · doctrina · BAJA

Mecanismo — El hecho es cierto (tres cadenas obligatorias, con un comentario que invoca B-7 en términos absolutos: «a block ships zero words of its own»), pero la consecuencia doctrinal no se sostiene. La enmienda del 2026-07-31 acota la propiedad de las palabras a los estados que el block coordina Y que pueden bloquear o estar en vuelo (blocks.md §Coordination: «The rule fires where a section can be blocked or in flight»). En pricing no se puede bloquear nada: el periodo cambia precios, no impide acciones. Su posición ya está declarada por escrito en la revisión documental de los 15: coordina por contexto, no necesita máquina ni palabras.

Evidencia — src/uix/blocks/pricing/pricing-switch.svelte:12-16 frente a docs/architecture/blocks.md §Coordination y pricing/README.md §Coordination.

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 — 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).

Evidencia — src/uix/blocks/testimonials/types.ts:26 y testimonials-item.svelte:13.

Disposición — Corregir el typedoc a outline. Fase 3, trivial.

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.

Evidencia — src/uix/blocks/testimonials/types.ts:24 y testimonials-items.svelte:11-19.

Disposición — Mover {...rest} detrás de los defaults, o destructurar align/width con su default. Fase 3.

A-29 — CONFIRMADO · pricing · testimonials · percepcion · BAJA

Mecanismo — El defecto es real, aunque la reclamación exagera en una pata y se queda corta en el alcance.

Cadena causal:

  1. web/routes/blocks/pricing/+page.svelte:14 y web/routes/blocks/testimonials/+page.svelte:12 construyen la URL de la vista previa como literal desnudo (const previewSrc = '/blocks/pricing/preview'), sin query string y sin $derived — a diferencia de los otros 12 demos, que sí la derivan (hero/+page.svelte:28, cta/+page.svelte:21, team/+page.svelte:21…).
  2. web/routes/blocks/_lib/BlockDemo.svelte:141-152 monta el iframe con src={previewSrc} tal cual. El iframe es OTRO documento: ni el data-theme/data-mode del [data-blocks-shell] (+layout@.svelte:150) ni el dir de [data-blocks-content] (+layout@.svelte:256) cruzan la frontera.
  3. web/routes/blocks/pricing/preview/+layout@.svelte:12-14 deriva los ejes SOLO de page.url.searchParams, con caídas duras a 'light', 'ltr', 'es'. Sin params → claro, ltr, es. Siempre.
  4. Los ejes de la sección viven como $state local en +layout@.svelte:68-71 y nunca se exponen (ni por contexto ni por prop) a BlockDemo, así que ni siquiera un demo bien intencionado podría heredarlos hoy.

Que es OMISIÓN y no diseño lo dice el propio código: web/routes/blocks/_lib/BootUix.svelte:8-9 documenta el contrato — «The axes arrive as props so the preview can be driven from its URL: ?mode=dark&dir=rtl&lang=en is what the demo's iframe passes». Ningún demo pasa mode ni lang, y estos dos no pasan nada. El comentario de pricing/+page.svelte:12-13 («no demo chrome needed beyond the section's axes») delata la suposición: el autor creía que los ejes de la sección cubrían también la rama estrecha.

Consecuencia que lo hace defecto: pricing y testimonials no tienen snippet controls propio, así que NO existe ninguna ruta en la interfaz para ver su rama de 375/768 en oscuro ni en RTL — ni el toggle de la topbar, ni un control local, ni el enlace «abrir ↗» (que reusa el mismo previewSrc pelado, BlockDemo.svelte:136). Bajo la doctrina de este repo (la superficie de demos ES el producto; las demos son testbeds interactivos) eso deja la rama estrecha sin auditar en dos de los tres ejes.

Dos correcciones a la reclamación:

  • La pata «español» es CIERTA como hecho de la URL pero INERTE como defecto: todas las cadenas de PricingSite.svelte y TestimonialsSite.svelte están escritas a pelo en castellano, y _lib/blocks-langs.ts:14-48 sólo registra contact.*. Poner EN en la topbar tampoco cambia nada en la vista a página completa de estos dos blocks. El defecto real son mode y dir, no lang.
  • La atribución se queda corta, no larga: el eje mode no viaja en NINGUNO de los 15 demos (grep de preview? en web/routes/blocks: 0 ocurrencias de mode= o lang=), y el eje dir falta además en faq/+page.svelte:12 y content-section/+page.svelte:31. Medí hero como control: con la sección en dark+rtl, su iframe sale ?…&dir=ltr y data-theme="base-light" — su control dir es propio del demo («dirección (solo la vista previa)», hero/+page.svelte:191), no el eje de la sección. Mal atribuir no refuta; sí mueve dónde se arregla.

Confianza alta: medido, no inferido.

Evidencia — Playwright headless contra http://127.0.0.1:5201, script en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-29.mjs (+ probe-A-29b.mjs). Puerta de hidratación respetada en AMBOS documentos (esperé <style id="uix-blocks-display">, que lo escribe un $effect de cliente) y con ida y vuelta reactiva probada antes de medir (roundTrip: {before:'light', after:'dark', reactive:true} al pulsar el botón de modo).

/blocks/pricing con la topbar en dark + RTL + EN:

  • shell: data-theme=dark, data-mode=dark, fondo pintado oklch(0.1776 0 0) (casi negro); [data-blocks-content] dir=rtl.
  • vista a página completa (mismo documento): section direction=rtl, color oklch(0.9491 0 0) (texto claro). Los ejes SÍ mandan aquí.
  • tras pulsar «375»: iframe src = "/blocks/pricing/preview", location.search = "" (vacío). Dentro: wrapper data-theme="base-light", data-mode="light", dir="ltr", section computed direction="ltr", color oklch(0.2435 0 0) (texto oscuro) y fondo REALMENTE PINTADO bajo la section = oklch(0.9911 0 0) (casi blanco), medido subiendo el árbol hasta el primer fondo no transparente. Es decir: documento blanco LTR incrustado en una página negra RTL. Con «768» idéntico: src = "/blocks/pricing/preview".
  • /blocks/testimonials: cifras idénticas (base-light, ltr, backdrop oklch(0.9911 0 0), section color oklch(0.2435 0 0), search vacío en 375 y 768).

Descarte de rescates:

  • Contexto con colorScheme:'dark' (prefersDark=true verificado en la página) sobre /blocks/pricing/preview SIN params → sigue base-light, color oklch(0.2435 0 0). El esquema del SO no salva la vista.
  • La maquinaria existe y funciona: /blocks/pricing/preview?mode=dark&dir=rtl&lang=en → data-theme="base-dark", direction="rtl", color oklch(0.9491 0 0). Sólo falta que el demo construya la URL.
  • Control de alcance (hero, que sí deriva la URL): sección en dark+rtl → iframe src ".../hero/preview?…&dir=ltr", interior data-theme="base-light", direction="ltr".

Nota de medición no relacionada con el hallazgo: dentro del iframe quedan nodos [data-animation-pending] que nunca se resuelven (elementos de reveal bajo el pliegue que no llegan a intersectar). No contaminó la medida — la puerta de hidratación interna sí se cumplió (styleTagPresent:true) y el h2 medido tiene geometría real (left 24, width 327 en viewport de 375).

No encaja en ningún falso positivo conocido (F15–F22), ni en «medir antes de hidratar» ni en «pestaña en segundo plano».

Disposición — Se arregla en la DEMO, no en el block ni en el canon — y en el sitio compartido, no en los dos ficheros señalados.

  1. web/routes/blocks/+layout@.svelte:68-71: exponer los ejes (mode, dir, language, density) por contexto de sección; hoy son $state local sin salida.
  2. web/routes/blocks/_lib/BlockDemo.svelte:141-152 y :136: que el shell lea ese contexto y ANEXE los ejes al previewSrc que recibe (respetando el ? o & que ya traiga), de modo que el iframe y el enlace «abrir ↗» hereden por defecto. Eso arregla mode (y density) para los 15 demos de golpe y dir para los 4 que no lo construyen (pricing, testimonials, faq/+page.svelte:12, content-section/+page.svelte:31) sin tocar los 11 que ya lo pasan.
  3. Decisión tuya, no mía: si el dir local de los demos que sí lo llevan (hero/+page.svelte:191, «solo la vista previa») debe seguir existiendo como anulación del eje de sección o desaparecer en favor del eje. Hay solape y conviene resolverlo antes de cablear el punto 2.
  4. density no viaja tampoco (BootUix.svelte:30 cae a 'comfortable'); si se toca el punto 2, entra gratis.
  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 — 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.

Evidencia — Lectura de los dos morfos. La medición del auditor (dos earcons a 19 ms) es coherente con la estructura; la parte audible se re-mide en la fase 2.

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

Mecanismo — La afirmación —«signal-warn-invalid … NUNCA se limpia»— es falsa en la línea que ella misma ancla. form-provider.svelte.ts:125-129 limpia el target ANTES de decidir si re-emite: const provider = this.runtime.partRef('provider'); if (provider) this.runtime.clearTarget(provider);, con el comentario «The clear IS the fix when the form now validates». El estampado risk no es indefinido.

Evidencia — src/uix/soma/components/form/form-provider.svelte.ts:120-152.

Disposición — No se arregla. Queda una pregunta distinta y más pequeña, que NO es lo reclamado: la limpieza ocurre en el siguiente submit, no en el instante en que el usuario corrige el campo. Si quieres esa lectura de untilFix, es un hallazgo nuevo con su propia fila, no éste.

A-32 — ARREGLADO · site-header · percepcion · ALTA

Mecanismo — CANON. El pack seleccionaba la parte item mientras el runtime estampaba en el trigger: la regla NUNCA casaba y el nav sonaba con la ganancia base de la familia (0.30) en vez de su commit.subtle (0.03) — 10× más fuerte de lo diseñado. Ahora morfo, runtime y pack dicen los tres lo mismo: commit-select→link, contact-activate→trigger, emerge-*→trigger (el content se monta y desmonta con el panel, así que un estampado dirigido a él muere).

Evidencia — Sonda sobre los tres gestos; morfo:check verde; suites de sema/morfo/soma 342/343 (el único fallo es un fichero SIN TRACKEAR de otra sesión).

A-33 — ARREGLADO · site-header · percepcion · ALTA

Mecanismo — CANON. openNow emitía commit-select+affirm y a openNow se llega por HOVER (scheduleOpen ← onpointerenter): barrer el ratón por la barra anunciaba selecciones que nadie había hecho. Desplegar un panel es una APARICIÓN, así que ahora habla como emerge-open/emerge-close, la misma lectura que dropdown-menu / popover / tooltip.

Evidencia — Sonda, hover sobre el trigger: antes commit-select+affirm; ahora emerge-open · familia=emerge.

A-34 — ARREGLADO · site-header · percepcion · ALTA

Mecanismo — Se cae con A-32 y A-33: la inversión de dominancia venía de que el hover sonaba a la ganancia BASE de la familia commit (porque el pack no casaba) mientras el gesto real no sonaba. Ahora el hover es un emerge suave, el gesto sobre el trigger tiene su contact-activate y el link su commit-select.

Evidencia — Ver A-32 y A-35.

A-35 — ARREGLADO · site-header · percepcion · ALTA

Mecanismo — CANON. NavigationMenuLinkProvider no registraba NINGÚN onclick, así que navegar —el acto evaluable del componente— era mudo. Ahora el morfo declara commit-select sobre la parte link (antes sobre item) y el provider lo emite en el clic.

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 — 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.

Cadena causal:

  1. web/routes/blocks/site-header/SiteHeaderSite.svelte:170-177 compone <Drawer.Trigger> con child → <Button {...props}>. Es la composición canónica (asChild), así que la parte trigger del Drawer y el provider del Button son EL MISMO nodo DOM.
  2. src/uix/soma/components/drawer/drawer-provider.svelte.ts:424-425 dispara runtime.trigger('open', { fallbackTarget: e.currentTarget }) — el fallback es INCONDICIONAL.
  3. src/uix/soma/runtime.svelte.ts:757 — opts.fallbackTarget ?? targetReg?.ref?.current ?? null: el fallback GANA al target declarado por el morfo (target: v.partRef('content'), src/uix/morfo/components/drawer.ts:22). Medido: el Content, ya montado y abierto, jamás recibe data-event (null). Y con el drawer cerrado el Content ni siquiera está en el DOM (medido), así que el fallback no es un accidente: es la única vía. emerge.open se proyecta SIEMPRE sobre el botón.
  4. Orden de los dos handlers: src/uix/soma/components/button/components/button.svelte:47 hace mergeProps(restProps, state.props) y src/uix/soma/props/props.ts:17-24 encadena a=drawer, b=button. El open (sequence 'post', drawer.ts:30) corre su handler y se suspende en await tick(); el contact-activate ('pre', src/uix/morfo/components/button.ts:57-66) llega a engine.emit y estampa síncronamente (src/uix/sema/engine.ts:405-408 → src/uix/sema/stamp.ts:26-36). Un microtask después el tick() resuelve y open reescribe LOS MISMOS cinco data-event-* sobre EL MISMO nodo: los attrs son un namespace compartido sin identidad por señal.
  5. Consecuencia CSS: [data-event-family='contact'][data-event-phase='active'] { animation: press-squeeze } (src/uix/eidos/generated/base.css:6531) es sustituida por [data-event^='open'][data-event-phase='active'] { animation: present-rise } (:6493) ANTES del primer paint. El botón hamburguesa no comprime: toca la animación de ENTRADA del overlay sobre sí mismo.

Matiz sobre las cifras de la reclamación (no la refutan, la agravan): el solapamiento medido es de 1.1 ms, no 3 ms, y el factor frente al hold canónico es ~125x, no 40x. Y el contact no llega a durar «3 ms visibles»: cero frames pintados.

Efecto secundario del mismo choque: el awaitExpression del canal visual (src/uix/sema/chans/visual.ts:108-131) lee target.getAnimations() del nodo compartido, así que el cleanup del contact se queda esperando al present-rise ajeno y el desestampado único cae a 374 ms.

Evidencia — Playwright headless sobre http://127.0.0.1:5201/blocks/site-header/preview, viewport 420x900 (bajo el breakpoint md, con el hamburguesa visible). Puerta de hidratación: espera a <style id="uix-blocks-display">, luego a que no queden [data-animation-pending], y después ida y vuelta reactiva REAL (click → aria-expanded false→true, Escape → true→false, residuo de attrs = null) antes de medir. Sondas: (a) contador WebAudio parcheando createOscillator/createBufferSource en addInitScript antes de tocar nada (10 osciladores en la sesión — el canal sonoro sí corre, no es el eje); (b) MutationObserver ligado AL NODO concreto del trigger con attributeOldValue.

Timeline medido en el hamburguesa (t = ms tras el click): 20.2 data-event null -> contact-activate 20.2 data-event-family null -> contact 20.2 data-event-phase null -> active 21.3 data-event contact-activate -> open <-- +1.1 ms 21.3 data-event-family contact -> emerge 21.3 data-event-id sig-3 -> sig-4 374 data-event open -> null (limpieza única)

Muestreo por rAF de el.getAnimations(): el PRIMER frame (t=26.9 ms) ya trae ev=open fam=emerge con present-rise corriendo; press-squeeze no aparece en ninguno de los 60 frames. Segunda sonda con muestreo cada 4 ms durante 2 s: nombres de animación vistos alguna vez sobre el hamburguesa = ['present-rise']. Cero apariciones de press-squeeze.

CONTROL en el mismo documento, Button normal sin drawer (header a[href="#pricing"][data-button], viewport 1280): 0.8 data-event null -> contact-activate 137.4 data-event contact-activate -> null 8 frames consecutivos con press-squeeze corriendo (~87 ms de muestras). Es decir 137 ms de contacto y compresión visible en el botón sano frente a 1.1 ms y cero frames en el hamburguesa.

Montaje del Content: antes del primer open [data-drawer-content] NO existe; abierto, existe y su data-event es null. Confirma que el fallback no es evitable con un simple if y que el target declarado por el morfo nunca se usa.

Disposición — CANON, no el block. site-header.svelte sólo compone <Drawer bind:open> (líneas 58-64) y la demo usa el patrón child que el propio CLAUDE.md prescribe; no hay nada que tocar ahí. El arreglo vive en dos sitios posibles, y conviene decidir cuál es el canon:

  1. src/uix/soma/runtime.svelte.ts:757 + src/uix/soma/components/drawer/drawer-provider.svelte.ts:424-433 — el fallbackTarget gana al target del morfo SIEMPRE. Debería ser lo que su nombre dice (targetReg?.ref?.current ?? opts.fallbackTarget), y como el Content no está montado al abrir, el emerge.open debería aplazarse al tick en que monta (mismo await tick() que ya hace la rama 'post') para estampar sobre el Content — que es donde el morfo lo declaró y donde present-rise tiene sentido.

  2. src/uix/sema/stamp.ts:26-46 + src/uix/sema/projection/dom.ts — la proyección es un namespace plano sin identidad: dos señales sobre un mismo nodo se pisan y el cleanup de una borra los tokens de la otra. Mientras eso siga así, cualquier composición asChild de dos partes con evento propio sobre el mismo elemento reproduce el defecto (el Drawer es sólo el caso que la demo expone).

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

Mecanismo — El block pasa color="currentColor" al descarte, así que la ✕ hereda la tinta de la tira en vez del primary de Button.

Evidencia — Medido en los cuatro intents: info 5.93→15.88 · warn 5.93→15.88 · risk 2.05→5.78, igualando la tinta del propio texto del banner (15.88/15.88/5.89). ⚠️ affirm EMPEORA (1.98→1.50) y se declara: el texto de esa tira ya está a 3.07:1 (F16) y el camino de color del Button oscurece un 30% el valor explícito, así que currentColor no puede reproducir la tinta heredada. Los dos arreglos son de canon y están escritos en el README.

Disposición — Declarado, no falseado.

A-38 — ARREGLADO · banner · percepcion · ALTA

Mecanismo — CANON. Tres defectos en link.css, los tres medidos: (1) subtle/plain forzaban color: inherit a (0,2,0), que gana a la regla de paleta a (0,1,0), así que la prop color era inerte — ahora heredan SOLO mientras no se pida color (:not([data-color]):not([data-color-custom])), y link.svelte deja de defaultear a 'primary' para que ese :not distinga «no pedí color» de «pedí primary»; (2) el hover clavaba --color-primary-solid-hover, así que un link con color se volvía morado bajo el puntero — ahora usa su propia ranura de paleta, añadida al slot link del recipe; (3) :active a (0,2,0) quedaba tapado por el hover de variante a (0,3,0) y el press no se veía con ratón — ahora va último y a la misma especificidad.

Evidencia — Medido en /blocks/site-footer/preview: el enlace legal con color="var(--color-content-muted)" pasa de computar EXACTAMENTE la tinta del padre a oklab(0.42529 0 0), y el hover a oklab(0.5628…) en vez de irse a primary. Suites del canon: 7 fallos = la línea base, sin regresión.

Disposición — Cierra también A-58 y A-59, y es la mitad de canon de A-02.

A-39 — ARREGLADO · newsletter · doctrina · ALTA

Mecanismo — El ejemplo de newsletter/index.ts enseña el handler en createForm, que es lo que el tipo ya documentaba.

Evidencia — newsletter/index.ts.

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.

Evidencia — Playwright headless (scratchpad/probe-A-40.mjs y probe-A-40b.mjs) sobre http://127.0.0.1:5201/blocks/contact/preview?challenge=off. Puerta de hidratación: espera a style#uix-blocks-display + ida y vuelta reactiva real (tecleo con keyboard en name/email/message → submit pasa de disabled:true a disabled:false) + espera a 0 [data-animation-pending]. Sondas: (a) contador de createOscillator/createBufferSource parcheado en BaseAudioContext.prototype vía addInitScript ANTES de interactuar; (b) MutationObserver atado al NODO del botón y al NODO del form, más red de seguridad en body/subtree filtrando data-event*. SUJETO: durante la pulsación (mousedown mantenido 60 ms) → 0 mutaciones, 0 nodos WebAudio, 0 atributos data-event* en el botón. Primera mutación a +113,4 ms: disabled null→"", aria-busy false→true, data-pending (estado, no sema). Única emisión sema: data-event=commit-submit, data-event-family=commit, data-event-intent=fulfill, data-event-phase=active a +1024,8 ms y SOBRE EL <form> (no sobre el botón), sostenida hasta +1429,3 ms; 4 osciladores WebAudio a +1025,5 ms. Etiqueta final «Enviado». Estilo calculado hover vs press idéntico (bg oklab(0.611511 -0.117573 0.046955) = oklch(0.6115 0.1266 158.23), misma color; transform/scale/translate/opacity/filter/box-shadow sin cambio) → tampoco hay respuesta CSS al gesto. CONTROL de sensibilidad de la sonda en /blocks/cta/preview con un <Button> canónico: data-event=contact-activate, data-event-family=contact a +99,9 ms del mousedown (≈40 ms tras el clic), limpiado a +245,3 ms, y 4 osciladores WebAudio. Es decir, la sonda SÍ ve el tick de contacto cuando existe; en el submit de contact no existe. Los 915 ms del auditor son el retardo artificial de la demo (900 ms) + ~110 ms de overhead; yo mido 1024,8 ms.

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

Mecanismo — CANON. Field emitía commit-submit en el Enter aunque un Form fuese su dueño, y el Enter dentro de un form dispara además la submission implícita del navegador: dos commits del MISMO verbo con intents contradictorios. Peor: con el envío bloqueado el del campo sonaba solo, anunciando que algo quedó fijado cuando no ocurrió nada. Ahora el campo calla cuando tiene Form padre —el form es el único que sabe si hubo consecuencia— y sigue hablando cuando está suelto, donde su sello es el único acuse.

Evidencia — Sonda G:/tmp/sonda-sema.mjs enter: antes 2 eventos a 11 ms (neutral sobre el input + fulfill sobre el form) y 4 nodos de audio; ahora 1 evento (commit-submit · fulfill) y 2 nodos. Reproducible por el usuario con un comando.

Disposición — Los hermanos que copian el patrón (password-field, search-field, textarea) declaran su propio commit-submit y NO están revisados: quedan como cola.

A-42 — ARREGLADO · contact · percepcion · ALTA

Mecanismo — CANON, además del arreglo block-side de A-04. FormSubmitProvider estampaba aria-label SIEMPRE, y el atributo gana al contenido en el cómputo del nombre accesible: cualquier <Form.Submit> con texto propio anunciaba «Enviar». Ahora la etiqueta explícita gana, el fallback localizado se aplica solo a un submit SIN contenido, y quien sabe si hay contenido —el componente de soma, que ve children/child— se lo dice al provider por hasContent.

Evidencia — Verificado con el AX tree del propio Chrome (CDP) en /blocks/newsletter/preview: aria-label = null y el nombre accesible sale de contents=Suscribirme. Antes: «Enviar» pisando el texto visible (fallo WCAG 2.5.3 para TODO consumidor). Test del provider actualizado al contrato nuevo; suites en la línea base.

A-43 — ARREGLADO · contact · percepcion · ALTA

Mecanismo — Ver A-15.

Evidencia — Ver A-15.

A-44 — ARREGLADO · contact · doctrina · ALTA

Mecanismo — La máquina separa incomplete (algún campo vacío, preguntado a los VALORES) de invalid (todo lleno pero algo no valida, preguntado al ESQUEMA), y canSubmit deja pasar invalid a propósito para que el Form del canon marque los campos, enfoque el primero y emita signal-warn-invalid.

Evidencia — Arco medido en navegador: email roto → botón HABILITADO y «Revisa los campos marcados» → al pulsar aparece aria-invalid y el texto de error «Debe ser una dirección de correo válida» → al corregir vuelve a ready. state.test.ts pasa de 9 a 12 casos.

Disposición — Cierra A-87 (duplicado) y la mitad de A-61 (el botón es alcanzable en invalid).

A-45 — ARREGLADO · pricing · percepcion · ALTA

Mecanismo — Ver A-23.

Evidencia — Ver A-23.

A-46 — REFUTADO · cta · percepcion · ALTA

Mecanismo — La reclamación tiene dos mitades y la que la convierte en defecto no se sostiene.

  1. «no emite ningún evento sema» — HECHO CIERTO, pero es comportamiento de canon, no omisión. src/uix/morfo/components/link.ts:12-16 declara Link explícitamente pasivo: «Link has hover/active visual states but no semantic events… When Link is used as a button-style affordance (form submit, dialog action), the consumer should wrap a Toolbar.Button instead». En web/routes/blocks/cta/CtaSite.svelte:72-74 el elemento es un enlace de NAVEGACIÓN (href="#ventas"), que es justo el uso canónico de Link; todo enlace del framework es mudo por diseño. Y «no se siente» tampoco es literal: medido en reposo→hover, link.css:62-69 baja la opacidad a 0.8, link.css:46-51 saca el subrayado en :hover y :focus-visible, link.css:27 da cursor:pointer (el párrafo tiene auto) y link.css:75-78 pinta el anillo de foco. Que la secundaria sea más callada que la primaria (que sí es Button+child y sí emite) es la jerarquía pretendida: la primaria lleva cromo de botón, así que debe comportarse como botón.

  2. «en reposo es tipográficamente idéntica al párrafo de la descripción» — FALSO, medido. El enlace pinta en «Times New Roman» (serif) y el párrafo en «Instrument Sans»: la misma cadena mide 144.4px vs 163.5px. No es idéntica: es una tipografía AJENA.

Ahora bien, esa medición destapa un defecto REAL DISTINTO en la misma ancla, que conviene abrir como hallazgo nuevo (percepción, atribución harness/canon, NO el que reclama A-46): el enlace se pinta en la serif por defecto del UA. link.css:18 declara font: inherit («Inherits the ambient typography», link.svelte:12-13), pero en el arranque de blocks no hay tipografía ambiente — html y body computan font-family: "Times New Roman" porque web/routes/blocks/_lib/reset.css sólo resetea el box-model y nadie aplica --font-family-primary (src/uix/eidos/generated/base.css:273) a la raíz; cada receta se la pone a sí misma (text.css:17 → --style-body-font-family, button.css:72 → --button-font-family). Resultado: en el panel del CTA «Hablar con ventas» sale en serif junto a un botón y un párrafo en sans. Se arreglaría en el harness (BootUix/reset.css fijando la familia de cuerpo en la raíz) o en canon (que la receta de Link no dependa de una ambiente que la fundación no garantiza).

Evidencia — Playwright headless:false sobre http://127.0.0.1:5201/blocks/cta/preview (scripts: scratchpad/probe-A-46.mjs y probe-A-46b.mjs).

PUERTA: esperado style#uix-blocks-display (attached) + waitForFunction a que no quede ningún [data-animation-pending] (0 restantes). Control positivo de hidratación y de sema vivo: click en a[href="#empezar"] (el Button con snippet child) → contador WebAudio parcheado ANTES de cargar (BaseAudioContext/AudioContext.prototype.createOscillator + createBufferSource) pasa de 0 a 4 osciladores, y el MutationObserver ligado AL NODO registra la secuencia completa: data-event=contact-activate, data-event-id=sig-0, data-event-phase=active, data-event-family=contact, y su retirada al cerrar el hold.

SECUNDARIA a[href="#ventas"]: click real de ratón → osc 0, buf 0, mutaciones 0; atributos tras el click sin ningún data-event*. Teclado real (focus() + keyboard.press('Enter')) → delta osc 0, buf 0, mutaciones 0. Muda: confirmado como hecho.

TIPOGRAFÍA EN REPOSO (sin click, sin hover, sin foco; ratón aparcado en 5,5):

  • enlace: font-family «Times New Roman», 20px, peso 400, rgb(255,255,255), text-decoration none, cursor pointer, rect 144.4×27.
  • párrafo descripción: font-family «Instrument Sans», «Instrument Sans Fallback», system-ui, sans-serif; 20px, peso 400, rgb(255,255,255), cursor auto, display block.
  • primaria: «Instrument Sans…», 20px, peso 500, fondo oklch(0.6406 0.1329 157.68).
  • prueba de pintura: canvas measureText('Hablar con ventas') = 144.4px con la fuente computada del enlace vs 163.5px con la del párrafo. document.fonts.check('20px "Instrument Sans"') = true (la webfont SÍ cargó; el enlace no la usa).
  • cadena ambiente: los 8 ancestros más html y body computan todos «Times New Roman». HOVER medido: opacidad 1 → 0.8 y subrayado; en :focus-visible, subrayado + anillo de foco (visible en la captura post-interacción).

Capturas miradas: scratchpad/A-46-rest.png (panel en reposo puro), A-46-zoom.png (el zoom deja ver los serifos de «Hablar con ventas» junto a «Empezar gratis» en sans) y A-46-panel.png (post-interacción, con anillo de foco y subrayado).

A-47 — CONFIRMADO · cta · percepcion · ALTA

Mecanismo — CONFIRMADO, pero SOLO en esquema oscuro y con la redacción corregida: el anillo SÍ se pinta y ambos elementos SÍ reciben :focus-visible — «quedan sin indicador de foco por teclado» es literalmente falso. Lo real es que el indicador queda por debajo de cualquier umbral de percepción sobre el panel.

Cadena causal:

  1. src/uix/blocks/cta/cta.svelte:111 fija variant="solid" y src/uix/blocks/cta/cta.svelte:39 deja color = 'primary' por defecto: los DOS únicos interactivos del block (el <a> pintado de Button y el Link, web/routes/blocks/cta/CtaSite.svelte:62 y :72) caen sobre un lienzo sólido de la MISMA familia de tono que el anillo.
  2. El anillo es de tono primario y translúcido: src/uix/eidos/generated/base.css:7953 (claro, color-mix(in srgb, var(--primitive-primary-8) 48%, transparent)) y :8669 (oscuro, --primitive-primary-8 al 52%). Lo consumen src/uix/eidos/components/button/button.css:115-122, src/uix/eidos/components/link/link.css:75-76 y la regla de fundación src/uix/eidos/archetypes.css:203.
  3. El lienzo sólido NO cambia entre esquemas — medido idéntico, rgb(102,55,144) en claro y en oscuro (src/uix/eidos/components/surface/surface.css:25-37). El pigmento del anillo SÍ: en claro primary-8 es srgb(0.745,0.577,0.894), muy por encima del lienzo; en oscuro baja a srgb(0.518,0.341,0.667), casi la misma claridad que el lienzo. Ahí converge y el anillo se disuelve.
  4. Surface variant='solid' no re-ancla --focus-ring-color para su subárbol, así que el anillo sigue tirando del token global aunque el fondo bajo él ya no sea la superficie de página.

Por qué el defecto se sostiene pese a los matices: en oscuro el anillo mide ΔE-OK 0.051 / 1.25:1 contra el panel, 4.2× menos separación que el MISMO anillo canónico sobre una página oscuro normal (0.214) — no es la línea base del sistema, es un colapso que causa el panel. Y afecta al 100% de la superficie accionable del block.

Por qué la reclamación está a la vez sobredimensionada: en modo CLARO el fenómeno NO existe. El anillo sobre el panel sólido mide ΔE-OK 0.141 / 1.84:1, es decir IGUAL o algo mejor que el anillo canónico sobre la superficie de página ordinaria (0.138 / 1.49:1). El panel no se come nada en claro; la severidad ALTA declarada corresponde más bien a MEDIA (un solo esquema).

Evidencia — Sonda Playwright headless propia: C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-47{,b,c,d}.mjs, contra el dev server ya arrancado en 127.0.0.1:5201.

Protocolo: (1) puerta de hidratación esperando document.getElementById('uix-blocks-display'); (2) espera a 0 nodos [data-animation-pending] — el reveal de Motion sólo lo quita el cliente, así que sirve de ida y vuelta reactivo real; (3) foco por TECLADO real (page.keyboard.press('Tab')), nunca .focus() programático — verificado el.matches(':focus-visible') === true en los 2 elementos; (4) captura de pantalla a deviceScaleFactor 1, decodificada en canvas dentro de la propia página, y muestreo de la COLUMNA de píxeles reales en el centro del borde superior (zona recta, sin el redondeo de esquina), leyendo el compuesto pintado (lienzo + gradiente + alfa del anillo), no el token nominal.

URLs medidas: /blocks/cta/preview · ?mode=dark · ?gradient=false · ?color=teal · ?color=neutral · controles en /blocks/faq/preview (+?mode=dark), /blocks/site-header/preview?mode=dark, /blocks/cta.

Números (bg adyacente → anillo · WCAG · ΔE-OK):

  • CTA panel sólido CLARO — «Empezar gratis» rgb(102,55,144)→rgb(144,98,183) · 1.84 · 0.1408 ; «Hablar con ventas» 1.86 · 0.1431
  • Línea base CLARO, mismo anillo canónico fuera de panel (faq) rgb(252,252,252)→rgb(222,201,240) · 1.49 · 0.1382
  • CTA panel sólido OSCURO — «Empezar gratis» rgb(102,55,144)→rgb(118,70,157) · 1.25 · 0.0508 ; «Hablar con ventas» 1.26 · 0.0530
  • Línea base OSCURO, mismo anillo fuera de panel (faq / site-header) rgb(17,17,17)→rgb(77,53,97) · 1.80 · 0.2135

Otros lienzos, claro: gradient=false 1.43 · teal 1.36/1.38 · neutral 1.49/1.52. Oscuro neutral 1.24/1.22. Ningún escalón sólido llega a 3:1.

Estilos calculados: outline 2px solid color(srgb 0.745076 0.576585 0.894185 / 0.48) offset 1px (claro) y color(srgb 0.517546 0.341223 0.66659 / 0.52) (oscuro); box-shadow: none en todos. Panel: background-color: oklch(0.5556 0.1829 305.86), background-image: linear-gradient(oklch(0.507448 …), oklch(0.411144 …)) — idéntico en ambos esquemas.

Umbral aplicado: WCAG 2.2 SC 1.4.11 (no textual / indicador de foco) = 3:1. Ninguna configuración lo alcanza; el hallazgo se sostiene sobre el DELTA contra la línea base del propio sistema, no sobre el umbral absoluto.

Disposición — CANON, no el block. El arreglo correcto es que Surface variant='solid' re-ancle --focus-ring-color para su subárbol en src/uix/eidos/components/surface/surface.css:25 (junto a donde ya fija la tinta de contraste), apuntándolo a la ranura de contraste del lienzo (currentColor / on-solid) en vez de dejar que herede el primario translúcido global de src/uix/eidos/generated/base.css:7953/:8669. Así se arregla de una vez TODO block que pone acciones sobre lienzo sólido (cta, hero, banner, newsletter…), no sólo éste, y Button/Link/archetypes.css:203 lo heredan gratis sin tocar sus recetas — exactamente lo que anticipa el comentario de button.css:118-120.

Parche local en el block NO recomendado: el contrato B de cta prohíbe .css propio (src/uix/blocks/cta/cta.svelte:14-16), así que sólo cabría un style en línea sobre el Surface de cta.svelte:111 — deuda y repetida en cada block hermano.

Al cerrarlo, corregir también la redacción del hallazgo en el ledger: el indicador EXISTE y ambos elementos son alcanzables por teclado; el defecto es de contraste del anillo y sólo en esquema oscuro. Y anotar el hueco de percepción en src/uix/blocks/cta/README.md, junto a los que ya declara el docstring (cta.svelte:18-23).

Re-medición y DISPOSICIÓN CORREGIDA (2026-08-17) — sigue viva y con el mismo número: 1,43:1 compuesto, en los dos elementos del block. Pero la disposición anterior («que Surface variant='solid' re-ancle --focus-ring-color para su subárbol») es la capa equivocada, y eso explica el revert de e468e764b.

La doctrina lo fija en theming/reference.md:1797 (§«Borders — two tiers»): el anillo de foco es «the one always-sole indicator» y está en el tramo load-bearing con objetivo 3:1 —no entre los decorativos exentos—, PERO su apariencia es «a config axis, parameterised, NOT hardcoded»: primitives.focusRing + color.focus.{ring,ringError}, emitidos UNA vez como --focus-ring-*. Y textualmente: «The shipped default (primary·8 @ ~50% translucent) measures sub-3:1 as a raw ratio; hardening it (opaque color, innerWidth > 0, offset) is a default-VALUE decision via config, never a per-component CSS change».

Es decir: el proyecto YA SABE que su anillo por defecto queda sub-3:1, y tiene decidido dónde se arregla — subiendo el valor por defecto en la config del tema, no re-anclando el token desde un componente. El intento revertido hacía justo lo segundo.

Disposición — decisión de VALOR sobre color.focus.ring / primitives.focusRing, fuera del tier blocks y fuera de Surface. El block cta no tiene nada que arreglar: sólo elige un lienzo sólido, que es donde el default se nota más.

A-48 — CONFIRMADO · banner · percepcion · MEDIA

Mecanismo — La reclamación acierta en el defecto y se equivoca en un detalle del mecanismo; la corrijo.

  1. «Sólo se expresa contact-activate» — CIERTO. El único gesto cableado es src/uix/blocks/banner/banner.svelte:59 (<Banner.Close onclick={onDismiss} />). Esa cadena es Banner.Close → IconButton → Button de eidos → Button de soma, cuyo morfo declara UN solo evento, contact-activate sin intent (src/uix/morfo/components/button.ts:56-72). El canon Banner declara CERO eventos a propósito (src/uix/morfo/components/banner.ts:15-28) y el README del block lo asume («It emits no semantic event… the verb belongs to whoever closes it», src/uix/blocks/banner/README.md:28-30). Quien cierra es la app: web/routes/blocks/banner/BannerSite.svelte:47 hace dismissed = true y :37 lo quita con un {#if} — sin emitir nada. Ni banner.css ni banner.svelte de eidos traen animación, transición ni @starting-style, así que la tira desaparece de golpe. Medido a nivel de documento: tras el descarte los ÚNICOS estampados sema de toda la página son los tres contact-* del botón. La desaparición, en efecto, no la expresa nadie.

  2. «El estampado cae sobre un nodo YA fuera del documento» — FALSO tal cual está escrito. El orden real es el inverso: mergeProps(restProps, state.props) (src/uix/soma/components/button/components/button.svelte:47) compone con composeHandlers(a, b) que ejecuta PRIMERO el del consumidor (src/uix/soma/props/props.ts:21-24), o sea onDismiss, y DESPUÉS el de soma, que llama void this.runtime.trigger('contact-activate') (src/uix/soma/components/button/button-provider.svelte.ts:134). Como el flush de Svelte es un microtask, cuando sema proyecta el data-event-* el nodo TODAVÍA está en el documento (medido isConnected: true, document.contains: true en el instante de la escritura). Se desconecta ~11 ms después.

  3. La CONSECUENCIA que convierte esto en defecto SÍ se sostiene, y es lo que lo confirma: entre el estampado y la retirada del nodo no llega a pintarse ni un fotograma, así que la reacción visual del canon para la familia contact —[data-event-family='contact'][data-event-phase='active'] { animation: press-squeeze … }, src/uix/eidos/generated/base.css:6531— NUNCA arranca sobre ese botón. Además el hold del canal visual (~133 ms) se consume entero sobre un nodo desconectado, y al final el desestampado también cae fuera del documento. Resultado neto: el único gesto del block no tiene expresión visual ninguna, ni la pulsación ni la consecuencia.

Atribución: el block hace lo que su doctrina manda (no poseer el verbo), así que no se arregla en banner.svelte. Confianza alta: las tres piezas (estampado, no-arranque de la animación, ausencia de cualquier otro evento) están medidas con dos sondas y con un botón de control en la misma página.

Evidencia — URL medida: http://127.0.0.1:5201/blocks/banner/preview (servidor ya arrancado; 127.0.0.1). Sondas en C:/Users/dev/AppData/Local/Temp/claude/.../scratchpad/probe-A-48.mjs y probe-A-48b.mjs (Playwright headless).

Puerta de hidratación: espera a document.getElementById('uix-blocks-display') (lo escribe el $effect de web/routes/blocks/_lib/BootUix.svelte:91-97), luego a [data-animation-pending].length === 0, luego 400 ms. Ida y vuelta reactivo verificado: el click hace desaparecer la tira (bannerGone: true), o sea que el {#if} del cliente reaccionó.

CONTROL (mismo documento, <Button> «Empezar gratis», canon real, NO se desmonta):

  • estampado: data-event=contact-activate / -phase=active / -family=contact en t=3752.1 ms, quitado en t=3900.2 (hold ≈148 ms).
  • animationstart press-squeeze en t=3770.5 y animationend en t=3900.4. → la reacción visual SÍ arranca.
  • WebAudio (prototipo parcheado ANTES de interactuar): createOscillator 0 → 2.

DESCARTE ([data-banner-close], click de ratón real sobre su boundingBox):

  • sonda (b) MutationObserver ligado AL NODO: estampado contact-activate + phase=active + family=contact; desestampado 132.6 ms después.
  • sonda síncrona (setAttribute parcheado en la instancia): en el instante de la escritura connectedAtWriteTime: true, inDoc: true, t=3873.9 → REFUTA el «ya fuera del documento». Retirada de la tira observada a t=3885.4 (+11.5 ms). Los removeAttribute del final del hold (t=4006.5) sí caen con connectedAtWriteTime: false.
  • animationstart sobre ese nodo: NINGUNO (closeAnims: []), frente al 1 del control. → la reacción visual no se pinta.
  • muestreo rAF: fotograma t=4529.6 {conn:true, ev:null}; siguiente fotograma t=4555 {conn:false, ev:'contact-activate'}. No existe ni un fotograma con el nodo conectado y estampado.
  • MutationObserver de documento entero (subtree, filtro data-event*): tras el descarte, cero estampados fuera del propio botón. Nadie expresa la desaparición.
  • WebAudio: 2 → 4 osciladores, es decir el descarte SÍ suena (el harness arranca con events: { sound: true, haptic: true }, BootUix.svelte:62). Matiz honesto: el canal sonoro sobrevive al desmontaje porque no depende del nodo; el visual no. En una app real el sonido va apagado por defecto, así que allí el gesto quedaría sin ninguna expresión.

Lo que NO pude medir: no probé lector de pantalla ni región viva; no hay announce declarado en el morfo de Button, así que la desaparición tampoco se anuncia, pero eso lo infiero del código, no lo medí.

Disposición — No en banner.svelte del block: su README ya cede el verbo a quien cierra, y ceder es correcto. Dos sitios:

  1. DEMO (lo barato y lo que la auditoría de percepción debería exigir): web/routes/blocks/banner/BannerSite.svelte:47. El dismissed = true es exactamente el punto donde el canon dice que vive el verbo — que emita el cierre (un commit/emerge con su intent) sobre una superficie que SOBREVIVA al desmontaje, no sobre el botón que se va con la tira. Hoy el escaparate del block enseña la doctrina «el verbo es tuyo» sin ejercerla, que es la peor lectura posible.

  2. CANON (opcional, mayor alcance): Banner no tiene expresión de salida — src/uix/eidos/components/banner/banner.css no trae animación ni @starting-style, y el README del block ya lo tiene registrado como hueco «Animated entrance / exit → deferred». Si se levanta ese diferido, la salida de la tira pasa a expresarse sola y el defecto se cierra en origen para todos los consumidores. Nótese que el argumento del diferido («desplazaría la página al cargar») habla de la ENTRADA; la SALIDA no lo hereda.

Lo que no hay que hacer: intentar salvar el press-squeeze del botón de descarte retrasando el desmontaje. El gesto correcto no es pintar la pulsación de un botón que se va, es expresar la consecuencia.

A-49 — REFUTADO · banner · percepcion · MEDIA

Mecanismo — El fenómeno es real pero la consecuencia que lo convertiría en defecto no se sostiene, y una parte de la reclamación es literalmente falsa.

  1. Qué es el CTA. web/routes/blocks/banner/BannerSite.svelte:56 pinta <Link href="#novedades" size="sm" color="currentColor">. Link es un primitivo eidos puro: src/uix/eidos/components/link/link.svelte:80-102 renderiza un <a data-link> crudo, sin runtime de morfo ni runtime.trigger. Por eso no hay estampa ni sonido: no hay superficie sema que emitir.

  2. Eso es doctrina declarada, no un olvido. src/uix/morfo/components/link.ts:12-16 («Passive on the morfo surface: Link has hover/active visual states but no semantic events. Click / navigation is the consumer's responsibility… cuando Link se usa como affordance de botón, el consumer compone un Toolbar.Button»), src/uix/eidos/components/link/README.md:80-86 (§Eventos Sema, «Link declara 0 eventos en su morfo»), README.md:95 (el gap commit-navigate está diferido con criterio explícito: «añadir cuando 2 capas lo consuman») y README.md:105-113 («expone estados visuales… pero no semantic events: hover y active no son verbos sema»). El block usa Link para lo que el canon lo reserva —navegación a un ancla— y donde sí hay un commit (descartar) usa la superficie correcta: src/uix/blocks/banner/banner.svelte:59 compone Banner.Close, que sí estampa y sí suena (medido).

  3. La cláusula «ni en hover» describe el comportamiento de TODO el framework, no del banner: ningún componente estampa ni suena al pasar el puntero (el Button de control, medido, también da 0/0 en hover), y el README lo dice en la línea 111. Y «completamente muda» es falso: en hover el <a> pasa text-decoration-line: none → underline y cambia de color; con Tab real gana outline: solid 2px. Hay realimentación perceptiva; lo que no hay es canal sema — que es exactamente lo diseñado para un ancla de navegación.

  4. Atribución: aunque se quisiera voz para el CTA, el block no puede dársela (contrato B-1, docs/architecture/blocks.md:119: sin morfo, sin pack sema); tendría que salir del gap diferido del canon, que hoy está resuelto conscientemente.

Confianza alta: medido con control positivo en la misma página y en el mismo instante.

Evidencia — Playwright headless sobre http://127.0.0.1:5201/blocks/banner/preview?intent=primary&variant=solid (scripts: scratchpad/probe-A-49.mjs y probe-A-49b.mjs).

Puerta de hidratación: esperado #uij→<style id="uix-blocks-display">, luego 0 [data-animation-pending]. Ida y vuelta reactivo real al final: clic en el dismiss → [data-banner] presente=true → ausente=false (el {#if !dismissed} del cliente reaccionó ⇒ hidratado de verdad).

Sonda (a) contador WebAudio: AudioContext.prototype.createOscillator/createBufferSource parcheados en addInitScript (antes de todo script de la página). Sonda (b) MutationObserver atado AL NODO del <a> (attributes+oldValue) + uno global filtrando data-event*.

CTA «Ver qué cambia →» (<a href="#novedades" data-link data-variant=default data-underline=hover data-color-custom>):

  • hover: osc 0, buf 0, mutaciones 0 (nodo) / 0 (documento).
  • clic de ratón real: osc 0, buf 0, mutaciones 0/0; navegó (hash = #novedades). Atributos finales: href, data-link, data-variant, data-underline, data-color-custom, style — ni un data-event*.
  • teclado real: 8 pulsaciones de Tab hasta enfocarlo (activeElement confirmado) + Enter → osc 0, buf 0, mutaciones 0/0.

CONTROL POSITIVO en la misma página y misma sesión (prueba de que ambas sondas funcionan y de que el canal de sonido está ENCENDIDO en esta app):

  • Button «Empezar gratis» (canon, intent fulfill), clic → estampa data-event=contact-activate, data-event-family=contact, data-event-phase=active, data-event-id=sig-0 y luego los retira; osc 0→2.
  • Banner.Close del propio banner, clic → mismo juego de 4 atributos (sig-1); osc 2→4.
  • El mismo Button en HOVER: mutaciones 0, osc +0 ⇒ nadie estampa ni suena en hover.

Perceptivo del CTA (mismo nodo, computed style): reposo color: oklab(0.773…) / text-decoration-line: none; hover color: oklch(0.5246 0.1755 305.39) / underline; foco outline: solid 2px. Es decir, sí cambia.

Nota honesta (hallazgo DISTINTO, no el mío): midiendo el compuesto real, el fondo pintado bajo el CTA es el <header data-banner> en oklch(0.5556 0.1829 305.86) = rgb(142,78,198). Contraste del texto en reposo rgb(181,181,181) = 2.53:1 (falla AA de cuerpo) y en hover rgb(131,71,185) = 1.14:1 (el enlace casi desaparece: la regla [data-link]:hover{color:var(--color-primary-solid-hover)} de link.css:62-64 ignora color="currentColor"). Eso es un defecto de contraste, no de mudez, y pertenece a otro id.

Mecanismo — Los NÚMEROS de la reclamación son exactos (los medí: 24 interactivos, 18 Link, y los 3 botones sociales sí emiten sonido), pero la cláusula que la convierte en defecto —«no tiene canal perceptivo para su gesto principal»— es falsa medida en el navegador, y la atribución al block no existe.

  1. El block no es el locus. src/uix/blocks/site-footer/site-footer.svelte no contiene ni un Link (grep: 0 ocurrencias); el ancla :77 es <AutoGrid minChildWidth={minColumnWidth} …>, la rejilla de columnas. Los 18 enlaces los compone la demo: web/routes/blocks/site-footer/SiteFooterSite.svelte:219 (16 de columna), :131 y :134 (2 de la línea legal). Mal atribuir no refuta por sí solo, pero fija dónde habría que mirar.

  2. El silencio de Link en sema NO es deriva: es contrato escrito. src/uix/morfo/components/link.ts:11-16 declara literalmente «Passive on the morfo surface: Link has hover/active visual states but no semantic events. Click / navigation is the consumer's responsibility», y :21 le da scope: ['eidos'] — sin capa soma no hay runtime.trigger que disparar. La misma posición se repite coherente en src/uix/morfo/components/anchor-nav.ts:25-27 («Passive (0 events, 0 keyboard): the links are NATIVE anchors») y en src/uix/morfo/components/breadcrumb.ts:8-11 («no events of its own — the items are Links whose native anchor semantics own the interaction»). Documentación y código dicen lo mismo. Contrasta con el caso hermano de navigation-menu (ledger :528), que SÍ es defecto porque el pack de sema promete commit-select por enlace y el provider nunca lo emite: allí hay contradicción doc-vs-código; aquí no la hay.

  3. La cláusula «sin canal perceptivo» es falsa al medirla. El gesto de navegación del Link sí tiene canal visual: reposo opacity: 1 / text-decoration: none → hover opacity: 0.8 + underline (src/uix/eidos/components/link/link.css:46-51 y :65-69), y con Tab real (:focus-visible verificado) un anillo de 2px (link.css:75-78). Según docs/CANON.md §7 la robustez es «una lectura que sobrevive a la pérdida de un canal»: el canal visual está.

  4. El término de comparación está mal descrito. Los «tres iconos decorativos» son Button etiquetados (aria-label = Boletín RSS / Foro de la comunidad / Escríbenos, SiteFooterSite.svelte:144-152); suenan porque src/uix/morfo/components/button.ts:52-72 declara contact-activate, que es el canon correcto, y porque la galería enciende los canales a propósito (web/routes/blocks/_lib/BootUix.svelte:62, events: { sound: true, haptic: true }). Su emisión es la referencia, no la anomalía.

RESIDUO MEDIDO que NO rescata la reclamación (y que ella no formula): en el instante del press el Link con variant='subtle' tiene delta CERO —:active matchea, pero [data-link][data-variant='subtle']:hover (0,2,1) en link.css:65-69 gana por especificidad a [data-link]:active (0,1,1) en link.css:71-73—, mientras el Button comprime a scale(0.985). Es un hecho de CSS del canon, distinto del reclamado (mudez de sema vs iconos que suenan), y aun así no deja el gesto sin canal: la aproximación (hover) y el foco sí responden. Merecería id propio si alguien quiere perseguirlo; confianza alta, medido tres veces.

Evidencia — URL medida: http://127.0.0.1:5201/blocks/site-footer/preview (chromium headless, 1440x900, Playwright por ruta absoluta). Puerta de hidratación: esperé document.getElementById('uix-blocks-display'); ida y vuelta reactiva real después (clic en [data-select-trigger] → [data-select-content] montado en el portal = true, Escape); luego esperé a 0 nodos [data-animation-pending].

CENSO (dentro de <footer>): 24 interactivos únicos = 18 [data-link] + 3 button ghost icon-only (aria-label RSS/Foro/Escríbenos) + 1 [data-form-submit] + 1 [data-select-trigger] + 1 input. Los 18/24 de la reclamación son EXACTOS.

SONDA (a) contador WebAudio: parcheé createOscillator/createBufferSource en BaseAudioContext/AudioContext/OfflineAudioContext.prototype vía addInitScript, ANTES de cualquier script de la página. SONDA (b) MutationObserver atado a los NODOS concretos (attributes, subtree, attributeOldValue) más un barrido documento-completo de data-event*.

  • Clic de ratón sobre Link «Características» (href #Características): osc=0, buf=0, 0 mutaciones en el nodo, 0 data-event* en todo el documento. Hash cambió a #Caracter%C3%ADsticas.
  • Activación por TECLADO real: Tab hasta el Link («Integraciones», :focus-visible = true) + Enter: osc=0, buf=0, 0 mutaciones. La mudez es total, ratón y teclado.
  • Clic sobre el Button social «Boletín RSS»: osc=2 y la secuencia de stamp data-event=contact-activate → data-event-id=sig-2 → data-event-phase=active → data-event-family=contact, y su retirada al cerrar el hold. Suena, confirmado.
  • Form.Submit «Enviar»: buf=1 + data-event=signal-warn-invalid, data-event-intent=risk sobre el <form>.

VISUALES del Link (getComputedStyle sobre el nodo real):

  • reposo: color oklch(0.2435 0 0), opacity 1, text-decoration-line none, outline 0px none.
  • hover (:hover matcheado): opacity 0.8, text-decoration-line underline. → hay delta.
  • press (:active matcheado = true): color, opacity, text-decoration y transform IDÉNTICOS al hover → delta cero en el instante del clic.
  • Tab (:focus-visible = true): outline 2px solid color(srgb 0.745076 0.576585 0.894185 / 0.48) + underline; --focus-ring-width = 2px.

VISUALES del Button social, para comparar: reposo bg rgba(0,0,0,0) → hover bg oklch(0.9932 0.0034 325.6) → press transform: matrix(0.985,0,0,0.985,0,0) (la microcompresión del libro).

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 — 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.

Evidencia — src/uix/morfo/components/form.ts:17-22,34-39,56-61 (targets) y :90-101 (la parte Submit).

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

Mecanismo — El FENÓMENO es cierto y lo medí: cerrar escribiendo la costura no emite nada. Pero la consecuencia que lo convertiría en defecto del block no se sostiene, por tres razones medidas/leídas:

  1. El block no cierra nada. src/uix/blocks/site-header/site-header.svelte:23,58 sólo declara mobileOpen = $bindable(false) y lo reenvía a <Drawer bind:open={mobileOpen}>: no hay watcher, no hay escritura propia. La escritura muda que la reclamación observa es del APP (web/routes/blocks/site-header/SiteHeaderSite.svelte:172,177,180, onclick={() => (mobileOpen = false)}), tierra de snippets. Y el propio README fija la posición firmada (README.md:31 «Owns nothing of its own… forwarding is not owning», ratificada en docs/process/CONTINUE-blocks.md:104 «costura bindable hacia el canon… reenviar no es poseer»).

  2. No es «el único camino de cierre» del block. Los snippets de la app se renderizan DENTRO del provider del Drawer: el mobileTrigger de la app monta un <Drawer.Trigger> real (medido: data-drawer-trigger, aria-haspopup="dialog", aria-controls="uix-drawer-content-s19"), luego el contexto llega, y Drawer.Close — que sí anuncia, vía dismissWith('cancel') en src/uix/soma/components/drawer/drawer-provider.svelte.ts:1258-1261 — es componible dentro de mobileNav exactamente igual. Además el block DOCUMENTA los caminos que sí suenan como propios del Drawer compuesto (web/routes/blocks/site-header/+page.svelte:212-215, «trae foco atrapado, bloqueo de scroll y descarte propios»), y ambos están vivos (medidos).

  3. La emisión del canon está atada al ACTO (runtime.trigger), no al estado, y no puede atarse al binding: TODOS los cierres canónicos escriben la MISMA celda (drawer-provider.svelte.ts:357 this.opts.open.current = false dentro de handleClose), así que anunciar la escritura duplicaría el disparo en Escape, overlay, Drawer.Close y drag. Es doctrina uniforme, no descuido: dialog-provider.svelte.ts:266,320,412,417,593 tiene la misma forma y ningún provider vigila open para emitir. Y el acto del caso documentado («cerrarlo al navegar», +page.svelte:186) es NAVEGAR, cuya superficie es muda POR DECISIÓN firmada del canon (src/uix/morfo/components/link.ts:11-18: «Passive on the morfo surface… no semantic events»); cuando el afordance sí posee señal (el CTA Button del cajón) el acto SÍ se anuncia — lo medí.

Detalle menor pero indicativo de que la reclamación se escribió sin medir: Escape y overlay NO emiten emerge.close; emiten el evento polimórfico close concretado como emerge.dismiss (DISMISS_CAUSES, drawer-provider.svelte.ts:64-68), con data-last-action dismissed / dismissed-outside.

Residuo real, no defecto del block: el cierre por costura pierde el data-last-action que tiñe la salida y el sello data-event-*; quien quiera esa señal compone Drawer.Close en mobileNav. La salida visual se anima igual en los cuatro caminos.

Evidencia — Playwright headless (dos pasadas idénticas), script en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-52.mjs, sobre http://127.0.0.1:5201/blocks/site-header/preview?breakpoint=md, viewport 390x844 (rama del cajón). Puerta de hidratación: espera a style#uix-blocks-display CON --density-space-scale dentro; después, cero [data-animation-pending]; ida y vuelta reactiva = clic en el trigger que voltea aria-expanded false→true y sella sema en vivo. Sondas instaladas ANTES de interactuar: (a) contador de nodos WebAudio parcheando BaseAudioContext/AudioContext.prototype.createOscillator|createBufferSource|createGain vía addInitScript; (b) MutationObserver de documento con attributeFilter data-event*/data-last-action, MÁS un observer ligado AL NODO [data-drawer-content] concreto (sobrevive al desmontaje). Sonido y haptic están ON en este boot (web/routes/blocks/_lib/BootUix.svelte, events: { sound: true, haptic: true }), así que el contador de audio es significativo. Sin errores de consola.

Números (delta por camino):

  • Abrir con trigger: 8 osciladores / 12 gains; sellos data-event=contact-activate (family contact, el Button) y data-event=open (family emerge, el Drawer) sobre [data-drawer-trigger], borrados a los ~371 ms.
  • Escape: 4 osc / 6 gains; sobre [data-drawer-content]: data-last-action='dismissed', data-event='close', data-event-family='emerge', data-event-phase='active'; sellos borrados a los ~252 ms y data-state='closed' ~258 ms DESPUÉS del sello (secuencia pre).
  • Overlay: 4 osc / 6 gains; idénticos sellos con data-last-action='dismissed-outside'.
  • Costura (clic en el Link «Analytics», onclick → mobileOpen = false): 0 osc / 0 gains, CERO sellos data-event* en TODO el documento, sin data-last-action; el contenido pasa a data-state='closed' en el mismo tick y se desmonta. El propio Link tampoco emite nada (canon: link muda por diseño).
  • Costura (CTA Button «Empezar gratis», misma escritura de la costura): 4 osc / 6 gains y sello contact-activate en el <a> del Button — el ACTO sí se anuncia; el cierre del cajón sigue sin data-last-action.
  • Prueba de contexto: el <Drawer.Trigger> declarado en el snippet de la APP rinde con data-drawer-trigger, aria-haspopup='dialog' y aria-controls='uix-drawer-content-s19' — el contexto del provider atraviesa los snippets, luego Drawer.Close es componible en mobileNav. (No monté un Drawer.Close real porque exigiría editar la demo del repo; la inferencia se apoya en esa medición.)

A-53 — CONFIRMADO · hero · percepcion · MEDIA

Mecanismo — Las dos acciones del hero son hermanas literales del mismo contenedor: src/uix/blocks/hero/hero.svelte:103 mete el snippet actions en un único <Group gap={3}>, y el probe confirma cta.parentElement === link.parentElement (div[data-group]). Dentro, web/routes/blocks/hero/HeroSite.svelte:92-98 pinta el primario con <Button variant="solid" intent="fulfill"> + snippet child que renderiza <a href="#pricing">, y HeroSite.svelte:99-106 pinta el secundario con <Link href="#docs" variant="subtle" size="lg">. Funcionalmente son EL MISMO acto (dos anclas de navegación por hash); sólo cambia la pintura. Pero el canal perceptivo no es una cuestión de grado, es binario: src/uix/morfo/components/button.ts:56-72 declara contact-activate (sequence:'pre', sin intent), que dispara la firma press-squeeze de src/uix/eidos/generated/base.css:6531 (keyframes en :6427, scale(0.96) + brightness(0.96)) y, con el sonido encendido en la galería (web/routes/blocks/_lib/BootUix.svelte:62, events:{sound:true,haptic:true}), dos osciladores arrancados. src/uix/morfo/components/link.ts:18-33 tiene scope:['eidos'] y parts sin un solo evento — Link es pasivo por diseño (comentario en :11-16). Resultado: el usuario pulsa dos cosas contiguas de la misma fila y el sistema acusa recibo de una y de la otra no. Y no es que el enlace responda «más flojo»: medido, su estado PULSADO es idéntico byte a byte a su estado hover — cero delta. Intenté refutarlo por cuatro vías y ninguna aguanta: (1) «primario/secundario deben diferir» — deben diferir en énfasis, no en presencia/ausencia del acuse de contacto; (2) «es canon, no del block» — mal atribuir no refuta, y además el block tiene salida propia: el idiom Button + child + <a> ya lo usa cuatro líneas más arriba (y web/routes/blocks/cta/CtaSite.svelte:62-67), así que un variant="ghost" con el mismo child conservaría la jerarquía visual y devolvería el contacto; (3) «los enlaces no responden al press en ningún sistema» — en ESTE sí: src/uix/eidos/components/link/link.css:71 declara [data-link]:active{color:var(--color-primary-active)}, sólo que está muerta para variant='subtle' porque link.css:65 ([data-link][data-variant='subtle']:hover, 0-3-0) gana por especificidad a :active (0-2-0) siempre que se pulsa con el puntero, que ya está en hover — o sea, ni siquiera cumple lo que el propio canon se prometió; (4) «los osciladores quizá no suenan» — no medí ganancia en dB, pero start() y connect() corren y el sonido está activado por configuración, y el eje visual (squeeze 0.96 vs cero) se sostiene solo aunque el audio estuviese mudo.

Evidencia — Playwright headless sobre http://127.0.0.1:5201/blocks/hero/preview (script en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-53.mjs y probe-A-53b.mjs). Puerta: esperado style#uix-blocks-display (ausente en el HTML del SSR: el fetch crudo da uix-blocks-display=false, data-animation-pending=true) y luego 0 nodos [data-animation-pending] — la desaparición del atributo la escribe el efecto cliente de motion.svelte:124, así que es ida y vuelta reactiva real. Localización: sameParent:true, padre div[data-group]; CTA 205x44 px, enlace 174x27 px. Sondas de sema: (a) contador WebAudio parcheando createOscillator/createBufferSource/AudioNode.connect en addInitScript ANTES de cargar — línea base tras hidratar: osc 0, started 0; (b) MutationObserver atado a CADA NODO. CTA (ratón): osc +2, started +2, connects +6; mutaciones data-event=contact-activate, data-event-id=sig-0, data-event-phase=active, data-event-family=contact puestos en t=6051 ms y retirados en t=6178 (ventana de hold ~127 ms); animationName=press-squeeze, transform=matrix(0.960068,…). ENLACE (ratón): osc +0, started +0, connects +0, CERO mutaciones, animationName=none, transform=none; hover color=oklch(0.2435 0 0) opacity=0.8 underline y PULSADO color=oklch(0.2435 0 0) opacity=0.8 underline — delta nulo. Teclado real (focus() + keyboard.press('Enter'), no .click()): CTA vuelve a estampar el ciclo completo (sig-1, t=8059→8194); enlace, 0 mutaciones (sólo el anillo de foco, outline: 2px solid color(srgb .745 .577 .894/.48)). Repetido en tres ejes con resultado idéntico: ?layout=split (defecto), ?layout=background y ?mode=dark — en los tres, CTA osc+2/started+2/connects+6 y enlace 0/0/0, con HELD≡hover.

Disposición — Se arregla en el BLOCK (la demo): en web/routes/blocks/hero/HeroSite.svelte:99-106, envolver la acción secundaria en el mismo idiom que ya usa la primaria cuatro líneas arriba — <Button variant="ghost" size="lg"> con {#snippet child({props, content})}<a href="#docs" {...props}>… — para que el acuse de contacto exista en las dos y la jerarquía siga viviendo en variant/peso, no en presencia/ausencia de firma. Nota aparte, de CANON y no de este hallazgo: src/uix/eidos/components/link/link.css:71 ([data-link]:active) es inerte para variant='subtle'|'plain' porque la regla de hover de link.css:65 gana por especificidad; si se prefiere no tocar el block, ese es el otro sitio donde el enlace podría recuperar su acuse visual de press.

A-54 — ARREGLADO · site-header · doctrina · MEDIA

Mecanismo — El intent evaluable deja de estar cargado en el marco: commit-select+affirm vive ahora en la parte Link, que es la que navega, y el despliegue tiene familia propia.

Evidencia — Ver A-32.

A-55 — CONFIRMADO · banner · doctrina · MEDIA

Mecanismo — Con onDismiss hay TRES márgenes automáticos en la misma fila flex y el hueco libre se reparte entre los tres, así que la columna del mensaje deja de estar centrada: [data-banner] es display:flex (banner.css:20); el Container que el block monta trae align='center' por defecto, que en container.svelte:57-58 se traduce en marginLeft:auto + marginRight:auto; y banner.css:150-151 añade margin-inline-start:auto al [data-banner-close]. Sin onDismiss los dos autos del Container centran bien — de ahí que la afirmación del block solo deje de ser cierta al pasar el descarte.

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 — ⚠️ 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

Mecanismo — B-9 prohíbe algo concreto y estrecho: «never inside a padded frame or a scroll box». Ni una cosa ni la otra ocurren. BlockDemo.svelte:69-73 renderiza {@render preview()} FUERA de su <Section><Container>, sin envoltorio, y la región de contenido de la galería no aporta padding: [data-blocks-content] { flex: 1 1 auto; min-inline-size: 0 }. El raíl del catálogo es un hermano flex que estrecha el viewport disponible en escritorio y desaparece por completo bajo 64rem — estrechar no es enmarcar—, y para el ancho real está la ruta preview/, que es un documento propio.

Evidencia — web/routes/blocks/_lib/BlockDemo.svelte:69-73, web/routes/blocks/blocks.css:56-65.

Disposición — No se toca.

A-57 — ARREGLADO · newsletter · percepcion · MEDIA

Mecanismo — Sin panel, descripción y nota comparten tinta (secondary) y la jerarquía la lleva el tamaño, que es lo que el block siempre documentó. Antes la nota caía al content-primary del recipe por no llevar prop.

Evidencia — Medido: antes nota 15.88:1 vs descripción 3.70:1 (la nota pesaba 4,3× más); ahora 5.77:1 ambas, las dos sobre AA. Con panel, 5.18:1 ambas.

Disposición — Dato de canon registrado en el README: muted sobre la superficie de página mide 3.70:1, bajo AA para cuerpo.

Mecanismo — Ver A-38: la demo del footer ya expresaba la intención correcta y ahora el canon la respeta.

Evidencia — Ver A-38.

Mecanismo — Ver A-38 para la mitad de la tinta (los enlaces ya no heredan la del título). El escalón de TAMAÑO del título de columna queda como está: es decisión de diseño tuya, no defecto medible.

Evidencia — Ver A-38.

Disposición — Pendiente tu criterio sobre size='xs' del título frente a los enlaces sm.

Mecanismo — La cadena se sostiene entera y la medí en los tres eslabones.

  1. src/uix/eidos/components/auto-grid/auto-grid.svelte:26 emite literalmente repeat(auto-fill, minmax(MIN, 1fr)). auto-fill NO colapsa las pistas sobrantes: las crea vacías y les reparte su 1fr.

  2. src/uix/blocks/site-footer/site-footer.svelte:77 mete las columnas en ese AutoGrid con minChildWidth={minColumnWidth} (default '7.5rem', línea 33) y columnGap={6}, dentro de la región minmax(0, 3fr) del Grid exterior (líneas 64-67). Esa región mide 714px al calibre lg, y a 7.5rem + 24px de hueco caben exactamente 5 pistas — el default está afinado PARA CINCO, como admite el propio comentario de las líneas 73-76 y types.ts:26.

  3. Con 2 grupos el gridTemplateColumns computado sigue siendo cinco pistas de 123.594px, tres de ellas vacías. Las dos columnas se apelotonan en 0..271px de 714 → 62.0% de la región muerto por caja, 64.4% por tinta. «Casi dos tercios» es exacto, no inflado.

El contrafáctico cierra la atribución: forzando auto-fit sobre el MISMO nodo, las tres pistas vacías colapsan a 0px, las dos columnas pasan a 345px y el borde derecho llega al final de la región (muerto por caja 0%, por tinta 64.4%→33.4%). O sea, auto-fill es la causa real, no un chivo expiatorio.

Por qué es DEFECTO y no una decisión: la frase auditada (README.md:17 «as many as fit, no breakpoints — same code for 2 groups or 5» y el comentario site-footer.svelte:72 «Fluid by construction») promete que el mismo código sirve para 2 y para 5. Medido, sólo aterriza en 5: muerto 0% con 5 grupos, 20.7% con 4, 41.3% con 3, 62.0% con 2. Y visualmente el destrozo es el desalineado, no el hueco en abstracto: con 2 grupos la región superior termina en x≈745 mientras el Separator y la barra inferior sí llegan a x≈1208 — el pie se ve cortado por arriba a la derecha.

La escapatoria existe pero refuerza el hallazgo en vez de refutarlo: subiendo minColumnWidth a 15rem se obtiene exactamente el resultado del auto-fit (dos pistas de 345px, 0% muerto por caja). Es decir, para 2 grupos hace falta OTRO minColumnWidth que para 5 — justo lo contrario de «el mismo código para 2 o para 5».

Descartados los falsos positivos conocidos: no es F15-F22, y no es medición pre-hidratación ni pestaña de fondo (ver evidencia).

Confianza alta: medido en navegador real, con contrafáctico y a tres anchos.

Evidencia — Playwright headless (chromium), viewport 1440x900, sobre http://127.0.0.1:5201/blocks/site-footer/preview?columns=N. Scripts en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-60.mjs y probe-A-60b.mjs.

Puertas cumplidas: esperé style#uix-blocks-display (lo escribe un $effect de cliente, no viene en el SSR), hice ida y vuelta reactivo REAL con page.keyboard.type() sobre el input[type=email] (hydrated: true en las 4 pasadas), metí el <footer> en viewport para que corriera el Motion trigger="viewport" y esperé a que no quedara ningún [data-animation-pending].

Contexto medido: containerWidth 1024px, Grid exterior computado 238px 714px, columnGap 24px, región AutoGrid = 714px.

gridTemplateColumns computado y ocupación (izq..der relativos a la región de 714px):

  • columns=2 → 123.594px ×5 (5 pistas, 3 VACÍAS); cajas 0..123.6 y 147.6..271.2 → muerto 62.0% (por tinta 64.4%); muerto sobre el calibre completo del contenedor 44.8%.
  • columns=3 → mismas 5 pistas; última caja acaba en 418.8 → muerto 41.3%.
  • columns=4 → mismas 5 pistas; última caja acaba en 566.4 → muerto 20.7%.
  • columns=5 → última caja acaba en 714.0 → muerto 0.0%.

Contrafáctico sobre el mismo nodo, columns=2:

  • auto-fit en vez de auto-fill → computado 345px 345px 0px 0px 0px; cajas 0..345 y 369..714 → muerto por caja 0%, por tinta 33.4%.
  • auto-fill con minmax(15rem, 1fr) (el knob minColumnWidth que el block ya expone) → computado 345px 345px, idéntico resultado.

Estabilidad: repetido a 1024 / 1280 / 1920px de viewport con columns=2 → muerto 62.1% / 62.0% / 62.0% (el contenedor está topado a lg, así que el defecto no se diluye ensanchando la ventana).

Capturas del <footer>: scratchpad/A-60-cols2.png (contenido superior acaba en x≈745 con el separador y la barra inferior llegando a x≈1208 — corte visible), A-60-cols5.png (alineado hasta el borde), A-60-cols2-autofit.png (contrafáctico).

Disposición — Se arregla en el BLOCK, con nota al canon.

Block (src/uix/blocks/site-footer/site-footer.svelte:77): la región de columnas no puede reservar 5 pistas cuando el app pasa 2. Opciones, por orden de cirugía: (a) que las pistas sobrantes colapsen (comportamiento auto-fit); (b) que el Grid exterior de las líneas 64-67 deje de dar 3fr incondicional a una región cuyo contenido no lo llena. Cualquiera de las dos exige tocar además README.md:17 y el comentario de la línea 72: la frase «el mismo código para 2 o para 5 grupos» es hoy falsa en el resultado, y si se opta por documentar el knob en vez de arreglar el layout, entonces la frase debe decir que minColumnWidth hay que ajustarlo al número de grupos.

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

Mecanismo — El mecanismo es real y se cierra en tres saltos. (1) src/uix/blocks/contact/contact-submit.svelte:21 pasa disabled={!canSubmit(state)} a Form.Submit; canSubmit (src/uix/blocks/contact/state.ts:65-67) sólo es cierto en ready, así que en incomplete · unverified · verifying · rejected · sending · sent el prop viaja por restProps → mergeProps (src/uix/soma/components/form/components/form-submit.svelte:31,37) y aterriza como atributo NATIVO disabled en el <button type="submit">. Un botón con disabled nativo no es enfocable ni por Tab ni por .focus(): sale del orden de tabulación. Medido. (2) La frase que lo explica (contact-reason.svelte:24-26, texto de CONTACT_REASON en state.ts:76-84) se pinta como un <span> estático visible INMEDIATAMENTE DESPUÉS del botón, sin aria-describedby desde el botón, sin role="status" y sin aria-live en ningún ancestro. Es decir: la razón nunca se asocia al control que explica ni se anuncia cuando cambia. Quien recorre el formulario con Tab (modo formularios de un lector de pantalla) va nombre → correo → mensaje → enlace hola@vicen.dev: ni toca el submit ni oye la frase. (3) El golpe más duro sale del MISMO mecanismo y lo medí aparte: con el botón enfocado y ready, pulsar Enter lo deshabilita al instante (sending), el foco cae a <body> y NO vuelve; a los ~900 ms el estado pasa a sent, aparece «Gracias. Te respondemos por correo.» sin región viva y el botón queda deshabilitado para siempre. El usuario de teclado que envió queda tirado al principio del documento sin que nada le confirme el envío. Matiz que la reclamación exagera y hay que declarar: «nunca llega al control» sólo es cierto MIENTRAS está bloqueado; en ready el Tab desde el textarea aterriza en el submit (medido), que es el comportamiento correcto del disabled nativo. Pero la consecuencia que lo convierte en defecto sí se sostiene: el propio README del block y state.ts:6-9 presumen de que «un estado bloqueado mudo es imposible», y para teclado/AT lo es a medias — la frase existe en píxeles, no en el árbol de accesibilidad ni en el recorrido. Parte de la cadena es del canon: FormSubmitProvider deshabilita nativamente durante el pending (src/uix/soma/components/form/form-provider.svelte.ts:198), así que la pérdida de foco al enviar la hereda cualquier Form.Submit, no sólo este block.

Evidencia — Playwright headless (127.0.0.1:5201), script …/scratchpad/probe-A-61b.mjs. Puerta de hidratación: espera a #uix-blocks-display + a que no queden [data-animation-pending], y luego ida y vuelta reactiva REAL con keyboard.type() (los 3 campos) → el botón pasa de disabled=true a disabled=false; se vacía el nombre con Ctrl+A/Backspace → vuelve a disabled=true. Números: (a) BLOQUEADO — button[type=submit]: disabled attr = true, aria-disabled = null, aria-describedby = null. Tab-walk de 6 saltos desde el <textarea>: A(hola@vicen.dev) → BODY → INPUT text → INPUT email → TEXTAREA → A; el submit NO aparece ninguna vez (isSubmit:false en los 6). Inventario de enfocables: 5 elementos, el submit es el único con disabled:true. .focus() programático sobre él: activeIsButton:false. (b) READY — Tab desde el textarea: primer salto = BUTTON type=submit id=uix-form-submit-s14 disabled:false (sí se alcanza). (c) LA FRASE — <span> «Completa todos los campos para enviar.» / «Resuelve la verificación de arriba.» / «Gracias. Te respondemos por correo.»: visible:true, opacity:1, afterButton:true, liveRegion:null, id:null (nada a lo que apuntar con aria-describedby). (d) ENVÍO — foco en el submit, Enter; muestreo a 30/300/700/1200/1800 ms: activeElement = BODY en LAS CINCO tomas; etiqueta «Enviando…» (aria-busy=true, disabled=true) → «Enviado» (disabled=true) y la frase de éxito aparece sin región viva. Tab-walk tras sent: el submit sigue fuera del recorrido (6/6 saltos sin tocarlo). (e) ?challenge=on, campos completos: disabled=true, razón «Resuelve la verificación de arriba.», tab-walk pasa por la aguja del ProofOfHuman (#uix-rotate-align-needle-s18) y salta al enlace — el submit tampoco aparece. No es ninguno de los falsos positivos catalogados (F15–F22 ni los de medición): la medida es post-hidratación y con reactividad probada.

Disposición — Se arregla en el BLOCK, con una pieza en el canon. Block (src/uix/blocks/contact/): dar id al Text de contact-reason.svelte y pasar aria-describedby a Form.Submit en contact-submit.svelte:21 para que el motivo viaje CON el control; envolver la razón en role="status" (o aria-live="polite") para que el cambio de estado —y sobre todo el «Enviado / Gracias…»— se anuncie; y evaluar cambiar disabled nativo por aria-disabled + no-op en el submit en los estados bloqueados por el usuario (incomplete/unverified/rejected), que es el patrón que mantiene el control en el recorrido y hace audible su explicación. Canon (src/uix/soma/components/form/form-provider.svelte.ts:198): la deshabilitación nativa durante isPending tira el foco al <body> en CUALQUIER Form.Submit; ahí toca decidir la retención de foco (o aria-disabled + aria-busy) a nivel de canon, no del block.

A-62 — CONFIRMADO · team · percepcion · MEDIA

Mecanismo — El hecho se sostiene, con una corrección de matiz y otra de atribución.

Cadena: web/routes/blocks/team/TeamSite.svelte:77 recorta 6 personas con columns=4 (por defecto) y :115-122 pone en cada tarjeta dos IconButton («Perfil de X», «Escribir a X») SIN onclick, SIN href y sin ninguna otra vía de resolución → 12 disparadores. IconButton compone Button (src/uix/eidos/components/icon-button/icon-button.svelte:23), cuyo provider dispara el evento de forma incondicional: src/uix/soma/components/button/button-provider.svelte.ts:129-135 (onclick → runtime.trigger('contact-activate')), sin mirar si el consumidor pasó handler. El morfo declara ese único evento (src/uix/morfo/components/button.ts:54-72, sequence: 'pre', sin intent) y eidos lo materializa: src/uix/eidos/generated/base.css:6531 → [data-event-family='contact'][data-event-phase='active'] { animation: press-squeeze … }. Añádase el canal de sonido, que en el sitio de blocks está ENCENDIDO: cada pulsación crea 2 osciladores + 3 nodos de ganancia.

Por qué es defecto y no mero dato: docs/CANON.md:244 (regla de composición 1) exige que «contact precede al resultado cuando hay acción directa (agencia)», y el propio morfo cita cap. 22 §8 en src/uix/morfo/components/button.ts:26-27 — «Contact inicia. Commit resuelve». Aquí el contacto acusa recibo (comprensión visual + tick audible) doce veces y NADA resuelve: sin navegación, sin diálogo, sin toast, sin cambio de estado, sin región viva. Además los dos verbos son navegaciones («Perfil de…» = una página; «Escribir a…» = un mailto) expresadas como <button>: la mini-página de team es la ÚNICA del corpus con 0 anclas — banner, cta, faq, hero, site-header, contact, newsletter y pricing resuelven sus navegaciones con <Link href="#…">. La excusa «el framework no envía personas de stock» (README del block) no cubre esto: los hermanos usan anclas de humo, no botones muertos.

Matiz que corrijo de la reclamación: «no hacen absolutamente nada» es literalmente falso — sí hay acuse de recibo perceptivo (press-squeeze + tick). Lo que no hay es resolución, que es exactamente lo que la propia reclamación glosa («el contacto inicia y nada resuelve»), así que el defecto se mantiene.

Atribución: el block NO pone los botones. src/uix/blocks/team/team-member-links.svelte:27 es un Group pelado que sólo alinea a los hijos; los 12 IconButton son de la demo. Se arregla en la demo, no en src/uix/blocks/team.

Evidencia — Playwright headless contra http://127.0.0.1:5201/blocks/team/preview (script en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-62.mjs).

Puerta de hidratación: esperé <style id="uix-blocks-display"> (verifiqué por fetch que NO viene en el SSR: uix-blocks-display in SSR: false) y luego a que [data-animation-pending] bajara de 9 (presentes en el HTML del SSR) a 0 — ida y vuelta de cliente real, no un atributo estático.

Inventario tras hidratar: 12 button[data-button], 0 a[href] en toda la página. Etiquetas: «Perfil de/Escribir a» × Ada Lovelace, Grace Hopper, Alan Turing, Katherine Johnson, Edsger Dijkstra, Barbara Liskov. type=button, data-archetype=trigger, cursor: pointer. (Dato colateral: el SSR sirve esos 12 botones SIN aria-label — el nombre accesible sólo aparece al hidratar.)

Emisión (sonda b, MutationObserver atado A CADA NODO): 12 clics de ratón real + 1 Enter con teclado real → 26 registros de data-event (13 estampados + 13 des-estampados), idénticos en los 12: data-event=contact-activate, data-event-family=contact, data-event-phase=active, data-event-id=sig-N, y data-event-intent AUSENTE (correcto: el contacto no lleva intent). getComputedStyle(...).animationName = press-squeeze durante la ventana.

Emisión (sonda a, WebAudio parcheado antes de interactuar): baseline tras hidratar = 0 osciladores / 0 buffers; tras cada clic +2 osciladores y +3 nodos de ganancia (2/4/6 en tres clics consecutivos); 26 osciladores en la pasada de 13 activaciones. El canal de sonido está activo en el sitio de blocks.

Resolución = cero, medida a la vez: docAddsCount = 0 (MutationObserver de documento entero, childList+subtree, excluyendo el interior de los botones), urlChanged = false, sin navegaciones de marco, bodyΔ = 0 en los 12 clics, única región viva aria-live=assertive vacía, consoleErrors = []. El foco queda en el botón pulsado y ahí muere.

Disposición — En la DEMO, no en el block ni en el canon: web/routes/blocks/team/TeamSite.svelte:115-122. Sustituir los dos IconButton sin destino por el patrón que ya usa todo el resto del corpus de demos — Link con href="#…" para el perfil y href="mailto:…" para el correo (el docstring de src/uix/blocks/team/team-member-links.svelte:1-6 contempla explícitamente «IconButtons o Links»), de modo que la fila emita shift-navigate y la agencia resuelva. Si se prefiere conservar botones, deben llevar un onclick que resuelva con su propio evento (commit.* / emerge.open de un popover de ficha). src/uix/blocks/team no se toca: team-member-links.svelte:27 es un Group pelado. Nota de alcance: web/routes/blocks/site-footer/SiteFooterSite.svelte:144-152 repite el mismo patrón con 3 IconButton sin destino — mismo arreglo, hallazgo distinto.

A-63 — REFUTADO · contact · percepcion · MEDIA

Mecanismo — Los DOS hechos de la reclamación son ciertos (los medí), pero la cláusula que los convierte en defecto —«es mudo», con la implicación de que el gesto se queda sin canal— no se sostiene, y además el block no tiene palanca alguna.

  1. El hecho, confirmado: el <a data-link data-variant=subtle> de web/routes/blocks/contact/ContactSite.svelte:71 es el ÚNICO interactivo de la columna de datos (censo en vivo de la página: 2 input, 1 textarea, 1 needle de ProofOfHuman, 1 button[data-form-submit] deshabilitado y ESTE enlace; los otros tres Contact.Detail son prosa). Clic y Enter: 0 osciladores, 0 createBufferSource, 0 data-event* en todo el documento, 0 mutaciones en el nodo.

  2. El silencio NO es deriva: es contrato escrito, y es de canon, no del block. src/uix/morfo/components/link.ts:11-16 declara literalmente «Passive on the morfo surface: Link has hover/active visual states but no semantic events. Click / navigation is the consumer's responsibility», y :21 le da scope: ['eidos'] — sin capa soma no existe runtime.trigger que disparar, no hay contradicción doc-vs-código. src/uix/eidos/components/link/README.md:80-86 («Link declara 0 eventos en su morfo») y :95 difieren el evento commit-navigate explícitamente bajo la regla 2-de-3. Contrasta con el caso hermano de navigation-menu, que sí es defecto porque su pack promete commit-select y el provider nunca lo emite.

  3. No hay palanca en el block, ni debe haberla: docs/architecture/blocks.md B-1 («un block no declara morfo, no envía pack de sema») y B-2 («todo interactivo es un componente canónico de eidos — Button, Link, Field…», nombrando Link). El block compone exactamente lo que el contrato le manda.

  4. La cláusula perceptiva es falsa medida: reposo opacity 1 / text-decoration: none → hover opacity 0.8 + underline (src/uix/eidos/components/link/link.css:46-51 y :65-69), y con Tab real :focus-visible pinta anillo 2px solid (link.css:75-78); cursor: pointer en los tres estados. Según docs/CANON.md §7 la robustez es «una lectura que sobrevive a la pérdida de un canal»: el canal visual está presente.

RESIDUOS MEDIDOS que la reclamación NO formula y que no la rescatan: (a) en el instante del press el delta es CERO — [data-link][data-variant='subtle']:hover (0,2,1) de link.css:65-69 gana por especificidad a [data-link]:active (0,1,1) de link.css:71-73; hecho de CSS de canon, idéntico al residuo ya registrado en A-50. (b) el href es #correo, un fragmento MUERTO (no existe elemento con ese id; scrollY 0→0 tras el clic) sobre lo que es una dirección de correo — debería ser mailto:hola@vicen.dev; eso es un defecto de autoría de la demo, de otra naturaleza que «no emite nada», y merecería id propio. Confianza alta: todo medido tres veces con las dos sondas.

Evidencia — URL medida: http://127.0.0.1:5201/blocks/contact/preview (chromium headless 1280x900, Playwright por ruta absoluta). Scripts: C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-63.mjs, probe-A-63b.mjs, probe-A-63c.mjs.

PUERTA DE HIDRATACIÓN: esperé document.getElementById('uix-blocks-display') (lo escribe el $effect de web/routes/blocks/_lib/BootUix.svelte:91-97), y después ida y vuelta reactiva REAL con teclado: page.keyboard.type('Ana') → input.value === 'Ana' y :placeholder-shown = false. Luego esperé a 0 nodos [data-animation-pending].

SONDA (a): parcheé createOscillator/createBufferSource/createGain en AudioContext.prototype vía addInitScript, ANTES de cualquier script de la página, más un Proxy sobre el constructor. SONDA (b): MutationObserver atado AL NODO concreto (attributes + attributeOldValue + subtree) y un segundo barrido documento-completo de data-event*.

CONTROL POSITIVO (las sondas SÍ detectan emisión): en /blocks/faq/preview, clic en un trigger de accordion → osc=2, ctx.state='running', y 8 mutaciones: data-event=expand, data-event-id=sig-0, data-event-phase=active, data-event-family=emerge y su retirada al cerrar el hold. La galería enciende los canales a propósito (BootUix.svelte:62, events:{sound:true,haptic:true} + los 72 packs de _lib/sema-packs.ts).

EL ENLACE (mismo run, mismas sondas):

  • Clic de ratón: osc=0, buf=0, 0 mutaciones en el nodo, 0 data-event* en el documento. URL pasó a …/preview#correo, scrollY 0→0.
  • Enter con el enlace enfocado: osc=0, buf=0, 0 mutaciones. Mudez de sema total, ratón y teclado.
  • Atributos del nodo: href=#correo, data-link, data-variant=subtle, data-underline=hover, data-color=primary. Ningún data-event* presente jamás.

VISUALES (getComputedStyle sobre el nodo real, ratón retirado a (5,5) para el reposo):

  • reposo: color oklch(0.61 0 0), opacity 1, text-decoration none, outline 0px, cursor pointer.
  • hover (:hover=true): opacity 0.8, text-decoration underline → hay delta.
  • press (:active=true, botón mantenido): color/opacity/decoration/transform IDÉNTICOS al hover → delta cero en el instante del clic (residuo (a)).
  • Tab real (:focus-visible=true): outline 2px solid color(srgb 0.745076 0.576585 0.894185 / 0.48) + underline.

CENSO de interactivos de la página (a,button,input,textarea,select,[role=button],[tabindex]): 6 — INPUT(fieldInput) x2, TEXTAREA(textareaInput), DIV(rotateAlignNeedle), BUTTON «Enviar mensaje» disabled=true, A «hola@vicen.dev». El 1-de-1 de la columna de datos que afirma la reclamación es EXACTO.

FALSOS POSITIVOS descartados: no es F15–F22; no medí antes de hidratar (crucé la puerta + ida y vuelta reactiva); pestaña activa y 0 [data-animation-pending]; usé teclado real (keyboard.type/press), no fill().

A-64 — ARREGLADO · pricing · percepcion · MEDIA

Mecanismo — Ver A-23: sin clic muerto no hay commit-toggle que anuncie algo que no pasó.

Evidencia — Ver A-23.

A-65 — CONFIRMADO · pricing · percepcion · MEDIA

Mecanismo — La cadena es real y la mitigación que el propio fichero declara no funciona. (1) <Button> declara su evento de morfo contact-activate, familia contact, sequence:'pre' — src/uix/morfo/components/button.ts:52-70. Al soltar el ratón, sema proyecta data-event-family="contact" + data-event-phase="active" sobre el propio <button> (src/uix/sema/projection/dom.ts vía src/uix/sema/stamp.ts:50-58). (2) El handler de la demo choose() en web/routes/blocks/pricing/PricingSite.svelte:29-46 hace await tick() y emite un SEGUNDO signal, commit-select / familia commit / intent affirm, con target = event.currentTarget — es decir, EL MISMO nodo. (3) DomSignalProjector.project() no serializa por target: reescribe data-event-* incondicionalmente (su propio test lo fija: src/uix/sema/projection/dom.test.ts:70-72, «Overwrite some attrs with a second projection»). (4) await tick() es un flush de microtarea: medido, los dos stamps distan 1,1 ms — muy por debajo de un frame (16,7 ms). El navegador nunca llega a recalcular estilo con data-event-family="contact", así que la regla [data-event-family='contact'][data-event-phase='active'] { animation: press-squeeze var(--duration-fast) … } (src/uix/eidos/generated/base.css:6531-6533) jamás se activa: no hay animationstart de press-squeeze ni siquiera un animationcancel. Sólo corre announce-pulse-affirm (la firma de commit+affirm, 320 ms). El comentario de PricingSite.svelte:33-37 afirma literalmente que el tick() «es lo que evita que las dos señales colisionen en el mismo elemento»; la medición demuestra que no lo evita. Calibración honesta (por eso MEDIA y no ALTA): el press no queda mudo del todo — la capa de estado [data-button]:active { transform: scale(var(--press-scale)) } (src/uix/eidos/components/button/button.css:107-114, 0.985 / 80 ms) sí corre, y el canal de sonido tampoco se pisa (ambas señales suenan). Lo que se destruye es la firma transmodal de contact (press-squeeze: escala 0.96 + achatado de depth + endurecido de shape, docs/theming/channels.md:96), o sea el momento «contact inicia» que el propio morfo del Button documenta como la mitad del arco (button.ts:31-36).

Evidencia — Playwright headless contra http://127.0.0.1:5201, script en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-65.mjs (+ …-control.mjs). Puerta de hidratación: esperé style#uix-blocks-display, luego 0 [data-animation-pending], y un ida y vuelta reactivo real (clic en «Anual» → precios 0/29/99 € → 0/23/79 €, confirmado hidratado). Sondas: (a) contador WebAudio parcheando createOscillator/createBufferSource en un addInitScript ANTES de interactuar; (b) MutationObserver ligado AL NODO del botón (no a un selector) sobre data-event*, más animationstart/end/cancel y un muestreo de getAnimations() por frame. — CASO /blocks/pricing/preview, CTA «Probar 14 días» (t=0 en mouseup): 95,5 ms data-event-family: null→"contact"; 96,6 ms data-event-family: "contact"→"commit" (+data-event-intent: null→"affirm", data-event-id: sig-1→sig-2) ⇒ Δ = 1,1 ms, cero frames entre medias (el rAF siguiente cae en 100,2 ms). animationstart observado: SÓLO announce-pulse-affirm (110,6 ms → animationend 427,3 ms, elapsed 0,32 s). NINGÚN press-squeeze, ningún animationcancel. Idéntico en el CTA «Empezar gratis»: Δ = 0,7 ms (95,7 → 96,4 ms), sólo announce-pulse-affirm. Nodos de audio: +4 osciladores en ese único clic ⇒ el canal de sonido sí recibe las dos señales; la colisión es exclusivamente de la proyección visual. — CONTROL A/B (misma CSS, misma versión, Button cuyo clic emite SÓLO el contact-activate del morfo): /blocks/cta/preview → 92,5 ms stamp contact, animationstart press-squeeze 109,5 ms, animationend 226,6 ms elapsed 0,12 s, desestampado 228,8 ms. /blocks/hero/preview → stamp 71 ms, animationstart press-squeeze 93,3 ms, animationend 206,5 ms elapsed 0,12 s. En ambos verifiqué que la regla servida existe: [data-event-family="contact"][data-event-phase="active"] { animation: press-squeeze var(--duration-fast) var(--ease-default); }, con --duration-fast: 120ms y --duration-slow: 320ms leídos del nodo. Conclusión del A/B: press-squeeze corre entero donde no hay segundo emit y no arranca nunca donde lo hay — la causalidad queda aislada.

Disposición — Se arregla en la DEMO (web/routes/blocks/pricing/PricingSite.svelte:29-46), no en el block: src/uix/blocks/pricing/pricing-plan-action.svelte es un <Box> y no emite nada. Dos vías: (a) mínima — sustituir await tick() por una espera que sobreviva al hold del contact (glimpse = 120 ms, src/uix/sema/holds.ts + durations.ts), p.ej. programar el commit-select con uix.timers tras el hold, y de paso corregir el comentario de las líneas 33-37, que hoy afirma una garantía que no existe; (b) mejor — emitir el commit sobre otro target (la tarjeta del plan / Pricing.Plan), que es además más fiel al canon: el contacto es del botón, la consecuencia es del plan elegido. Complemento a nivel de CANON (no obligatorio para cerrar este hallazgo, pero es la causa raíz genérica): DomSignalProjector.project() (src/uix/sema/projection/dom.ts) no serializa por target — dos signals sobre el mismo nodo se pisan sin que nadie lo note. Como el propio morfo del Button enseña ese patrón («Consumers wanting deferred semantic feedback … emit their own commit.*», button.ts:31-36), convendría que el engine encolara o al menos avisara en dev cuando una proyección se sobrescribe antes de haber pintado un frame.

A-66 — REFUTADO · pricing · percepcion · MEDIA

Mecanismo — El HECHO es cierto y lo he medido, pero la CONSECUENCIA que lo convertiría en defecto no se sostiene: la reclamación colapsa dos canales que el canon mantiene separados a propósito.

  1. Lo que hace el código. web/routes/blocks/pricing/PricingSite.svelte:141 grada el CTA con intent={plan.featured ? 'fulfill' : 'affirm'}, y PricingSite.svelte:38-45 emite siempre commit-select con intent: 'affirm', sin mirar featured. Medido: el destacado pinta data-color="fulfill" y los tres emiten data-event-family=commit / data-event-intent=affirm idéntico.

  2. intent en Button NO es el canal perceptual. src/uix/morfo/components/button.ts:18-34 lo declara literalmente prop VISUAL ("Button.intent es un VISUAL prop only… no carga el evento sema"), y button.ts:61-71 deja contact-activate SIN intent citando cap. 22 §11; cap. 22 §8 nombra contact.press + fulfill ANTIPATRÓN ("el sistema dice 'ya terminó' cuando solo ha empezado"). El prop desemboca en data-color (button.ts:99-121 + src/uix/soma/components/button/button-provider.svelte.ts:81-82), que es un consumidor INDEPENDIENTE del mismo prop, no el complemento del canal perceptual (doctrina explícita del proyecto: data-color nunca explica el intent percibido). No existe, pues, ninguna "lectura unificada" contratada entre el tinte del disparador y el intent de la consecuencia: el canon prohíbe justo ese eco.

  3. El intent es intrínseco AL ACTO, no heredado del contexto del componente. El acto que describe commit-select es "quedó fijada una elección", y es el MISMO en los tres planes; que el vendedor recomiende uno es contexto aguas abajo (misma forma que el close-save del Dialog, que no hereda el peso destructivo del diálogo). docs/CANON.md §«Intent assignment» canoniza elegir/deselegir un ítem = affirm; fulfill es "objetivo cumplido (resuelve tensión)" (§3), que no describe escoger una tarifa. Y docs/CANON.md §5 llama antipatrón capital cargar el intent desde el marco: graduar fulfill sólo al plan destacado haría que el sistema dijera "objetivo cumplido" por preferencia comercial del vendedor, que es el defecto que la reclamación pide introducir.

Conclusión: los tres commits suenan igual porque describen el mismo acto; el verde del destacado es jerarquía visual, no una afirmación evaluativa sobre la consecuencia. Confianza alta (doctrina explícita en tres sitios + medición).

Observación adjunta que NO es este hallazgo (no lo confirmo bajo A-66): dentro del propio plan destacado el acento visual sí se parte — src/uix/blocks/pricing/pricing-plan.svelte:31 y el badge de PricingSite.svelte:116 usan color="primary" (violeta, oklch h≈306) mientras el CTA salta a fulfill (verde, oklch h≈158). Eso es coherencia de acento en app-land, no la ruptura sonora reclamada.

Evidencia — Sonda Playwright headless (script propio en scratchpad/probe-A-66.mjs y -A-66b.mjs) sobre http://127.0.0.1:5201/blocks/pricing/preview.

Puerta de hidratación: esperado document.getElementById('uix-blocks-display'), luego 0 nodos [data-animation-pending], y IDA Y VUELTA REACTIVA REAL — clic en el toggle "Anual" cambia los precios de "29 €/99 €" a "23 €/79 €" y vuelve con "Mensual" (hydrated=true).

Estado visual (compuesto pintado, no el token): CTA #0 "Empezar gratis" data-color=affirm, variant=surface, bg oklch(0.9937 0.0044 179.74), fg oklch(0.5521 0.101 178.79); CTA #1 "Probar 14 días" data-color=fulfill, variant=solid, bg oklch(0.6406 0.1329 157.68), fg blanco; CTA #2 "Hablar con ventas" idéntico al #0. Tokens: --color-primary-solid oklch(0.5556 0.1829 305.86), --color-affirm-solid oklch(0.6491 0.1136 181.96), --color-fulfill-solid oklch(0.6406 0.1329 157.68). Tarjetas: neutral / primary(borde oklch h=309.69) / neutral; badge primary oklch(0.5556 0.1829 305.86).

Emisión sema con LAS DOS sondas. (a) MutationObserver ligado a CADA nodo botón (no a selector), clic real de ratón: los TRES producen exactamente la misma secuencia — data-event=contact-activate + data-event-family=contact SIN data-event-intent (t+0 ms), luego data-event=commit-select + data-event-family=commit + data-event-intent=affirm (t+2 ms), y limpieza a null a los ~337 ms. Cero diferencia entre el destacado (b1) y los otros dos. (b) Contador WebAudio parcheado en AudioContext.prototype.createOscillator/createBufferSource ANTES de interactuar: 4 osciladores y 0 buffer-sources por clic, el mismo número en los tres — el sonido del destacado es el mismo que el de los otros, tal como afirma el hecho.

A-67 — CONFIRMADO · faq · percepcion · MEDIA

Mecanismo — El mecanismo se sostiene entero y es del CANON, no del block. Faq.List fija type='single' (src/uix/blocks/faq/faq-list.svelte:14) — que además ya es el default del Accordion canónico (src/uix/soma/components/accordion/components/accordion.svelte:14). Con selección única, un clic sobre OTRA pregunta pasa por AccordionProvider.toggleItem → setValue (src/uix/soma/components/accordion/accordion-provider.svelte.ts:113-120), que tras un solo await tick() recorre dos bucles seguidos en el MISMO tick: for (const value of closing) …emitToggleEvent('collapse') (línea 119) y for (const value of opening) …emitToggleEvent('expand') (línea 120). Son dos runtime.trigger distintos sobre dos ítems distintos → dos señales sema emerge (morfo: src/uix/morfo/components/accordion.ts:28 y :46, ambas sequence:'post'). El pack de sonido las tuñe por separado: expand = emerge.soft (gain 0.08) y collapse = emerge.exit con gain:0.06 (src/uix/sema/components/accordion.ts:32-38), sobre la base de familia emerge (pitch 600, contour ascendente, gain 0.2 — src/uix/sema/sema-map.ts:156-170); emerge.exit resta 120 Hz y pone contorno descendente (src/uix/sema/sounds.ts:155). El árbitro que debería arbitrar la superposición NO actúa: applyDominance (src/uix/sema/engine.ts:559-593) solo silencia si una ocurrencia activa tiene rango ESTRICTAMENTE mayor (active.rank > rank, línea 576), y aquí ambas son estructurales sin intent → occurrenceRank = 0 para las dos (líneas 224-227), empate ⇒ ninguna se atenúa; y la primera (collapse) ya tiene su nota agendada, así que tampoco puede callarse a posteriori. EngineSound.synthesize (src/arts/sound/engine-sound.ts:517-589) agenda ambas en el mismo ctx.currentTime, sin límite de polifonía ni ducking entre earcons. Resultado: un gesto, dos notas de 150 ms exactamente coincidentes con glissandi opuestos. Matiz donde la reclamación se pasa de rosca: «sin atenuar» es impreciso en absoluto — ambas SÍ están atenuadas respecto a la base de familia (0.08 = 40 % y 0.06 = 30 % de 0.2) y la suma lineal (0.14) queda por debajo de un solo emerge.strong (0.15), o sea no hay pico de volumen; lo que no existe es atenuación de una FRENTE A la otra, que es la sustancia de la reclamación (0.06 vs 0.08 = −2,5 dB, «peso casi igual»). El defecto es de legibilidad perceptual y contradice la propia regla de simultaneidad del canon (docs/CANON.md:253-254: «on a tie, the most recent»), que en el empate del mismo tick no se aplica en ninguna dirección.

Evidencia — Playwright headless sobre http://127.0.0.1:5201/blocks/faq/preview (scripts probe-A-67.mjs y probe-A-67b.mjs en el scratchpad). Puerta de hidratación: espera a document.getElementById('uix-blocks-display'), luego a 0 nodos [data-animation-pending], y un ida y vuelta reactivo real (clic en el trigger 0 → aria-expanded pasa a true). Sonda (a) contadores WebAudio parcheando AudioContext.prototype.createOscillator/createBufferSource y OscillatorNode.prototype.start ANTES de interactuar; sonda (b) MutationObserver atado a los NODOS [data-accordion-item] concretos. Gesto medido = un solo clic en el trigger 1 con el ítem 0 abierto. Números: 4 osciladores arrancados, 0 bufferSources — voz A fundamental 480 Hz + quinta 720 Hz, voz B 600 Hz + 900 Hz; los CUATRO con el mismo when de AudioContext (1.4628571428571429 en la corrida b; 1.401904761904762 en la a) y el mismo fin (1.6128571428571428) ⇒ solape del 100 % durante 150 ms. Envolventes: pico 0.06 (voz 480, la de collapse) y 0.08 (voz 600, expand), ambas con rampa de caída al mismo instante. Contornos opuestos confirmados en el detune: owner de 480 Hz setValueAtTime(+400) → linearRampToValueAtTime(-400) (DESCENDENTE); owner de 600 Hz setValueAtTime(-400) → linearRampToValueAtTime(+400) (ASCENDENTE). Estampas visuales del MutationObserver en el mismo milisegundo: ítem 0 data-event=collapse / data-event-family=emerge / sig-1, ítem 1 data-event=expand / emerge / sig-2. Control (segundo clic sobre el mismo ítem abierto, un solo cambio de estado): solo 2 osciladores (480 + 720), una rampa 0.06, una estampa collapse ⇒ el doblete es específico del cambio de pregunta. Sonido activo por configuración real de la página: events: { sound: true, haptic: true, components: semaPacks } en web/routes/blocks/_lib/BootUix.svelte:62 (y en web/routes/blocks/+layout@.svelte:61), con dominance en su default true (src/uix/sema/engine.ts:257).

Disposición — Canon, no block: en src/uix/blocks/faq no hay nada que tocar (solo elige type='single', que es el default del Accordion canónico). Tres sitios posibles, por orden de preferencia: (1) pack sema del accordion — src/uix/sema/components/accordion.ts, dar al par swap una tuña propia (p. ej. que el collapse coincidente baje a emerge.exit.soft/gain ~0.02 o se calle, dejando el expand como único signo del gesto); (2) árbitro de dominancia — src/uix/sema/engine.ts:559-593, resolver el empate del mismo tick según docs/CANON.md:253-254 («on a tie, the most recent») atenuando la ocurrencia estructural anterior en vez de exigir > estricto; (3) soma — src/uix/soma/components/accordion/accordion-provider.svelte.ts:119-120, emitir un único evento para el intercambio en modo single en lugar de collapse+expand (esta opción cambia el contrato del morfo, así que es la más cara). Severidad: la declarada MEDIA es razonable como problema de legibilidad, no de volumen (la suma 0.06+0.08 = 0.14 queda por debajo de un solo emerge.strong = 0.15).

A-68 — CONFIRMADO · stats-band · percepcion · MEDIA

Mecanismo — La banda monta DOS disparadores de viewport independientes que nadie sincroniza. (1) La entrada: stats-band.svelte:41 pone data-stagger en el AutoGrid y stats-band-stat.svelte:18 hace de cada stat un <Motion trigger="viewport">; la fundación escribe --motion-stagger-index por :nth-child (src/uix/eidos/lib/render-css.ts:930) y el preset retrasa la entrada index × --motion-stagger-each, con default 70 ms para animadores de viewport (src/uix/eidos/components/motion/motion.css:41-43) y relleno backwards que mantiene el fotograma inicial —opacidad 0— hasta que llega su turno (render-css.ts:1117-1118). (2) El contador: stats-band-value.svelte:22 compone <CountUp to={count} {...countOptions} /> sin pasar startWhen ni delay, y CountUp trae SU PROPIO IntersectionObserver sobre el span interior con opciones por defecto —threshold 0, sin rootMargin— (src/uix/eidos/components/count-up/count-up.svelte:149-160), mientras que el de Motion exige threshold 0.1 y rootMargin: '0px 0px -10% 0px' (motion.svelte:44-61). Un IntersectionObserver ignora la opacidad: el span cuenta aunque su ancestro esté a opacity: 0. Resultado: los contadores arrancan todos a la vez y ANTES que cualquier revelado, y cada cifra se hace visible con su muelle ya avanzado. La atribución no es sólo del block: Motion no expone su momento seen (la variable es privada, motion.svelte:42) y el índice de stagger sólo existe en CSS, así que el block no tiene hoy con qué alimentar el startWhen/delay que CountUp sí ofrece (count-up.svelte:31,34). El defecto surge en la costura que monta el block, pero la pieza que falta está en canon.

Evidencia — Playwright headless (script en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-68.mjs) sobre http://127.0.0.1:5201/blocks/stats-band/preview, 1280x800, puerta de hidratación por style#uix-blocks-display + espera a que no quede [data-animation-pending] (rAF vivo: se muestrearon 394 fotogramas y las opacidades sí rampan). TEST A (banda en pantalla al cargar, 4 stats del demo: 12.500 / 340 / «99,98 %» sin count / 48): los tres contadores pintan su valor inicial en el MISMO fotograma (t=3171,3 ms) y avanzan en paralelo; las opacidades salen de 0 escalonadas a t=3252,9 / 3320,4 / 3386,2 / 3453,9 ms (deltas de ~67 ms = el stagger de 70 ms). En el fotograma en que cada cifra deja de ser invisible marca: stat[0] «0» (0 %), stat[1] «18» de 338 (5,3 %), stat[3] «10» de 48 → 20,8 % del valor final, que es exactamente el 21 % reclamado. TEST B (misma página con la banda empujada bajo el pliegue por CSS y entrada por scroll lento, que es como la lee un humano): con el borde superior de la banda a 6 px dentro del viewport los cuatro stats siguen data-animation-pending, opacity: 0, y los contadores YA corren (1144 / 31 / 4); tras 2,6 s en esa posición siguen invisibles (pending=true, opacity 0) con los contadores prácticamente terminados (12.121 / 330 / 47); al completar el scroll la primera cifra aparece con «12.160» de 12.428 = 97,8 %, es decir, el count-up entero ocurre fuera de la vista y el lector ve la cifra ya hecha. No es ninguno de los falsos positivos catalogados (F15–F22 ni pestaña en segundo plano: el pending se limpió y las animaciones corrieron).

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 — 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.

Evidencia — Playwright headless (probe-A-69b.mjs) sobre http://127.0.0.1:5201/blocks/stats-band/preview. Puerta de hidratación: esperado <style id="uix-blocks-display"> (3,95 s); comprobado document.hidden=false, 0 nodos [data-animation-pending], prefers-reduced-motion=false, 3 nodos [data-count-up-value] con top=70 y en viewport. Muestreo DIRECTO de textContent cada 40 ms durante 25 s (la primera sonda con MutationObserver se descartó: entrega asíncrona en microtarea → valores sesgados). Arranque simultáneo: firstT = 0,04 s en los tres. Último cambio visible: 12.500 → 7,88 s · 340 → 5,09 s · 48 → 3,57 s. Modelo analítico independiente: 7,866 / 5,076 / 3,560 s — coincide con lo medido dentro de ±0,02 s. Valores finales correctos (relectura directa a 25 s y a 33 s: 12.500 / 340 / 48). Barrido de duration (0,3–2): settle(12500) 4,60→7,87 s y settle(48) 2,08→3,56 s, razón 2,21 invariante. Los números literales de la reclamación (7,9 s y 3,6 s) se reproducen exactamente. Anomalía NO reproducida y por tanto no imputada: en la primera sonda, con el MutationObserver global activo, el nodo grande quedó en 12.499 a los 18 s; la sonda limpia no lo reproduce.

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

Mecanismo — La reclamación tiene dos mitades y sólo la primera aguanta.

MITAD CIERTA (esto sí es DATA): feature-grid no compone ningún componente de soma con morfo. La raíz coloca Section/Container/Stack (src/uix/blocks/feature-grid/feature-grid.svelte:25-35), la rejilla es un AutoGrid (feature-grid-items.svelte:26), la celda un Motion+Stack (feature-grid-item.svelte:21-25), el chip un Surface (feature-grid-item-icon.svelte:16) y el resto Heading/Text. No hay ningún runtime.trigger() en el árbol, luego no hay nada que emitir por los canales de sema — medido y confirmado: cero nodos WebAudio, cero vibrate, cero mutaciones de atributo.

MITAD FALSA (y es la que decide el veredicto): «nada que auditar perceptivamente en él» no se sostiene. El block ES perceptivo por construcción propia, no heredada: feature-grid-items.svelte:32 estampa data-stagger y feature-grid-item.svelte:21 envuelve CADA celda en Motion trigger="viewport", así que cada celda se pinta a opacity: 0 (src/uix/eidos/components/motion/motion.css:18-22) hasta que el IntersectionObserver de motion.svelte:44-61 la ve. Medido en móvil: 4 de 6 celdas a opacity 0 tras la puerta de hidratación, revelándose al hacer scroll con retardos 0/70/140/210/280/350 ms y un fade-in de 240 ms. Eso es exactamente materia de auditoría perceptiva — si el observador no dispara, o la puerta @media (scripting: enabled) cae, el usuario ve una sección en blanco; y bajo prefers-reduced-motion el preset fade es el destino canónico de degradación (base.css:6573-6579), no un fallo. Y la casilla no sólo no está vacía: contiene una lectura bajo umbral. El texto que el propio block elige pintar (feature-grid-item-text.svelte:12, Text color="muted") mide 3,70:1 a 16 px sobre el fondo compuesto en claro y 4,46:1 en oscuro, ambos por debajo del suelo AA de cuerpo (4,5:1). El valor sale del token de canon (content-muted), no del block, pero es el block quien elige el rol muted para su descripción — o sea, algo que la reclamación habría cerrado sin mirar. Confianza alta: dos sondas independientes, todas las cifras medidas.

Evidencia — URL medida: http://127.0.0.1:5201/blocks/feature-grid/preview?count=6&align=center (también con &mode=dark&dir=rtl&lang=en). Playwright headless desde el scratchpad: probe-A-70.mjs (1280x800), probe-A-70b.mjs (390x640) y probe-A-70c.mjs (contraste claro/oscuro), en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/.

PUERTA DE HIDRATACIÓN: esperado style#uix-blocks-display; el HTML del SSR (fetch crudo) NO lo trae (ssr_has_display_style=false) y sí trae 9 data-animation-pending, que en el DOM vivo acaban en 0. Ida y vuelta reactivo puramente de cliente: t0 = 4 celdas con data-animation-pending y opacity: 0 (tops 610/790/970/1151 px) → tras hacer scroll, pending=0 y las 6 a opacity 1 con texto. document.visibilityState = 'visible' (pestaña activa; no quedó ningún [data-animation-pending] colgado).

EMISIÓN (dos sondas, como se pedía): (a) AudioContext.prototype.createOscillator/createBufferSource parcheados en addInitScript ANTES de cargar → osc=0, buf=0 tras hover + click forzado sobre la celda + Tab×12 + Enter + Space (1 construcción de AudioContext por el arranque de uix, ningún nodo); navigator.vibrate parcheado → 0 llamadas. (b) MutationObserver atado a 8 NODOS concretos (la

y cada celda del grid), subtree+attributeOldValue → 0 mutaciones. Ningún [data-event]/[data-event-phase]/[data-event-family] en el subárbol en ninguna variante.

INTERACTIVIDAD: en la

del block, 0 coincidencias de a/button/input/select/textarea/summary/[role]/[tabindex]/[href]/[onclick] y 0 elementos con tabIndex>=0 sobre 58 nodos; el recorrido de Tab×12 con teclado real nunca sale de BODY. Idéntico en 390 px + dark + rtl + lang=en (hits=0, focusables=0).

PERCEPCIÓN MEDIDA (lo que la reclamación daba por vacío): stagger resuelto por celda --motion-stagger-index 0..5, --motion-stagger-each 70 ms, animation-delay 0/0,07/0,14/0,21/0,28/0,35 s, animation-name fade-in 0,24 s, data-state=open, data-animation-trigger=viewport. Con reducedMotion:'reduce' emulado no queda ninguna celda invisible (todas llegan a opacity 1); con la pref de app data-motion='reduce' en la raíz, opacidades 1 en las 6. Contraste compuesto (color resuelto por canvas, no por regex sobre oklch, y fondo = primer ancestro opaco): ItemText 16 px → 3,70:1 en claro (fg oklch(0.61 0 0)) y 4,46:1 en oscuro (fg oklch(0.5829 0 0) sobre rgb(17,17,17)); ItemTitle 15,88:1 / 16,28:1; glifo blanco sobre el chip solid primary 5,18:1.

Mecanismo — El arnés pinta el block —que trae sus propios h2/h3— antes del h1 de la página: BlockDemo.svelte:69-73 renderiza el preview y solo en la línea 85 aparece <Heading level={1}>. El documento arranca en h2/h3 y el h1 llega el último, en la superficie donde B-8/B-9 piden coherencia. Afecta a las 15 demos por igual: es del arnés, no de un block.

Evidencia — web/routes/blocks/_lib/BlockDemo.svelte:69-73 y :85.

Disposición — Arnés (app-land, dentro de la frontera): o el h1 va antes del preview, o la identidad de la página encabeza y el block se muestra debajo. Fase 3.

A-72 — REFUTADO · contact · percepcion · BAJA

Mecanismo — La mitad factual se sostiene, pero la consecuencia que la convertiría en defecto NO.

  1. Es cierto que no hay sustain ni spinner. src/uix/morfo/components/form.ts:15-100 sólo declara commit-submit (sequence:'post'), signal-warn-invalid y commit-reset: ningún evento de familia sustain. Y src/uix/eidos/components/form/form-submit.svelte:27-45 renderiza el Form.Submit de soma en crudo — no compone Button, así que no arrastra su prop loading ni su button-spinner.svelte. Medido: 0 data-event* en todo el documento durante los 900 ms, 0 spinners, 0 nodos WebAudio.

  2. Pero la ventana NO es muda, y "sólo el cambio de etiqueta" es falso. Medí cuatro canales simultáneos, tres de ellos perceptibles sin tecnología asistiva:

    • etiqueta "Enviar mensaje" → "Enviando…" (state.ts:87-95, contact-submit.svelte:22);
    • el botón se apaga de opacity: 1 a opacity: 0.4 — el disabled que decide canSubmit en contact-submit.svelte:21;
    • cursor: progress sobre todo el formulario (src/uix/eidos/components/form/form.css:68-70, disparado por el data-pending que estampa src/uix/soma/components/form/form-provider.svelte.ts:201);
    • aria-busy="true" en form y submit (morfo/components/form.ts:82-87 y :108-115). Un cambio de estado doble (texto + 60 % de atenuación) es exactamente la afordancia canónica para una espera sub-segundo; no es "sin canal propio".
  3. Los 900 ms no son del block. Salen de web/routes/blocks/contact/ContactSite.svelte:35-39, una latencia fabricada por la demo (uix.timers.schedule(null, 900, …)). El block no define ventana alguna: dura lo que dure el onSend de la app.

  4. Y el block no está cerrado a un spinner: contact-submit.svelte:22 entrega el estado al llamante ({@render children({ state })}), que es justo lo que un block puede hacer bajo contrato B — sin morfo y sin .css no puede declarar un sustain ni pintar un indicador; eso sería canon.

Residuo honesto, que NO sostiene esta reclamación pero merecería hallazgo propio: al deshabilitarse el botón el foco cae a BODY durante toda la ventana y la región aria-live sigue vacía, de modo que el canal de etiqueta es sólo visual. Eso es un argumento de accesibilidad que la reclamación nunca hace (ella misma lo llama "cambio de etiqueta visible"), y su arreglo viviría en el canon (Form.Submit), no aquí.

Evidencia — Playwright headless sobre http://127.0.0.1:5201/blocks/contact/preview?challenge=off (script en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-72.mjs y probe-A-72-focus.mjs).

Protocolo: puerta de hidratación esperando document.getElementById('uix-blocks-display'); después ida y vuelta reactiva real (tecleo 'J' → value="J", :placeholder-shown=false, [data-field]:focus-within=true); espera a 0 [data-animation-pending]; visibilityState="visible". Los tres campos rellenados con page.keyboard.type() (no fill). Sondas de sema: (a) AudioContext.prototype.createOscillator/createBufferSource parcheados vía addInitScript ANTES de cargar; (b) MutationObserver atado a los NODOS [data-form-submit] y [data-form] más un observador de documento filtrando data-event*.

Muestreo cada ~40 ms, 23 muestras dentro de la ventana (t=46 ms → t=932 ms):

  • etiqueta: "Enviar mensaje" (pre) → "Enviando…" (toda la ventana) → "Enviado" (t=963 ms).
  • disabled: false → true; opacity computada del submit: 1 → 0.4.
  • data-pending="" presente en [data-form] Y [data-form-submit]; aria-busy: "false"→"true" (submit) y ausente→"true" (form).
  • cursor computado del form: auto → progress → auto.
  • data-event* en el documento: 0 en las 23 muestras. Primera y única aparición a t=963 ms, ya fuera de la ventana: announce-pulse-fulfill sobre FORM (el commit-submit con sequence:'post').
  • spinners ([data-button-spinner],[data-spinner],[data-progress],[role=progressbar],[role=status]): 0 en todas las muestras.
  • document.getAnimations(): 6 en t=46-160 (transiciones de color de los campos al deshabilitarse), 0 desde t=207 hasta t=932.
  • WebAudio en toda la sesión: 1 AudioContext, 2 osciladores, 0 buffer sources — ninguno dentro de la ventana.

Sonda de foco (Enter con el submit enfocado): activeElement = BUTTON/submit antes; BODY desde t=102 ms y durante los 900 ms; única región viva del documento = aria-live="assertive" con texto vacío en todas las muestras. 0 errores de página.

A-73 — DATO · content-section · percepcion · BAJA

Mecanismo — El hecho es cierto y medido, y no hay defecto detrás de él: content-section es un block editorial de sólo lectura por construcción, y su composición no incluye ni un solo control.

Cadena: src/uix/blocks/content-section/content-section.svelte:69-104 monta <section aria-labelledby> → Section → Grid, y dentro sólo Box + Motion + Stack + Heading + Text (líneas 82-98) y {@render children?.()} (103). content-section-body.svelte:24-28 es Box + Prose (el HTML lo pone la app). content-section-media.svelte:37-56 es Box + Motion + <figure> / <figcaption> + Text, y su propio comentario de cabecera (líneas 8-9) ya lo declara: «Neither element is interactive, so this is document structure — the one a11y surface a block owns (B-8) — not the admission rule firing». No hay ningún semanticEvent / data-event / morfo en todo el directorio (grep de sema|semantic|data-event|trigger( sobre src/uix/blocks/content-section/ → 0 aciertos de código; sólo prosa de comentarios).

La demo que ancla la reclamación (web/routes/blocks/content-section/ContentSectionSite.svelte:68-124) compone Badge (soft), Callout (role="note", sin descarte — src/uix/eidos/components/callout/callout.svelte:69 sólo estampa role), AspectRatio + Surface, y dos bloques de HTML crudo (líneas 36-65) que deliberadamente no llevan ningún <a>. Prose no añade tabindex a nada (grep sobre src/uix/eidos/components/prose/ → 0).

Por qué NO es defecto: lo que sema estampa son eventos de interacción; sin interacción no hay nada que estampar, y la superficie de accesibilidad que sí le toca a un block —la estructura del documento— está presente y correcta (<section aria-labelledby> apuntando al Heading con id en :88, <figure>+<figcaption> reales en :39-54, role="note" del Callout). La ausencia de emisión aquí es el resultado esperado del contrato B, no una omisión. Si algún día la app mete enlaces en su HTML, la interactividad la aporta la app, no el block. Confianza alta: la sonda mide exactamente el hecho reclamado, no un proxy.

Evidencia — Sonda Playwright headless (Chromium, 1280x900) en http://127.0.0.1:5201/blocks/content-section/preview, script en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-73.mjs.

Puerta de hidratación cumplida: <style id="uix-blocks-display"> presente, 57 caracteres; [data-animation-pending] = 0 (pestaña activa, rAF corriendo). Ida y vuelta reactivo real: navegación de cliente a ?figure=full sin recarga (sameDoc: true) y el <figure> pasó de 528px a 1264px de ancho medido — el runtime de Svelte estaba vivo cuando medí.

Inventario dentro de la región del block (section[aria-labelledby="s1-title"]) y en TODO el documento, con el selector focusable completo (a[href], button, input, select, textarea, summary, iframe, object, embed, [contenteditable], [tabindex], audio/video[controls]):

  • focusables en la región: 0
  • focusables en el documento entero: 0
  • <a>: 0 · <button>: 0 · inputs/select/textarea: 0
  • elementos con rol interactivo (button/link/checkbox/radio/switch/tab/menuitem/option/slider/textbox/combobox): 0
  • estructura sí presente: <figure>: 1 · [role="note"]: 1 · 1761 caracteres de texto.

Teclado real: 12 pulsaciones de Tab, cada una seguida de Enter y Space. El foco se quedó en body las 12 veces — nunca entró en ningún elemento.

Sema, con las DOS sondas exigidas y ambas armadas ANTES de interactuar: (a) contador WebAudio parcheado en AudioContext.prototype.createOscillator / createBufferSource (y webkitAudioContext) vía addInitScript: osc = 0, bufferSource = 0 tras 7 clics (h2, [role=note], figure, figcaption, p, li, blockquote) y el barrido de teclado. Nota incidental: new AudioContext() sí se construyó 1 vez durante el arranque, pero no produjo ningún nodo — ningún sonido salió, y eso queda fuera de esta reclamación. (b) MutationObserver ligado AL NODO section[aria-labelledby] (subtree, attributeOldValue) más un segundo observer sobre document.documentElement: 0 mutaciones de data-event* / data-sema* / data-state. Atributos data-event* estáticos en el DOM: 0.

Contraste: en la galería /blocks/content-section hay 47 focusables, pero son los controles del harness de la demo (la página renderiza el block inline, hasIframe: false); ninguno pertenece a la región del block.

A-74 — CONFIRMADO · feature-split · percepcion · BAJA

Mecanismo — Las dos patas de la reclamación se sostienen medidas por separado.

(1) Emisión correcta — verificada, no supuesta. web/routes/blocks/feature-split/FeatureSplitSite.svelte:112-120 compone FeatureSplit.Actions (que es un Group, src/uix/blocks/feature-split/feature-split-actions.svelte:9) con un Button en modo child, que rinde un <a> con data-archetype=trigger. Al activarlo (ratón y Enter) el runtime estampa data-event=contact-activate / data-event-family=contact / data-event-phase=active y los limpia al cerrar la ventana de hold, y el canal de sonido crea 2 osciladores por activación. El arco llega íntegro hasta «te he sentido».

(2) Destino inexistente — el href se deriva de la prosa. FeatureSplitSite.svelte:116 escribe <a href="#{row.eyebrow.toLowerCase()}"> sobre los eyebrows declarados en FeatureSplitSite.svelte:33,42,51. Eso produce #analítica, #en el bolsillo y #automatizaciones. Ningún nodo del documento lleva esos id ni ese name: los únicos id del documento son los de infraestructura (uix-eidos-static, uix-eidos-theme, uix-blocks-display, uix-button-s1..s3, svelte-announcer). El bloque tampoco los podría llevar: FeatureSplitRowProps (src/uix/blocks/feature-split/types.ts:27-37) es un tipo limpio SIN HTMLAttributes y feature-split-row.svelte:15 no recoge ...rest, así que la app no puede anclar una fila ni queriendo.

Intenté refutarlo por tres vías y ninguna aguanta:

  • «Es el placeholder de siempre»: hay 23 href="#…" muertos en 10 demos de blocks, o sea convención de corpus. Pero #en el bolsillo no es un placeholder redactado: es copy de pantalla, con espacios, volcado mecánicamente a una URL. Ningún id de ninguna app va a ser jamás «en el bolsillo», así que la lectura «ya lo sustituirá la app» no aplica a esta expresión.
  • «Sí ocurre algo»: ocurre, y es peor callado — history.length sube 2→3 por clic y salta hashchange. Invisible para el usuario y deja entradas muertas en el botón «atrás».
  • «No es de este block»: el href está en el fichero que el auditor ancló, así que la atribución es correcta; lo del Row sin id es un agravante adicional del block.

Severidad BAJA es la justa: es el único cierre perceptivo que le falta a un block por lo demás presentacional.

Evidencia — Sonda Playwright headless (…/scratchpad/probe-A-74.mjs) sobre http://127.0.0.1:5201/blocks/feature-split/preview, Chromium 1280x900.

Puertas: <style id="uix-blocks-display"> presente antes de medir. [data-animation-pending] = 6 tras la carga (las filas son Motion trigger="viewport", feature-split-row.svelte:26,39); tras barrer la página con rueda real bajan a 0. Ida y vuelta reactivo: Tab real deja document.activeElement = A[href="#analítica"] con :focus-visible verdadero y los atributos que sólo estampa el cliente (data-button, data-archetype=trigger, id=uix-button-s1).

Inventario: exactamente 3 elementos interactivos, los 3 <a> de CTA. Resolución de fragmento por elemento (getElementById(decodeURIComponent(...)) y [name=...]):

  • «Ver la analítica» → raw #analítica → #anal%C3%ADtica → targetExists=false, namedTarget=false
  • «Configurar alertas» → raw #en el bolsillo → #en%20el%20bolsillo → false / false
  • «Crear una regla» → raw #automatizaciones → false / false id presentes en todo el documento: uix-eidos-static, uix-eidos-theme, uix-blocks-display, uix-button-s1, uix-button-s2, uix-button-s3, svelte-announcer.

Sonda (a) WebAudio: createOscillator/createBufferSource parcheados en addInitScript ANTES de cargar. osc 0 → 2 tras el clic de ratón → 4 tras el Enter (2 por activación); buf 0. Sonda (b) MutationObserver atado AL NODO (más un segundo observer de árbol): por activación registra data-event=contact-activate, data-event-id=sig-0 (sig-1 en la segunda), data-event-phase=active, data-event-family=contact, y luego los cuatro a null. Sobrevive al cierre del hold.

Consecuencia medida: clic de ratón → url …/preview → …/preview#anal%C3%ADtica; scrollY 0 → 0 (sin desplazamiento); history.length 2 → 3; hashchange disparado; document.activeElement sigue siendo el propio <a>, no un destino. Enter en el segundo CTA → url #anal%C3%ADtica → #en%20el%20bolsillo; scrollY 5 → 5; segundo hashchange. Cero errores y cero avisos de consola.

Alcance del corpus: grep 'href="#' en web/routes/blocks/ da 23 aciertos en 10 demos (banner, contact, cta, faq, feature-split, hero, newsletter, pricing, site-footer, site-header). Sólo medí la resolución de fragmentos en feature-split; en los otros nueve no la he comprobado.

Disposición — Se arregla en DOS sitios, y el principal es la demo.

  1. DEMO (web/routes/blocks/feature-split/FeatureSplitSite.svelte:116) — es donde vive el defecto. Dejar de derivar el fragmento de la prosa: row.eyebrow.toLowerCase() genera #en el bolsillo, con espacios, para cualquier eyebrow de dos palabras. Dos salidas honradas: (a) darle destino real dentro del propio documento — un id por fila, o apuntar las tres al suelo de página de FeatureSplitSite.svelte:126-136, que ya existe y no tiene id; o (b) si se decide que el CTA de una sección de marketing no tiene a dónde ir en una demo, poner un href que lo declare y añadir un slug estable en el array de rows (FeatureSplitSite.svelte:32-57), nunca la copy en minúsculas.

  2. BLOCK / CANON (src/uix/blocks/feature-split/types.ts:27-37 + feature-split-row.svelte:15) — hoy la opción (a) es IMPOSIBLE: FeatureSplitRowProps no extiende HTMLAttributes y Row no recoge ...rest, así que la app no puede poner id (ni aria-label/aria-labelledby) en una fila. El README justifica el tipo limpio por la colisión del snippet media con el atributo HTML homónimo, lo cual no obliga a cerrar id: bastaría admitirlo explícito. Decisión de diseño del usuario, no la tomo yo.

  3. CORPUS — la misma expresión aparece en site-footer/SiteFooterSite.svelte:219 (href="#{label}", también copy cruda). Si se toca esto, mirar los 23 href="#…" de web/routes/blocks/ de una pasada en vez de por block.

A-75 — CONFIRMADO · pricing · percepcion · BAJA

Mecanismo — El defecto es real y está en la demo, no en el block.

web/routes/blocks/pricing/PricingSite.svelte:29-46 — choose(plan, event) no tiene guarda de idempotencia. En CADA clic asigna chosen = plan (línea 31) y, tras await tick(), emite incondicionalmente uix.events?.emit({ name:'commit-select', family:'commit', intent:'affirm' }) (39-45). Si el plan YA era el elegido, la asignación no cambia nada — el rótulo de la línea 144 (chosen === plan.name ? 'Plan elegido' : plan.cta) no se recalcula — pero la ceremonia semántica se dispara entera igual: estampa visual data-event/-family/-intent durante el hold + grafo de sonido, idénticos al commit verdadero.

Por qué eso es defecto y no criterio: el canon hace exactamente lo contrario en TODOS sus selectores de valor único, y cortocircuita la re-selección redundante ANTES de emitir. src/uix/soma/components/radio-group/radio-group-provider.svelte.ts:136 → if (this.opts.value.current === value) return; (el runtime.trigger('commit-select') está en la 144, detrás de la guarda). Igual en tabs/tabs-provider.svelte.ts:110, navigation-menu/navigation-menu-provider.svelte.ts:143 y :190, menubar/menubar-provider.svelte.ts:130 (if (prev === value) return;) y listbox/listbox-provider.svelte.ts:189 (if (next !== current) { … emit … }). Cinco componentes, la misma decisión: una re-selección redundante NO es una elección activa y no merece un commit. La demo premia un no-op con el mismo commit+affirm que el canon suprime a propósito, y el commit es justo la familia que promete que algo quedó fijado.

Matiz que consideré como refutación y NO se sostiene: sí existe una diferencia de ESTADO (el botón ya dice «Plan elegido»), y la primera pulsación arrastra una mutación extra que la segunda no tiene. Pero eso distingue los dos estados, no las dos pulsaciones: el feedback del gesto es indistinguible, y es el evento —no el rótulo— el que miente sobre la consecuencia. Mi defensa inicial («affirm = confirmación suave de una elección activa», docs/CANON.md:100 y :122, así que reafirmar no es mentir) cae contra la práctica uniforme del propio canon.

Confianza alta: medido en navegador, no inferido. Salvedad honesta: conté nodos WebAudio creados (sonda sancionada), no medí la ganancia, así que no afirmo que suene — sí que el grafo se construye idéntico. La estampa visual data-event*, que es la que consume eidos, sí es idéntica con certeza.

Evidencia — URL medida: http://127.0.0.1:5201/blocks/pricing/preview (servidor ya arrancado, 127.0.0.1). Playwright headless desde el scratchpad: probe-A-75.mjs / probe-A-75b.mjs / probe-A-75c.mjs.

Puertas cumplidas: (1) esperado style#uix-blocks-display (lo escribe el $effect de cliente, no el SSR); (2) esperado a 0 nodos [data-animation-pending]; (3) ida y vuelta reactiva REAL antes de medir — clic en el switch de facturación: precios 0/29/99 € → Anual 0/23/79 € → Mensual 0/29/99 €. Hidratación probada, no supuesta.

Sondas de emisión, las dos: (a) contador parcheando AudioContext.prototype.createOscillator/createBufferSource en addInitScript, ANTES de cualquier script de página; (b) MutationObserver ligado AL NODO concreto del botón Pro (no a un selector), con attributeOldValue + characterData.

Números (4 pulsaciones, ids sig-N normalizados):

  • Pulsación 1 (Pro, primera elección): 15 mutaciones, 4 osciladores. Secuencia: data-event: null→contact-activate (sig-2) → characterData "Plan elegido" → data-event: contact-activate→commit-select (sig-3), family: contact→commit, intent: null→affirm → limpieza a null.
  • Pulsación 2 (Pro YA elegido): 14 mutaciones, 4 osciladores. Misma secuencia EXACTA sin el characterData.
  • Pulsación 3 (Pro ya elegido otra vez): 14 mutaciones, 4 osciladores. p2 === p3: true (byte a byte tras normalizar ids).
  • p1 menos p2 = un solo elemento: PRO: characterData text="Plan elegido". Nada más distingue la elección real de la redundante.
  • Pulsación 4 (cambiar a Starter): 16 mutaciones — revierte el rótulo de Pro y estampa el commit en Starter. La discriminación por rótulo sí funciona cuando hay cambio real.

Estado visual del plan elegido vs no elegido (getComputedStyle sobre los nodos reales): el rótulo es el ÚNICO discriminador persistente. aria-pressed: null, aria-current: null, data-state: null, disabled: false, cursor: pointer, data-color/data-variant sin cambio en ambos. Las diferencias de fondo que vi (oklch(0.6406 …) → oklch(0.6115 …)) son la capa de hover con el ratón aparcado tras el clic, no un token de estado elegido.

Contraprueba de canon intentada en vivo: el ToggleGroup de esta misma página resultó deseleccionable (la pulsación «redundante» lo apagó, data-state: on→off), así que no sirve de análogo idempotente; la práctica del canon la verifiqué entonces en fuente (radio-group:136, tabs:110, navigation-menu:143/190, menubar:130, listbox:189).

Observación adyacente medida, fuera del alcance de A-75 y que no computo aquí: el commit-select pisa el contact-activate aún en phase: active sobre el mismo nodo (entradas 4-9 del log de la pulsación 1), pese a que el comentario de PricingSite.svelte:33-37 afirma que el await tick() evita esa colisión.

No es ninguno de los falsos positivos conocidos (F15-F22, medir-antes-de-hidratar, pestaña en segundo plano).

Disposición — DEMO — web/routes/blocks/pricing/PricingSite.svelte:29-46. El block está limpio: src/uix/blocks/pricing/pricing-plan-action.svelte es un Box pelado que no emite nada, y por doctrina un block no tiene morfo ni inventa el resultado de la acción de la app (lo dice el propio comentario en :24-26). Arreglo mínimo, copiando la guarda que el canon ya usa: if (chosen === plan) return; como primera línea de choose(), espejo de radio-group-provider.svelte.ts:136 y tabs-provider.svelte.ts:110. Si se decide que re-pulsar SÍ debe responder algo (en una app real el CTA llevaría a checkout otra vez), la alternativa canónica no es callar sino degradar el intent: la matriz de deshacer de docs/CANON.md:118-128 gradúa como neutral lo que no es progreso. Cualquiera de las dos es decisión del usuario, no mía. Severidad BAJA declarada: correcta.

A-76 — DATO · testimonials · percepcion · BAJA

Mecanismo — El hecho es cierto en las dos mitades y no hay defecto detrás.

(1) Cero afordancia interactiva por construcción. src/uix/blocks/testimonials/testimonials.svelte:19-27 compone Section·Container·Stack; testimonials-items.svelte:11-21 un AutoGrid; testimonials-item.svelte:23-29 un Motion trigger="viewport" sobre una Card; testimonials-quote.svelte:9, testimonials-author.svelte:15-20, y los Author*Name/Role son Text/Group. Ni un Button, ni un a[href], ni un role/tabindex. Nada que pulsar ⇒ nada que pueda quedarse mudo: el silencio no es una omisión de sema, es la ausencia de emisor.

(2) La Card NO finge afordancia. En src/uix/eidos/components/card/card.css TODA la sección «Interactive treatment» está cerrada tras [data-card][data-interactive]: cursor:pointer + user-select:none (línea 172), el translateY+box-shadow de hover (línea 182), el background de hover para variant='outline' (línea 191), el scale de :active (línea 201) y el outline de :focus-visible (línea 206). testimonials-item.svelte:12-18 sólo fija variant='outline', color='neutral', size='lg': nunca estampa interactive, así que ninguna de esas reglas casa. La transition de la línea 64-68 sigue declarada, pero no hay regla que cambie ninguna de las cuatro propiedades ⇒ es una transición sin disparador, no una afordancia.

Único movimiento del block es presencia (entrada viewport escalonada vía data-stagger en testimonials-items.svelte:18), que ocurre una vez al entrar en pantalla y no responde al puntero: no promete pulsación.

Nota de atribución (no altera el veredicto): el demo declara la brecha del carrusel como diferida en web/routes/blocks/testimonials/+page.svelte:92-95, o sea que la ausencia de interacción es decisión documentada, no olvido.

Confianza alta: medido, no inferido.

Evidencia — Sonda Playwright headless sobre http://127.0.0.1:5201/blocks/testimonials/preview (script en C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/65ab52b0-378f-44bf-85bf-2c9250e2882d/scratchpad/probe-A-76.mjs), viewport 1280x900, tema base-light.

PUERTA DE HIDRATACIÓN cumplida antes de medir: style#uix-blocks-display presente; scroll completo de la página; [data-animation-pending] = 0; y prueba de ida y vuelta del cliente — al no haber ningún input en el block, la verifiqué por la escritura que sólo hace el cliente: los 6 envoltorios Motion acabaron con data-animation-trigger=viewport data-animation-style=fade data-state=open (el IntersectionObserver corrió). 0 errores de consola.

INVENTARIO del <section> del block: 59 nodos, 1 h2, 5 [data-card]. Selector amplio (button, a[href], a, input, select, textarea, summary, details, [role=button|link|tab|checkbox|switch|menuitem|option], [tabindex], [contenteditable], [data-interactive], label) ⇒ 0 coincidencias. 0 atributos on* inline. En el SSR crudo, también 0.

FOCO REAL (teclado, no sintético): censo de focusables en TODO el documento = 0; 6 pulsaciones reales de Tab dejan document.activeElement en BODY las 6 veces. No hay parada de tabulación que pudiera quedar muda.

CARDS: las 5 con data-variant=outline, data-interactive ausente, tabindex=null, role=null, cursor: auto (no pointer), user-select: auto, pointer-events: auto.

HOVER (afordancia fingida): computado antes vs. después de hover() + 500 ms sobre la primera Card — transform: none → none, box-shadow: none → none, background: oklch(0.9911 0 0) → oklch(0.9911 0 0), border-top-color: oklch(0.8514 0 0) → oklch(0.8514 0 0), cursor: auto → auto. Diferencia: ninguna. La cita interior: SPAN con cursor: auto.

EMISIÓN DE SEMA, las DOS sondas exigidas: (a) contador WebAudio parcheado en AudioContext.prototype.createOscillator/createBufferSource vía addInitScript ANTES de cargar la página ⇒ tras click + Enter + Space sobre la Card: osc=0, buf=0; (b) MutationObserver atado AL NODO concreto de la Card (attributes + subtree + oldValue), que sobreviviría a su desmontaje ⇒ 0 mutaciones. Barrido de todo el documento por atributos data-event*: 0 nodos. Coherente: sin emisor no hay emisión, y no hay ningún gesto que quedara sin respuesta.

Disposición — Nada que arreglar: no es defecto ni en block ni en canon. Si algún día el block gana la variante carrusel diferida en web/routes/blocks/testimonials/+page.svelte:92-95, ese día sí habrá controles que auditar por emisión.

A-77 — DATO · stats-band · percepcion · BAJA

Mecanismo — El hecho es cierto y no constituye defecto — es exactamente el caso «no hay elementos interactivos, luego no emite nada». Cadena: src/uix/blocks/stats-band/stats-band.svelte:26-47 compone <section> → Section → Container → AutoGrid data-stagger, todos cajas; stats-band-stat.svelte:18-22 envuelve un Motion trigger="viewport" sobre Metrics (div, metrics.svelte:68-77); stats-band-value.svelte:20-26 rinde Metrics.Value (div, metrics-value.svelte:35) con CountUp dentro (count-up.svelte:199-207, <span data-count-up>); stats-band-label.svelte:10-12 un div. Ninguna pieza declara href, onclick, tabindex ni rol interactivo. El ÚNICO emisor de sema alcanzable desde el árbol es el puente live del canon Metrics (metrics.svelte:57-63 → runtime.trigger('signal-notify-update')), doblemente cerrado: live = false por defecto (metrics.svelte:30) y stats-band-value.svelte:20 pasa value={count} constante, de modo que el $effect comparador de metrics-value.svelte:21-32 nunca ve un cambio y jamás llama a notifyUpdate. Por tanto no existe ningún trigger que la cascada de sema pueda estampar: la ausencia de data-event no es un fallo de estampado, es la consecuencia de que no hay evento. Y la consecuencia perceptiva que quedaría por cubrir sí está cubierta: la superficie del block es la ENTRADA (índice estructural data-stagger + Motion viewport + CountUp), y la medí funcionando. Confianza alta: leído y medido. Matiz sin efecto sobre el veredicto: StatsBandStatProps = MetricsProps (types.ts:28), así que una app PUEDE pasar live y entonces sí habría emisión — es superficie del canon abierta a la app, no del block tal como se compone y se demuestra.

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 — 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.

Evidencia — src/uix/blocks/stats-band/stats-band.svelte:10,26,47 y el grep de landmark = 0 en su README.

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.

Mecanismo — El selector del pie (web/routes/blocks/site-footer/SiteFooterSite.svelte:156-192) es un Select canónico cuyo onValueChange (líneas 162-166) escribe uix.prefs.setIntent('language', …). El morfo de Select (src/uix/morfo/components/select.ts:15-24) declara para el ítem commit-select con family:'commit', intent:'affirm', y nada más: la elección de un ítem es lo único que el componente sabe. La CONSECUENCIA — que cambia el régimen de la app entera — es app-land, y la demo no la enuncia: no hay ninguna emisión adicional. Medido en navegador: al elegir «English» se estampan exactamente emerge-open (trigger), commit-select+affirm (ítem) y emerge-close; cero eventos de familia shift. Y el régimen SÍ cambia: <html lang> pasa es→en y el texto del framework se retraduce en vivo. Que eso es un defecto y no una opinión lo fija el propio corpus: (1) docs/CANON.md:77 da a shift la pregunta «¿cambió el contexto / régimen?», su verbo context (línea 196) y la regla de composición «shift must orient the context change (focus + title + landmark)» (línea 250); (2) src/uix/morfo/components/field-langs.ts:39-54 y su comentario de Menu (líneas 141-145) declaran que cambiar de idioma es shift-navigate y RECHAZAN explícitamente las semánticas de commit del Select para ese gesto («no Select commit semantics leak into the field»), apilando ambos niveles cuando el commit del control también ocurre; (3) el block hermano web/routes/blocks/pricing/PricingSite.svelte:21-45 documenta el patrón exacto que falta aquí — «lo que NINGUNA referencia puede expresar es la CONSECUENCIA, así que el app emite» vía uix.events?.emit({...}) tras un tick() para no colisionar con la proyección del contacto. La demo tiene la API, el patrón y un precedente en su misma galería, y aun así el cambio de régimen suena y se estampa como elegir un topping de pizza. Matiz que NO refuta: la reclamación menciona «dirección», y con este catálogo (es/en/fr/de, todos LTR) dir no se mueve — la dimensión direction sí deriva de language (src/arts/prefs/dimensions/direction.ts:30), así que con un idioma RTL voltearía; el defecto se sostiene solo con lang + langs. Tampoco se sostiene la salida «emitir shift sería inerte»: SEMA_MAP.families.shift (src/uix/sema/sema-map.ts:171-186) tiene firma de sonido propia (pitch 500, decay 220, duration 160), distinta de la del commit, y esta galería arranca con sound: true (web/routes/blocks/_lib/BootUix.svelte:63).

Evidencia — Playwright headless sobre http://127.0.0.1:5201/blocks/site-footer/preview (scripts en …/scratchpad/probe-A-79.mjs y probe-A-79b.mjs). Puerta de hidratación: espera a style#uix-blocks-display + a que no queden [data-animation-pending]; ida y vuelta reactivo real con page.keyboard.type() (valor del input = "no-es-un-correo") y aria-expanded del trigger false→true al abrir. Sondas de sema instaladas ANTES de interactuar: (a) contador WebAudio parcheando AudioContext.prototype.createOscillator/createBufferSource → 6 osciladores, 0 buffers en la secuencia abrir+elegir (o sea: suena); (b) MutationObserver ligado AL NODO del ítem [data-select-item][data-value="en"] (sobrevive al desmontaje del portal) + observador de documento sobre data-event*. Estampas capturadas, completas: trigger emerge-open/emerge/—; ítem commit-select/commit/affirm/phase=active; trigger emerge-close/emerge/—. Ninguna con data-event-family="shift". Régimen antes/después: <html lang> "es"→"en", dir "ltr"→"ltr"; diff del innerText del body = 2 líneas de 36, ambas del framework: «Debe ser una dirección de correo válida»→«Must be a valid email address» y la etiqueta del trigger «Español»→«English». Orientación tras el cambio: activeElement sigue siendo el mismo button[data-select-trigger], document.title vacío, sin ningún otro movimiento — no se cumple la regla de orientación de CANON §8.5. No es ninguno de los falsos positivos F15–F22 (el F22 «un Select controlado muestra el valor crudo» convive en este mismo fichero, anotado en las líneas 169-175, pero es otra reclamación).

Disposición — Se arregla en la DEMO (app-land), no en el canon: Select hace bien en emitir commit-select+affirm — no puede saber que esta lista es el eje de idioma de la app. En web/routes/blocks/site-footer/SiteFooterSite.svelte:162-166, tras el setIntent, emitir el nivel superior con el patrón ya documentado en web/routes/blocks/pricing/PricingSite.svelte:38-45: await tick() y luego uix.events?.emit({ target, name: 'shift-context', family: 'shift', message: language }) (verbo context de docs/CANON.md:196; shift no lleva intent). El tick() no es cosmético: evita que la proyección del commit-select del ítem y la del shift se pisen en el mismo turno. Si además se quiere cumplir CANON §8.5 («shift must orient»), la demo debería orientar el cambio — devolver el foco al trigger ya reetiquetado basta como mínimo, y sería el sitio natural para un anuncio en vivo. Nota para Fase 5: blocks:check no puede ver esto (el block no tiene morfo); si se quiere guardia, la regla es «demo que escribe en uix.prefs debe emitir un shift».

A-80 — ARREGLADO · faq · sin dim · —

Mecanismo — Faq.Item deja de reimplementar la autogeneración del value; la hace el canon. README, tipo y demo dejan de atribuírsela.

Evidencia — Verificado en navegador tras el cambio: 5 ítems, abre y cierra, aria-expanded false→true y contenido data-state=open.

A-81 — ARREGLADO · cta · sin dim · —

Mecanismo — El ejemplo de cta/index.ts deja de anunciar el variant que se cortó, y explica por qué no existe.

Evidencia — cta/index.ts.

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.

Evidencia — web/routes/blocks/cta/+page.svelte:15,157, CtaSite.svelte:18, preview/+page.svelte:10.

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 · —

Mecanismo — Ver A-02: el estado «la copia va sobre sólido» deja de aplicarse pieza a pieza y pasa a cubrir el racimo entero.

Evidencia — Ver A-02.

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.

Evidencia — src/uix/blocks/feature-grid/types.ts:22-26, feature-grid.svelte:19,30, web/routes/blocks/feature-grid/FeatureGridSite.svelte:42,45.

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 — 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.

Cadena exacta:

  1. La demo escribe el atributo DESPUÉS del spread (<Button {...props} … aria-label="Abrir navegación">), así que gana en el objeto de props: eidos Button lo pasa en headlessProps (src/uix/eidos/components/button/button.svelte:42,113), soma Button lo desestructura (src/uix/soma/components/button/components/button.svelte:21,42) y lo alimenta al morfo (src/uix/morfo/components/button.ts:164-169, condicional prop-truthy).
  2. El MISMO nodo <button> está registrado como parte de DOS morfos con syncAttrs: true: ButtonProvider (src/uix/soma/components/button/button-provider.svelte.ts:111-116) y DrawerTriggerProvider (src/uix/soma/components/drawer/drawer-provider.svelte.ts:412-420).
  3. syncPartAttrs (src/uix/soma/runtime.svelte.ts:502-521) escribe los atributos compilados de cada parte de forma imperativa con dom.apply dentro de un $effect. No hay fusión: gana el último que escribe.
  4. El morfo del trigger del cajón declara aria-label INCONDICIONAL con v.translationRef('#?components.drawer.trigger|Open drawer') (src/uix/morfo/components/drawer.ts:181-185, espejado igual de incondicional en drawer-provider.svelte.ts:444). Su efecto corre después del del Button y sobrescribe con la traducción genérica.

Resultado: el nombre accesible final es «Abrir cajón», la etiqueta genérica del canon. Y no es esquivable desde el sitio de llamada: poner el aria-label en Drawer.Trigger tampoco sirve, porque drawer-trigger.svelte:26 hace mergeProps(restProps, state.props) y state.props (segundo argumento) gana también. El consumidor NO tiene ninguna vía para nombrar ese botón.

Evidencia — URL medida: http://127.0.0.1:5201/blocks/site-header/preview (viewport 390x844, Chromium/Playwright headless). Puerta de hidratación: espera a document.getElementById('uix-blocks-display') (efecto de cliente, ausente en SSR) + espera a 0 [data-animation-pending] + ida y vuelta reactiva real (clic → data-state pasa de closed a open).

  1. SSR crudo (fetch sin JS) del <button data-drawer-trigger>: SIN aria-label (ni type, ni aria-controls, ni role) — prueba de que esos atributos los estampa syncAttrs en el cliente.

  2. MutationObserver instalado ANTES de la hidratación (addInitScript, attributeFilter:['aria-label'], attributeOldValue) sobre el árbol, filtrando por el nodo del trigger. Log completo, 2 escrituras en el mismo tick (t≈3450 ms):

    • {old: null → "Abrir navegación"}
    • {old: "Abrir navegación" → "Abrir cajón"} Es decir: el Button escribe la etiqueta de la demo y el Drawer la pisa inmediatamente. Tras interactuar (clic) hay una tercera escritura re-afirmando «Abrir cajón».
  3. Atributo final en el DOM (cerrado y abierto): aria-label="Abrir cajón"; el botón no tiene texto visible (textContent vacío, sólo Icon.Menu), así que el aria-label ES el nombre.

  4. Cómputo REAL del nombre accesible vía CDP Accessibility.getPartialAXTree: AX role: button | AX name: "Abrir cajón" | ignored: false, fuente attribute aria-label "Abrir cajón". Recuento por rol+nombre: getByRole('button', {name:'Abrir navegación'}) = 0; getByRole('button', {name:'Abrir cajón'}) = 1.

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

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.

  2. src/uix/soma/runtime.svelte.ts:502-521: el caso general —dos partes de morfos distintos con syncAttrs: true sobre el MISMO nodo (composición child/asChild)— es un pisotón silencioso de último-escritor-gana. Merece al menos un aviso en dev cuando dos partes reclaman el mismo atributo del mismo nodo con valores distintos; hoy no se detecta ni en blocks:check ni en el lint de eidos.

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 — 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.

Evidencia — src/uix/blocks/pricing/types.ts:34-41 y src/uix/blocks/pricing/index.ts:11.

Disposición — Corregir el ejemplo. Fase 3, junto a A-39 y A-81.

A-87 — ARREGLADO · contact · sin dim · —

Mecanismo — Duplicado de A-44, cerrado con él.

Evidencia — Ver A-44.

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.

Evidencia — Playwright headless (script en …/scratchpad/probe-A-88.mjs y probe-A-88b.mjs), Chromium 1280x900, sobre http://127.0.0.1:5201/blocks/team/preview?align=center&columns=4&bio=true. Puerta de hidratación: esperado #uix-blocks-display (presente), scroll completo y 0 nodos [data-animation-pending]; 7 [data-animation-trigger=viewport][data-state=open] estampados por el observer del cliente (prueba de que el JS corrió, no medí el SSR estático). TAL COMO SE SIRVE (bio=true): retícula align-items: stretch, columnas 224px x4. Celdas = div del Motion con display: block. Fila 1: Ada cellH=288 / stackH=287.8; Grace, Alan, Katherine cellH=288 / stackH=267.5 → 20.3px sin absorber. getComputedStyle([data-group]).marginTop = 0px en las SEIS tarjetas, pese a --box-margin-top: auto. linksTop fila 1 = [462, 442, 442, 442] → desviación 20px; fila 2 = [741, 762] → 21px. Captura A-88-shipped2.png: los iconos de Ada y de Barbara caen visiblemente más bajos que los de sus vecinas. CONTRAFACTUAL A (div del Motion a display:flex; flex-direction:column + Stack con flex:1 1 auto): marginTop pasa a 20.30px y linksTop = [462,462,462,462] y [762,762] → desviación 0px. CONTRAFACTUAL B (sólo [data-stack] { height: 100% } dentro de la celda): mismo resultado, desviación 0px (captura A-88-fixed.png). LÍNEA BASE sin biografía: todas las tarjetas 195px, desviación 0px — es decir, la regla efectivamente «no cambia nada» ahí, que es lo único que el README acierta.

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 · —

Mecanismo — Las dos mitades literales son ciertas (el block escribe sent = true y su máquina no tiene arista de vuelta), pero la consecuencia que las convierte en defecto —«la sección ya no puede volver a enviar NUNCA»— es falsa: sent es un prop $bindable público (contact.svelte:43 sent = $bindable(false), types.ts:46), así que el app decide cuándo la sección vuelve a estar disponible, igual que decide cuándo se envió. Que la vuelta sea del producto y no del block es coherente con la doctrina: el block coordina su sección, no gobierna el ciclo de vida del envío.

Evidencia — src/uix/blocks/contact/contact.svelte:43,62,85 y src/uix/blocks/contact/types.ts:46.

Disposición — No se toca el comportamiento. Queda un asunto menor y distinto: el ejemplo de uso no enseña la costura de vuelta (bind:sent) — si se roza el fichero, se añade.

A-90 — ARREGLADO · contact · sin dim · —

Mecanismo — ContactVerification es ahora ProofOfHumanStatus importado del canon, no una copia de las mismas cuatro palabras.

Evidencia — contact/state.ts.

Powered by TurnKey Linux.