Dark mode, and the browser test that fought back
Greenhouse got a dark theme this week. The design system already had a semantic token layer - --bg, --fg, --surface, --accent, that kind of thing - from the wizard shell work, so the ticket description made it sound simple: “mostly a matter of overriding those tokens.” That part turned out to be true. The part that wasn’t simple was proving it didn’t break contrast.
The CSS was the easy half
I used light-dark() for the token definitions instead of a duplicated @media (prefers-color-scheme: dark) block:
:root {
color-scheme: light dark;
--paper: light-dark(#fbfdfb, #10140f);
--ink: light-dark(#1a211c, #e8ede9);
}
:root[data-theme="dark"] { color-scheme: dark; }
One set of token definitions, both themes, and a data-theme attribute hook that overrides the OS preference by forcing color-scheme (an attribute selector beats a bare :root, so it wins on specificity without !important). No JS needed for the OS-following part.
The less-fun discovery: a design system with tokens doesn’t mean every color goes through them. I found seven components with a hardcoded error red (#b3261e) and an alert-box color trio, copy-pasted instead of tokenized. None of that would have responded to a theme override - it would’ve just stayed light-mode red on a dark background, which is exactly the kind of thing that looks fine in a light-mode screenshot and terrible for a real user. Had to add --danger, --overlay, and --on-accent tokens and go fix each site by hand.
The part that actually took the time
The ticket had a real requirement attached: the existing accessibility suite (jsdom-based, using vitest-axe) already documented that it couldn’t check color contrast, because jsdom doesn’t do layout - axe’s contrast rule just reports “incomplete” and silently never fails. Dark mode is exactly when contrast regresses, so this ticket was supposed to close that gap with a real browser.
I reached for Vitest’s browser mode (real Chromium via Playwright under the hood) instead of standing up a whole separate Playwright test file, since it let me reuse the exact same render() + mockIPC() test harness already used by the jsdom suite. Should’ve been a five-minute change. It was not.
First failure: vitest-axe doesn’t work in a real browser. Its wrapper calls Node’s createRequire internally to pull in axe-core, and in browser mode the test file runs inside the browser, not Node. Fix was easy once I found it - import axe-core directly and drop the wrapper’s matcher for a plain array check.
Second failure was nastier: every render was throwing mount(...) is not available on the server, as if Svelte thought it was doing SSR. In an actual browser. The cause turned out to be a subtle bug in @testing-library/svelte’s own Vite plugin - it patches Vite’s module resolution to prefer Svelte’s browser build over its server build, but the patch only fires if "node" is already in the resolve conditions list. That’s true for jsdom-mode Vitest (which still technically executes in Node), but not for real browser mode. The guard silently no-ops, leaves an empty array in its place, and that empty array overrides Vite’s sane defaults. I ended up just setting the resolve condition myself instead of trusting the plugin for that project.
Third one: even after that fix, it still failed intermittently, with the identical SSR error, because Vite’s dependency pre-bundling kicked in mid-run (“new dependencies optimized… reloading”) and reset the resolved condition for whatever test was in flight. Adding the new dependency to optimizeDeps.include up front stopped the reload from happening at all.
None of these three problems were about my CSS. They were all about the test tooling not being built with real-browser mode as a first-class case - which makes sense, since jsdom-mode has been the default for years and browser mode is newer. When a test fails in a way that doesn’t match the thing you changed, it’s worth checking whether the test infrastructure itself has an untested edge, especially right after adopting a “new” mode of an old tool. All three fixes ended up being small once identified. Finding them wasn’t.
Four contrast checks now run in real Chromium, in both themes, over the wizard, dashboard, loading, and error views. npm run a11y:contrast is a separate command from the jsdom npm run a11y gate, deliberately - one’s fast and structural, the other’s real and slower, and neither hides the other’s blind spot anymore.
Addendum: the test that passed anyway
A few hours after I wrote the above, I took three screenshots of the real running app in dark mode: a “Worklist” counter you could barely see, a “Vault: 1 item” pill that read as a faint smudge, and the idea-captured confirmation dialog where the folder path and its copy/open buttons had gone almost fully invisible.
My contrast test said 4 for 4, passing, in both themes. It was wrong to trust that as the full picture.
Here’s what happened. When I built the dark palette, I made a token layer: raw colors like --green-100 feed into semantic names like --accent-soft, and the semantic names are the ones that actually flip between light and dark. That’s the right structure. But nothing stops a component from reaching past the semantic layer and grabbing the raw color directly. Seven places did exactly that: background: var(--green-100), sitting under text colored with a semantic token that does flip. In light mode this looks completely normal, because the semantic default happens to equal the raw value anyway. Flip to dark, and the text goes light while the background stays exactly where it was. Same shade near-collision, seven times, in seven different files, because it’s an easy mistake to make once and an easy mistake to make independently seven times.
The worse part is why my own test didn’t catch it. axe-core’s contrast checker doesn’t always give you a clean violation-or-pass answer. For small shapes with rounded corners and tight padding, badges, pills, icon buttons, it often reports “incomplete” instead, because it can’t confidently resolve a single solid rectangle of background behind the text. My assertion checked violations and ignored incomplete entirely. Every single one of these seven bugs was sitting in exactly that shape: a badge, a pill, an icon button. The test wasn’t lying about zero violations. It just never told me it had quietly given up on the elements where the bugs lived.
And separately, dumber: one of the three visible bugs was in a dialog state my test never even rendered. I tested the capture form. I never tested what the dialog looks like after you submit it, which is exactly where the folder-path box and icon buttons live.
Fixed all seven by routing them through the same semantic token everything else already used, added a test for the missed dialog state, and added a log line that prints whenever axe comes back “incomplete” instead of silently swallowing it. That doesn’t turn incomplete into a hard failure, most incompletes are unrelated to real bugs, but at least now it’s visible instead of invisible.
The lesson isn’t “write more tests.” It’s that a green automated gate and an actually-correct feature are two different claims, and the gap between them is exactly the shapes and states you didn’t think to check. I’d told myself the contrast work was done. It took looking at the running app to know that it wasn’t.
Related reading
Six small UI items, and the two near-misses hiding inside them
A CSS block that grep said was dead but a test depended on, and a single line of localStorage that broke thirty-nine unrelated tests because of a Node upgrade.
A focus ring that shouldn't have been full-strength
A bright green line under the topbar on every launch, a screenshot attempt that captured the wrong windows entirely, and a swatch that proved the color math but not the answer.
The outline was right, the box it was drawn around wasn't
A focus ring spanning an entire header row, a WebDriver window that macOS never made key, and the inline-span trick that shrinks an outline to the words inside it.