Four UI bugs that never failed
Most bugs report themselves: something throws, a test goes red, and you follow the signal back. A lot of UI bugs give you no signal. The colour is a valid colour, the import is a valid import, the page returns 200 — nothing is wrong as far as the program is concerned, so nothing fails, so the build stays green and the bug ships. They are hard to test for, too: a test checks what you already knew to ask about, and nothing here points you at the question.
I built this site over the last few days, in the open, and four of them were live while I did it. Not the same mistake four times — each was hiding in a different place:
- Code nobody reads.
- Something the eye cannot measure.
- Something that does not happen on my machine.
- Something that leaves no mark on the page.
The site is an Nx monorepo: a React Router app, server-rendered on Cloudflare Workers, plus two libraries for the design tokens and the components. Radix handles anything that needs a keyboard, and the icons are vendored from simple-icons and turned into React components at build time.
1. Code nobody reads: two icons that were black on black
The GitHub and LinkedIn icons arrive from simple-icons like this:
<svg viewBox="0 0 24 24"><path d="M12 .297c-6.63 0-12 5.373…"/></svg>
There is no fill on that path, and my SVGR config had the rule everybody writes:
replaceAttrValues: { '#000': 'currentColor' },
It matched nothing. A substitution needs something to substitute, and there is no colour in that file at all: black is the initial value of fill, which the browser applies when nobody states one. So the icons rendered black, on a near-black background.

The component that renders them is generated by SVGR at build time and git-ignored, so it never reaches a diff and review cannot see it. The axe (opens in a new tab) sweep passed them too, rightly: its contrast rule compares text against its background, and an icon is not text. So a screen reader read those buttons out perfectly, and everyone else saw two dark shapes.
I fixed the config, not the SVGs, which are vendored and would be overwritten at the next download. Instead of replacing a colour that is not there, an SVGO plugin now supplies one: fill="currentColor" on the root <svg>, but only where no fill is stated, so a stroke-drawn icon keeps the fill="none" it declared on purpose.
The gate I added reads the generated output rather than the config, and fails on any icon that hardcodes a colour or never mentions currentColor. It has to look there, because a check on the config would have passed: the config was well-formed and its rule was sensible. It just did not do anything.
2. Something the eye cannot measure: the chips looked like they had no border
The chips did not look right: the border was there — written, painted, one pixel around each chip — and it seemed to have almost no contrast against the card. So I stopped looking and measured.
Contrast is a ratio between two colours: identical colours give 1:1, black on white gives 21:1. WCAG asks for 4.5:1 on body text and 3:1 on everything else that carries meaning, which is criterion 1.4.11, non-text contrast (opens in a new tab) — and it covers the icons of the last section too: one rule, two of my four bugs, no automated check for either.
The text on the chips came out at 6.74:1. The border was 1.18:1.

At 1.18 the two colours are all but the same one, which is why there was no border to see. The code was blameless — a valid colour, resolving to a real token, on the right element — and my eye could tell me the border was weak, but not whether it sat at 2.8 or at 1.18. Those need different fixes: one is a colour to darken, the other is a colour that does not work.
That axe said nothing is not axe's fault. 1.4.11 cannot be automated, because answering it means deciding which borders carry meaning and which are decoration — and at the time my card's hairline and my chips' outline were the same token, --color-border. A person can tell those two apart, a rule cannot, and axe only ships rules that need no judgement (opens in a new tab). So the distinction is my job, and I had stopped making it: a green axe run says I conform to the part of WCAG a machine can decide, not that I conform to WCAG.
The border is now 3.94:1, on a token of its own.
3. Something that does not happen on my machine: the site was waiting for Google
I added Lighthouse to the repo to have a baseline. It said 71: first paint 4.6 seconds, total blocking time 0 ms. Read together, those two numbers say the browser had no work to do — for four and a half seconds it was not busy, it was waiting.

It was waiting for Google Fonts: one line in the head, two round trips in practice. The browser fetches a render-blocking stylesheet from fonts.googleapis.com, and only after parsing it does it discover that the font itself lives on fonts.gstatic.com — two DNS lookups, two TLS handshakes, two requests, all between the visitor and the first pixel. The site was not slow; it was blocked, on infrastructure I do not control, to load a typeface.
My own site would never have shown me this, because my fonts are cached and my connection is fast, so those round trips do not happen here. They happen on a cold load, on someone else's network — the state a lab run reproduces on demand, which is why Lighthouse found it and I never did.
Self-hosting fixes it, and font-display: swap then paints the text immediately in a fallback face. The fallback has different metrics, though, so when the real font lands the layout jumps — which you fix by overriding the fallback's ascent, descent and width so it takes up the same space the real font will. One catch, which cost me three attempts: I had given JetBrains Mono the same Arial fallback as the body text, and Arial is not monospaced, so the nav re-wrapped and the page jumped 33 pixels every time the real font arrived. Layout shift went from 0.022 to zero.
4. Something that leaves no mark on the page: the home page was downloading a dropdown it does not have
The sort control on the post list is a Radix Select. It is heavy: a portal, a focus scope and floating positioning, around 69 KiB.
Every page was paying it. The component library is imported by the root route, so its chunk is shared by everything, and Radix Select sat inside it: a 102 KiB bundle downloaded on every page, dropdown or no dropdown. Nothing about that is visible — same pixels, same clicks, same results — so the only place the cost shows up is the build output.
I lazy-loaded the filter bar with a dynamic import, which should give it a chunk of its own. It changed nothing: same 102 KiB chunk, same Radix on the home page.
The reason is the barrel file. The root route imports Button from the library's index.ts, which re-exports every component, Select included. To drop Select the bundler must delete that import, and deleting an import is only safe if importing the file does nothing on its own — but a module is allowed to act simply by being loaded: register a global, patch a prototype, inject a stylesheet. The bundler cannot prove mine does not, so it keeps it, and my dynamic import was useless because Select came in through the barrel anyway.
What was missing is a promise the package makes to the bundler:
"sideEffects": ["*.css"]
Importing any of my modules does nothing by itself, so if you do not use its exports, throw it away. It has to be that and not false: a CSS import genuinely is a side effect, and false would have let the bundler delete import './prose.module.css' as dead code, dropping the styles with no error at all.

Two changes, and neither does anything without the other. The shared chunk went from 102 KiB to 12 KiB, and Radix stayed exactly where it was.
What they have in common
None of the four failed, and I would have approved all four diffs myself. Looking at the running site does not help either: two of them are not on the page at all, a third only happens on a network I am never on, and the one I could see, I could not measure. So each needed its own instrument, and I ended up with four checks on a build that was already running: the generated icons are read for a hardcoded or missing colour; axe runs over every page; Lighthouse runs cold over the production build; the bundle report is read per route.
Three of those were added after the bug rather than before it, and one of the four kinds still has no automated check at all, so this is not a setup to copy. The question that produced it is the part worth keeping: for each way the interface can be wrong, ask what would have told me. If the answer is that I would have noticed, that is not an answer — it is what I thought before each of these four shipped.
Where it landed
Performance went from 71 to 82, first paint from 4.6s to 3.5s, layout shift to zero, accessibility 100 on every page, and the two icons are visible. Still not fast enough: around 114 KiB of JavaScript ships for pages that are mostly text. That is next.
Fabio
Full stack engineer in Cusco, Peru. Writes about architecture, tooling and developer experience.