π React Native Module Federation series β read it in full on warrendeleon.com, where new parts land first.
Post 11 closed on a promise: accessibility across the seam, a shared testing package that checks touch targets, contrast and focus order in remotes the host only meets at runtime. Two of those three fit in a Jest suite. Focus order does not, and What these checks cannot see says who it belongs to instead.
The problem is post 11's problem one level up. Three teams ship three bundles on their own schedules, each with its own idea of what counts as accessible, and those ideas drift exactly the way the greys drifted before a design system existed. Writing the bar down in a wiki page does not stop the drift, because a convention has no version and no install step. A package has both.
So the bar becomes @pokedex/a11y-testing: a Jest preset, a NativeWind-aware render, a small set of WCAG (Web Content Accessibility Guidelines) assertion helpers, the A and AA criteria catalogue, and a reporter. It publishes to the same local Verdaccio registry as every other package at the seam, and both source packages and both remotes install it. It is also the first @pokedex package that never reaches a bundle. Every consumer takes it as a devDependency, so no shared map changes and no runtime moves.
The criteria being checked are not this project's invention. The European Accessibility Act (Directive (EU) 2019/882) has applied since 28 June 2025 to the products and services it names. The Act itself sets functional requirements and names no standard; EN 301 549, which applies WCAG 2.1's A and AA criteria to mobile apps, is the standard that European practice measures against, and its revision to WCAG 2.2 is expected.
Why not jest-axe
On the web this is a solved problem with a package attached. jest-axe wraps axe-core, expect(await axe(container)).toHaveNoViolations() covers a wide slice of the rules in one line, and for a React web app it is the right first move.
axe-core tests HTML. jest-axe needs a jsdom environment, and its own README says colour contrast checks do not work in jsdom, so they are switched off even there. @axe-core/react-native is not a package at all: the npm registry answers 404. Deque, who maintain axe-core, do sell React Native tooling, including device SDKs that a native test run can call. That is the device-audit layer What these checks cannot see names, not the Jest one this post builds.
React Native Testing Library (RNTL) supplies the queries and the matchers and stops short of an audit. Nothing in its API returns a list of violations. Searching for prior art on accessibility testing across Module Federation remotes turned up nothing, in React Native or on the web. Two community packages come up when you search for React Native accessibility assertions, and neither is the shape this needs. react-native-accessibility-engine last published in 2022. react-native-ama is alive, though you have to know it moved: the unscoped package stopped at 0.7.5 in 2023 and the work moved to a scope. Seven of the eight @react-native-ama/* packages shipped 1.2.1 in August 2025, and five of the eight shipped a 2.0 beta in July 2026. It is a component library with dev-time runtime checks rather than a Jest assertion layer, so it answers a different question from the one in this post.
The bar gets assembled instead: a render that resolves classes, a handful of assertions, and a report.
π Diagram: view it on warrendeleon.com
Four arrows, two jobs. Two go to the packages that own the shared pixels, where a check runs once for everybody. Two go to the teams, where each suite covers the screens only that team composes.
The package
Start from post 11's tag:
git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-11-design-system
Three things have to be true before anything here runs, and all three are easy to assume you
already have. The local Verdaccio registry needs to be up with @pokedex/contracts,
@pokedex/ui and @pokedex/detail published to it, which is post 11's work. npm needs to know
that the @pokedex scope lives there. And you need to be logged in to that registry, which is
post 5's step rather than post 11's,
because the publishes later in this post are authenticated though the reads are not.
Start the registry first, because npm adduser below needs something to log in to:
npx verdaccio@6.2.0 # :4873, leave it running
Set the other two once, for your user, because npm reads its project config from the directory
holding package.json, not from the repository root, so the repo's own .npmrc is invisible to
a command run inside packages/ui:
npm config set @pokedex:registry http://localhost:4873/
npm adduser --registry http://localhost:4873/ # only if you have not already
If you did not build post 11, its A package for the pixels and The detail finishes dressing
sections publish the three packages this one installs. apps/host needs its dependencies; the two remotes get theirs with their packages further down:
( cd apps/host && npm install )
The source is more than a post can usefully retype. Fetch the reference copy once and publish it the way post 5 and post 6 published theirs. The tag is released the day this post goes live, so degit can reach it:
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 )
The anatomy follows the packages before it: publishConfig points at Verdaccio, as it has since post 5, and react-native-builder-bob builds an ES-module lib/, the arrangement @pokedex/ui introduced in post 11. One deliberate omission. The package ships no exports map. Consumers reach for the root, /jest-preset in every Jest config, and /reporter.js in every test:a11y script, and a map has to name all three. Omit one and Node answers ERR_PACKAGE_PATH_NOT_EXPORTED, which for the preset means no test run at all.
jest-preset.js extends @react-native/jest-preset rather than replacing it, which is what lets every suite that already existed keep passing untouched. The two additions that matter here are both about NativeWind. nativewind/babel joins the transform presets, so className reaches the styling runtime as style instead of sitting there as an inert prop that makes every colour assertion read undefined. And react-native-css-interop/dist/test/setupAfterEnv.js joins setupFilesAfterEnv, which is where the toHaveStyle matcher lives. Neither line is what makes the checks in this post work (the contrast matrix reads the preset's colour object directly, and the component checks read class strings), but a shared preset has to serve the suite that does assert on a resolved style, and both are asserted present so a future edit cannot quietly drop them.
Two of the three entry points this package imports are undocumented.
nativewind/babelis fine: it is step 3 of NativeWind's own installation guide. The other two are not written down anywhere.react-native-css-interop/dist/test/setupAfterEnv.js, which the preset adds tosetupFilesAfterEnv, andnativewind/test, which the render imports, are real and shipped and power NativeWind's own suite, and nativewind.dev has no testing section at all. So the versions this is proven against are recorded rather than assumed: nativewind 4.2.6 and react-native-css-interop 0.2.6, carried on caret ranges and re-checked whenever NativeWind moves.
RNTL is held below 14 for a related reason: version 14 rewrote render, fireEvent and act as async, and this render path has not been exercised against it. Never reach for @testing-library/jest-native either; it is deprecated, and RNTL has shipped its own matchers since 12.4.
createThemedRender binds a Tailwind preset once and hands back the render every suite in that package uses:
const renderWithTheme = createThemedRender(require('@pokedex/ui/tailwind.preset.js'));
const { getByRole } = await renderWithTheme(<SomeButton disabled />);
Taking the preset as an argument keeps the accessibility package free of a dependency on the design system. It is a tool, not a member of the federation. Binding it to @pokedex/ui's own preset is what lets a suite resolve a class the way the app will, which is what the touch-target checks read and what the contrast helpers are computed against.
The helpers are plain functions that call the global expect, not expect.extend matchers. A helper imported by name needs no setup file, behaves identically in every package, and appears in a stack trace as itself. None of them import React Native: a test element is anything with props. Each one is tied to a success criterion (SC), and to the bar that criterion sets.
| What it checks | Criterion | Level | The bar |
|---|---|---|---|
| Text on a surface | SC 1.4.3 Contrast (Minimum) | AA | 4.5:1 normal text, 3:1 large |
| A control's own boundary | SC 1.4.11 Non-text Contrast | AA | 3:1 |
| What a screen reader reads out | SC 4.1.2 Name, Role, Value | A | name, role and state |
| Meaning not carried by colour | SC 1.4.1 Use of Color | A | the information is in words too |
| A change announced without moving focus | SC 4.1.3 Status Messages | AA | a role or live region carries it |
| Touch target | SC 2.5.5 Target Size (Enhanced) | AAA | 44 by 44 CSS pixels |
The touch-target row is the one to read twice. 44pt is this project's bar, and it is Apple's number rather than WCAG's. The current Human Interface Guidelines give iOS and iPadOS a default control size of 44 by 44 points and a minimum of 28 by 28, so 44 is the size Apple asks you to design to, not the smallest it will accept. An older Apple page, still widely quoted, says only "at least 44 points x 44 points", which is where the "Apple's minimum" shorthand comes from. On the WCAG side, SC 2.5.5 sits at Level AAA, and WCAG 2.2's AA criterion, SC 2.5.8 Target Size (Minimum), asks for 24 by 24 CSS pixels with five exceptions. Forty-four clears that comfortably. It does not clear everything: Android's guidance asks for at least 48dp, so a project that ships to both platforms and stops at 44 has taken Apple's recommended size and accepted that Android would prefer more. That is a decision worth making on purpose. Calling any of it "what WCAG AA requires" is the mistake to avoid.
The contrast helper uses 0.04045 in the channel linearisation. The relative-luminance definition carried 0.03928 before May 2021 and the older constant is still copied around widely. The spec's own note says the change has no practical effect on the results, and the current constant costs nothing.
The touch-target helper has one behaviour that decides how much the whole layer is worth: it throws where it cannot see, rather than passing. That applies per axis. A control declaring a height and no width has not been measured on its width, and substituting the bar for the axis nobody stated would report it as accessible at exactly the moment the test could not see it. A hitSlop is measured too, not merely noted as present, because hitSlop: 0 extends nothing.
That rule is the difference between a suite and a decoration, and it is the one this package got wrong first: an early version borrowed the bar for an undeclared axis, so a button that stated only its height passed on a width nobody had measured. It has its own regression test now, in the package's own suite.
Test the source
Both source packages install the bar and point Jest at it. @tailwindcss/container-queries is not optional here: the test render fails with Cannot find module without it.
( 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 )
The suites, the two Jest configs and the per-suite not-applicable declarations come from the reference copy, and each package gains a test:a11y script:
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/
The design-system changes come across the same way. These are the token repairs the rest of this
section is about, and they have to be in the tree before the matrix can pass:
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 takes the two-surface decision the matrix is about to prove. The other three are
the same fault on smaller controls: the back pill's dark scrim, which the next section measures
alongside the badge's; the card's remove badge, whose touch target Same bar for every remote
comes back to; and the two secondary-text lines the card and the empty slot paint, which the
section after that repairs.
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 needs one more script change, and skipping it publishes a package that cannot be imported. Its suite lives in __tests__, so its tsconfig.json now includes that directory, which gives tsc two roots and moves the output down to dist/src/. package.json still declares dist/index.js. So set packages/detail's build script by hand, in its package.json, to the config that keeps src as the only root, clearing dist first so nothing from the old layout survives:
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json"
A tsc that exits 0 and writes the files one directory too deep is the same fault as a green check measuring the wrong thing: nothing complains until whichever app installs the package first stops with Cannot find module.
packages/ui already had unit tests for its components. It gains an accessibility layer beside them, and the token matrix is the interesting half. Every foreground and background pair the design system promises is checked once, in the package that owns the 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 is the interesting part, and it is there because the first version of this file did not have it. That version mapped the class to a literal: 'text-black': '#000000'. All eighteen types passed, and the number it printed was true of a colour the app never painted.
The design system defines its own
black.tailwind.preset.jssetsblack: '#2E3138'for near-black surfaces, which shadows Tailwind's default. Sotext-blackpainted#2E3138, and on the water fill that is 3.74:1 where the test itself demanded 4.5:1. Two of the eighteen were failing on screen while the matrix reported all eighteen green, because the matrix was measuring a constant instead of the token.
The repair is in two parts, and only the second one is durable. The badge foreground moved to its own token, typeInk, which is the real black the contrast map was computed against all along. And the matrix stopped writing hex at all: it resolves both sides of every pair out of the preset, so a token that is renamed or shadowed fails the suite rather than slipping past it. Both sides matter. An interim version resolved only the foreground and kept reading backgrounds from the token module, which left the same hole open on the other half: darkening a type fill to 1.64:1 still left all eighteen pairs green. Point typeInk back at #2E3138 in the preset now and six checks go red immediately: the water and psychic badges on their own fills, the rock, ghost and dragon badges on the hero surface, and the check that pins the preset to the token modules.
All eighteen fills pass now, and the sentence means something it did not mean before. A pair that passes here passes in every remote that composes it, and nobody re-checks a type badge in the Party app.
"Every remote that composes it" is carrying weight in that sentence, and it is worth being precise about what composing a pair means. A badge on a card sits on the type colour. A badge on the detail hero does not: the hero is the type colour, so a solid pill would vanish into it, and the hero variant lays a 30% white scrim down instead. That scrim is translucent but it is not transparent, and the foreground chosen for the solid fill is the wrong one for four of the six types dark enough to take white text on it. Rock, ghost, dragon and steel were landing at 3.17:1, 3.14:1, 3.39:1 and 2.71:1 on the surface they were actually drawn on, while the matrix checked a surface they were not.
So there are two decisions, not one, and the design system now computes both. The matrix composites the scrim exactly as the runtime composites it and checks the badge against that:
const scrim = composite(hexForClass('bg-white'), HERO_SCRIM_ALPHA, hexForClass(bgClassForType(type)));
expectColorContrast(hexForClass(textOnHeroScrimClass(type)), scrim, 'normalText');
Both faults have the same shape: a check measuring something adjacent to what ships. No green suite reveals it.
The floating back pill is the third of that shape, one control further out. It lays its own
translucent scrim over whichever hero it lands on, and it was painting bg-black/35, the
near-black neutral again, at 35%. On the three palest fills the white chevron came out at 2.81:1
on flying, 2.83 on ice and 2.84 on electric, under the 3:1 SC 1.4.11 asks of a control's own
boundary. Real black at the same alpha clears every fill, worst case 3.48.
It does not borrow typeInk for that. typeInk is a foreground: every use of it is a text-
class, and the experiment above reverts it to prove badge text moves. A scrim painted from the
same token would be dragged along by that experiment, which is how a decision about badge ink
would come to own an unrelated control. The background half of the same fact gets its own name:
export const BACK_PILL_SCRIM_ALPHA = 0.35;
export const BACK_PILL_SCRIM_CLASS = 'bg-scrim/35';
The matrix reads the token and the alpha out of that one class, so the value the pill paints and
the value the map composites cannot drift apart.
One token did not pass. midGrey (#9A9AB0) painted secondary text in four places on three light surfaces: two section headings on the detail sheet (2.60:1), the card's number line inside its grey pill (2.46:1), and the disabled Add button's label on its fill (2.02:1). The matrix recorded each surface separately, through a helper the package exports:
knownFinding('disabled button label on its fill', '2.02:1, exempt under 1.4.3 Incidental', () => {
expectColorContrast(colours.midGrey, colours.lightGrey);
});
knownFinding wraps it.failing, which Jest reports as passing, so the suite stays green while the finding stays visible and the reporter lists it apart from the violations. The helper is there because the first version wrote that call by hand, with a title starting (known that the reporter matched on. A marker that is only a string is a marker one typo away from silence: misspell it and the reporter stops seeing a tracked finding, it.failing goes on reporting green, and a live violation reads as an ordinary pass. Writing the title in one place is what makes the marker impossible to misspell.
Two more surfaces joined the list once the matrix started resolving what it measures. The empty slot's caption is text-midGrey/70, not the token: composited over the app background it is 1.88:1. And everything so far was the light theme. Both remotes mount a theme toggle in their header, so every one of those surfaces has a dark counterpart the design system also composes, and text-midGrey carried no dark: override at either site. The card's number line sits on bg-white/10 over near-black at 3.44:1, and the empty slot's caption on navy at 3.82:1. Both are xs text, so both need 4.5:1.
Five pairs, then, every one of them below the bar and on a screen both apps ship. They stayed parked for five rounds behind a reason that reads well and does not survive arithmetic: there is no single darker value that fixes them. Past roughly #5F5F6D the token clears AA on the three light surfaces, and the detail sheet is dark:bg-navy, so a pair sitting at a comfortable 6.48:1 would drop to 2.84:1. All true, and none of it load-bearing, because nothing here needs a single value. The design system is theme-aware. It already shipped dark:text-lightGrey in eight other places, and secondary text had simply never been given the same treatment:
<Text size="xs" className="text-darkGrey dark:text-lightGrey">
packages/ui carries two of those four sites, and they arrive with the components copied earlier: the card's number line and the empty slot's caption, which now paints the token at full strength rather than at 70%. The other two are the detail sheet's section headings, so they are a hand edit in packages/detail/src/PokemonDetailView.tsx, on both of them:
// Both section headings. Secondary text is themed here like every other secondary
// line in the design system; the parked version had no dark override at all.
<Text size="xs" bold className="uppercase tracking-widest text-darkGrey dark:text-lightGrey">
The worst of the six pairs is now 6.94:1. Parking a finding is a real option and this post still keeps one, but "it is a palette decision" turned out to be cover for a change that took one class.
Then the matrix went green, and green was not enough. Putting text-midGrey back on the card afterwards left all one hundred and forty-nine checks passing, because a matrix measures pairs and never sees which class a component paints. That is the same hole the host's tab tints fall through, and it takes the same answer: a check that reads the component's own source.
expect(secondaryClass('pokemon-card.tsx')).toEqual({ light: 'darkGrey', dark: 'lightGrey' });
A render assertion would not have closed it. nativewind/test compiles only the tree handed to render, so a class a component picks inside its own render never compiles, and the assertion passes on nothing at all.
One finding stays tracked, and it is not a criterion failure. SC 1.4.3's Incidental exception exempts "text or images of text that are part of an inactive user interface component", so the disabled Add label has no contrast requirement to fail. It is tracked because this project would rather a disabled control stayed readable, and it sits under a Project bars heading that says so, rather than under 1.4.3 where it would be the same mis-citation the touch-target framing is careful to avoid.
packages/detail gets its own suite next, and the shape matters more there than the assertions: a component library that ships its own accessibility tests. The tests hand PokemonDetailView its props directly, which is the props seam from post 8 again. No store, no query client, no navigator. Both consumers inherit whatever this file proves, and when it finds something, one patch release repairs both of them. One ordering note if you run this suite early: it reads the hero-scrim decision out of @pokedex/ui, and post 11's lockfile pins a version that predates it. The suite runs, and twenty-four of its thirty checks fail, from four causes. Four throw textOnHeroScrimClass is not a function, which the design-system release further down repairs. One is the Add button's touch target, and one is the sheet's section headings still on the parked token. The other eighteen are one check per type, all saying the same thing about the hero's dex number, which the next section repairs in a single line. Three of those four causes are findings this post is about, so a red run here is the expected shape rather than a broken clone.
A failing test, then the fix
The failure this suite was built to catch did not happen. The expectation was the disabled Add button reporting no accessibilityState, so a screen reader would meet a button that greys out on screen and says nothing about being unavailable. It reports the state correctly. React Native's Pressable copies the disabled prop into accessibilityState itself:
_accessibilityState =
disabled != null ? {..._accessibilityState, disabled} : _accessibilityState;
The test stays anyway, as a regression net rather than a fix. The day someone disables that button by swapping the handler for a no-op and greying the fill by hand, the state goes quiet and this test says so.
What did fail was the check beside it:
β 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.
Two things sit behind that line, and both reach past this button.
The button declared no size the suite could read. Its height came from gluestack's size="lg" variant, which composes px-6 h-11 while the component renders. In the app that class is fine: Tailwind scans the design system's source, finds the literal h-11, and the built bundle carries the rule. In the test tree it is not, and the reason is broader than this button. nativewind/test compiles the class strings it finds on the element tree you hand to render, and feeds those to Tailwind as its content. A class that appears inside a child component is not on that tree, so it never compiles: not one a variant builds, and not a literal one either. The Add button arrived carrying {"alignSelf": "stretch"} and nothing else, and the helper throws on nothing, which is the only reason this surfaced.
And
h-11is not 44 anyway. Tailwind generates.h-11 { height: 2.75rem }, and NativeWind's rem on React Native is 14, not the browser's 16. A literal<View className="h-11" />rendersheight: 38.5, in the test tree and in the app alike. So the size that variant asks for was under Apple's 44 all along, whether or not any suite could see it. Two separate faults, one control: a size the test could not read, and a size that would not have cleared the bar if it had. Touch-target checks read declared style and props for this reason, rather than deriving a number from a class.
The fix puts the bar where a variant cannot quietly move it. In packages/detail/src/PokemonDetailView.tsx, on the Add button:
// The 44pt bar is declared here rather than inherited from the size variant.
// A variant is a visual decision that can change; the minimum tappable size of
// the screen's one primary action is a commitment, and the accessibility suite
// can only verify what the control actually declares. `alignSelf: 'stretch'`
// makes the button far wider than 44, but stretch is a layout instruction, not
// a measurement, so the minimum is stated on both axes.
style={{ alignSelf: 'stretch', minWidth: 44, minHeight: 44 }}
The same file carried the opposite fault, a hundred lines up, in the hero the button sits under. The
hero paints the type's colour full strength, and a comment above it explains that its text cannot be a
fixed colour, because the design system already decided per type and the hero should ask the token
rather than assume. The dex number under the name did assume. It muted itself:
// Before: a second answer, invented one line under the comment saying not to.
const heroMuted = heroInk ? 'text-white/70' : 'text-black/60';
text-black resolves to #2E3138 here, the neutral the preset warns is not a foreground, and at
60% over the fill it measures 2.21:1 on water. Sixteen of the eighteen types fail. No alpha rescues
it: sweep the value from 1% to 100% and the best case is full strength, where water still sits at
3.74:1, because the ink is the wrong ink. So the line asks what the name asks, in
packages/detail/src/PokemonDetailView.tsx:
// Delete heroMuted. The dex number takes the same decision the name takes.
<Text size="sm" className={`font-head ${onHero}`}>
{dexNumber}
</Text>
Worst case becomes steel at 4.71:1, and the pair is one the token matrix already measures, so
nothing new needs adding to it. A muted variant would have needed its own row.
The Add button's fault sits one level lower too. The design system's ErrorState builds its retry from size="md", which is h-10, or 35 points, and that retry is the way back from a failed load in all three apps. Its declaration goes into the design system rather than into whichever app noticed first, in packages/ui/src/components/error-state.tsx:
// The retry is the way back from a failed load, so its tappable size is declared here
// rather than left to the size variant. A variant is a visual decision; the minimum
// target is a commitment, and a declared one is the only kind a suite can verify.
// Both axes are declared: the button is far wider than 44 in every layout it appears in,
// but a width nobody states is a width nobody has measured.
<Button
action="primary"
size="md"
onPress={onRetry}
style={{ minWidth: 44, minHeight: 44 }}>
One more finding sits beside them, in two components at once, and it is the one a sighted review never catches at all. Neither ErrorState nor LoadingState had any status semantics: no role, no live region, nothing. They looked correct, and to a screen reader a failed load simply happened in silence.
They do not get the same repair, because they are not the same kind of message. A failed load should interrupt; a spinner should not. So error-state.tsx becomes an alert, which announces on both platforms without a live region:
<Center className="flex-1 px-6" accessible accessibilityRole="alert">
and loading-state.tsx gets the polite variant, which waits for whatever the screen reader is already saying to finish:
<Center className="flex-1" accessible accessibilityLiveRegion="polite">
The design system carries five of these repairs and packages/detail three, so each is one release
rather than several:
( 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 )
The host takes the release too, and it has a repair of its own, the only fault in this post
that lives in the shell rather than in a package or a 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/
Both tab labels are 10pt, and both were failing. The focused one took colours.blue, the brand
fill: 3.48:1 on the light bar, 3.74:1 on the dark. The unfocused one was never set at all, so
React Navigation derived it by mixing the theme's text halfway into the bar's own colour, which
gives 3.27:1 on white. One tab is always unfocused, so that pair is on screen whenever the
app is. colours.ts brings two readable blues with it; the greys are the ones every other
secondary line already uses:
tabBarActiveTintColor: mode === 'dark' ? colours.blueTextDark : colours.blueText,
tabBarInactiveTintColor: mode === 'dark' ? colours.lightGrey : colours.darkGrey,
TabBar.test.tsx is why the guard is worth its own file. The matrix in @pokedex/ui proves those
four pairs clear AA, and it cannot prove the host uses them: reverting the active tint to
colours.blue leaves every one of the design system's checks green. A matrix measures pairs; only
the app can be asked whether it composes them. It reads source rather than a render, because a
navigator option is resolved inside React Navigation and reaches no element a suite can query.
( cd apps/host && npm install @pokedex/ui@1.0.13 )
Two patch releases, three apps, and no coordination between the teams that ship them. The caret range every consumer already carried is what makes this a patch rather than a negotiation. The PokΓ©dex suite records what that means in the place it is easiest to misread:
// This passes because the design system declares the size on ErrorState's button, not
// because this app did anything. One package release, every remote's retry covered.
expectMinTouchTarget(buttonContaining(getByText('Try again')));
It renders the same ErrorState in its remote-load boundary, so it moves to the same version as the two remotes rather than lagging behind them, which for a package the host provides as an eager singleton is not optional.
How far that retry gets you depends on what failed. A failed data request retries cleanly. A remote that never answered is a harder case: the boundary discards React's cached rejection and starts a fresh import, which is why the button visibly does something, but the same failure comes back every time. The runtime behaves as though it is still holding the failed manifest fetch underneath it. That is an observation rather than a documented guarantee, so treat the behaviour as the finding and not the cause. Clearing it needs cache-busting the runtime, and cache-busting is only worth having once there is something to fall back to, so both arrive together in the resilience post later in this series. What the checks in this post can say is narrower and still worth saying: whatever the retry does, it is reachable, it is named, and it is big enough to hit.
Same bar for every remote
Both remotes install the same three devDependencies, and take the two fixed packages in the same command:
( 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 )
Each app takes its own suite, its Jest config and its not-applicable declarations, and the README catches up with the 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 .
The two screens carry the counter repairs. PokedexScreen.tsx has the live-region fix below, and
both screens paint the same count pill, whose numeral was text-darkGreen on bg-lightGreen:
1.53:1, 10.5pt bold, in each remote's header. darkGreen is #A6D3A0, the same value as
the grass fill, so the name was the only dark thing about it. Both now use text-darkGrey, the
colour the label beside them already used. At 7.18:1, it is a pair the token matrix has held
all along. The counter was composing a different one nobody had measured.
The styleMock.js is duller and just as necessary, and it is there because PartyStack pulls in global.css, one hop away through ./styles, so the remote's
classes reach the shared styling runtime when the host loads the exposed module. The app's own
entry already covers the standalone build; without the import in PartyStack the styles work
standalone and quietly do nothing federated. Jest has no CSS loader, so the copied config maps
that import to the stub. Without it the Party suite does not fail; it refuses to run.
Then the script that runs the accessibility files through the 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
Both Jest configs point at the shared preset. Because it extends the React Native preset, the suites each app already had keep running, and the rule is to widen its allowlist rather than replace it. In outline, since the copied files carry more entries than this:
const preset = require('@pokedex/a11y-testing/jest-preset');
module.exports = {
preset: '@pokedex/a11y-testing',
transformIgnorePatterns: [
`node_modules/(?!(${[...preset.uncompiledPackages, '@react-navigation'].join('|')})/)`,
],
};
Replacing that array is how a shared preset quietly stops being shared. It does not fail quietly, though: the styling stack ships ES modules, so Jest hits SyntaxError: Cannot use import statement outside a module and runs nothing at all. That is the good case. The trap is that the error names a file deep in node_modules and reads like a broken dependency rather than like a config line the app owns. Exporting the list makes widening the easy move.
Each suite covers only what its team composes, and the way to keep that honest is to render the team's own screens rather than the design system's components. Both files mount the real stack through the real store and the real navigator, the same harness the apps' existing tests use:
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>,
);
That is more setup than mounting a PokemonCard directly, and it is the difference between testing this app and testing somebody else's package. A suite that renders design-system components re-checks what the source already settled, which is exactly the duplication the source contract exists to remove.
So the PokΓ©dex checks the rows its own query data produces, the states its screen can land in, and its party counter. The Party checks its grid: a filled slot is a button naming its PokΓ©mon, the empty ones are named gaps rather than four identical silences, and removal is an accessibility action on the card rather than a second focus stop beside it.
expect(getByLabelText(/^Pikachu, number 025/).props.accessibilityActions).toEqual([
{ name: 'remove', label: 'Remove from party' },
]);
The β badge is small (21 points, because h-6 is 1.5rem and NativeWind's rem is 14, so its hitSlop carries it to 45 rather than the 41 it had), and making it its own focus stop would double the number of things to swipe past on a full party. The design system hides it and exposes removal as an action instead, so a screen-reader user reaches it through VoiceOver's rotor or TalkBack's actions menu. The check is that the action exists at all, because a hidden control with nothing behind it is simply unreachable.
Rendering the real screen is also what turned the counter's second fault from a note into a fix. The PokΓ©dex header shows "My Party" beside a count out of six, and that count changes when something is added from a screen away, without focus moving. A sighted user watches the number tick; a screen-reader user was told nothing, because the header was static text. That is the whole of SC 4.1.3, and it is now a live region with a label that spells the ratio out, because a bare "3/6" is left to how a given screen reader says it:
<Box
accessible
accessibilityLiveRegion="polite"
accessibilityLabel={`My Party, ${partyCount} of ${MAX_PARTY}`}>
The Party's own header needs the same three props, and that is the cost of a fault living in app code twice over: two screens, two suites, and nothing that makes the second one look.
A test asserting that against a hand-written object would have proved nothing about the app. Against the rendered screen it fails if the props are removed, which is the only version worth keeping.
The report
One accessibility-report.md comes out of each suite, written by the reporter as the run finishes:
( cd packages/ui && npm run test:a11y )
The reporter reads the criterion out of each describe title, so WCAG 1.4.3 β¦ is all the wiring there is. The design system's run opens like this:
# 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.
The exempt pair does not appear there. It has its own heading, because a threshold a project
chooses is not a result against a criterion, and filing one as the other is how a coverage
number stops meaning anything:
## 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)
The denominator is where a coverage number is easiest to inflate, so it is built in two steps. The catalogue carries all 50 of WCAG 2.1's A and AA criteria and tags each one with the layer that can decide it: 15 a Jest process can, and the rest belonging to the device audit, the manual pass, or to nothing here at all, like the audio and video criteria in an app with neither. A suite then removes the ones it declares inapplicable, which is why the design system is measured out of 13. Those declarations live in an a11y-report.config.js at the suite root, and each carries a reason, whether the criterion was in the counted set or not. Nothing enforces that the reason is any good; it is there so a reviewer can disagree with it.
The touch-target work appears in the reports of the suites that do it, and never in that fraction. The design system's run says so in its own section:
## 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
SC 2.5.5 is Level AAA, so counting it would inflate an A and AA figure. Dropping it silently would hide a check that ran. The report does neither.
That is roughly the artefact a paid accessibility scanner sells, generated from tests the team already owns.
What these checks cannot see
A green suite here proves less than it looks like it proves.
| Layer | What it proves | What it cannot |
|---|---|---|
| Jest suite | token pairs, declared sizes, the name, role and state a control exposes | what a device paints, real traversal order, hit regions after clipping |
| Device audit | contrast as drawn, clipped targets, sizing on a real screen | whether a label reads well out loud |
| Manual pass | the actual experience, traversal order included | catching every regression, on every commit |
The device audit is performAccessibilityAudit on iOS and the Accessibility Test Framework on Android; the manual pass is a person with VoiceOver or TalkBack.
Focus order sits with the manual pass, which is the honest answer to the third thing post 11 promised. A Jest suite can say that focusable elements are present. It cannot say what order a screen reader traverses them in, because that order comes out of layout on a real screen. Nor is the device layer a clean home for it: Android's Accessibility Test Framework has a traversal-order check and iOS's performAccessibilityAudit has no equivalent, and a criterion only one platform can automate is not one a report should claim. So it stays with the person driving the screen reader, which is where the catalogue tags it.
The layers are not a ladder where the top one contains the ones below it. The token matrix checks the pairs the design system composes, on the surfaces it composes them on, whether or not a screen shows a given pair today; a device audit checks what is on the screen it was pointed at, which is a different set and never the whole palette. A clean automated run is necessary and not sufficient, and the same is true of a clean device pass.
Run it
Every suite, including the ones that predate this 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 )
The first line is the bar testing itself. A package that tells four workspaces what accessible
means should be able to prove its own helpers, and the cases in there are the ones earlier
versions got wrong.
Then the accessibility layer on its own, with the report:
( 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 )
No simulator and no dev servers. Every package was published as it was built, so nothing here republishes anything.
What you built, and what's next
One package now holds the accessibility bar for the whole federation, and five suites run against it: the bar's own helpers, the tokens and components in @pokedex/ui, the shared screen in @pokedex/detail, and each remote's own screens.
What came out of it divides by where the fault lived, and that division is the point. @pokedex/ui held eleven: two badge foregrounds below AA on their own fill, four more below it on the hero scrim, the back pill's glyph below the non-text bar on three of the eighteen heroes, two secondary-text lines failing in both themes, and two components that announced a failed load in silence. Add the retry button's touch target and the card badge's, and it is thirteen. All three apps took every one of them by a version bump, which is the whole argument for a design system stated as a number.
@pokedex/detail held three, and the host does not install it, so all three stopped at the two remotes: the Add button's target, the hero's dex number, and the sheet's two section headings.
Four could not travel at all, and they are the interesting ones. Two are the host's own tab labels, in the shell that owns no screens. The other two are the party counter: app code, the same header written twice, once in each remote, owned by no package. It carried one fault of each kind, a count that announced nothing and a numeral at 1.53:1 on its own pill. Each of those four had to be fixed where it sat, and two of them had to be fixed twice.
The faults that mattered more were in the checks. This post showed four: a helper that borrowed the bar for an axis nobody had declared, a matrix reading a constant instead of the token, the same matrix reading the wrong surface, and a marker that was only a string. More turned up afterwards, in the design system's suites and in the bar's own. A scrim guard in the design system compared two constants exported from the same module. In a clamp test the value never reached the ceiling it was clamping to. The bar's own use-of-colour helper read screen-reader metadata rather than the text on screen. And a target guard fell back to a number written in the test when its own pattern stopped matching. Every one of them now has a case that fails when the repair is taken out again, which is the only evidence a guard is real. A check aimed slightly beside the thing it names reports green, and goes on reporting green. Nothing in the output distinguishes it from a check that works.
Nothing stays open against a criterion. Five pairs did for five rounds, all of them the same secondary-text token, parked on the reasoning that no single darker value clears every surface. That was true and it was answering the wrong question: the design system is theme-aware, and the pairs took a themed class rather than a new token. One finding stays tracked and it is not a criterion result, because SC 1.4.3 exempts an inactive control's label. The report prints it under Project bars, counted nowhere.
The honest limit is the layer. This post builds the Jest layer and neither of the other two. The device audit harness is real work that has not been done here, and the manual VoiceOver and TalkBack pass is not automatable by design. This layer exists for the regression: the things you have already fixed staying fixed, on every commit, in every remote, against one definition.
Next: the host lends the remotes its native side. A navigation bridge launches a native screen from the Party tab and waits for its result.
Sources
- WCAG 2.2, SC 2.5.5 Target Size (Enhanced) β Level AAA, 44 by 44 CSS pixels
- WCAG 2.2, SC 2.5.8 Target Size (Minimum) β the AA criterion, 24 by 24 with five exceptions
- WCAG 2.2, contrast (minimum) β 4.5:1 for normal text, 3:1 for large
- WCAG 2.2, relative luminance β the formula, and the note recording the May 2021 change from 0.03928 to 0.04045
- Apple Human Interface Guidelines: Accessibility β the current control-size table: 44 by 44 points as the iOS and iPadOS default, 28 by 28 as the minimum
- Apple: UI Design Dos and Don'ts β the older page, "at least 44 points x 44 points"
- Android accessibility help: touch target size β the 48dp guidance
- jest-axe β the jsdom requirement and the disabled contrast checks
- Directive (EU) 2019/882 β the European Accessibility Act, applied from 28 June 2025
- EN 301 549 β the European standard that applies WCAG 2.1 to mobile apps
- React Native Testing Library β the queries and matchers this builds on
-
nativewind β
nativewind/test, shipped and undocumented -
react-native-css-interop β the
toHaveStylematcher, and the rem this stack resolves against -
react-native-module-federation β the companion repo, the build at the tag
post-12-a11y-testing
Top comments (0)