Classes that compile, ship, and do nothing
The focus ring on my most-used component was invisible on six of eight color schemes for months, and every check I had reported success the whole time.
Tailwind v4 generates utilities from theme tokens. If you write a class naming a token that doesn’t exist, that is not an error. It is not a warning. The build succeeds, the class sits in your className looking exactly like the ones on either side of it, and it produces no CSS at all.
Twelve of those had shipped in Roster, the component library I maintain and five of my own apps depend on. Two were the focus ring, on three different controls.
A focus ring the color of the text it surrounds
TL;DR
Button asked for focus-visible:ring-ring and ring-offset-background; Badge and Input each carried one of the two. Reasonable-looking class names. Neither --color-ring nor --color-background was ever defined in the theme, so both utilities emitted zero rules.
A ring with no color doesn’t disappear. Tailwind’s ring is drawn from var(--tw-ring-color, currentcolor), and with nothing setting the first half, the fallback wins. The focus ring became the text color.
On a solid button, the text is white. So the focus ring was white, drawn on a white page, around a control that had just received keyboard focus. The library ships eight color schemes; the solid teal and amber fills carry dark text, so their rings came out near-black and were merely wrong rather than absent. The other six vanished on any light background, which is where every app that installs Roster puts them.
Focus moved. Nothing indicated where.
This site runs on Roster, so that isn’t a description. Both of these are the real component, drawn with the focus ring showing. The ring color is the only thing that differs between them:
Button, with the focus ring drawn at rest so the difference does not depend on you pressing Tab. The ring color is the only thing that differs. Both cells are pinned white in either theme, because a white ring on a dark page is perfectly visible and would show you nothing. If you do tab through them, the square outline that appears is this page keeping focus visible, not part of the demonstration.Three green checks, all blind in the same direction
TL;DR
TypeScript can’t read a class name. To the compiler a className is a string, and "ring-ring" is exactly as valid as "ring-primary-500". There is nothing for it to check.
The unit tests asserted presence, not effect. They checked that the button rendered with the class applied. It did. That assertion stays true for a class that does nothing, which is the entire problem. The test and the bug are compatible.
Storybook looked fine, because a missing focus ring is not a visible defect. It is a missing one. Nothing is drawn in the wrong place, nothing overlaps, no color is off. You have to know to press Tab, and then you have to notice the absence of a thing you were not looking for.
That last one generalizes past this bug. Visual review catches things that are drawn wrong. It is structurally poor at things that aren’t drawn at all, and no amount of looking harder changes that.
Check the artifact, not the source
TL;DR
Defining --color-ring was the easy half and worth almost nothing on its own, because the mistake isn’t one I had made once. It is one the tooling permits, silently, every time.
So the check has to compare what the components ask for against what the build actually produced. The class names come out of the component source; the rules come out of the compiled stylesheet. Anything referenced and not emitted fails the build.
# roster/package.json
"build": "tsc -b && vite build
&& node scripts/check-prefix.mjs
&& node scripts/check-classes-emit.mjs
&& node scripts/check-tokens.mjs"It runs after vite build rather than instead of it, because the bug only exists in the artifact. Reading the source tells you what was requested. Only the stylesheet knows what was granted.
Three classes are exempt, and the exemption list is where the check nearly went wrong. group, peer and dark are markers read by other selectors and legitimately emit nothing. I nearly added sr-only to it, which would have been wrong: it emits a real rule, so exempting it would have blinded the check to a regression in the one utility whose whole job is serving people who can’t see the screen. An allowlist is where a guard goes quietly blind, so it is the one part of a check like this worth re-reading every time you add to it.
The ring wasn’t special
TL;DR
I expected the check to sit there and earn its keep slowly. Its first run named ten more, across three files I had no reason to suspect.
Seven were the tooltip’s entire entrance animation: animate-in, fade-in-0, zoom-in-95 and four directional slides. The tooltip had never animated. It appeared instantly, which reads as a deliberate choice rather than a broken one, and so had never been reported by anyone including me.
Two were gradient stops on the countdown component, naming an accent color family that didn’t exist. One was a scrollbar style on the textarea. Every one of them had shipped, in a published package, to every app that installs it.
Twelve shipped classes, then, between the two rounds. Not one of them broke anything loudly enough to be noticed.
There were two more, and they are the part worth keeping. fade-in and zoom-in were sitting in a stories file, and the check never saw them, because it skips *.stories.* and *.test.* on purpose, because documentation blurbs are full of CSS in code fences and scanning them produces nothing but false alarms. A deliberate exclusion, made for a good reason, and it is a hole. I found those two by hand.
Which is the rule applying to itself. A check is a decision about what to look at, and every such decision draws a boundary somewhere. This one draws it at stories files, for a good reason, and the boundary is exactly where the next two were sitting. Knowing where your checks stop looking is the useful thing to know about them.
A green check is a claim about what it can see
TL;DR
This is not a Tailwind problem, which is what took me a while to understand. Tailwind is where I happened to meet it.
A type check that passed because of a background process. My site’s type check passed every time I ran it locally. On its first run in CI it produced sixty-one errors. Next generates the type declarations for routes and image imports into files that are also gitignored, and my development server had been quietly regenerating them the entire time. The check was real. Its prerequisites were being supplied by something nobody would think to list as a dependency.
A test with an early return. I wrote a guard to stop one case study rendering another one’s screenshots, after doing exactly that. It parsed the file into sections keyed by study, and for a key it didn’t recognize it returned early rather than failing. A reformat that indented one nested key two spaces too few would have cut a study’s section short and handed the remainder to a name the check didn’t know, so most of that study would have gone unexamined with the suite still green. It came out in review before it shipped, which is the stage at which this class of fault is cheap to find. Once a check like that is green in CI, nothing downstream ever questions it again.
A comment that argued its way out of a necessary clause. A comparator in another app was missing a rule. The comment where the rule should have been explained that it could never change an answer the remaining rules did not already give. That was wrong, and it made the comparator inconsistent: for two particular rows it claimed each should sort after the other, so the winner depended on the order the API happened to send them in. Every test written to prove that comparator was order-independent missed it, because all of them set one field that made the broken branch unreachable.
The question to ask a green check
TL;DR
I had been treating my checks as a set that collectively covered the work. They do not collectively cover anything. Each one answers one narrow question, and the gaps between them aren’t visible from inside any of them.
The question worth asking is not whether a check passes. It is what would have to be true for this check to pass while the thing is broken. For the type checker, a wrong string. For the unit test, a class that exists and does nothing. For Storybook, an absence. Each answer is a specific, writable check.
Defining the missing color was the smaller half of that day’s work and the less useful one. The check is what found the other ten, within a minute of existing, in three files I would never have thought to open.