El post 11 va tancar amb una promesa: accessibilitat a la frontera, un paquet de tests compartit que comprova àrees tàctils, contrast i ordre de focus en remotes que el host només coneix en runtime. Dues d’aquestes tres coses caben en una suite de Jest. L’ordre de focus no, i El que aquestes comprovacions no veuen diu a qui pertoca.
El problema és el del post 11 un nivell més amunt. Tres equips publiquen tres bundles amb els seus calendaris, cadascun amb la seva idea del que compta com a accessible, i aquestes idees divergeixen igual que divergien els grisos abans que existís un design system. Escriure el llistó en una pàgina de wiki no atura aquesta deriva, perquè una convenció no té versió ni pas d’instal·lació. Un paquet té totes dues coses.
Així que el llistó passa a ser @pokedex/a11y-testing: un preset de Jest, un render que entén NativeWind, un conjunt reduït d’helpers d’asserció WCAG (Web Content Accessibility Guidelines), el catàleg de criteris A i AA, i un reporter. Es publica al mateix registry local de Verdaccio que la resta de paquets de la frontera, i l’instal·len tant els dos paquets d’origen com els dos remotes. És, a més, el primer paquet @pokedex que no arriba mai a un bundle. Tots els consumidors l’agafen com a devDependency, així que no canvia cap mapa compartit ni es mou res en runtime.
Els criteris que es comproven no són un invent d’aquest projecte. La Directiva europea d’accessibilitat (Directiva (UE) 2019/882) s’aplica des del 28 de juny de 2025 als productes i serveis que enumera. La Directiva fixa requisits funcionals i no anomena cap norma; EN 301 549, que aplica els criteris A i AA de WCAG 2.1 a les apps mòbils, és la norma amb què mesura la pràctica europea, i se n’espera la revisió a WCAG 2.2.
Per què no jest-axe
Al web això és un problema resolt i amb paquet propi. jest-axe embolcalla axe-core, expect(await axe(container)).toHaveNoViolations() cobreix una porció àmplia de les regles en una línia, i per a una app React de web és el primer moviment correcte.
axe-core comprova HTML. jest-axe necessita un entorn jsdom, i el seu README diu que les comprovacions de contrast de color no funcionen a jsdom, així que allà van desactivades. @axe-core/react-native directament no existeix com a paquet: el registry d’npm respon 404. Deque, que manté axe-core, sí que ven eines per a React Native, incloent-hi SDK de dispositiu que una execució de tests nativa pot cridar. Aquesta és la capa d’auditoria en dispositiu que anomena El que aquestes comprovacions no veuen, no la capa Jest que construeix aquest post.
React Native Testing Library (RNTL) aporta les queries i els matchers, i es queda a les portes d’una auditoria. Res de la seva API no retorna una llista de violacions. Quan vaig buscar treball previ sobre tests d’accessibilitat entre remotes de Module Federation no va aparèixer res, ni a React Native ni al web. Quan busques assercions d’accessibilitat per a React Native surten dos paquets de la comunitat, i cap dels dos no té la forma que cal aquí. react-native-accessibility-engine va publicar per última vegada el 2022. react-native-ama continua viu, tot i que has de saber que es va moure: el paquet sense scope es va quedar a 0.7.5 el 2023 i la feina va passar a un scope. Set dels vuit paquets @react-native-ama/* van publicar 1.2.1 l’agost de 2025, i cinc dels vuit van publicar una beta 2.0 el juliol de 2026. És una biblioteca de components amb comprovacions en runtime durant el desenvolupament, no una capa d’assercions per a Jest, així que respon una pregunta diferent de la d’aquest post.
El llistó es munta a mà: un render que resol classes, un grapat d’assercions i un informe.
Quatre fletxes, dues feines. Dues van als paquets que defineixen els píxels compartits, on una comprovació s’executa un cop per a tothom. Dues van als equips, on cada suite cobreix només les pantalles que compon aquell equip.
El paquet
Comença des del 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
Hi ha tres coses que han de ser certes abans que res d’això funcioni, i totes tres són fàcils de
donar per fetes. El registry local de Verdaccio ha d’estar aixecat amb @pokedex/contracts,
@pokedex/ui i @pokedex/detail publicats, que és la feina del post 11. npm ha de saber que
l’scope @pokedex és allà. I has d’haver iniciat sessió en aquest registry, que és un pas del
post 5 i no del post 11, perquè els publish
de més avall van autenticats encara que les lectures no.
Aixeca primer el registry, perquè l’npm adduser de sota necessita algun lloc on iniciar sessió:
npx verdaccio@6.2.0 # :4873, leave it running
Configura les altres dues coses un cop, per al teu usuari, perquè npm llegeix la configuració de
projecte des del directori que conté package.json, no des de l’arrel del repositori, així que
l’.npmrc del repo és invisible per a una ordre executada dins 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 vas construir el post 11, les seves seccions Un paquet per als píxels i El detail estrena
disseny publiquen els tres paquets que aquest instal·la. apps/host necessita les seves dependències; els dos remotes reben les seves amb els seus paquets més avall:
( cd apps/host && npm install )
El codi font és més del que un post pot reescriure amb profit. Descarrega la còpia de referència un cop i publica-la igual que el post 5 i el post 6 van publicar els seus. El tag s’allibera el mateix dia que surt aquest post, així que degit hi pot arribar:
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 )
L’anatomia segueix la dels paquets anteriors: publishConfig apunta a Verdaccio, com des del post 5, i react-native-builder-bob construeix un lib/ com a mòdul ES, el muntatge que va introduir @pokedex/ui al post 11. Hi ha una omissió deliberada. El paquet no inclou cap mapa exports. Els consumidors fan servir l’arrel, /jest-preset a cada configuració de Jest i /reporter.js a cada script test:a11y, i un mapa hauria d’anomenar-los tots tres. Si n’omets un, Node respon ERR_PACKAGE_PATH_NOT_EXPORTED, que en el cas del preset vol dir que no s’executa ni un test.
jest-preset.js estén @react-native/jest-preset en lloc de substituir-lo, i això és el que permet que totes les suites que ja existien continuïn passant sense tocar-les. Les dues incorporacions que importen aquí tenen a veure amb NativeWind. nativewind/babel se suma als presets de transformació, així que className arriba al runtime d’estils convertit en style i no s’hi queda com una prop inerta que fa que tota asserció de color llegeixi undefined. I react-native-css-interop/dist/test/setupAfterEnv.js se suma a setupFilesAfterEnv, que és on viu el matcher toHaveStyle. Cap de les dues línies no és el que fa funcionar les comprovacions d’aquest post: la matriu de contrast llegeix directament l’objecte de colors del preset, i les comprovacions de component llegeixen cadenes de classes. Però un preset compartit també ha de servir la suite que sí que fa assercions sobre un estil resolt, i es comprova que totes dues hi són perquè una edició futura no les pugui treure sense fer soroll.
Dos dels tres punts d'entrada que aquest paquet importa no estan documentats.
nativewind/babelno és cap problema: és el pas 3 de la guia d'instal·lació de NativeWind. Els altres dos no estan escrits enlloc.react-native-css-interop/dist/test/setupAfterEnv.js, que el preset afegeix asetupFilesAfterEnv, inativewind/test, que importa el render, existeixen, es publiquen i sostenen la suite del mateix NativeWind, i nativewind.dev no té cap secció de testing. Així que les versions amb què està provat queden registrades en lloc de donar-se per fetes: nativewind 4.2.6 i react-native-css-interop 0.2.6, amb rangs caret i revisades cada cop que NativeWind es mou.
RNTL es manté per sota de la 14 per un motiu relacionat: la versió 14 va reescriure render, fireEvent i act com a asíncrons, i aquesta ruta de render no s’hi ha provat. Tampoc no recorris a @testing-library/jest-native; està deprecat, i RNTL porta els seus matchers des de la 12.4.
createThemedRender enllaça un preset de Tailwind un sol cop i retorna el render que fan servir totes les suites d’aquell paquet:
const renderWithTheme = createThemedRender(require('@pokedex/ui/tailwind.preset.js'));
const { getByRole } = await renderWithTheme(<SomeButton disabled />);
Rebre el preset com a argument manté el paquet d’accessibilitat lliure de qualsevol dependència del design system. És una eina, no un membre de la federació. Enllaçar-lo al preset del mateix @pokedex/ui és el que permet que una suite resolgui una classe igual que la resoldrà l’app, que és el que llegeixen les comprovacions d’àrea tàctil i contra què es calculen els helpers de contrast.
Els helpers són funcions normals que criden l’expect global, no matchers d’expect.extend. Un helper importat pel seu nom no necessita fitxer de setup, es comporta igual a tots els paquets i apareix a la traça amb el seu nom. Cap no importa React Native: un element de test és qualsevol cosa amb props. Cadascun està lligat a un criteri d’èxit (SC) i al llistó que fixa aquell criteri.
| Què comprova | Criteri | Nivell | El llistó |
|---|---|---|---|
| Text sobre una superfície | SC 1.4.3 Contrast (Minimum) | AA | 4,5:1 text normal, 3:1 text gran |
| El contorn del mateix control | SC 1.4.11 Non-text Contrast | AA | 3:1 |
| El que llegeix un lector de pantalla | SC 4.1.2 Name, Role, Value | A | nom, rol i estat |
| Significat que no depèn del color | SC 1.4.1 Use of Color | A | la informació també és en paraules |
| Un canvi anunciat sense moure el focus | SC 4.1.3 Status Messages | AA | ho transmet un rol o una live region |
| Àrea tàctil | SC 2.5.5 Target Size (Enhanced) | AAA | 44 per 44 píxels CSS |
La fila de l’àrea tàctil convé llegir-la dues vegades. Els 44 pt són el llistó d’aquest projecte, i són el número d’Apple, no el de WCAG. Les Human Interface Guidelines actuals donen a iOS i iPadOS una mida de control per defecte de 44 per 44 punts i un mínim de 28 per 28, així que 44 és la mida amb què Apple et demana dissenyar, no la més petita que admet. Una pàgina més antiga d’Apple, que encara se cita molt, només diu “at least 44 points x 44 points”, i d’aquí ve l’abreviatura «el mínim d’Apple». Pel costat de WCAG, l’SC 2.5.5 és de nivell AAA, i el criteri AA de WCAG 2.2, l’SC 2.5.8 Target Size (Minimum), demana 24 per 24 píxels CSS amb cinc excepcions. Quaranta-quatre ho supera de sobres. No ho supera tot: la guia d’Android demana com a mínim 48 dp, així que un projecte que publica a totes dues plataformes i es queda a 44 ha pres la mida recomanada per Apple i ha acceptat que Android en preferiria més. És una decisió que val la pena prendre conscientment. L’error que cal evitar és dir-ne, de qualsevol d’aquestes coses, «el que exigeix WCAG AA».
L’helper de contrast fa servir 0.04045 a la linealització de canal. La definició de luminància relativa portava 0.03928 abans del maig de 2021 i la constant antiga encara es copia arreu. La nota de l’especificació diu que el canvi no afecta els resultats a la pràctica, i la constant actual no costa res.
L’helper d’àrea tàctil té un comportament que decideix quant val tota la capa: quan no pot veure, llança en lloc de passar. I això s’aplica per eix. Un control que declara alçada però no amplada no ha estat mesurat en amplada, i manllevar el llistó per a l’eix que ningú no ha declarat el donaria per accessible justament en el moment en què el test no el pot veure. El hitSlop també es mesura, no només es comprova que hi sigui, perquè un hitSlop: 0 no amplia res.
Aquesta regla és la diferència entre una suite i un adorn, i és el que aquest paquet va fer malament primer: una versió inicial va manllevar el llistó per a un eix sense declarar, així que un botó que només indicava l’alçada passava amb una amplada que ningú no havia mesurat. Ara té el seu test de regressió, a la suite del mateix paquet.
Comprovar l’origen
Els dos paquets d’origen instal·len el llistó i apunten Jest cap a ell. @tailwindcss/container-queries no és opcional aquí: sense ell, el render de test falla amb 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 )
Les suites, les dues configuracions de Jest i les declaracions de no aplicabilitat de cada suite venen de la còpia de referència, i a cada paquet s’hi afegeix 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/
Els canvis del design system arriben per la mateixa via. Són les reparacions de tokens de què tracta la resta d’aquesta secció, i han de ser a l’arbre abans que la matriu pugui passar:
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 recull la decisió de dues superfícies que la matriu és a punt de demostrar. Els
altres tres són la mateixa errada en controls més petits: l’scrim fosc del botó flotant de retorn, que
la secció següent mesura al costat del del badge; el badge d’eliminar de la targeta, l’àrea tàctil del
qual reprèn El mateix llistó per a cada remote; i les dues línies de text secundari que pinten la
targeta i el buit, que repara la secció 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 necessita un canvi més de script, i saltar-se’l publica un paquet que no es pot importar. La seva suite viu a __tests__, així que el seu tsconfig.json ara inclou aquell directori, cosa que dona a tsc dues arrels i baixa la sortida a dist/src/. package.json continua declarant dist/index.js. Així que ajusta a mà l’script de build de packages/detail, al seu package.json, amb la configuració que manté src com a única arrel i que neteja dist abans perquè no sobrevisqui res del muntatge anterior:
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json"
Un tsc que acaba amb codi 0 i escriu els fitxers un directori més avall és la mateixa errada que una comprovació en verd que mesura el que no toca: ningú no es queixa fins que la primera app que instal·la el paquet s’atura amb Cannot find module.
packages/ui ja tenia tests unitaris per als seus components. Hi suma una capa d’accessibilitat al costat, i la meitat interessant és la matriu de tokens. Cada parella de primer pla i fons que promet el design system es comprova un cop, al paquet que defineix els 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 és la part interessant, i hi és perquè la primera versió d’aquest fitxer no la tenia. Aquella versió mapava la classe a un literal: 'text-black': '#000000'. Els divuit tipus passaven, i el número que imprimia era cert per a un color que l’app no pintava mai.
El design system defineix el seu propi
black.tailwind.preset.jsfixablack: '#2E3138'per a superfícies gairebé negres, i això emmascara el valor per defecte de Tailwind. Així quetext-blackpintava#2E3138, i sobre el farciment d'aigua això dona 3,74:1 on el mateix test exigia 4,5:1. Dos dels divuit fallaven a la pantalla mentre la matriu informava dels divuit en verd, perquè la matriu mesurava una constant en lloc del token.
La reparació té dues parts, i només la segona és duradora. El primer pla del badge va passar al seu propi token, typeInk, que és el negre real contra el qual s’havia calculat el mapa de contrast des del principi. I la matriu va deixar d’escriure hexadecimals: resol els dos costats de cada parella des del preset, així que un token reanomenat o emmascarat fa fallar la suite en lloc de passar desapercebut. Els dos costats importen. Una versió intermèdia resolia només el primer pla i continuava llegint els fons del mòdul de tokens, cosa que deixava el mateix forat obert per l’altra meitat: enfosquir un farciment de tipus fins a 1,64:1 continuava deixant les divuit parelles en verd. Torna a apuntar typeInk a #2E3138 al preset i sis comprovacions passen a vermell a l’instant: els badges d’aigua i psíquic sobre els seus farciments, els de roca, fantasma i drac sobre la superfície del hero, i la comprovació que lliga el preset als mòduls de tokens.
Ara passen els divuit farciments, i la frase vol dir una cosa que abans no volia dir. Una parella que passa aquí passa a tots els remotes que la componen, i ningú no torna a comprovar un badge de tipus a l’app Party.
«Tots els remotes que la componen» carrega molt de pes en aquesta frase, i val la pena precisar què vol dir compondre una parella. Un badge sobre una targeta es recolza en el color del tipus. Un badge sobre el hero de detall no: el hero és el color del tipus, així que una píndola sòlida s’hi dissoldria, i la variant de hero hi deixa al damunt un scrim blanc al 30%. Aquest scrim és translúcid, però no transparent, i el primer pla triat per al farciment sòlid és l’equivocat per a quatre dels sis tipus prou foscos per admetre text blanc a sobre. Roca, fantasma, drac i acer es quedaven a 3,17:1, 3,14:1, 3,39:1 i 2,71:1 sobre la superfície on realment es dibuixaven, mentre la matriu comprovava una superfície que no era aquella.
Així que hi ha dues decisions, no una, i el design system ara les calcula totes dues. La matriu compon l’scrim exactament com el compon el runtime i comprova el badge contra això:
const scrim = composite(hexForClass('bg-white'), HERO_SCRIM_ALPHA, hexForClass(bgClassForType(type)));
expectColorContrast(hexForClass(textOnHeroScrimClass(type)), scrim, 'normalText');
Totes dues errades tenen la mateixa forma: una comprovació que mesura una cosa contigua al que es publica. Cap suite en verd no ho revela.
El botó flotant de retorn és el tercer d’aquesta forma, un control més enllà. Deixa el seu propi scrim
translúcid sobre el hero on aterri, i pintava bg-black/35, un altre cop el neutre gairebé negre, al
35%. Sobre els tres farciments més clars el xebró blanc es quedava a 2,81:1 en volador, 2,83 en gel i
2,84 en elèctric, per sota del 3:1 que l’SC 1.4.11 demana per al contorn d’un control. El negre real
amb la mateixa alfa supera tots els farciments, amb 3,48 en el pitjor cas.
Per a això no manlleva typeInk. typeInk és un primer pla: cada ús seu és una classe text-, i
l’experiment de més amunt el reverteix per demostrar que el text del badge es mou. Un scrim pintat des
d’aquest mateix token quedaria arrossegat per aquell experiment, i així una decisió sobre la tinta del
badge acabaria governant un control que no hi té res a veure. La meitat de fons del mateix fet rep el
seu nom:
export const BACK_PILL_SCRIM_ALPHA = 0.35;
export const BACK_PILL_SCRIM_CLASS = 'bg-scrim/35';
La matriu llegeix el token i l’alfa d’aquesta única classe, així que el valor que pinta el botó i el valor que compon el mapa no es poden separar.
Un token no passava. midGrey (#9A9AB0) pintava text secundari en quatre llocs sobre tres superfícies clares: dos encapçalaments de secció al full de detall (2,60:1), la línia del número dins de la seva píndola grisa a la targeta (2,46:1) i l’etiqueta del botó Add desactivat sobre el seu farciment (2,02:1). La matriu registrava cada superfície per separat, mitjançant un helper que exporta el paquet:
knownFinding('disabled button label on its fill', '2.02:1, exempt under 1.4.3 Incidental', () => {
expectColorContrast(colours.midGrey, colours.lightGrey);
});
knownFinding embolcalla it.failing, que Jest informa com a passat, així que la suite es manté en verd mentre la troballa continua a la vista i el reporter la llista a part de les violacions. L’helper hi és perquè la primera versió escrivia aquella crida a mà, amb un títol que començava per (known i sobre el qual feia match el reporter. Un marcador que és només una cadena és a una errada tipogràfica del silenci: escriu-lo malament i el reporter deixa de veure una troballa registrada, it.failing continua informant en verd, i una violació real es llegeix com un test normal que passa. Escriure el títol en un únic lloc és el que fa impossible equivocar-se amb el marcador.
Dues superfícies més es van sumar a la llista tan bon punt la matriu va començar a resoldre el que mesura. La llegenda del buit és text-midGrey/70, no el token: composta sobre el fons de l’app dona 1,88:1. I tot això era el tema clar. Els dos remotes munten un toggle de tema a la capçalera, així que cadascuna d’aquelles superfícies té una contrapart fosca que el design system també compon, i text-midGrey no portava cap variant dark: en cap dels dos llocs. La línia del número de la targeta es recolza en bg-white/10 sobre gairebé negre amb 3,44:1, i la llegenda del buit sobre navy amb 3,82:1. Totes dues són text xs, així que totes dues necessiten 4,5:1.
Cinc parelles, doncs, totes per sota del llistó i en una pantalla que publiquen les dues apps. Van quedar aparcades durant cinc rondes darrere d’un motiu que es llegeix bé i no sobreviu a l’aritmètica: no hi ha cap valor més fosc que les arregli totes. A partir de més o menys #5F5F6D el token supera AA a les tres superfícies clares, i el full de detall és dark:bg-navy, així que una parella que avui és a uns còmodes 6,48:1 cauria a 2,84:1. Tot cert, i res d’això decisiu, perquè aquí no cal cap valor únic. El design system entén de temes. Ja publicava dark:text-lightGrey en vuit llocs més, i al text secundari senzillament mai no se li havia donat el mateix tracte:
<Text size="xs" className="text-darkGrey dark:text-lightGrey">
packages/ui es queda dos d’aquells quatre llocs, i arriben amb els components copiats abans: la línia del número de la targeta i la llegenda del buit, que ara pinta el token a plena intensitat en lloc del 70%. Els altres dos són els encapçalaments de secció del full de detall, així que són una edició a mà a packages/detail/src/PokemonDetailView.tsx, en tots dos:
// Els dos encapçalaments de secció. El text secundari es tematitza aquí com qualsevol
// altra línia secundària del design system; la versió aparcada no tenia variant fosca.
<Text size="xs" bold className="uppercase tracking-widest text-darkGrey dark:text-lightGrey">
La pitjor de les sis parelles és ara 6,94:1. Aparcar una troballa és una opció legítima i aquest post encara en manté una, però «és una decisió de paleta» va resultar ser la tapadora d’un canvi que es va resoldre amb una classe.
I llavors la matriu es va posar verda, i amb el verd no n’hi havia prou. Tornar a posar text-midGrey a la targeta va deixar en verd les cent quaranta-nou comprovacions, perquè una matriu mesura parelles i no veu mai quina classe pinta un component. És el mateix forat pel qual s’escolen els tints de les pestanyes del host, i admet la mateixa resposta: una comprovació que llegeix el codi del mateix component.
expect(secondaryClass('pokemon-card.tsx')).toEqual({ light: 'darkGrey', dark: 'lightGrey' });
Una asserció sobre el render no ho hauria tancat. nativewind/test compila només l’arbre que passes a render, així que una classe que un component tria dins del seu propi render no es compila mai, i l’asserció passa sense comprovar res.
Queda una troballa registrada, i no és cap incompliment de criteri. L’excepció Incidental de l’SC 1.4.3 eximeix el text i les imatges de text que formen part d’un component d’interfície inactiu, així que l’etiqueta del botó Add desactivat no té cap requisit de contrast que incomplir. Està registrada perquè aquest projecte prefereix que un control desactivat continuï essent llegible, i viu sota un encapçalament Project bars que ho diu, en lloc de sota 1.4.3, on seria la mateixa citació indeguda que el plantejament de l’àrea tàctil s’esforça a evitar.
packages/detail rep després la seva suite, i allà la forma importa més que les assercions: una biblioteca de components que publica els seus tests d’accessibilitat. Els tests passen a PokemonDetailView les seves props directament, que és un altre cop la frontera de props del post 8. Sense store, sense query client, sense navigator. Els dos consumidors hereten el que demostri aquest fitxer, i quan hi troba alguna cosa, una release de pedaç els repara tots dos. Una nota d’ordre si executes aquesta suite aviat: llegeix la decisió de l’scrim del hero des de @pokedex/ui, i el lockfile del post 11 fixa una versió anterior a aquella decisió. La suite s’executa, i vint-i-quatre de les seves trenta comprovacions fallen, per quatre causes. Quatre llancen textOnHeroScrimClass is not a function, que repara la release del design system de més avall. Una és l’àrea tàctil del botó Add, i una altra són els encapçalaments de secció del full, encara amb el token aparcat. Les altres divuit són una comprovació per tipus, i totes diuen el mateix sobre el número de dex del hero, que la secció següent repara en una línia. Tres d’aquelles quatre causes són troballes de què tracta aquest post, així que una execució en vermell aquí és la forma esperada i no un clon trencat.
Un test que falla, i després l’arranjament
L’errada per a la qual es va construir aquesta suite no es va produir. S’esperava que el botó Add desactivat no informés de cap accessibilityState, de manera que un lector de pantalla es trobés un botó que s’apaga visualment i no diu res sobre no estar disponible. N’informa correctament. El Pressable de React Native copia pel seu compte la prop disabled dins d’accessibilityState:
_accessibilityState =
disabled != null ? {..._accessibilityState, disabled} : _accessibilityState;
El test es queda igualment, com a xarxa de regressió i no com a arranjament. El dia que algú desactivi aquell botó canviant el handler per una funció buida i apagant el farciment a mà, l’estat es quedarà mut i aquest test ho dirà.
El que sí que va fallar va ser la comprovació del costat:
● 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.
Darrere d’aquella línia hi ha dues coses, i totes dues van més enllà d’aquest botó.
El botó no declarava cap mida que la suite pogués llegir. La seva alçada venia de la variant size="lg" de gluestack, que compon px-6 h-11 mentre el component es renderitza. A l’app aquella classe no dona problemes: Tailwind escaneja el codi del design system, hi troba el literal h-11, i el bundle construït porta la regla. A l’arbre de test no, i el motiu va més enllà d’aquest botó. nativewind/test compila les cadenes de classes que troba a l’arbre d’elements que passes a render, i les dona a Tailwind com a contingut seu. Una classe que apareix dins d’un component fill no és en aquell arbre, així que no es compila mai: ni una que construeixi una variant, ni una de literal. El botó Add arribava amb {"alignSelf": "stretch"} i res més, i l’helper llança quan no hi ha res, que és l’única raó per la qual això va sortir a la llum.
I
h-11no són 44, d'entrada. Tailwind genera.h-11 { height: 2.75rem }, i el rem de NativeWind a React Native és 14, no el 16 del navegador. Un<View className="h-11" />literal renderitzaheight: 38.5, tant a l'arbre de test com a l'app. Així que la mida que demana aquella variant era per sota dels 44 d'Apple des del principi, la pogués veure o no cap suite. Dues errades diferents en un mateix control: una mida que el test no podia llegir, i una mida que no hauria superat el llistó si l'hagués pogut llegir. Per això les comprovacions d'àrea tàctil llegeixen l'estil i les props declarats, en lloc de deduir un número a partir d'una classe.
L’arranjament posa el llistó on una variant no el pugui moure sense fer soroll. A packages/detail/src/PokemonDetailView.tsx, al botó Add:
// El llistó de 44 pt es declara aquí en lloc d'heretar-lo de la variant de mida.
// Una variant és una decisió visual que pot canviar; la mida mínima polsable de
// l'acció principal de la pantalla és un compromís, i la suite d'accessibilitat
// només pot verificar el que el control declara de debò. `alignSelf: 'stretch'`
// fa el botó molt més ample que 44, però stretch és una instrucció de layout,
// no una mesura, així que el mínim s'indica en tots dos eixos.
style={{ alignSelf: 'stretch', minWidth: 44, minHeight: 44 }}
El mateix fitxer portava l’errada contrària, cent línies més amunt, al hero sota el qual es recolza el botó. El hero pinta el color del tipus a plena intensitat, i un comentari a sobre explica que el seu text no pot ser un color fix, perquè el design system ja va decidir per tipus i el hero hauria de preguntar-ho al token en lloc de donar-ho per fet. El número de dex sota el nom sí que ho donava per fet. S’atenuava sol:
// Abans: una segona resposta, inventada una línia sota el comentari que deia que no.
const heroMuted = heroInk ? 'text-white/70' : 'text-black/60';
text-black es resol aquí a #2E3138, el neutre que el preset adverteix que no és un primer pla, i
al 60% sobre el farciment mesura 2,21:1 en aigua. Fallen setze dels divuit tipus. No hi ha cap alfa que
ho rescati: escombra el valor de l’1% al 100% i el millor cas és plena intensitat, on aigua continua a
3,74:1, perquè la tinta és la tinta equivocada. Així que la línia pregunta el mateix que pregunta el
nom, a packages/detail/src/PokemonDetailView.tsx:
// Esborra heroMuted. El número de dex pren la mateixa decisió que pren el nom.
<Text size="sm" className={`font-head ${onHero}`}>
{dexNumber}
</Text>
El pitjor cas passa a ser acer amb 4,71:1, i la parella és una que la matriu de tokens ja mesura, així que no cal afegir-hi res de nou. Una variant atenuada hauria necessitat la seva pròpia fila.
L’errada del botó Add es repeteix un nivell més avall. L’ErrorState del design system construeix el seu botó de reintent amb size="md", que és h-10, o 35 punts, i aquell reintent és la tornada enrere des d’una càrrega fallida a les tres apps. La seva declaració va al design system i no a l’app que se’n va adonar primer, a packages/ui/src/components/error-state.tsx:
// El reintent és la tornada enrere des d'una càrrega fallida, així que la seva mida polsable
// es declara aquí en lloc de deixar-la en mans de la variant de mida. Una variant és una
// decisió visual; l'àrea mínima és un compromís, i una suite només pot verificar una àrea
// declarada. Es declaren tots dos eixos: el botó és molt més ample que 44 a tots els layouts
// on apareix, però una amplada que ningú no indica és una amplada que ningú no ha mesurat.
<Button
action="primary"
size="md"
onPress={onRetry}
style={{ minWidth: 44, minHeight: 44 }}>
Al costat hi ha una troballa més, en dos components alhora, i és la que una revisió visual no detecta mai. Ni ErrorState ni LoadingState no tenien cap semàntica d’estat: ni rol, ni live region, res. Es veien correctes, i per a un lector de pantalla una càrrega fallida senzillament passava en silenci.
No reben la mateixa reparació, perquè no són el mateix tipus de missatge. Una càrrega fallida ha d’interrompre; un spinner no. Així que error-state.tsx passa a ser una alerta, que s’anuncia a totes dues plataformes sense necessitat de live region:
<Center className="flex-1 px-6" accessible accessibilityRole="alert">
i loading-state.tsx rep la variant educada, que espera que acabi el que el lector de pantalla ja està dient:
<Center className="flex-1" accessible accessibilityLiveRegion="polite">
El design system es queda cinc d’aquestes reparacions i packages/detail tres, així que cadascun és
una release i no diverses:
( 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 també es queda la release, i té una reparació pròpia: l’única errada d’aquest post que viu al shell i no en un paquet 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/
Les dues etiquetes de pestanya són de 10 pt, i totes dues fallaven. L’activa agafava colours.blue, el
farciment de marca: 3,48:1 sobre la barra clara, 3,74:1 sobre la fosca. La inactiva no es fixava enlloc,
així que React Navigation la derivava barrejant a mitges el text del tema amb el color de la mateixa
barra, cosa que dona 3,27:1 sobre blanc. Sempre hi ha una pestanya inactiva, així que aquella parella és
a la pantalla sempre que hi és l’app. colours.ts porta dos blaus llegibles; els grisos són els que ja
fa servir qualsevol altra línia secundària:
tabBarActiveTintColor: mode === 'dark' ? colours.blueTextDark : colours.blueText,
tabBarInactiveTintColor: mode === 'dark' ? colours.lightGrey : colours.darkGrey,
TabBar.test.tsx explica per què aquesta comprovació mereix fitxer propi. La matriu de @pokedex/ui
demostra que aquelles quatre parelles superen AA, i no pot demostrar que el host les faci servir:
revertir el tint actiu a colours.blue deixa en verd totes les comprovacions del design system. Una
matriu mesura parelles; només a l’app se li pot preguntar si les compon. Llegeix codi en lloc d’un
render, perquè una opció del navigator es resol dins de React Navigation i no arriba a cap element que
una suite pugui consultar.
( cd apps/host && npm install @pokedex/ui@1.0.13 )
Dues releases de pedaç, tres apps i cap coordinació entre els equips que les publiquen. El rang caret que ja portava cada consumidor és el que converteix això en un pedaç i no en una negociació. La suite de la Pokédex deixa constància del que això vol dir al lloc on és més fàcil malinterpretar-ho:
// Això passa perquè el design system declara la mida al botó d'ErrorState, no
// perquè aquesta app hagi fet res. Una release de paquet, el reintent de cada remote cobert.
expectMinTouchTarget(buttonContaining(getByText('Try again')));
Renderitza el mateix ErrorState al seu boundary de càrrega de remotes, així que passa a la mateixa versió que els dos remotes en lloc de quedar-se enrere, cosa que per a un paquet que el host proporciona com a singleton eager no és opcional.
Fins on et porta aquell reintent depèn de què hagi fallat. Una petició de dades fallida es reintenta sense problema. Un remote que no ha respost mai és un cas més difícil: el boundary descarta el rebuig que React té en cache i engega una importació nova, i és per això que el botó visiblement fa alguna cosa, però torna la mateixa errada un cop i un altre. El runtime es comporta com si continués retenint per sota la petició fallida del manifest. Això és una observació i no una garantia documentada, així que tracta el comportament com la troballa i no com la causa. Netejar-ho requereix invalidar el cache del runtime, i aquella invalidació només val la pena quan ja hi ha alguna cosa a què recórrer, així que totes dues coses arriben juntes al post de resiliència, més endavant en aquesta sèrie. El que sí que poden dir les comprovacions d’aquest post és més estret i val la pena dir-ho igualment: faci el que faci el reintent, s’hi pot arribar, té nom i és prou gran per polsar-lo.
El mateix llistó per a cada remote
Els dos remotes instal·len les mateixes tres devDependencies, i agafen els dos paquets reparats a la mateixa ordre:
( 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 agafa la seva suite, la seva configuració de Jest i les seves declaracions de no aplicabilitat, i el README es posa al dia amb 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 .
Les dues pantalles porten les reparacions del comptador. PokedexScreen.tsx té l’arranjament de live
region de més avall, i totes dues pantalles pinten la mateixa píndola de recompte, el numeral de la qual
era text-darkGreen sobre bg-lightGreen: 1,53:1, negreta de 10,5 pt, a la capçalera de cada
remote. darkGreen és #A6D3A0, el mateix valor que el farciment de planta, així que l’única cosa
fosca d’aquell token era el nom. Totes dues fan servir ara text-darkGrey, el color que ja feia servir
l’etiqueta del costat, amb 7,18:1, una parella que la matriu de tokens sosté des de sempre. El
comptador en componia una altra que ningú no havia mesurat.
L’styleMock.js és més avorrit i igual de necessari, i hi és perquè PartyStack arrossega global.css, a un salt de distància a través de ./styles, perquè les classes del remote arribin al runtime d’estils compartit quan el host carrega el mòdul exposat. L’entry de la mateixa app ja cobreix la build standalone; sense la importació a PartyStack els estils funcionen en standalone i en federat no fan res, sense avisar. Jest no té loader de CSS, així que la configuració copiada mapa aquella importació a l’stub. Sense ell la suite de Party no falla: es nega a executar-se.
Després, l’script que passa els fitxers d’accessibilitat pel 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
Les dues configuracions de Jest apunten al preset compartit. Com que estén el preset de React Native, les suites que ja tenia cada app continuen executant-se, i la regla és ampliar-ne l’allowlist en lloc de reemplaçar-la. En esquema, perquè els fitxers copiats porten més entrades que aquestes:
const preset = require('@pokedex/a11y-testing/jest-preset');
module.exports = {
preset: '@pokedex/a11y-testing',
transformIgnorePatterns: [
`node_modules/(?!(${[...preset.uncompiledPackages, '@react-navigation'].join('|')})/)`,
],
};
Reemplaçar aquell array és la manera silenciosa que un preset compartit deixi de ser compartit. No falla en silenci, això sí: l’stack d’estils publica mòduls ES, així que Jest topa amb SyntaxError: Cannot use import statement outside a module i no executa res. Aquest és el cas bo. El parany és que l’error assenyala un fitxer al fons de node_modules i es llegeix com una dependència trencada en lloc d’una línia de configuració que pertany a l’app. Exportar la llista fa que ampliar-la sigui el moviment fàcil.
Cada suite cobreix només el que compon el seu equip, i la manera de mantenir-ho honest és renderitzar les pantalles del mateix equip en lloc dels components del design system. Tots dos fitxers munten l’stack real amb l’store real i el navigator real, el mateix bastiment que fan servir els tests que ja tenien les 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>,
);
Això és més preparació que muntar un PokemonCard directament, i és la diferència entre comprovar aquesta app i comprovar el paquet d’un altre. Una suite que renderitza components del design system torna a comprovar el que l’origen ja va resoldre, que és justament la duplicació que el contracte d’origen ha d’eliminar.
Així que la Pokédex comprova les files que produeixen les seves dades de query, els estats en què pot acabar la seva pantalla i el seu comptador de party. La Party comprova la seva graella: un buit ocupat és un botó que anomena el seu Pokémon, els que queden per omplir són buits amb nom i no quatre silencis idèntics, i eliminar és una acció d’accessibilitat sobre la targeta i no una segona parada de focus al costat.
expect(getByLabelText(/^Pikachu, number 025/).props.accessibilityActions).toEqual([
{ name: 'remove', label: 'Remove from party' },
]);
El badge ✕ és petit, 21 punts, perquè h-6 són 1.5rem i el rem de NativeWind és 14, així que el seu hitSlop el porta a 45 en lloc dels 41 que tenia, i convertir-lo en la seva parada de focus duplicaria el nombre de coses per passar lliscant en una party plena. El design system l’amaga i exposa l’eliminació com a acció, així que qui fa servir un lector de pantalla hi arriba pel rotor de VoiceOver o pel menú d’accions de TalkBack. La comprovació és que l’acció existeixi, perquè un control amagat sense res al darrere és senzillament inabastable.
Renderitzar la pantalla real és també el que va convertir la segona errada del comptador en un arranjament i no en una nota. La capçalera de la Pokédex mostra «My Party» al costat d’un recompte sobre sis, i aquell recompte canvia quan s’afegeix alguna cosa des d’una altra pantalla, sense que el focus es mogui. Qui veu la pantalla mira com avança el número; a qui fa servir un lector de pantalla no se li deia res, perquè la capçalera era text estàtic. Això és l’SC 4.1.3 sencer, i ara és una live region amb una etiqueta que explicita la proporció, perquè un «3/6» i prou queda a mercè de com el llegeixi cada lector de pantalla:
<Box
accessible
accessibilityLiveRegion="polite"
accessibilityLabel={`My Party, ${partyCount} of ${MAX_PARTY}`}>
La capçalera de la mateixa Party necessita les mateixes tres props, i aquest és el cost d’una errada que viu en codi d’app per duplicat: dues pantalles, dues suites i res que obligui a mirar la segona.
Un test que comprovés això contra un objecte escrit a mà no hauria demostrat res sobre l’app. Contra la pantalla renderitzada falla si es treuen les props, que és l’única versió que val la pena conservar.
L’informe
De cada suite en surt un accessibility-report.md, que escriu el reporter quan acaba l’execució:
( cd packages/ui && npm run test:a11y )
El reporter llegeix el criteri del títol de cada describe, així que WCAG 1.4.3 … és tota la configuració que hi ha. L’execució del design system comença així:
# 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.
La parella exempta no hi apareix. Té el seu encapçalament, perquè un llindar que tria un projecte no és un resultat contra un criteri, i arxivar l’un com l’altre és la manera que un número de cobertura deixi de significar res:
## 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 és el més fàcil d’inflar en un número de cobertura, així que es construeix en dos passos. El catàleg porta els 50 criteris A i AA de WCAG 2.1 i etiqueta cadascun amb la capa que el pot decidir: 15 els pot decidir un procés de Jest, i la resta pertoquen a l’auditoria en dispositiu, a la passada manual, o a res d’això, com els criteris d’àudio i vídeo en una app que no té ni l’una cosa ni l’altra. Després, cada suite en retira els que declara no aplicables, i per això el design system es mesura sobre 13. Aquelles declaracions viuen en un a11y-report.config.js a l’arrel de la suite, i cadascuna porta un motiu, fos o no al conjunt comptat el criteri. Res no obliga que el motiu sigui bo; hi és perquè qui revisi hi pugui no estar d’acord.
La feina d’àrea tàctil apareix als informes de les suites que la fan, i mai en aquella fracció. L’execució del design system ho diu a la seva secció:
## 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
L’SC 2.5.5 és de nivell AAA, així que comptar-lo inflaria una xifra d’A i AA. Descartar-lo sense dir-ho amagaria una comprovació que sí que es va executar. L’informe no fa cap de les dues coses.
Això és més o menys l’artefacte que ven un escàner d’accessibilitat de pagament, generat a partir de tests que l’equip ja té.
El que aquestes comprovacions no veuen
Una suite en verd aquí demostra menys del que sembla.
| Capa | Què demostra | Què no pot |
|---|---|---|
| Suite de Jest | parelles de tokens, mides declarades, el nom, el rol i l’estat que exposa un control | què pinta un dispositiu, l’ordre real de recorregut, les zones polsables després del retall |
| Auditoria en dispositiu | el contrast tal com es dibuixa, els objectius retallats, la mida en una pantalla real | si una etiqueta es llegeix bé en veu alta |
| Passada manual | l’experiència real, ordre de recorregut inclòs | detectar cada regressió, a cada commit |
L’auditoria en dispositiu és performAccessibilityAudit a iOS i l’Accessibility Test Framework a Android; la passada manual és una persona amb VoiceOver o TalkBack.
L’ordre de focus pertoca a la passada manual, que és la resposta honesta a la tercera cosa que prometia el post 11. Una suite de Jest pot dir que hi ha elements enfocables. No pot dir en quin ordre els recorre un lector de pantalla, perquè aquell ordre surt del layout en una pantalla real. I la capa de dispositiu tampoc no és un bon lloc per a això: l’Accessibility Test Framework d’Android té una comprovació d’ordre de recorregut i el performAccessibilityAudit d’iOS no en té equivalent, i un criteri que només una plataforma pot automatitzar no és un criteri que un informe hagi de reclamar. Així que es queda amb la persona que fa servir el lector de pantalla, que és on l’etiqueta el catàleg.
Les capes no són una escala on la de dalt conté les de sota. La matriu de tokens comprova les parelles que compon el design system, sobre les superfícies on les compon, mostri o no una pantalla avui una parella concreta; una auditoria en dispositiu comprova el que hi ha a la pantalla cap a la qual se la va apuntar, que és un altre conjunt i mai la paleta sencera. Una execució automàtica neta és necessària i no suficient, i el mateix val per a una passada neta en dispositiu.
Executa-ho
Totes les suites, incloses les anteriors a aquest 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ínia és el llistó que es comprova a si mateix. Un paquet que diu a quatre workspaces què significa accessible hauria de poder demostrar els seus helpers, i els casos que hi ha són els que versions anteriors van fer malament.
Després, la capa d’accessibilitat pel seu compte, amb l’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 )
Sense simulador i sense servidors de desenvolupament. Cada paquet es va publicar a mesura que es construïa, així que aquí no es torna a publicar res.
El que has construït, i el que ve
Un sol paquet sosté ara el llistó d’accessibilitat de tota la federació, i cinc suites s’executen contra ell: els helpers del mateix llistó, els tokens i components de @pokedex/ui, la pantalla compartida de @pokedex/detail, i les pantalles de cada remote.
El que en va sortir es reparteix segons on vivia l’errada, i aquell repartiment és la clau. @pokedex/ui en tenia onze: dos primers plans de badge per sota d’AA sobre el seu farciment, quatre més per sota sobre l’scrim del hero, el glif del botó flotant per sota del llistó de contrast no textual en tres dels divuit heroes, dues línies de text secundari que fallaven en tots dos temes, i dos components que anunciaven una càrrega fallida en silenci. Suma l’àrea tàctil del botó de reintent i la del badge de la targeta, i en són tretze. Les tres apps se’ls van endur tots amb un salt de versió, que és l’argument sencer a favor d’un design system expressat com a número.
@pokedex/detail en tenia tres, i el host no l’instal·la, així que tots tres es van quedar als dos remotes: l’àrea del botó Add, el número de dex del hero i els dos encapçalaments de secció del full.
Quatre no van poder viatjar gens, i són els interessants. Dos són les etiquetes de pestanya del mateix host, al shell que no té cap pantalla. Els altres dos són el comptador de party: codi d’app, la mateixa capçalera escrita dues vegades, una a cada remote, sense cap paquet que la sostingui, amb una errada de cada classe, un recompte que no anunciava res i un numeral a 1,53:1 sobre la seva píndola. Cadascun d’aquells quatre es va haver d’arreglar allà on era, i dos es van haver d’arreglar dues vegades.
Les errades que van importar més eren a les comprovacions. Aquest post n’ha ensenyat quatre: un helper que va manllevar el llistó per a un eix que ningú no havia declarat, una matriu que llegia una constant en lloc del token, aquella mateixa matriu que llegia la superfície equivocada, i un marcador que era només una cadena. Després en van aparèixer més, a les suites del design system i a les del mateix llistó: una comprovació de scrim al design system que comparava dues constants exportades pel mateix mòdul, un test de clamp el valor del qual no arribava mai al sostre que limitava, un helper d’ús del color al mateix llistó que llegia metadades de lector de pantalla en lloc del text a la pantalla, i una comprovació d’àrea tàctil que requeia en un número escrit al test quan el seu patró deixava de fer match. Cadascuna té ara un cas que falla si es treu la reparació, que és l’única prova que una comprovació és real. Una comprovació apuntada una mica al costat del que diu mesurar informa en verd, i continua informant en verd. Res de la sortida no la distingeix d’una que funciona.
No queda res obert contra un criteri. Cinc parelles ho van estar durant cinc rondes, totes amb el mateix token de text secundari, aparcades amb el raonament que cap valor més fosc no supera totes les superfícies. Era cert i responia la pregunta equivocada: el design system entén de temes, i les parelles van agafar una classe tematitzada en lloc d’un token nou. Queda una troballa registrada i no és un resultat contra un criteri, perquè l’SC 1.4.3 eximeix l’etiqueta d’un control inactiu. L’informe la imprimeix sota Project bars, sense comptar-la enlloc.
El límit honest és la capa. Aquest post construeix la capa Jest i cap de les altres dues. El bastiment d’auditoria en dispositiu és feina real que aquí no s’ha fet, i la passada manual amb VoiceOver i TalkBack no és automatitzable per disseny. Aquesta capa existeix per a la regressió: que el que ja has arreglat continuï arreglat, a cada commit, a cada remote, contra una única definició.
El següent: el host presta als remotes el seu costat natiu. Un pont de navegació obre una pantalla nativa des de la pestanya Party i n’espera el resultat.
Fonts
- WCAG 2.2, SC 2.5.5 Target Size (Enhanced) — nivell AAA, 44 per 44 píxels CSS
- WCAG 2.2, SC 2.5.8 Target Size (Minimum) — el criteri AA, 24 per 24 amb cinc excepcions
- WCAG 2.2, contrast (mínim) — 4,5:1 per a text normal, 3:1 per a text gran
- WCAG 2.2, luminància relativa — la fórmula, i la nota que recull el canvi del maig de 2021 de 0.03928 a 0.04045
- Apple Human Interface Guidelines: Accessibility — la taula actual de mides de control: 44 per 44 punts com a valor per defecte a iOS i iPadOS, 28 per 28 com a mínim
- Apple: UI Design Dos and Don’ts — la pàgina antiga, “at least 44 points x 44 points”
- Ajuda d’accessibilitat d’Android: mida de l’àrea tàctil — la guia dels 48 dp
- jest-axe — el requisit de jsdom i les comprovacions de contrast desactivades
- Directiva (UE) 2019/882 — la Directiva europea d’accessibilitat, aplicable des del 28 de juny de 2025
- EN 301 549 — la norma europea que aplica WCAG 2.1 a les apps mòbils
- React Native Testing Library — les queries i els matchers sobre els quals es construeix això
- nativewind —
nativewind/test, publicat i sense documentar - react-native-css-interop — el matcher
toHaveStyle, i el rem contra el qual resol aquest stack - react-native-module-federation — el repositori company, la build al tag
post-12-a11y-testing