A filled primary button at 1.06:1 against the page background. That is not low contrast — that is an invisible button. TypeScript was happy. The unit tests were happy. CI was green.
That number is real, measured on a production codebase a few days after we bumped Mantine from 7.17.8 to 8.3.15. This post is not "how we migrated Mantine". It is about something more transferable: what kind of breakage survives a green pipeline, and what kind of test catches it — because the answer to the first question is "more than you think" and the answer to the second is not "more unit tests".
The codebase is a production pnpm monorepo I work on: several user-facing apps, a shared UI package of a few hundred components organized as atoms, molecules and organisms, and a Storybook that acts as the single source of truth for the design system. Mantine is the base library under all of it.
The visual safety net, before the migration started, had three layers: Storybook, Chromatic running on CI against those stories, and a small in-house Playwright CLI that captures route-level screenshots of the app surfaces that do not fit in the Chromatic snapshot budget.
That net is the protagonist of this story. Everything else failed quietly.
We did not bump blind. The first move was a spike branch to see what actually broke —
and the thing that blocked the upgrade was not Mantine at all. It was
mantine-react-table, pinned at a
2.0 beta that did not support Mantine 8.x and showed no sign of moving.
The way out was to stop depending on it: we replaced the internals of our own DataTable
wrapper with
@tanstack/react-table, keeping
the wrapper's public API intact so call sites did not change. We had made the same move
earlier with the date-picker inputs — funneling every usage through one wrapper component
— and that pattern is what made both swaps tractable: the third-party API surface you have
to migrate is one file, not ninety.
The dependency that blocks your upgrade is almost never the one you are upgrading. A one-day spike is the cheapest insurance there is against a two-week dead branch.
Here is the part that surprises people. Most of the migration's follow-up work went to agents — narrow issues, isolated branches, PRs that auto-merge on green. The bump itself did not. It was a single atomic PR, written and reviewed by humans, deliberately.
The reason is acceptance criteria. A major version bump is an enormous change surface with a fuzzy definition of done: an agent cannot tell whether a visual change is an intended v8 default or a regression, because nobody can tell from the diff alone — it takes product context and judgment. Agents excel at the opposite shape of work, and they got it later.
The division of labor that came out of this migration, stated plainly: the human does the narrow, dangerous change; the agents do the wide, shallow cleanup that follows.
The bump compiled almost cleanly — a handful of mechanical API changes and one pinned component version. Then the app went visibly, sometimes spectacularly, wrong.
Every place our code had pinned a color that only ever worked in one scheme — a hardcoded ink on a surface that now flips, a CSS variable with no dark-scheme counterpart — kept compiling and broke visually. I have started calling this bug class scheme-invariant color: values that are constant in code but only correct in one of the two schemes the code runs in.
What the major version changed underneath matters less than it looks. These values were already wrong; they had just never been asked the other question. A design-system bump is simply the event that asks it, all at once, across every surface you own.
Measured examples from the weeks after the bump, straight from the fix log:
| Symptom | Contrast ratio |
|---|---|
| Filled primary buttons invisible against the page background | 1.06:1 |
| Brand text token on dark scheme (a sign-in page) | 1.65:1 |
| Code swatches in the docs pinning a light background under dark text | 1.20–1.52:1 |
| A print sheet forced to white receiving dark-scheme inks — 280 nodes | 1.18–1.49:1 |
| Highlighted table row: scheme-invariant fill under scheme-aware text | 1.84–2.03:1 |
| Status initials rendered dark-on-dark | 1.22–3.30:1 |
| Captions on dark alert tints, below AA | 3.98–4.45:1 |
And the inverse case, which proves this was never just "dark mode is hard": the app shell showing a dark gray background in light mode. Scheme-invariance breaks in both directions.
The one to remember is the print sheet. A print view is forced to a white background by its nature — and it was receiving dark-scheme inks on 280 DOM nodes. Nobody tests the print sheet. No unit test renders it, no e2e flow asserts on it, and it is exactly the kind of surface a design-system migration silently destroys.
Did not catch it: TypeScript — every one of those breakages type-checks.
Unit tests — they assert behavior and DOM structure, and the DOM was fine.
And, mostly, Cypress — with one revealing exception: after the bump, Switch toggles
stopped responding to Cypress clicks entirely, across three different click strategies.
The e2e suite did not detect the visual problem; the e2e suite became a problem. When
your test runner and your component library disagree about what a click is, no assertion
in the suite is telling you what a user sees.
Caught it: the visual layer, in order of leverage. Storybook, because having a story
per component state means those states exist somewhere renderable at all. Chromatic on
CI, flagging diffs on every story on every PR. The Playwright CLI for the route-level
surfaces outside the snapshot budget — which is what caught the print sheet. And one cheap
configuration change that paid for itself immediately: promoting the accessibility
checks in our Storybook interaction tests from todo to error, which turned "someone
should look at this someday" into a red build with a measured ratio in it.
That Playwright CLI is worth dwelling on, because it is the layer I built myself and the only one that found the print sheet. It is not clever. It walks a capture matrix — each surface in light and dark, at desktop and mobile — shoots every cell, and puts the before and after side by side. Component states come from the stories; the roughly eighty route-level entries are whole app surfaces that no story renders, which is the half of the matrix Chromatic's budget does not stretch to. Everything that makes it useful happens after that: I look at the pairs, and I am the one who decides which differences are regressions. The tool has no opinion. It cannot have one — 280 changed nodes on a surface nobody tests is not a signal the tool can grade, it is a surface that needs a person who knows what the print sheet is for to look at it and say "that ink is wrong on white".
That is a slower loop than a green check mark, and it is the reason it worked. An automated threshold would have had to choose between flagging every anti-aliasing shift or flagging nothing, and either setting would have hidden the print sheet. A human reviewing pairs is expensive per diff and unbeatable at the one question the machine cannot answer.
I am rebuilding that approach in the open, as
packages/visual-diff
in this repo — capture, compare, and the CI report are live now, backed by a 106-variant
baseline corpus. The point of doing it again in public is to find out how much
of my own judgement I can actually encode, and where the honest answer stays "a person has
to look at this".
One honest caveat, because this is where visual regression tooling gets oversold: a visual diff does not tell you what is wrong. It tells you what changed. Whether a given diff is a regression or an intended improvement is a human call, every time. What the tooling buys is not correctness — it is visibility. With 280 affected nodes on a single surface, visibility is the difference between fixing it and never knowing.
After the bump merged, the fix queue was long, repetitive, and objective: 54 commits over the following four weeks touch dark-mode, contrast, or scheme handling. That queue went overwhelmingly to the agent pipeline, and it is worth being precise about why this class of work suits agents so well.
Each fix is narrow — one component, one token, one surface. The acceptance criterion is objective and machine-checkable: does the pair now clear WCAG AA? And the result is visually verifiable by the same Chromatic diff that flagged it. The agent does not need to understand the product; it needs to understand a token. That is the "wide, shallow cleanup" half of the division of labor above.
The v8 migration proved the mechanism; what it did not produce is clean numbers, because we were not instrumenting from day one. The v9 migration will be, before it starts: components and stories touched, visual diffs flagged versus regressions confirmed, agent hours against the manual estimate, and the number nobody publishes — what percentage of agent fixes needed human correction before merge.
I'll publish the numbers when we make the jump.
If this post has a single takeaway, it is the question to ask of your own pipeline: if every color in the app shifted one scheme sideways tomorrow, which test would fail? If the answer is "none", your CI is measuring compilation, not the product. I wrote more about how to layer that visual net — and where pixel diffs stop being enough once agents write part of the code — in Visual regression with agents.