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

224 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 CONFIRMADO
A-08 cta doctrina MEDIA C La medida de la descripción se construye a mano con un valor fuera de escala (60ch) en vez de usar el vocabulario de medida que el canon ya expone… src/uix/blocks/cta/cta.svelte ARREGLADO
A-09 stats-band doctrina MEDIA C En modo live el anuncio a lectores de pantalla puede llevar la cifra CRUDA mientras en pantalla se ve la formateada por locale: el block pasa… src/uix/blocks/stats-band/stats-band-value.svelte CONFIRMADO
A-10 site-header doctrina MEDIA C Si el cajón está abierto y el viewport cruza el breakpoint, el propio block esconde cajón + overlay + trigger con display:none, pero el Drawer… src/uix/blocks/site-header/site-header.svelte ARREGLADO
A-11 site-header doctrina MEDIA C Las demos de site-header y hero redeclaran a mano uniones que ya existen en el canon, y ya han derivado: el xxl de ContainerSize es inalcanzable… web/routes/blocks/site-header/SiteHeaderSite.svelte CONFIRMADO
A-12 feature-grid doctrina MEDIA C El tipo y la demo describen el chip del icono como Surface soft; el código lo pinta solid por defecto, y el README dice solid. Dos de las cuatro… src/uix/blocks/feature-grid/types.ts ARREGLADO
A-13 testimonials percepcion MEDIA C Mismo defecto: height="100%" sobre Card es inerte, así que la tarjeta no rellena la altura de fila y las caras NO se alinean cuando las citas… src/uix/blocks/testimonials/testimonials-item.svelte ARREGLADO
A-14 feature-split · pricing · testimonials doctrina MEDIA C Ninguno de los tres README declara el landmark ni la jerarquía de encabezados, que es la segunda mitad literal de B-8 y el único apartado a11y propio… src/uix/blocks/pricing/README.md CONFIRMADO
A-15 contact doctrina MEDIA C La frase del estado no llega a la tecnología de apoyo: el submit va disabled nativo (no es alcanzable con teclado, así que un lector de pantalla… src/uix/blocks/contact/contact-reason.svelte ARREGLADO
A-16 contact doctrina MEDIA C Deriva README↔código: el README afirma en negrita que el block «no valida y no pinta los errores de los campos», y Contact.Fields hace exactamente… src/uix/blocks/contact/README.md ARREGLADO
A-17 content-section doctrina MEDIA C La pestaña API de la demo (superficie obligatoria por B-9) publica un default falso para ContentSection.Media: dice width «def. wide» cuando el… web/routes/blocks/content-section/+page.svelte ARREGLADO
A-18 team doctrina MEDIA C Comentario nuevo en castellano dentro de la fuente de un block, contra la regla del proyecto (comentarios en inglés; los castellanos que quedan son… src/uix/blocks/team/team-member-links.svelte ARREGLADO
A-19 faq percepcion BAJA C El typedoc de Faq.Header promete un Box «centered» que el propio root desmiente: la raíz apila con align="stretch", así que la cabecera queda… src/uix/blocks/faq/types.ts CONFIRMADO
A-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 CONFIRMADO
A-26 pricing doctrina BAJA C El block exige a la app tres cadenas obligatorias para nombrar el ÚNICO estado que coordina, y justifica esa exigencia citando una lectura de B-7 que… src/uix/blocks/pricing/pricing-switch.svelte REFUTADO
A-27 testimonials doctrina BAJA C El tipo público documenta un default de variant que el componente no usa. src/uix/blocks/testimonials/types.ts CONFIRMADO
A-28 testimonials doctrina BAJA C .Items tipa toda la superficie de AutoGrid pero descarta en silencio parte de ella: el spread del consumidor va ANTES de props fijadas a mano. src/uix/blocks/testimonials/testimonials-items.svelte CONFIRMADO
A-29 pricing · testimonials percepcion BAJA C Las vistas de 375/768 de estas dos demos no heredan NINGUNO de los ejes de la sección: siempre salen en claro, español y ltr — justo la rama estrecha… web/routes/blocks/pricing/+page.svelte CONFIRMADO
A-30 newsletter doctrina ALTA SV UNA sola pulsación de Enter dentro del campo de correo emite DOS commit-submit con intents contradictorios (neutral y fulfill) y suenan DOS earcons… src/uix/blocks/newsletter/newsletter.svelte:105 CONFIRMADO
A-31 newsletter doctrina ALTA SV signal-warn-invalid está declarado persistence:'untilFix' pero NUNCA se limpia al arreglar: corregido el correo, el form sigue estampado en… src/uix/soma/components/form/form-provider.svelte.ts:129 REFUTADO
A-32 site-header percepcion ALTA SV El pack de sema de NavigationMenu NUNCA casa: su regla de cascada apunta a la parte item y el estampado cae siempre en el trigger, asi que el nav… src/uix/sema/components/navigation-menu.ts ARREGLADO
A-33 site-header percepcion ALTA SV El nav del header emite commit-select + affirm — con earcon audible — por el simple HOVER del puntero, sin un solo clic: un barrido del raton… src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts ARREGLADO
A-34 site-header percepcion ALTA SV Inversion de dominancia atencional: pasar el raton por encima del nav suena mas largo y mas fuerte que pulsar el CTA principal del block, y con… web/routes/blocks/site-header/SiteHeaderSite.svelte ARREGLADO
A-35 site-header percepcion ALTA SV MUDOS: 4 de los 7 elementos interactivos visibles del header no emiten NADA, y son precisamente los que navegan — el brand «Acme», los… web/routes/blocks/site-header/SiteHeaderSite.svelte ARREGLADO
A-36 site-header percepcion ALTA SV Carrera de estampado en el trigger del drawer: contact-activate vive 3 ms antes de que emerge.open lo pise en el MISMO nodo, asi que el feedback… src/uix/blocks/site-header/site-header.svelte CONFIRMADO
A-37 banner doctrina ALTA SV El block cablea el descarte con variant="ghost" y sin tinta, así que la ✕ se pinta SIEMPRE con el ink primary del Button sobre el lienzo que el… src/uix/blocks/banner/banner.svelte ARREGLADO
A-38 banner percepcion ALTA SV La pestaña «Notas» afirma que la acción en línea HEREDA la tinta de contraste de la tira «en todos los roles y en los dos modos». La medición lo… web/routes/blocks/banner/+page.svelte ARREGLADO
A-39 newsletter doctrina ALTA SV El ejemplo de uso de la puerta de entrada del block anuncia onValidSubmit — exactamente el prop que el block decidió NO exponer porque es un no-op… src/uix/blocks/newsletter/index.ts ARREGLADO
A-40 contact percepcion ALTA SV El clic sobre «Enviar mensaje» —la accion principal de la seccion— no estampa NINGUN evento sema en el momento del gesto; la unica emision llega 915… src/uix/blocks/contact/contact-submit.svelte CONFIRMADO
A-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 CONFIRMADO
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 CONFIRMADO
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 CONFIRMADO
A-79 site-footer percepcion SOSP SV Cambiar de idioma en el selector del footer cambia el régimen de toda la página (langs, formatos, dirección) pero suena y se estampa exactamente… web/routes/blocks/site-footer/SiteFooterSite.svelte:164 CONFIRMADO
A-80 faq — — R El block reimplementa la autogeneración del value que el canon ya hace, y README+index la venden como «la única conveniencia» de .Item. — ARREGLADO
A-81 cta — — R El ejemplo del index.ts anuncia variant="soft", una prop que se CORTÓ a propósito: copiarlo es un error de tipos. — ARREGLADO
A-82 cta — — R La demo redeclara en app-land la unión que el block EXPORTA (CtaLayout), duplicando vocabulario. — CONFIRMADO
A-83 hero — — R El block deriva el estado «la copia va sobre sólido» y lo aplica solo a dos de sus piezas, sin publicarlo: cada consumidor lo re-deriva en el punto… — ARREGLADO
A-84 feature-grid — — R align está aplicado a medias: el block declara la prop y solo mueve la caja de la cabecera; el propio tipo instruye al app a repetir la alineación… — CONFIRMADO
A-85 site-header — — R El aria-label que la demo escribe para el trigger del cajón nunca llega al DOM: el lector de pantalla anuncia la etiqueta genérica del canon. — CONFIRMADO
A-86 pricing — — R El ejemplo de uso de la superficie pública del block no compila: omite dos props obligatorias. — CONFIRMADO
A-87 contact — — R El propio gating del block deja INALCANZABLE el error por campo: con progressive los errores solo se escriben tras un envío inválido, y el envío… — ARREGLADO
A-88 team — — R marginTop: 'auto' no ancla nada: el ítem de la retícula es el <div> del Motion (block, estirado), y el Stack de dentro mide su contenido, así… — CONFIRMADO
A-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

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.

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.

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.

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

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 — Block-side: no pasar value cuando se compone CountUp. Fase 3.

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 — CONFIRMADO · feature-split · pricing · testimonials · doctrina · MEDIA

Mecanismo — Ninguno de los tres README declara landmark ni jerarquía de encabezados. Es la misma falta que A-03, medida sobre el tier entero: 8 de los 15 READMEs no mencionan «landmark» ni una vez.

Evidencia — Grep sobre los 15 src/uix/blocks/*/README.md: 0 menciones en contact · faq · feature-grid · feature-split · pricing · stats-band · team · testimonials.

Disposición — Se arregla junto a A-03: párrafo «Landmark + headings» en los 8, y regla en blocks:check (fase 5).

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

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

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

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

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

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

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

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 — Block-side: align="left" en el Container (o paddingX={0} con márgenes explícitos) cuando hay descarte. Fase 3.

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

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.

A-69 — CONFIRMADO · 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.

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

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

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

A-85 — CONFIRMADO · 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

Disposición — Se arregla en el CANON, no en el block (la demo compone correctamente y el block site-header sólo renderiza el snippet mobileTrigger). Dos puntos posibles, por orden de limpieza:

  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.

A-86 — CONFIRMADO · 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 — CONFIRMADO · 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.

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.