There's a category of CSS bug that is specifically annoying because it's your own fault and you know it immediately.
I added resting text-decoration underlines to every link on my portfolio. The system looked right on desktop. Then I opened mobile and watched a button-like terminal CTA — one that explicitly declared text-decoration: none in its own CSS class — sit there wearing a hairline underline like a child who knows the rule and is breaking it anyway.
The declaration was there. The rule was there. It was just losing.
Why Resting Underlines at All
The portfolio uses a bright green (#6ee7a7) for its accent color, and that accent color has a day job and a night job. During the day it marks interactive elements — links, CTAs, active navigation items. At night it marks command text and terminal prompts in the boot section's UI. Green-as-command and green-as-link are visually identical at rest, which means a reader can't tell, purely from color, whether a green word is going somewhere or just styled.
The WCAG guidelines on use of color have thoughts on this: don't rely on color alone to convey information about an interactive element. The visual ambiguity was real and worth solving.
Resting underlines solve it. A 1px hairline underline at rest tells you unambiguously that something is a link before you hover. The hover state can still do something expressive, but the at-rest state is no longer ambiguous between "clickable" and "styled to look like clickable."
The implementation was a single global rule in tokens.css:
a:not(.nav-link):not(.btn):not(.no-pop) {
text-decoration: underline;
text-decoration-thickness: 0.0625rem; /* 1px hairline */
text-underline-offset: 0.2em;
}Three :not() exclusions: nav links keep their existing animated underline behavior, buttons are filled pills where an underline would look wrong, and .no-pop is an existing opt-out class for structural components — cards, chips, TOC rows, pager controls — that want to style their own affordances. Desktop structural links already used .no-pop. Mobile components didn't. They had explicit text-decoration: none declarations in their own CSS classes, and I figured that was enough.
It wasn't.
The Math I'd Half-Remembered Wrong
CSS specificity is usually described as a three-bucket score: IDs as (1,0,0), class selectors and pseudo-classes as (0,1,0), element selectors as (0,0,1). A class beats an element. An ID beats a class. Higher wins.
The trap is :not(). The CSS Selectors spec says the :not() pseudo-class itself contributes nothing to specificity — but the specificity of its argument does, as if it were written directly in the selector. So a:not(.nav-link) has specificity (0,1,1): the element a plus the class .nav-link from inside the negation.
And a:not(.nav-link):not(.btn):not(.no-pop) has specificity (0,3,1). Three class-level arguments. Three class-level points accumulated.
My mobile rule .m-panel-cta { text-decoration: none; } scores (0,1,0).
(0,3,1) beats (0,1,0). Not barely — by two full class-level points. The global underline rule was outranking every single-class override in mobile.css by a comfortable margin. Bringing one class rule to fight a selector that had three hidden class contributions is like bringing a knife to a knife fight where the other side had three knives and didn't mention it.
This looks like it should work. A .m-panel-cta class rule feels like it should beat a bare a selector, because that's how CSS usually goes. The trick is that it's not a bare a. It's a with three accumulated class-level contributions from the :not() arguments. The total outweighs a single class, and every text-decoration: none declaration on a single-class mobile rule was quietly, politely losing.
The confusing part is that some mobile rules were winning — the ones that got their text-decoration: none into a higher-specificity selector, or that had text-decoration: none declared directly in mobile.css on a rule that happened to have more than one class. The inconsistency made the diagnosis harder. Some links were fine. Others weren't. There was no obvious pattern until I worked out the specificity math.
The Escape Hatch Was Already There
Once the math clicked, the fix was obvious in retrospect.
The codebase already had .no-pop as the answer. When I wrote the global rule, I built the exclusion into it. I just hadn't applied the exclusion to mobile components that needed it. Desktop structural links used .no-pop. Mobile structural links, built before the underline system existed, didn't know they'd need it.
The list of affected components was longer than I expected:
- MobileWriting: filter chip links, writing row links, RSS footer link
- MobileProjects: section header link, project CTA buttons
- MobileWorkLog: work entry rows, GitHub footer link
- MobileNav: brand home link, scroll anchor links, navigation menu links
Each one was custom-styled. Writing rows had their own border-bottom as the structural affordance. Filter chips were interactive toggles with data-active state styling. Navigation links used inline color to mark the active route. None of them wanted a text-decoration underline on top of their existing design. They all needed .no-pop.
The fix per component was a className addition — mechanical, fast, a few minutes total across all four files. What took time was the diagnosis, not the repair.
The gap this exposed: mobile was built incrementally, each component's links designed in isolation. The .no-pop pattern existed on desktop because desktop structural links were present when the system was designed with them in mind. Mobile links came later, or were designed alongside global rules that didn't exist yet. When the global rule arrived, they had no defense and no reason to have one.
The Part Your Linter Can't See
After fixing it, I added Playwright regression tests. Not because I expected to reintroduce the bug immediately, but because of the broader problem this surfaced: CSS cascade bugs are invisible to every tool in the standard toolchain.
tsc has no model of the cascade. eslint doesn't either. Unit tests run against logic, not computed styles. You can have perfectly typed, lint-clean, fully-passing code where the cascade is quietly doing the wrong thing at runtime, and nothing will flag it until someone opens a browser.
The tests navigate each affected mobile page, query the computed text-decoration-line value on each affected selector, and assert it resolves to "none". If a future global CSS change accumulates enough specificity to override mobile styles again — or if someone removes a .no-pop class not knowing why it's there — the test fails immediately in CI instead of surviving to a screenshot review a PR later.
The most useful part was running the tests before the fix. Watching .m-wrow should not carry the global underline fail with Received: "underline", Expected: "none" confirmed the test was actually detecting the problem and not just passing vacuously. Then after the fix: passing. That cycle — write the guard, watch it catch the exact regression, apply the fix, watch it pass — is how you know the test is connected to reality.
CSS cascade bugs live in the gap between static analysis and the browser. That gap doesn't close on its own.
The specificity math in the CSS spec is clear. :not() arguments contribute their own specificity. Three :not(class) clauses mean three class-level contributions. I'd read this before. I just didn't think about it when writing the rule.
Building global CSS systems is worth it. One consistent link affordance rule beats fifteen slightly-different underline implementations scattered across components. But the global rule needs an escape hatch, and the escape hatch needs to be applied everywhere — not just to the components you were thinking about when you wrote the rule.
.no-pop was already there.
I just forgot to bring it to mobile.