Un gran medidor sobre un pie y tres paneles con forma de móvil, cada uno con su propio medidor pequeño, con un tubo fino que sale de cada panel hacia el medidor grande

Tests de accesibilidad entre remotes federados en React Native

El post 11 cerró con una promesa: accesibilidad en la frontera, un paquete de tests compartido que comprueba áreas táctiles, contraste y orden de foco en remotes que el host solo conoce en runtime. Dos de esas tres cosas caben en una suite de Jest. El orden de foco no, y Lo que estas comprobaciones no ven dice a quién le corresponde.

El problema es el del post 11 un nivel más arriba. Tres equipos publican tres bundles con sus propios calendarios, cada uno con su idea de qué cuenta como accesible, y esas ideas divergen igual que divergieron los grises antes de que existiera un design system. Escribir el listón en una página de wiki no frena esa deriva, porque una convención no tiene versión ni paso de instalación. Un paquete tiene las dos cosas.

Así que el listón pasa a ser @pokedex/a11y-testing: un preset de Jest, un render que entiende NativeWind, un conjunto reducido de helpers de aserción WCAG (Web Content Accessibility Guidelines), el catálogo de criterios A y AA, y un reporter. Se publica en el mismo registry local de Verdaccio que el resto de paquetes de la frontera, y lo instalan tanto los dos paquetes de origen como los dos remotes. Es además el primer paquete @pokedex que nunca llega a un bundle. Todos los consumidores lo toman como devDependency, así que no cambia ningún mapa compartido ni se mueve nada en runtime.

Los criterios que se comprueban no son un invento de este proyecto. La Directiva europea de accesibilidad (Directiva (UE) 2019/882) se aplica desde el 28 de junio de 2025 a los productos y servicios que enumera. La propia Directiva fija requisitos funcionales y no nombra ninguna norma; EN 301 549, que aplica los criterios A y AA de WCAG 2.1 a las apps móviles, es la norma con la que mide la práctica europea, y se espera su revisión a WCAG 2.2.

Por qué no jest-axe

En web esto es un problema resuelto y con paquete propio. jest-axe envuelve axe-core, expect(await axe(container)).toHaveNoViolations() cubre una porción amplia de las reglas en una línea, y para una app React de web es el primer movimiento correcto.

axe-core comprueba HTML. jest-axe necesita un entorno jsdom, y su propio README dice que las comprobaciones de contraste de color no funcionan en jsdom, así que allí van desactivadas. @axe-core/react-native directamente no existe como paquete: el registry de npm responde 404. Deque, que mantiene axe-core, sí vende herramientas para React Native, incluidos SDK de dispositivo que una ejecución de tests nativa puede invocar. Esa es la capa de auditoría en dispositivo que nombra Lo que estas comprobaciones no ven, no la capa Jest que construye este post.

React Native Testing Library (RNTL) aporta las queries y los matchers, y se queda a las puertas de una auditoría. Nada en su API devuelve una lista de violaciones. Al buscar trabajo previo sobre tests de accesibilidad entre remotes de Module Federation no apareció nada, ni en React Native ni en web. Al buscar aserciones de accesibilidad para React Native salen dos paquetes de la comunidad, y ninguno tiene la forma que hace falta aquí. react-native-accessibility-engine publicó por última vez en 2022. react-native-ama sigue vivo, aunque hay que saber que se movió: el paquete sin scope se quedó en 0.7.5 en 2023 y el trabajo pasó a un scope. Siete de los ocho paquetes @react-native-ama/* publicaron 1.2.1 en agosto de 2025, y cinco de los ocho publicaron una beta 2.0 en julio de 2026. Es una librería de componentes con comprobaciones en runtime durante el desarrollo, no una capa de aserciones para Jest, así que responde a una pregunta distinta de la de este post.

El listón se monta a mano: un render que resuelve clases, un puñado de aserciones y un informe.

Cuatro flechas, dos trabajos. Dos van a los paquetes que definen los píxeles compartidos, donde una comprobación se ejecuta una vez para todos. Dos van a los equipos, donde cada suite cubre solo las pantallas que compone ese equipo.

El paquete

Empieza desde el tag del post 11:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-11-design-system

Hay tres cosas que tienen que ser ciertas antes de que nada de esto funcione, y las tres son fáciles de dar por supuestas. El registry local de Verdaccio tiene que estar levantado con @pokedex/contracts, @pokedex/ui y @pokedex/detail publicados en él, que es el trabajo del post 11. npm tiene que saber que el scope @pokedex vive ahí. Y tienes que haber iniciado sesión en ese registry, que es un paso del post 5 y no del post 11, porque los publish de más abajo van autenticados aunque las lecturas no.

Levanta primero el registry, porque el npm adduser de abajo necesita algo donde iniciar sesión:

npx verdaccio@6.2.0                              # :4873, leave it running

Configura las otras dos cosas una vez, para tu usuario, porque npm lee la configuración de proyecto desde el directorio que contiene package.json, no desde la raíz del repositorio, así que el .npmrc del repo es invisible para un comando lanzado dentro de packages/ui:

npm config set @pokedex:registry http://localhost:4873/
npm adduser --registry http://localhost:4873/     # only if you have not already

Si no construiste el post 11, sus secciones Un paquete para los píxeles y El detail estrena diseño publican los tres paquetes que este instala. apps/host necesita sus dependencias; los dos remotes reciben las suyas junto con sus paquetes más abajo:

( cd apps/host && npm install )

El código fuente es más de lo que un post puede reescribir con provecho. Descarga la copia de referencia una vez y publícala igual que publicaron los suyos el post 5 y el post 6. El tag se libera el mismo día en que sale este post, así que degit puede alcanzarlo:

npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-12-a11y-testing /tmp/pokedex-a11y-ref
cp -R /tmp/pokedex-a11y-ref/packages/a11y-testing packages/
( cd packages/a11y-testing && npm install && npm run build && npm publish )

La anatomía sigue la de los paquetes anteriores: publishConfig apunta a Verdaccio, como desde el post 5, y react-native-builder-bob construye un lib/ como módulo ES, el montaje que introdujo @pokedex/ui en el post 11. Hay una omisión deliberada. El paquete no incluye ningún mapa exports. Los consumidores usan la raíz, /jest-preset en cada configuración de Jest y /reporter.js en cada script test:a11y, y un mapa tendría que nombrar los tres. Si omites uno, Node responde ERR_PACKAGE_PATH_NOT_EXPORTED, que en el caso del preset significa que no se ejecuta ni un test.

jest-preset.js extiende @react-native/jest-preset en lugar de sustituirlo, y eso es lo que permite que todas las suites que ya existían sigan pasando sin tocarlas. Las dos incorporaciones que importan aquí tienen que ver con NativeWind. nativewind/babel se suma a los presets de transformación, así que className llega al runtime de estilos convertido en style y no se queda ahí como una prop inerte que hace que toda aserción de color lea undefined. Y react-native-css-interop/dist/test/setupAfterEnv.js se suma a setupFilesAfterEnv, que es donde vive el matcher toHaveStyle. Ninguna de las dos líneas es lo que hace funcionar las comprobaciones de este post: la matriz de contraste lee directamente el objeto de colores del preset, y las comprobaciones de componente leen cadenas de clases. Pero un preset compartido tiene que servir también a la suite que sí hace aserciones sobre un estilo resuelto, y se comprueba que ambas estén presentes para que una edición futura no pueda quitarlas sin ruido.

Dos de los tres puntos de entrada que este paquete importa no están documentados. nativewind/babel no es problema: es el paso 3 de la propia guía de instalación de NativeWind. Los otros dos no están escritos en ninguna parte. react-native-css-interop/dist/test/setupAfterEnv.js, que el preset añade a setupFilesAfterEnv, y nativewind/test, que importa el render, existen, se publican y sostienen la propia suite de NativeWind, y nativewind.dev no tiene ninguna sección de testing. Así que las versiones con las que está probado quedan registradas en lugar de darse por supuestas: nativewind 4.2.6 y react-native-css-interop 0.2.6, con rangos caret y revisadas cada vez que NativeWind se mueve.

RNTL se mantiene por debajo de la 14 por un motivo relacionado: la versión 14 reescribió render, fireEvent y act como asíncronos, y esta ruta de render no se ha probado contra ella. Tampoco recurras a @testing-library/jest-native; está deprecado, y RNTL trae sus propios matchers desde la 12.4.

createThemedRender enlaza un preset de Tailwind una sola vez y devuelve el render que usan todas las suites de ese paquete:

const renderWithTheme = createThemedRender(require('@pokedex/ui/tailwind.preset.js'));

const { getByRole } = await renderWithTheme(<SomeButton disabled />);

Recibir el preset como argumento mantiene el paquete de accesibilidad libre de cualquier dependencia del design system. Es una herramienta, no un miembro de la federación. Enlazarlo al preset del propio @pokedex/ui es lo que permite que una suite resuelva una clase igual que la resolverá la app, que es lo que leen las comprobaciones de área táctil y contra lo que se calculan los helpers de contraste.

Los helpers son funciones normales que llaman al expect global, no matchers de expect.extend. Un helper importado por su nombre no necesita fichero de setup, se comporta igual en todos los paquetes y aparece en la traza con su propio nombre. Ninguno importa React Native: un elemento de test es cualquier cosa con props. Cada uno está atado a un criterio de éxito (SC) y al listón que fija ese criterio.

Qué compruebaCriterioNivelEl listón
Texto sobre una superficieSC 1.4.3 Contrast (Minimum)AA4,5:1 texto normal, 3:1 texto grande
El contorno del propio controlSC 1.4.11 Non-text ContrastAA3:1
Lo que lee un lector de pantallaSC 4.1.2 Name, Role, ValueAnombre, rol y estado
Significado que no depende del colorSC 1.4.1 Use of ColorAla información también está en palabras
Un cambio anunciado sin mover el focoSC 4.1.3 Status MessagesAAlo transmite un rol o una live region
Área táctilSC 2.5.5 Target Size (Enhanced)AAA44 por 44 píxeles CSS

La fila del área táctil conviene leerla dos veces. Los 44 pt son el listón de este proyecto, y son el número de Apple, no el de WCAG. Las Human Interface Guidelines actuales dan a iOS y iPadOS un tamaño de control por defecto de 44 por 44 puntos y un mínimo de 28 por 28, así que 44 es el tamaño con el que Apple te pide diseñar, no el más pequeño que acepta. Una página más antigua de Apple, que se sigue citando mucho, solo dice “at least 44 points x 44 points”, y de ahí viene la abreviatura «el mínimo de Apple». Por el lado de WCAG, el SC 2.5.5 está en nivel AAA, y el criterio AA de WCAG 2.2, el SC 2.5.8 Target Size (Minimum), pide 24 por 24 píxeles CSS con cinco excepciones. Cuarenta y cuatro lo supera con holgura. No lo supera todo: la guía de Android pide al menos 48 dp, así que un proyecto que publica en las dos plataformas y se queda en 44 ha tomado el tamaño recomendado por Apple y ha aceptado que Android preferiría más. Es una decisión que merece tomarse a conciencia. El error a evitar es llamar a cualquiera de estas cosas «lo que exige WCAG AA».

El helper de contraste usa 0.04045 en la linealización de canal. La definición de luminancia relativa llevaba 0.03928 antes de mayo de 2021 y la constante antigua se sigue copiando por todas partes. La propia nota de la especificación dice que el cambio no afecta a los resultados en la práctica, y la constante actual no cuesta nada.

El helper de área táctil tiene un comportamiento que decide cuánto vale toda la capa: cuando no puede ver, lanza en lugar de pasar. Y eso se aplica por eje. Un control que declara alto pero no ancho no ha sido medido en su ancho, y aplicar el listón al eje que nadie declaró lo daría por accesible justo en el momento en que el test no puede verlo. El hitSlop también se mide, no solo se comprueba que exista, porque un hitSlop: 0 no amplía nada.

Esa regla es la diferencia entre una suite y un adorno, y es lo que este paquete hizo mal al principio: una versión temprana tomó prestado el listón para un eje sin declarar, así que un botón que solo indicaba su alto pasaba con un ancho que nadie había medido. Ahora tiene su propio test de regresión, en la suite del propio paquete.

Comprobar el origen

Los dos paquetes de origen instalan el listón y apuntan Jest hacia él. @tailwindcss/container-queries no es opcional aquí: sin él, el render de test falla con Cannot find module.

( cd packages/ui && npm install -D @pokedex/a11y-testing@1.0.18 @testing-library/react-native@13.3.3 @tailwindcss/container-queries@0.1.1 @types/jest@29.5.14 @types/react-test-renderer@19.1.0 )
( cd packages/detail && npm install -D @pokedex/a11y-testing@1.0.18 @testing-library/react-native@13.3.3 @tailwindcss/container-queries@0.1.1 @types/jest@29.5.14 @types/react-test-renderer@19.1.0 )

Las suites, las dos configuraciones de Jest y las declaraciones de no aplicabilidad de cada suite vienen de la copia de referencia, y a cada paquete se le añade un script test:a11y:

for pkg in ui detail; do
  cp /tmp/pokedex-a11y-ref/packages/$pkg/jest.config.js \
     /tmp/pokedex-a11y-ref/packages/$pkg/a11y-report.config.js \
     /tmp/pokedex-a11y-ref/packages/$pkg/.gitignore packages/$pkg/
done
mkdir -p packages/ui/src/tokens/__tests__ packages/ui/src/components/__tests__
cp /tmp/pokedex-a11y-ref/packages/ui/src/tokens/__tests__/contrast.accessibility.ts packages/ui/src/tokens/__tests__/
cp /tmp/pokedex-a11y-ref/packages/ui/src/components/__tests__/components.accessibility.tsx packages/ui/src/components/__tests__/
cp /tmp/pokedex-a11y-ref/packages/detail/__tests__/detail-view.accessibility.tsx packages/detail/__tests__/
cp /tmp/pokedex-a11y-ref/packages/detail/__tests__/detail-view.test.tsx packages/detail/__tests__/
cp /tmp/pokedex-a11y-ref/packages/ui/tsconfig.json packages/ui/
cp /tmp/pokedex-a11y-ref/packages/detail/tsconfig.json \
   /tmp/pokedex-a11y-ref/packages/detail/tsconfig.build.json packages/detail/

Los cambios del design system llegan por la misma vía. Son las reparaciones de tokens de las que trata el resto de esta sección, y tienen que estar en el árbol antes de que la matriz pueda pasar:

cp /tmp/pokedex-a11y-ref/packages/ui/tailwind.preset.js packages/ui/
cp /tmp/pokedex-a11y-ref/packages/ui/src/tokens/colours.ts \
   /tmp/pokedex-a11y-ref/packages/ui/src/tokens/typeColours.ts packages/ui/src/tokens/
cp /tmp/pokedex-a11y-ref/packages/ui/src/components/type-badge.tsx \
   /tmp/pokedex-a11y-ref/packages/ui/src/components/back-pill.tsx \
   /tmp/pokedex-a11y-ref/packages/ui/src/components/pokemon-card.tsx \
   /tmp/pokedex-a11y-ref/packages/ui/src/components/empty-slot.tsx packages/ui/src/components/

type-badge.tsx recoge la decisión de dos superficies que la matriz está a punto de demostrar. Los otros tres son el mismo fallo en controles más pequeños: el scrim oscuro del botón flotante de retroceso, que la sección siguiente mide junto al del badge; el badge de eliminar de la tarjeta, cuya área táctil retoma El mismo listón para cada remote; y las dos líneas de texto secundario que pintan la tarjeta y el hueco vacío, que repara la sección posterior.

for pkg in packages/ui packages/detail; do
  ( cd $pkg && npm pkg set 'scripts.test:a11y=jest --testPathPattern accessibility --reporters=default --reporters=@pokedex/a11y-testing/reporter.js' )
done

packages/detail necesita un cambio más de script, y saltárselo publica un paquete que no se puede importar. Su suite vive en __tests__, así que su tsconfig.json ahora incluye ese directorio, lo que le da a tsc dos raíces y baja la salida a dist/src/. package.json sigue declarando dist/index.js. Así que ajusta a mano el script de build de packages/detail, en su package.json, con la configuración que mantiene src como única raíz y que limpia dist antes para que no sobreviva nada del montaje anterior:

"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json"

Un tsc que termina con código 0 y escribe los ficheros un directorio más abajo es el mismo fallo que una comprobación en verde que mide lo que no es: nadie protesta hasta que la primera app que instala el paquete se detiene con Cannot find module.

packages/ui ya tenía tests unitarios para sus componentes. Suma una capa de accesibilidad al lado, y la mitad interesante es la matriz de tokens. Cada par de primer plano y fondo que promete el design system se comprueba una vez, en el paquete que define los tokens:

describe('WCAG 1.4.3 Contrast (Minimum) — type colours on the solid fill', () => {
  test.each(TYPE_NAMES)('%s badge text clears AA on its type fill', (type: string) => {
    expectColorContrast(
      hexForClass(textOnTypeClass(type)),
      hexForClass(bgClassForType(type)),
      'normalText',
    );
  });
});

hexForClass es la parte interesante, y está ahí porque la primera versión de este fichero no la tenía. Aquella versión mapeaba la clase a un literal: 'text-black': '#000000'. Los dieciocho tipos pasaban, y el número que imprimía era cierto para un color que la app no pintaba nunca.

El design system define su propio black. tailwind.preset.js fija black: '#2E3138' para superficies casi negras, y eso eclipsa el valor por defecto de Tailwind. Así que text-black pintaba #2E3138, y sobre el relleno de agua eso da 3,74:1 donde el propio test exigía 4,5:1. Dos de los dieciocho fallaban en pantalla mientras la matriz informaba de los dieciocho en verde, porque la matriz medía una constante en lugar del token.

La reparación tiene dos partes, y solo la segunda es duradera. El primer plano del badge pasó a su propio token, typeInk, que es el negro real contra el que se había calculado el mapa de contraste desde el principio. Y la matriz dejó de escribir hexadecimales: resuelve los dos lados de cada par desde el preset, así que un token renombrado o eclipsado hace fallar la suite en vez de colarse. Los dos lados importan. Una versión intermedia resolvía solo el primer plano y seguía leyendo los fondos del módulo de tokens, lo que dejaba el mismo agujero abierto por la otra mitad: oscurecer un relleno de tipo hasta 1,64:1 seguía dejando los dieciocho pares en verde. Vuelve a apuntar typeInk a #2E3138 en el preset y seis comprobaciones se ponen en rojo al instante: los badges de agua y psíquico sobre sus propios rellenos, los de roca, fantasma y dragón sobre la superficie del hero, y la comprobación que ata el preset a los módulos de tokens.

Ahora pasan los dieciocho rellenos, y la frase significa algo que antes no significaba. Un par que pasa aquí pasa en todos los remotes que lo componen, y nadie vuelve a comprobar un badge de tipo en la app Party.

«Todos los remotes que lo componen» carga con mucho peso en esa frase, y conviene precisar qué significa componer un par. Un badge sobre una tarjeta se apoya en el color del tipo. Un badge sobre el hero de detalle no: el hero es el color del tipo, así que una píldora sólida se disolvería en él, y la variante de hero deja en su lugar un scrim blanco al 30%. Ese scrim es translúcido, pero no transparente, y el primer plano elegido para el relleno sólido es el equivocado para cuatro de los seis tipos lo bastante oscuros como para admitir texto blanco encima. Roca, fantasma, dragón y acero se quedaban en 3,17:1, 3,14:1, 3,39:1 y 2,71:1 sobre la superficie en la que realmente se dibujaban, mientras la matriz comprobaba una superficie que no era esa.

Así que hay dos decisiones, no una, y el design system calcula ahora las dos. La matriz compone el scrim exactamente como lo compone el runtime y comprueba el badge contra eso:

const scrim = composite(hexForClass('bg-white'), HERO_SCRIM_ALPHA, hexForClass(bgClassForType(type)));
expectColorContrast(hexForClass(textOnHeroScrimClass(type)), scrim, 'normalText');

Los dos fallos tienen la misma forma: una comprobación que mide algo contiguo a lo que se publica. Ninguna suite en verde lo revela.

El botón flotante de retroceso es el tercero de esa forma, un control más allá. Deja su propio scrim translúcido sobre el hero en el que aterrice, y pintaba bg-black/35, otra vez el neutro casi negro, al 35%. Sobre los tres rellenos más claros el chevron blanco se quedaba en 2,81:1 en volador, 2,83 en hielo y 2,84 en eléctrico, por debajo del 3:1 que el SC 1.4.11 pide para el contorno de un control. El negro real con la misma alfa supera todos los rellenos, con 3,48 en el peor caso.

Para eso no toma prestado typeInk. typeInk es un primer plano: cada uso suyo es una clase text-, y el experimento de arriba lo revierte para demostrar que el texto del badge se mueve. Un scrim pintado desde ese mismo token quedaría arrastrado por ese experimento, y así una decisión sobre la tinta del badge acabaría gobernando un control que no tiene nada que ver. La mitad de fondo del mismo hecho recibe su propio nombre:

export const BACK_PILL_SCRIM_ALPHA = 0.35;
export const BACK_PILL_SCRIM_CLASS = 'bg-scrim/35';

La matriz lee el token y el alfa desde esa única clase, así que el valor que pinta el botón y el valor que compone el mapa no pueden separarse.

Un token no pasaba. midGrey (#9A9AB0) pintaba texto secundario en cuatro sitios sobre tres superficies claras: dos encabezados de sección en la hoja de detalle (2,60:1), la línea del número dentro de su píldora gris en la tarjeta (2,46:1) y la etiqueta del botón Add deshabilitado sobre su relleno (2,02:1). La matriz registraba cada superficie por separado, mediante un helper que exporta el paquete:

knownFinding('disabled button label on its fill', '2.02:1, exempt under 1.4.3 Incidental', () => {
  expectColorContrast(colours.midGrey, colours.lightGrey);
});

knownFinding envuelve it.failing, que Jest informa como pasado, así que la suite se mantiene en verde mientras el hallazgo sigue a la vista y el reporter lo lista aparte de las violaciones. El helper existe porque la primera versión escribía esa llamada a mano, con un título que empezaba por (known y sobre el que hacía match el reporter. Un marcador que es solo una cadena está a una errata del silencio: escríbelo mal y el reporter deja de ver un hallazgo registrado, it.failing sigue informando en verde, y una violación real se lee como un test normal que pasa. Escribir el título en un único sitio es lo que hace imposible equivocarse con el marcador.

Dos superficies más se sumaron a la lista en cuanto la matriz empezó a resolver lo que mide. La leyenda del hueco vacío es text-midGrey/70, no el token: compuesta sobre el fondo de la app da 1,88:1. Y todo lo anterior era el tema claro. Los dos remotes montan un toggle de tema en su cabecera, así que cada una de esas superficies tiene un equivalente oscuro que el design system también compone, y text-midGrey no llevaba ninguna variante dark: en ninguno de los dos sitios. La línea del número de la tarjeta se apoya en bg-white/10 sobre casi negro con 3,44:1, y la leyenda del hueco vacío sobre navy con 3,82:1. Las dos son texto xs, así que las dos necesitan 4,5:1.

Cinco pares, entonces, todos por debajo del listón y en una pantalla que publican las dos apps. Se quedaron aparcados durante cinco rondas detrás de un motivo que se lee bien y no sobrevive a la aritmética: no hay un único valor más oscuro que los arregle. A partir de más o menos #5F5F6D el token supera AA en las tres superficies claras, y la hoja de detalle es dark:bg-navy, así que un par que hoy está en unos cómodos 6,48:1 caería a 2,84:1. Todo cierto, y nada de ello decisivo, porque aquí no hace falta un único valor. El design system entiende de temas. Ya publicaba dark:text-lightGrey en otros ocho sitios, y al texto secundario sencillamente nunca se le había dado el mismo trato:

<Text size="xs" className="text-darkGrey dark:text-lightGrey">

packages/ui se lleva dos de esos cuatro sitios, y llegan con los componentes copiados antes: la línea del número de la tarjeta y la leyenda del hueco vacío, que ahora pinta el token a plena intensidad en lugar de al 70%. Los otros dos son los encabezados de sección de la hoja de detalle, así que son una edición a mano en packages/detail/src/PokemonDetailView.tsx, en los dos:

// Los dos encabezados de sección. El texto secundario se tematiza aquí como cualquier
// otra línea secundaria del design system; la versión aparcada no tenía variante oscura.
<Text size="xs" bold className="uppercase tracking-widest text-darkGrey dark:text-lightGrey">

El peor de los seis pares está ahora en 6,94:1. Aparcar un hallazgo es una opción legítima y este post mantiene una, pero «es una decisión de paleta» resultó ser la tapadera de un cambio que se resolvió con una clase.

Y entonces la matriz se puso verde, y el verde no bastaba. Volver a poner text-midGrey en la tarjeta dejó pasando las ciento cuarenta y nueve comprobaciones, porque una matriz mide pares y no ve nunca qué clase pinta un componente. Es el mismo agujero por el que se cuelan los tintes de las pestañas del host, y admite la misma respuesta: una comprobación que lee el código del propio componente.

expect(secondaryClass('pokemon-card.tsx')).toEqual({ light: 'darkGrey', dark: 'lightGrey' });

Una aserción sobre el render no lo habría cerrado. nativewind/test compila solo el árbol que le pasas a render, así que una clase que un componente elige dentro de su propio render no se compila nunca, y la aserción pasa sin comprobar nada.

Queda un hallazgo registrado, y no es un incumplimiento de criterio. La excepción Incidental del SC 1.4.3 exime el texto y las imágenes de texto que forman parte de un componente de interfaz inactivo, así que la etiqueta del botón Add deshabilitado no tiene ningún requisito de contraste que incumplir. Está registrada porque este proyecto prefiere que un control deshabilitado siga siendo legible, y vive bajo un encabezado Project bars que lo dice, en lugar de bajo 1.4.3, donde sería la misma cita indebida que el planteamiento del área táctil se cuida de evitar.

packages/detail recibe después su propia suite, y ahí la forma importa más que las aserciones: una librería de componentes que publica sus propios tests de accesibilidad. Los tests le pasan a PokemonDetailView sus props directamente, que es otra vez la frontera de props del post 8. Sin store, sin query client, sin navigator. Los dos consumidores heredan lo que demuestre este fichero, y cuando encuentra algo, una release de parche los repara a los dos. Una nota sobre el orden si ejecutas esta suite pronto: la suite lee la decisión del scrim del hero desde @pokedex/ui, y el lockfile del post 11 fija una versión anterior a esa decisión. La suite se ejecuta, y veinticuatro de sus treinta comprobaciones fallan, por cuatro causas. Cuatro lanzan textOnHeroScrimClass is not a function, que repara la release del design system de más abajo. Una es el área táctil del botón Add, y otra son los encabezados de sección de la hoja, todavía con el token aparcado. Las otras dieciocho son una comprobación por tipo, y todas dicen lo mismo sobre el número de dex del hero, que la siguiente sección repara en una línea. Tres de esas cuatro causas son hallazgos de los que trata este post, así que una ejecución en rojo aquí es la forma esperada y no un clon roto.

Un test que falla, y luego el arreglo

El fallo para el que se construyó esta suite no se produjo. Se esperaba que el botón Add deshabilitado no informara de ningún accessibilityState, de modo que un lector de pantalla se encontrara con un botón que se apaga visualmente y no dice nada sobre no estar disponible. Informa del estado correctamente. El Pressable de React Native copia por su cuenta la prop disabled dentro de accessibilityState:

_accessibilityState =
  disabled != null ? {..._accessibilityState, disabled} : _accessibilityState;

El test se queda igualmente, como red de regresión y no como arreglo. El día en que alguien deshabilite ese botón cambiando el handler por una función vacía y apagando el relleno a mano, el estado se quedará mudo y este test lo dirá.

Lo que sí falló fue la comprobación de al lado:

● WCAG 2.5.5 Target Size — the Add action › the Add button declares at least 44pt on both axes

  Element has no measurable size and no hitSlop; cannot verify the 44pt touch target.
  Give the control an explicit width/height or a hitSlop, or assert on a parent that
  has one.

Detrás de esa línea hay dos cosas, y las dos van más allá de este botón.

El botón no declaraba ningún tamaño que la suite pudiera leer. Su alto venía de la variante size="lg" de gluestack, que compone px-6 h-11 mientras el componente se renderiza. En la app esa clase no da problemas: Tailwind escanea el código del design system, encuentra el literal h-11, y el bundle construido lleva la regla. En el árbol de test no, y el motivo va más allá de este botón. nativewind/test compila las cadenas de clases que encuentra en el árbol de elementos que le pasas a render, y se las da a Tailwind como su contenido. Una clase que aparece dentro de un componente hijo no está en ese árbol, así que no se compila nunca: ni una que construya una variante, ni una literal. El botón Add llegaba con {"alignSelf": "stretch"} y nada más, y el helper lanza cuando no hay nada, que es la única razón por la que esto salió a la luz.

Y h-11 no son 44, para empezar. Tailwind genera .h-11 { height: 2.75rem }, y el rem de NativeWind en React Native es 14, no el 16 del navegador. Un <View className="h-11" /> literal renderiza height: 38.5, tanto en el árbol de test como en la app. Así que el tamaño que pide esa variante estaba por debajo de los 44 de Apple desde el principio, pudiera verlo o no alguna suite. Dos fallos distintos en un mismo control: un tamaño que el test no podía leer, y un tamaño que no habría superado el listón si hubiera podido. Por eso las comprobaciones de área táctil leen el estilo y las props declarados, en lugar de deducir un número a partir de una clase.

El arreglo pone el listón donde una variante no pueda moverlo sin ruido. En packages/detail/src/PokemonDetailView.tsx, en el botón Add:

// El listón de 44 pt se declara aquí en lugar de heredarse de la variante de tamaño.
// Una variante es una decisión visual que puede cambiar; el tamaño mínimo pulsable de
// la acción principal de la pantalla es un compromiso, y la suite de accesibilidad
// solo puede verificar lo que el control declara de verdad. `alignSelf: 'stretch'`
// hace el botón mucho más ancho que 44, pero stretch es una instrucción de layout,
// no una medida, así que el mínimo se indica en los dos ejes.
style={{ alignSelf: 'stretch', minWidth: 44, minHeight: 44 }}

El mismo fichero llevaba el fallo contrario, cien líneas más arriba, en el hero bajo el que queda el botón. El hero pinta el color del tipo a plena intensidad, y un comentario encima explica que su texto no puede ser un color fijo, porque el design system ya decidió por tipo y el hero debería preguntar al token en lugar de dar por hecho. El número de dex bajo el nombre sí daba por hecho. Se atenuaba solo:

// Antes: una segunda respuesta, inventada una línea debajo del comentario que decía que no.
const heroMuted = heroInk ? 'text-white/70' : 'text-black/60';

text-black se resuelve aquí a #2E3138, el neutro que, como avisa el preset, no es un primer plano, y al 60% sobre el relleno mide 2,21:1 en agua. Fallan dieciséis de los dieciocho tipos. No hay alfa que lo rescate: barre el valor del 1% al 100% y el mejor caso es plena intensidad, donde agua sigue en 3,74:1, porque la tinta es la tinta equivocada. Así que la línea pregunta lo mismo que pregunta el nombre, en packages/detail/src/PokemonDetailView.tsx:

// Borra heroMuted. El número de dex toma la misma decisión que toma el nombre.
<Text size="sm" className={`font-head ${onHero}`}>
  {dexNumber}
</Text>

El peor caso pasa a ser acero con 4,71:1, y el par es uno que la matriz de tokens ya mide, así que no hay que añadirle nada nuevo. Una variante atenuada habría necesitado su propia fila.

El fallo del botón Add se repite un nivel más abajo. El ErrorState del design system construye su botón de reintento con size="md", que es h-10, o 35 puntos, y ese reintento es la vuelta atrás desde una carga fallida en las tres apps. Su declaración va al design system y no a la app que se dio cuenta primero, en packages/ui/src/components/error-state.tsx:

// El reintento es la vuelta atrás desde una carga fallida, así que su tamaño pulsable se
// declara aquí en lugar de dejarlo en manos de la variante de tamaño. Una variante es una
// decisión visual; el área mínima es un compromiso, y una suite solo puede verificar
// un área declarada. Se declaran los dos ejes: el botón es mucho más ancho que 44 en
// todos los layouts donde aparece, pero un ancho que nadie indica es un ancho que nadie
// ha medido.
<Button
  action="primary"
  size="md"
  onPress={onRetry}
  style={{ minWidth: 44, minHeight: 44 }}>

Al lado hay un hallazgo más, en dos componentes a la vez, y es el que una revisión visual no detecta jamás. Ni ErrorState ni LoadingState tenían ninguna semántica de estado: ni rol, ni live region, nada. Se veían correctos, y para un lector de pantalla una carga fallida sencillamente ocurría en silencio.

No reciben la misma reparación, porque no son el mismo tipo de mensaje. Una carga fallida debe interrumpir; un spinner no. Así que error-state.tsx pasa a ser una alerta, que se anuncia en las dos plataformas sin necesidad de live region:

<Center className="flex-1 px-6" accessible accessibilityRole="alert">

y loading-state.tsx recibe la variante educada, que espera a que termine lo que el lector de pantalla esté diciendo:

<Center className="flex-1" accessible accessibilityLiveRegion="polite">

El design system se lleva cinco de estas reparaciones y packages/detail tres, así que cada uno es una release y no varias:

( cd packages/ui && npm version 1.0.13 --no-git-tag-version && npm run build && npm publish )
( cd packages/detail && npm install @pokedex/ui@1.0.13 && npm version 4.0.12 --no-git-tag-version && npm run build && npm publish )

El host también se lleva la release, y tiene una reparación propia: el único fallo de este post que vive en el shell y no en un paquete o un remote.

cp /tmp/pokedex-a11y-ref/apps/host/App.tsx apps/host/
cp /tmp/pokedex-a11y-ref/apps/host/__tests__/TabBar.test.tsx apps/host/__tests__/
cp /tmp/pokedex-a11y-ref/apps/host/tsconfig.json apps/host/

Las dos etiquetas de pestaña son de 10 pt, y las dos fallaban. La activa tomaba colours.blue, el relleno de marca: 3,48:1 sobre la barra clara, 3,74:1 sobre la oscura. La inactiva no se definía en ninguna parte, así que React Navigation la derivaba mezclando a medias el texto del tema con el color de la propia barra, lo que da 3,27:1 sobre blanco. Siempre hay una pestaña inactiva, así que ese par está en pantalla siempre que lo está la app. colours.ts trae dos azules legibles; los grises son los que ya usa cualquier otra línea secundaria:

tabBarActiveTintColor: mode === 'dark' ? colours.blueTextDark : colours.blueText,
tabBarInactiveTintColor: mode === 'dark' ? colours.lightGrey : colours.darkGrey,

TabBar.test.tsx explica por qué esta comprobación merece fichero propio. La matriz de @pokedex/ui demuestra que esos cuatro pares superan AA, y no puede demostrar que el host los use: revertir el tinte activo a colours.blue deja en verde todas las comprobaciones del design system. Una matriz mide pares; solo a la app se le puede preguntar si los compone. Lee código en lugar de un render, porque una opción del navigator se resuelve dentro de React Navigation y no llega a ningún elemento que una suite pueda consultar.

( cd apps/host && npm install @pokedex/ui@1.0.13 )

Dos releases de parche, tres apps y ninguna coordinación entre los equipos que las publican. El rango caret que ya llevaba cada consumidor es lo que convierte esto en un parche y no en una negociación. La suite de la Pokédex deja constancia de lo que eso significa en el sitio donde es más fácil malinterpretarlo:

// Esto pasa porque el design system declara el tamaño en el botón de ErrorState, no
// porque esta app haya hecho nada. Una release de paquete, el reintento de cada remote cubierto.
expectMinTouchTarget(buttonContaining(getByText('Try again')));

Renderiza el mismo ErrorState en su boundary de carga de remotes, así que pasa a la misma versión que los dos remotes en lugar de quedarse atrás, algo que para un paquete que el host proporciona como singleton eager no es opcional.

Hasta dónde te lleva ese reintento depende de qué haya fallado. Una petición de datos fallida se reintenta sin problema. Un remote que nunca respondió es un caso más difícil: el boundary descarta el rechazo cacheado de React y arranca una importación nueva, que es por lo que el botón visiblemente hace algo, pero vuelve el mismo fallo una y otra vez. El runtime se comporta como si siguiera reteniendo por debajo la petición fallida del manifiesto. Eso es una observación y no una garantía documentada, así que trata el comportamiento como el hallazgo y no como la causa. Limpiarlo requiere invalidar la caché del runtime, y esa invalidación solo merece la pena cuando ya hay algo a lo que recurrir, así que las dos cosas llegan juntas en el post de resiliencia, más adelante en esta serie. Lo que sí pueden decir las comprobaciones de este post es más estrecho y merece decirse igualmente: haga lo que haga el reintento, se puede alcanzar, tiene nombre y es lo bastante grande para pulsarlo.

El mismo listón para cada remote

Los dos remotes instalan las mismas tres devDependencies, y toman los dos paquetes reparados en el mismo comando:

( cd apps/list && npm install @pokedex/detail@4.0.12 @pokedex/ui@1.0.13 && npm install -D @pokedex/a11y-testing@1.0.18 @testing-library/react-native@13.3.3 @tailwindcss/container-queries@0.1.1 )
( cd apps/party && npm install @pokedex/detail@4.0.12 @pokedex/ui@1.0.13 && npm install -D @pokedex/a11y-testing@1.0.18 @testing-library/react-native@13.3.3 @tailwindcss/container-queries@0.1.1 )

Cada app toma su propia suite, su configuración de Jest y sus declaraciones de no aplicabilidad, y el README se pone al día con el tag:

for app in list party; do
  cp /tmp/pokedex-a11y-ref/apps/$app/jest.config.js \
     /tmp/pokedex-a11y-ref/apps/$app/a11y-report.config.js \
     /tmp/pokedex-a11y-ref/apps/$app/tsconfig.json apps/$app/
  cat /tmp/pokedex-a11y-ref/apps/$app/.gitignore > apps/$app/.gitignore
done
cp /tmp/pokedex-a11y-ref/apps/list/__tests__/ListStack.accessibility.tsx apps/list/__tests__/
cp /tmp/pokedex-a11y-ref/apps/party/__tests__/PartyStack.accessibility.tsx apps/party/__tests__/
cp /tmp/pokedex-a11y-ref/apps/list/src/PokedexScreen.tsx apps/list/src/
cp /tmp/pokedex-a11y-ref/apps/party/src/PartyScreen.tsx apps/party/src/
mkdir -p apps/party/__mocks__
cp /tmp/pokedex-a11y-ref/apps/party/__mocks__/styleMock.js apps/party/__mocks__/
cp /tmp/pokedex-a11y-ref/README.md .

Las dos pantallas llevan las reparaciones del contador. PokedexScreen.tsx tiene el arreglo de live region de más abajo, y las dos pantallas pintan la misma píldora de recuento, cuyo numeral era text-darkGreen sobre bg-lightGreen: 1,53:1, negrita de 10,5 pt, en la cabecera de cada remote. darkGreen es #A6D3A0, el mismo valor que el relleno de planta, así que lo único oscuro de ese token era el nombre. Las dos usan ahora text-darkGrey, el color que ya usaba la etiqueta de al lado, con 7,18:1, un par que la matriz de tokens sostiene desde siempre. El contador componía otro que nadie había medido.

El styleMock.js es más aburrido y no menos necesario, y está ahí porque PartyStack arrastra global.css, a un salto de distancia a través de ./styles, para que las clases del remote lleguen al runtime de estilos compartido cuando el host carga el módulo expuesto. El entry propio de la app ya cubre la build standalone; sin la importación en PartyStack los estilos funcionan en standalone y en federado no hacen nada, sin avisar. Jest no tiene loader de CSS, así que la configuración copiada mapea esa importación al stub. Sin él la suite de Party no falla: se niega a ejecutarse.

Después, el script que pasa los ficheros de accesibilidad por el reporter:

for app in apps/list apps/party; do
  ( cd $app && npm pkg set 'scripts.test:a11y=jest --testPathPattern accessibility --reporters=default --reporters=@pokedex/a11y-testing/reporter.js' )
done

Las dos configuraciones de Jest apuntan al preset compartido. Como extiende el preset de React Native, las suites que ya tenía cada app siguen ejecutándose, y la regla es ampliar su allowlist en vez de reemplazarla. En esquema, porque los ficheros copiados llevan más entradas que estas:

const preset = require('@pokedex/a11y-testing/jest-preset');

module.exports = {
  preset: '@pokedex/a11y-testing',
  transformIgnorePatterns: [
    `node_modules/(?!(${[...preset.uncompiledPackages, '@react-navigation'].join('|')})/)`,
  ],
};

Reemplazar ese array es la forma silenciosa de que un preset compartido deje de ser compartido. No falla en silencio, eso sí: el stack de estilos publica módulos ES, así que Jest se topa con SyntaxError: Cannot use import statement outside a module y no ejecuta nada. Ese es el caso bueno. La trampa es que el error nombra un fichero en lo hondo de node_modules y se lee como una dependencia rota en lugar de como una línea de configuración que pertenece a la app. Exportar la lista hace que ampliarla sea el movimiento fácil.

Cada suite cubre solo lo que compone su equipo, y la forma de mantener eso honesto es renderizar las pantallas del propio equipo en lugar de los componentes del design system. Los dos ficheros montan el stack real con el store real y el navigator real, el mismo andamiaje que usan los tests que ya tenían las apps:

const store = configureStore({ reducer: rootReducer });
for (const member of party) {
  store.dispatch(addToParty(member));
}
await renderWithTheme(
  <Provider store={store}>
    <SafeAreaProvider initialMetrics={metrics}>
      <NavigationContainer>
        <PartyStack />
      </NavigationContainer>
    </SafeAreaProvider>
  </Provider>,
);

Eso es más preparación que montar un PokemonCard directamente, y es la diferencia entre comprobar esta app y comprobar el paquete de otro. Una suite que renderiza componentes del design system vuelve a comprobar lo que el origen ya resolvió, que es justo la duplicación que el contrato de origen existe para eliminar.

Así que la Pokédex comprueba las filas que producen sus propios datos de query, los estados en los que puede acabar su pantalla y su contador de party. La Party comprueba su cuadrícula: un hueco ocupado es un botón que nombra a su Pokémon, los vacíos son huecos con nombre y no cuatro silencios idénticos, y eliminar es una acción de accesibilidad sobre la tarjeta y no una segunda parada de foco al lado.

expect(getByLabelText(/^Pikachu, number 025/).props.accessibilityActions).toEqual([
  { name: 'remove', label: 'Remove from party' },
]);

El badge ✕ es pequeño, 21 puntos, porque h-6 son 1.5rem y el rem de NativeWind es 14, así que su hitSlop lo lleva a 45 en lugar de los 41 que tenía, y convertirlo en su propia parada de foco duplicaría el número de cosas que hay que pasar deslizando en una party llena. El design system lo oculta y expone la eliminación como acción, así que quien usa un lector de pantalla llega a ella por el rotor de VoiceOver o el menú de acciones de TalkBack. La comprobación es que la acción exista, porque un control oculto sin nada detrás es sencillamente inalcanzable.

Renderizar la pantalla real es también lo que convirtió el segundo fallo del contador en un arreglo y no en una nota. La cabecera de la Pokédex muestra «My Party» junto a un recuento sobre seis, y ese recuento cambia cuando se añade algo desde otra pantalla, sin que el foco se mueva. Quien ve la pantalla observa el número avanzar; a quien usa un lector de pantalla no se le decía nada, porque la cabecera era texto estático. Eso es el SC 4.1.3 entero, y ahora es una live region con una etiqueta que enuncia la proporción en palabras, porque un «3/6» a secas queda a merced de cómo lo lea cada lector de pantalla:

<Box
  accessible
  accessibilityLiveRegion="polite"
  accessibilityLabel={`My Party, ${partyCount} of ${MAX_PARTY}`}>

La cabecera de la propia Party necesita las mismas tres props, y ese es el coste de un fallo que vive en código de app por duplicado: dos pantallas, dos suites y nada que obligue a mirar la segunda.

Un test que comprobara eso contra un objeto escrito a mano no habría demostrado nada sobre la app. Contra la pantalla renderizada falla si se quitan las props, que es la única versión que merece conservarse.

El informe

De cada suite sale un accessibility-report.md, que escribe el reporter cuando termina la ejecución:

( cd packages/ui && npm run test:a11y )

El reporter lee el criterio del título de cada describe, así que WCAG 1.4.3 … es toda la configuración que hay. La ejecución del design system empieza así:

# Accessibility report — ui

Automated coverage: **5 of 13** WCAG 2.1 A + AA criteria that a Jest suite can decide,
after 2 declared not applicable here.

## Violations (0)

None.

## Known findings (0)

None.

El par exento no aparece ahí. Tiene su propio encabezado, porque un umbral que elige un proyecto no es un resultado contra un criterio, y archivar uno como el otro es la forma de que un número de cobertura deje de significar nada:

## Project bars

- **not held** — pairs held above what the criteria require · disabled button label
  on its fill (known: 2.02:1, exempt under 1.4.3 Incidental)

El denominador es lo más fácil de inflar en un número de cobertura, así que se construye en dos pasos. El catálogo lleva los 50 criterios A y AA de WCAG 2.1 y etiqueta cada uno con la capa que puede decidirlo: 15 puede decidirlos un proceso de Jest, y el resto corresponden a la auditoría en dispositivo, al pase manual, o a nada de esto, como los criterios de audio y vídeo en una app que no tiene ni lo uno ni lo otro. Después, cada suite retira los que declara no aplicables, y por eso el design system se mide sobre 13. Esas declaraciones viven en un a11y-report.config.js en la raíz de la suite, y cada una lleva un motivo, estuviera el criterio en el conjunto contado o no. Nada obliga a que el motivo sea bueno; está ahí para que alguien que revise pueda no estar de acuerdo.

El trabajo de área táctil aparece en los informes de las suites que lo hacen, y nunca en esa fracción. La ejecución del design system lo dice en su propia sección:

## Checked beyond the counted scope

These ran and are reported, but they sit outside the WCAG 2.1 A + AA set the coverage
number is measured against, so they do not raise it.

- **WCAG 2.5.5** not in the A + AA catalogue — 4 checks

El SC 2.5.5 es de nivel AAA, así que contarlo inflaría una cifra de A y AA. Descartarlo sin decirlo ocultaría una comprobación que sí se ejecutó. El informe no hace ninguna de las dos cosas.

Eso es más o menos el artefacto que vende un escáner de accesibilidad de pago, generado a partir de tests que el equipo ya tiene.

Lo que estas comprobaciones no ven

Una suite en verde aquí demuestra menos de lo que parece.

CapaQué demuestraQué no puede
Suite de Jestpares de tokens, tamaños declarados, el nombre, el rol y el estado que expone un controlqué pinta un dispositivo, el orden real de recorrido, las zonas pulsables tras el recorte
Auditoría en dispositivoel contraste tal como se dibuja, las áreas táctiles recortadas, el tamaño en una pantalla realsi una etiqueta se lee bien en voz alta
Pase manualla experiencia real, orden de recorrido incluidodetectar cada regresión, en cada commit

La auditoría en dispositivo es performAccessibilityAudit en iOS y el Accessibility Test Framework en Android; el pase manual es una persona con VoiceOver o TalkBack.

El orden de foco corresponde al pase manual, que es la respuesta honesta a la tercera cosa que prometía el post 11. Una suite de Jest puede decir que hay elementos enfocables. No puede decir en qué orden los recorre un lector de pantalla, porque ese orden sale del layout en una pantalla real. Y la capa de dispositivo tampoco es un buen sitio para ello: el Accessibility Test Framework de Android tiene una comprobación de orden de recorrido y el performAccessibilityAudit de iOS no tiene equivalente, y un criterio que solo una plataforma puede automatizar no es un criterio que un informe deba reclamar. Así que se queda con la persona que maneja el lector de pantalla, que es donde lo etiqueta el catálogo.

Las capas no son una escalera donde la de arriba contiene a las de abajo. La matriz de tokens comprueba los pares que compone el design system, sobre las superficies en las que los compone, muestre o no una pantalla hoy un par dado; una auditoría en dispositivo comprueba lo que hay en la pantalla a la que se la apuntó, que es otro conjunto y nunca la paleta entera. Una ejecución automática limpia es necesaria y no suficiente, y lo mismo vale para un pase limpio en dispositivo.

Ejecútalo

Todas las suites, incluidas las anteriores a este post:

( cd packages/a11y-testing && npm test )
( cd packages/ui && npm test )
( cd packages/detail && npm test )
( cd apps/list && npm test )
( cd apps/party && npm test )
( cd apps/host && npm test )

La primera línea es el listón comprobándose a sí mismo. Un paquete que le dice a cuatro workspaces qué significa accesible debería poder demostrar sus propios helpers, y los casos que hay ahí son los que versiones anteriores hicieron mal.

Después, la capa de accesibilidad por su cuenta, con el informe:

( cd packages/ui && npm run test:a11y )
( cd packages/detail && npm run test:a11y )
( cd apps/list && npm run test:a11y )
( cd apps/party && npm run test:a11y )

Sin simulador y sin servidores de desarrollo. Cada paquete se publicó según se construía, así que aquí no se vuelve a publicar nada.

Lo que has construido, y lo que viene

Un solo paquete sostiene ahora el listón de accesibilidad de toda la federación, y cinco suites se ejecutan contra él: los helpers del propio listón, los tokens y componentes de @pokedex/ui, la pantalla compartida de @pokedex/detail, y las pantallas propias de cada remote.

Lo que salió de ahí se reparte según dónde vivía el fallo, y ese reparto es la clave. @pokedex/ui tenía once: dos primeros planos de badge por debajo de AA sobre su propio relleno, cuatro más por debajo sobre el scrim del hero, el glifo del botón flotante por debajo del listón de contraste no textual en tres de los dieciocho heroes, dos líneas de texto secundario que fallaban en los dos temas, y dos componentes que anunciaban una carga fallida en silencio. Suma el área táctil del botón de reintento y la del badge de la tarjeta, y son trece. Las tres apps se llevaron todos ellos con un salto de versión, que es el argumento entero a favor de un design system expresado como número.

@pokedex/detail tenía tres, y el host no lo instala, así que los tres se quedaron en los dos remotes: el área del botón Add, el número de dex del hero y los dos encabezados de sección de la hoja.

Cuatro no pudieron viajar en absoluto, y son los interesantes. Dos son las propias etiquetas de pestaña del host, en el shell que no posee ninguna pantalla. Los otros dos son el contador de party: código de app, la misma cabecera escrita dos veces, una en cada remote, sin paquete que la sostenga, con un fallo de cada clase, un recuento que no anunciaba nada y un numeral a 1,53:1 sobre su propia píldora. Cada uno de esos cuatro hubo que arreglarlo donde estaba, y dos de ellos hubo que arreglarlos dos veces.

Los fallos que importaron más estaban en las comprobaciones. Este post enseñó cuatro: un helper que tomó prestado el listón para un eje que nadie había declarado, una matriz que leía una constante en vez del token, esa misma matriz que leía la superficie equivocada, y un marcador que era solo una cadena. Aparecieron más después, en las suites del design system y en las del propio listón: una comprobación de scrim en el design system que comparaba dos constantes exportadas por el mismo módulo, un test de clamp cuyo valor no llegaba nunca al techo que limitaba, un helper de uso del color en el propio listón que leía metadatos de lector de pantalla en lugar del texto en pantalla, y una comprobación de área táctil que recurría a un número escrito en el test cuando su propio patrón dejaba de hacer match. Cada una de ellas tiene ahora un caso que falla si se quita la reparación, que es la única prueba de que una comprobación es real. Una comprobación apuntada un poco al lado de lo que dice medir informa en verde, y sigue informando en verde. Nada en la salida la distingue de una que funciona.

No queda nada abierto contra un criterio. Cinco pares lo estuvieron durante cinco rondas, todos con el mismo token de texto secundario, aparcados con el razonamiento de que ningún valor más oscuro supera todas las superficies. Era cierto y respondía a la pregunta equivocada: el design system entiende de temas, y los pares tomaron una clase tematizada en lugar de un token nuevo. Queda un hallazgo registrado y no es un resultado contra un criterio, porque el SC 1.4.3 exime la etiqueta de un control inactivo. El informe lo imprime bajo Project bars, sin contarlo en ninguna parte.

El límite honesto es la capa. Este post construye la capa Jest y ninguna de las otras dos. El andamiaje de auditoría en dispositivo es trabajo real que aquí no se ha hecho, y el pase manual con VoiceOver y TalkBack no es automatizable por diseño. Esta capa existe para la regresión: que lo que ya has arreglado siga arreglado, en cada commit, en cada remote, contra una única definición.

Lo siguiente: el host presta a los remotes su lado nativo. Un puente de navegación lanza una pantalla nativa desde la pestaña Party y espera su resultado.

Fuentes

Warren de Leon
Warren de Leon

Software Engineering Manager. Recientemente lideré el equipo de Mobile Platform en Hargreaves Lansdown. Escribo sobre liderazgo técnico, React Native y cómo construir buenos equipos.

Ver perfil