SheetOpen · inclusive.io
ScopeDesign system and ATS product, solo
BuiltTwice. The foundation had to change
npm@hawkbass/inclusive-design-core
Case study · inclusive.io · the claim that came back off me
The design system, and the gate that took my claim off me.
For eight months I told people this system met WCAG 2.2 AAA. I had done the work: 7:1 contrast, 44px targets, reduced motion treated as a requirement rather than a toggle, and I believed all of it. I was wrong, and how I found out is the only genuinely interesting thing about this project.
I audited my own repository as though a stranger had written it. Twenty-five of 167 routes had ever been scanned. The accessibility suite had been green for eight months and had never once loaded the rule that separates AAA from AA.
- 117Components
- 26ATS Workflows
- 174Routes Audited
- 0Axe Violations
It had never been asked the question.
Sheet01 · What it is
Groundbone
EvidenceWhy an ATS
Sourcesrc/data/case-study-narrative.ts
01 · What it is
A design system with a real product bolted on top
117 components. 26 more that only make sense in recruitment. 174 routes. Every component ships with a unit test, a Storybook story and a documentation page.
All 117, not most of them, and that distinction matters because 93 of 117 was the number going into this pass and the 24 that were missing one turned out to be exactly where the problems had been living the whole time. A component library where coverage is nearly complete is a component library with a list of exceptions nobody's written down.
It runs from 280px, which is a Galaxy Fold shut, all the way out to 5120px, which is a 5K ultrawide, across twelve breakpoints with ninety visual baselines checked on every single run. I don't own a screen at most of those widths, which is exactly why something other than my eyes has to be doing the checking.
Honestly, most component libraries are a Storybook and a promise, so I wanted to know whether mine survived contact with a real product and built an applicant tracking system on top of it: pipeline boards, scorecards, interview schedulers, offer letters, diversity reporting. Recruitment software is data dense and permission heavy and legally exposed, and it's full of the sort of table nobody designs on purpose, so if a system holds up in there it holds up most places.
Sheet02 · How it was investigated
Grounddeeper
EvidenceCriteria first
Sourcesrc/data/case-study-narrative.ts
02 · How it was investigated
Nothing got rebuilt until something had been measured
Three strands, run in parallel. Twelve products read against. 31 sources cited. Every internal finding required to name a file and a line.
That last constraint is the whole reason the numbers in this case study are reproducible, because nothing here is coming from memory and anything that couldn't be traced back to a file didn't survive the pass. It's a low bar, and I'd rather clear a low bar in public than describe a high one and hope nobody asks.
The whole thing ran to a seven phase framework, written down before it started rather than reconstructed after: design foundations, codebase analysis, research, decisions, specification, implementation, documentation. Forty-seven documents came out of it. Eighteen research papers, three audit reports, and a fourteen document handover pack for a project with nobody to hand it over to, which is either thorough or daft depending on the morning you ask me. I wrote it that way because a system one person can run and nobody else can pick up isn't a system, it's a hostage.
The external read was twelve products, because this system has to hold up on two fronts at once: five recruitment platforms for the product surface, seven design systems for the documentation surface. Ashby, Greenhouse, Lever, Workable, iCIMS. Polaris, Carbon, Atlassian, Primer, Pajamas, Material 3, Spectrum. They're named on the page for a reason. Benchmarked against industry leaders is a sentence anyone can type and nobody can check.
The standards got read directly rather than from folklore, WCAG 2.2 and the DTCG Format Module, the whole way through. It's genuinely surprising how much accepted practice in this industry turns out to be a blog post about a specification, quoted by a second blog post, cited by a third, and by that point nobody involved has opened the actual document in years.
Sheet03 · The foundation
Groundbone
EvidenceThe tell
Sourcesrc/data/case-study-narrative.ts
03 · The foundation
HSL is a lie, and it took a full rebuild to admit it
V1 ran on HSL, it shipped, people used it, it solved real problems. It also made every contrast decision in the system a guess.
I want to be fair to V1 here, because it wasn't a bad build, but contrast maths on HSL is arithmetic dressed up as certainty and it'll sit there telling you two colours share a lightness while you're looking straight at the pair of them and one is obviously darker, which is not a rounding error. What it means is that every contrast call in the system was somebody eyeballing a swatch and hoping, which is fine right up until the morning you claim a ratio in public and somebody checks it.
OKLCH fixes it: same lightness number, same perceived lightness, right across the palette, and the moment that became true contrast stopped being a matter of judgement and turned into something a script could check on every commit. That one property is what made the gate possible and the register possible and every number on this page possible.
It cost a full rebuild of the token layer and 1,157 hardcoded utilities that had to be migrated before the tokens meant anything at all. Frankly, I put that off for longer than I should have, and the reason is embarrassing: the old palette looked fine.
Rebuilding something that already works takes more nerve than starting fresh, because you have to say out loud, to people who can see it working, that the foundation won't take what's coming next.
Sheet04 · The instrument
Grounddeep
EvidenceTwo pinned values
Sourcesrc/data/case-study-narrative.ts
04 · The instrument
I built the thing that could take the claim off me
360 sanctioned pairings. Four themes. Checked on every commit. The build fails if any of them drop below threshold.
A test written to confirm gets written against what the code already does, and a test written to fail gets written against what the standard says, which sounds like wordplay. It stops sounding like wordplay the first time you run the second kind and it comes back and takes six tokens off you and you have to go and change the palette.
The gate reads the token source directly rather than sampling rendered pages, and it models a tint on a card on a card because that's what the interface actually does, which turns out to matter, because a flat model had been telling me those pairings were fine. The composite model failed six text tokens sitting between 6.71 and 6.85 to one. Close enough to pass a glance and close enough to pass a screenshot and nowhere near close enough to pass the standard.
I could have patched the call sites instead, and a grep found that pairing in well over a hundred places, but patching them only fixes the symptom wherever a route happens to get scanned while leaving the guarantee false everywhere else. That's not a fix, it's picking the failure up and moving it somewhere nobody's looking.
AA gets you compliant, AAA gets you a product people can actually use, which is a different thing and worth the extra work. Neither one means anything at all until something in CI can take it away from you.
Sheet05 · Why it was not caught
Groundbone
EvidenceThe tell
Sourcesrc/data/case-study-narrative.ts
05 · Why it was not caught
Five checks that looked like they were running
Nothing on this list is a bug. Every one is a piece of verification that existed, was wired up, appeared in the README, and never actually ran.
This is the part I'd most want somebody else to read, because it's the part that generalises: a failing test gets fixed the day it fails, and a test that never runs gets trusted for exactly as long as nobody looks at it, with everything downstream of it inheriting that trust without ever having earned it.
Each of these got found by reading the configuration rather than the code, which isn't where anybody looks first. Four of the five were a single line, and every one of them had been sitting there passing. The fifth was an import that had been sitting in a setup file for months, correctly written and correctly registered and never once called by any of the 92 test files it was sitting there to serve.
The AAA target had zero automated coverage
e2e/accessibility.spec.tsThe axe suite ran with the tags wcag2a, wcag2aa, wcag21a, wcag21aa and wcag22aa. There's no wcag2aaa in that list, and without it axe never loads color-contrast-enhanced, which is the 7:1 rule.
The suite was green for eight months and had never been asked the question the badge was making. A test that doesn't load a rule can't fail it, and a green suite reads identically either way.
Route coverage was fifteen percent
the axe suite, against the appThe suite enumerated 25 routes. The app had 167 page.tsx segments at the time. Of 114 component documentation routes, 6 were scanned.
A result about 15 percent of a system read exactly like a result about the system. Nothing in the output said which one it was.
npm run test never terminated
packages/react/package.jsonThe script was declared as "test": "vitest", which is watch mode. The README documented running it with a coverage flag, and that command didn't return locally. It passed in CI only because vitest detects CI=true and switches to a single run.
The documented command was broken for every human who ran it, and the one context where it worked was the context nobody watches.
A single mis-pathed line disabled a check
.prettierignoreThe file contained public/sw.js, and that pattern anchors at the ignore file's own directory, so it matched a path that doesn't exist. The real file lives at apps/docs/public/sw.js and is regenerated on every prebuild, so format:check could never pass after a build.
The line directly beneath it used the correct full-path form. One line that looked exactly like its working neighbour, silently doing nothing.
jest-axe was wired up and never called
src/test/setup.tsThe setup file imported toHaveNoViolations and registered it. Zero of 92 test files ever called it.
The README described "jest-axe per component". The import was real, the registration was real, and the assertion was never made once.
Not one of these is a bug. Every one is a check that looked like it was running. That is the category worth worrying about, because a failing test gets fixed on the day it fails, and a test that never runs gets trusted for as long as nobody looks.
Gates8/8 turbo tasks, 0 lint errors
Unit806 tests across 111 files
End-to-end1,564 passed, 0 failed, no retries
Packagingpublint + attw over the real tarballs
06 · What it found
Fifteen percent coverage was hiding Level A failures
Twelve unlabelled form controls. Six buttons with no accessible name. Thirty-seven routes shipping an invisible skip link. All of it in the 85% nothing had ever scanned.
Level A, not AAA, not AA, the most basic tier of the whole standard, sitting underneath a system I'd been advertising as meeting the top one. Truthfully that's the part that stung, and it wasn't the enhanced contrast work that caused it, or the tokens, or anything I'd actually been worrying about, it was the coverage.
Then the gates started returning things no amount of reading the code would ever have surfaced. Every skip link in the product rendered as an invisible one by one box across 37 routes, because a hand written sr-only rule with !important beat the focus style that was meant to reveal it, and when you take the !important out the cascade layers go and beat it again. That's 2.4.7 Focus Visible, Level AA, and I found it while investigating AAA.
Every sheet in the system produced an unnamed dialog, mobile navigation included. A menu marked the current page by colour alone whenever it was driven by onClick instead of href. A rich text editor's toolbar states had never worked at all, because it read the selection during render and held no state to re-render with. Not one of those is a thing you find by reading code, or by clicking round the product for an afternoon, or by asking somebody to have a look at it for you. You find them by writing something whose entire job is to fail you, and then running it.
- 01First full runAA, 174 routes33 violations
- 02After the fixesSame sweep, same scope0 violations
- 03Gate rebuiltModels the rendered tint57 violations
- 04After the second passAAA where it holds14 violations
- 05Where it stands0 axe, both themes0 violations
Writing the gate is the work. A standard you can only assert is a standard you haven't met yet, and you won't know which until somebody checks.
Sheet07 · The calls
Grounddeeper
EvidenceOn the demo data
Sourcesrc/data/case-study-narrative.ts
07 · The calls
Four decisions where the easier option was sitting right there
A case study without these is a list of things that went well. These are the four that cost something.
Say AA, earn AAA
Stop making a blanket AAA claim the evidence didn't support.
The system was advertising AAA while shipping text that failed AA. The honest position is AA as a non-negotiable baseline, AAA where the visual language genuinely allows it, and the exceptions published rather than omitted. Loses a louder headline. Gains a claim that survives someone checking it.
Do not fabricate the demo data
Label demo data as demo data, and make the interactions believable client-side instead.
An ATS demo shows diversity and pipeline metrics. Inventing numbers there isn't a harmless placeholder. It's fabricated D&I reporting, on exactly the content where a reader is most entitled to trust what they see. A less impressive-looking dashboard. It's not a close call.
Migrate colour with the redesign, not before it
Fold roughly 1,751 hardcoded palette utilities into the redesign rather than sweeping them mechanically first.
A blind sweep would have touched every screen with no visual review and no way to tell a fix from a regression. Doing it behind semantic tokens during the redesign meant the visual work and the token work landed together, verified together. Slower. The alternative was faster and unverifiable.
Diagnose before prescribing
Treat 2,430 TypeScript errors as a symptom before treating them as a workload.
The obvious reading was an untyped library needing a rewrite. It was a publishing defect: the library shipped no declaration files, so every consumer silently fell back to bundled JavaScript and every component's props collapsed to any. Emitting declarations took it to 599. A single directive in a service worker, stripping the DOM lib from the whole program, accounted for the rest. Days of investigation before a line was changed. It replaced a rewrite with a config fix.
Sheet08 · Still wrong with it
Groundbone
EvidenceWhy this act exists
Sourcesrc/data/case-study-narrative.ts
08 · Still wrong with it
The list is public, so the list includes what isn't done
A case study that ends on a conclusion has quietly implied the work is finished. This one isn't, and the gaps are cheap, which makes naming them easier and also more embarrassing.
Publishing what's still missing is the same discipline as publishing the exceptions in the register rather than quietly leaving them out. An exception you disclose is a decision. One you leave out is a defect, sitting there waiting for somebody else to find it and tell you about it in public.
The first of these is the one I'd lead with if somebody asked me what's weakest here, because it's the gap between a thing being verified and a thing being used, and I've spent this whole case study arguing that verification is what makes a claim worth anything, and it does, but it still isn't the same as somebody else running it on their machine.
Nobody has ever installed it
The package sits at 0.0.1 and has never been published, and everything about it is verified. The exports map resolves under both node16 ESM and bundler resolution, the use client directive survives on all 23 chunks that need it, and publint and attw both pass against the real tarballs.
Verified isn't used, and the distance between a gate saying the tarball's correct and somebody installing it on their own machine and having it work is exactly where packaging bugs live, and this pass found two of them sitting in that gap. Both invisible to a green build. So the strongest honest claim is packaged correctly, not shipping. I'd rather say the smaller true thing.
Cost to close An afternoon. The steps are written down and the verification is already automated.
Storybook is not hosted
117 components and more than 500 stories, and the only way to look at any of them is to clone the repository and run a dev server.
For a design system the browsable component explorer is the shop window, and mine is currently a locked door with a note on it saying the stock's excellent. The static build works and emits 239 files. It isn't hosted because I hadn't got to it, and that's the whole reason.
Cost to close Under an hour on Vercel.
Nothing reviews a component visually on its own
The visual suite covers 14 routes across 2 themes and 3 widths, which comes out at 84 snapshots of pages. No component is snapshotted on its own. So a regression in one variant of one component gets caught only if that variant happens to turn up on one of those 14 routes.
Chromatic's installed and registered and has no project token and no CI job, so it does nothing at all. That's the sixth thing on this site that was wired up and never ran, and I found it the same way as the other five. By reading the configuration rather than trusting it.
Cost to close About an hour, and free at this volume.
None of this is hard and none of it's expensive. It's on the list because the list is public, and a list of everything that went well is a brochure.
Contrast360 pairings, 4 themes, every commit
Unit806 tests across 111 files
End to end1,564 passed, 0 failed, no retries
Axe0 violations across 174 routes, both themes
09 · What I would do differently
The badge was honest in intent and unenforceable in fact
AAA at scale is doable as the default rather than a nice-to-have you reach if there's time left over. The proof isn't that I say so. It's 1,564 tests you can run yourself.
I didn't lie about AAA, I'd done the work and most of it held up. What I hadn't done was build the one thing capable of contradicting me, so the claim and the reality were free to drift apart for eight months with nothing in the repository able to notice, and nothing in the output that would have looked any different if they had.
The order was wrong, that's the part worth taking away, because I spent months on enhanced contrast while unlabelled inputs sat on routes nothing had ever scanned, and fifteen percent coverage of a hard standard is worth less than full coverage of an easy one. The failures you haven't looked for aren't distributed like the ones you have, they're worse.
So here's the question I'd put to anybody shipping a component library with an accessibility claim on the README. How many of your routes has anything actually scanned? Not how many are covered by a rule you've configured. How many has a machine genuinely looked at. If the honest answer is a sample, then what you've got is a result about the sample.
If I started again I'd write the gate before the components. Not after the claim, and not after the rebuild. First.
The conformance register now reads four criteria enforced by test, thirteen met, eleven not applicable, three published exceptions, zero outstanding. The exceptions are published rather than omitted, because an exception you disclose is a decision and one you hide is a defect.
- 117/117Components With a Test and a Story
- 1,564End-to-End Tests Passing
- 806Unit Tests Across 111 Files
- 360Contrast Pairings Gated, 4 Themes
- 0AAA Criteria Outstanding
- 9Defects the New Gates Found