SheetOpen · inclusive.io
RoleHired to design the ATS, then co-founder
DatesFeb 2024 to now
ScopeDesign system and ATS product, solo
BuiltTwice so far. The third is custom
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.
Skip to the app, running, with axe on it
- 117Components
- 26ATS Workflows
- 174Routes Audited
- 0Axe Violations
It had never been asked the question.
SheetPart one · 01
Groundbone
EvidenceWhy the third
Sourcesrc/data/case-study-narrative.ts
Part one · 01 · The brief
A company called inclusive couldn't be inclusive by name only
I was hired to design an applicant tracking system, and the first thing settled, before a single screen existed, was that a company with that word in its name was going to have to earn it.
Recruitment software is where a lot of people meet a company for the first time, usually at the worst possible moment to be locked out of anything. A hiring product a screen reader can't get through, or a form a keyboard can't finish, is the business quietly deciding who gets to apply, and for a company called inclusive that would have turned the whole name into a lie on the first form anybody filled in. So accessibility was never going to be a line on a roadmap. It was the foundation, and everything else was going to have to stand on it.
Which is how I ended up setting out to build the most thorough out of the box WCAG coverage of any design system made for recruitment, anywhere. It's an ambitious sentence to write down and I'd write it again, because the alternative is accessible enough, and accessible enough always turns out to mean accessible for somebody else. Nothing I could find shipped the enhanced criteria as standard. There wasn't a playbook to copy, or a worked example of how you'd go about proving any of it.
The plan was never to hand build every primitive on day one, though. The first version would stand on other people's foundations, because the fastest way to learn what a product like this actually needs is to build it on tools that already work, and the version worth owning outright, every primitive and every style, would be the third. Two versions to find out what the system had to be, and a third to make it completely mine.
SheetPart one · 02
Grounddeeper
EvidenceSame shape, one tier down
Sourcesrc/data/case-study-narrative.ts
Part one · 02 · The first build
ShadCN and Radix were the right call for a first version
74 components on ShadCN and Radix UI, colour held in HSL across one flat layer of CSS variables, light and dark themes, three breakpoints, and a recruitment dashboard built out of the same parts.
ShadCN was quick to move into and good for settling patterns, and settling patterns was exactly what a first version was for. I didn't need a bespoke dialog on day one. I needed to find out how many dialogs an applicant tracking system actually has, what a candidate pipeline wants from a table, and which components the product kept reaching for that no library ships. Radix gave me sound keyboard behaviour and correct roles underneath, which left the time for the product rather than for writing my own focus traps.
The accessibility claim on V1 was AA, and the coverage behind it was partial. Checking it meant me looking at it. Contrast was a swatch and a judgement, focus was something I tabbed through by hand, and a component counted as accessible once I'd had a proper go at breaking it and couldn't. That's a real standard of care, and it's also a standard that lives entirely in one person's attention, which holds up right until the day that person is busy.
SheetPart one · 03
Groundbone
EvidenceStill online
Sourcesrc/data/case-study-narrative.ts
Part one · 03 · Where it stopped
The AAA went up on the page before anything could check it
V1's accessibility page opened by promising WCAG 2.1 AA compliance, and a little further down it set AAA in bold over the words Contrast Ratios.
That's where the claim started, and it started small. Nobody sat down and decided to advertise a standard the system hadn't met. There was a contrast checker on the same page reporting a pass at 7:1, a 7:1 target was genuinely the plan, and the plan found its way onto the page as a figure, because that's what an ambition turns into when nothing in the pipeline is able to say not yet.
The foundation couldn't have carried it either, even if I'd set out to earn it properly. HSL made every contrast decision a guess, since two colours with the same lightness number don't look the same lightness, and a ratio you can't predict is a ratio you can't gate. The tokens were one flat layer, so there was nowhere to say that a colour is text on a raised surface in high contrast mode rather than simply a purple, and enhanced contrast needs that semantic layering to exist before anybody can enforce it.
So V1 had done its job. It proved what the product needed, it proved the ambition was the right one, and it proved, a lot more quietly than I'd have liked, that the foundation was going to have to come out.
Still online V1 is live below exactly as it was. Taking it down would tidy this story up and make it less true, and a case study about a claim that outran its evidence doesn't get to delete the evidence. Point axe at it and it finds nine problems, one of them critical, counted on 24 September 2026, and they stay for the same reason it does.
Radix V1
Radix V1 ran here, live and edge to edge, from its own deployment. That deployment is offline at the moment, so there's a note here instead of an error page. Aria V2, the version that replaced it, runs further down this page.
SheetPart two · 04
Grounddeeper
EvidenceCriteria first
Sourcesrc/data/case-study-narrative.ts
Part two · 04 · 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.
SheetPart two · 05
Groundbone
EvidenceThe tell
Sourcesrc/data/case-study-narrative.ts
Part two · 05 · The rebuild
HSL is a lie, and it took a full rebuild to admit it
V2 started exactly where V1 stopped, at the colour. HSL came out, OKLCH went in, and contrast stopped being something I had to take on trust.
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.
The same rebuild went all the way down to the component API and stopped there. React Aria took over from Radix, which put keyboard and screen reader behaviour into a library built by people who'd done that work properly rather than into my head, and a three tier W3C DTCG token system replaced the flat variables, reference then semantic then component. Same component names and the same props on top, and new foundations underneath every one of them.
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.
SheetPart two · 06
Grounddeeper
EvidenceWhy an ATS
Sourcesrc/data/case-study-narrative.ts
Part two · 06 · What V2 became
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.
SheetPart two · 07
Groundbone
EvidenceWhat they say now
Sourcesrc/data/case-study-narrative.ts
Part two · 07 · How far the claim got
By V2 the claim was in the source of 29 components
WCAG 2.2 AAA compliant, in the documentation comments of 29 components, where it shipped inside the package source and rendered straight into the generated docs.
I'd been telling people in more places than I'd realised. The design principles page promised 7:1 contrast for normal text and said that exceeded AAA, when 7:1 simply is the AAA requirement. It said the system targeted WCAG 2.2 AAA as the highest level of compliance there is. And the conformance page contradicted itself, publishing two criteria as known exceptions while its own register, further down the very same page, marked both of them met.
I want to be exact about how that happened, because the how is the useful part. I'd done the work the sentence described: 7:1 contrast targets, 44px touch targets, reduced motion treated as a requirement rather than a toggle. What I hadn't built was anything able to tell me where the work stopped and the sentence carried on, so the sentence carried on, into JSDoc and a principles page and a register, each copy of it a little more confident than the one before.
SheetPart two · 08
Grounddeep
EvidenceTwo pinned values
Sourcesrc/data/case-study-narrative.ts
Part two · 08 · 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.
SheetPart two · 09
Groundbone
EvidenceThe tell
Sourcesrc/data/case-study-narrative.ts
Part two · 09 · 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
Part two · 10 · 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.
SheetPart two · 11
Grounddeeper
EvidenceOn the demo data
Sourcesrc/data/case-study-narrative.ts
Part two · 11 · 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.
Aria V2 · the app, running
Click about in it, then run axe on it
This is the applicant tracking app itself, built from the same source and running in this page. Pick a screen or click around inside it, then run axe on whatever you've landed on. It checks in your browser and shows you exactly what it finds.
SheetEpilogue · 12
Groundbone
EvidenceWhy this act exists
Sourcesrc/data/case-study-narrative.ts
Epilogue · 12 · 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 V2
V2's packages are at 1.0.0 with public access set, and not one of them has been published. The only package of mine anybody can install is the V1 core. Everything about the V2 tarballs 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
Epilogue · 13 · What I'd change, and V3
The third version gets its gate before its components
The badge was honest in intent and unenforceable in fact.
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.
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.
And I am starting again, which is the part of this that was always the plan. V3 is the version that was going to be fully custom from the day the brief was written, and it replaces what V2 still borrows at the foundation: in-house headless primitives where React Aria and React Stately are now, and an in-house styling layer where Tailwind is. Nothing underneath the component API that I didn't write myself.
It hasn't started, and there's no date on it, and I'd rather say that plainly than put a quarter on a roadmap and watch it go past. What it does have is the one thing neither of the first two had on day one. The gates already exist, the 360 contrast pairings and the axe sweep over every route and the register with its exceptions published, so V3 gets to be the first version of this system written against its gate from the first component instead of caught out by one after the claim.
If I started again I'd write the gate before the components, not after the claim and not after the rebuild. V3 is me starting again, and the gate goes first.
- 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