DEV Community

Cover image for Four CSS bugs my headless tests couldn't see
ShengXing Chi
ShengXing Chi

Posted on

Four CSS bugs my headless tests couldn't see

I test Cá Viên Chiên mostly with headless Playwright: walks over every screen at several widths, layout measurements, screenshots. That catches a lot. These four bugs got past all of it, and each one taught me which kind of check I was missing.

1. The minifier kept the prefix and dropped the property

Symptom. On a computer, the game page's header slides away while you play. Once it was gone, the small background button in the top corner looked smudged, as if seen through frosted glass.

Cause. The header has backdrop-filter: blur(8px). When it collapses, I turned that off by writing both forms:

.site-header.is-away {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}
Enter fullscreen mode Exit fullscreen mode

The minifier decided these were duplicates and kept only the prefixed one. Chrome ignores -webkit-backdrop-filter, so it kept blurring through the now-empty header box, right over the button.

Why tests missed it. Headless Chromium doesn't draw backdrop blur, so every screenshot looked sharp. I only saw it in a real Chrome tab.

Fix and guard. Only the unprefixed property is written now, and a unit test parses the stylesheet's collapse rules and fails if -webkit-backdrop-filter reappears in them.

2. The button that ran away from the pointer

Symptom. With the header away, moving the mouse to that same button made it slide down out of reach. Following it made it slide back up.

Cause. The header comes back when the pointer is over the strip at the top of the page. The button sat under that strip, so pointing at the button also hovered the strip. The header came back, the button moved down with it (it rides along with the header), and the pointer was no longer over the strip, so the header left again, and so on.

Fix. The button now sits above the strip (z-index), so pointing at it doesn't count as hovering the strip. With the header showing, moving down to the button sets data-bg-hold="shown" on <html>, which keeps the header in place while the pointer travels to the button. Opening the panel holds the header in whichever state it was in until the panel closes.

Guard. A pointer walk in Chromium and WebKit at 1280 / 1440 / 1920, vi and en. It moves the mouse step by step to the button, into the panel and away again, sampling the header's and the button's positions every frame. It fails if either moves while the pointer is on the button. That was 120 checks, all passing.

3. Safari's fade that dipped and replayed

Symptom. Switching the page background cross-fades the new scene over the old one. In Safari it flickered at the end: the new picture faded in, briefly dimmed, and faded in again.

Cause. I used a keyframe animation for the fade-in and an opacity transition on the same layer. When the two met, WebKit let a finished fade-in dip (opacity 0.99 → 0.84) and run again. Chrome didn't.

Fix. Transitions only, no keyframes. The new scene's layer jumps to transparent with transitions switched off, the style is flushed, and then the transition back to opaque runs:

.page-bg::after { transition: opacity 0.7s; }
html[data-bg-fade='start'] .page-bg::after { opacity: 0 !important; transition: none !important; }
Enter fullscreen mode Exit fullscreen mode
html.dataset.bgFade = 'start';                      // top layer transparent at once
html.style.setProperty('--page-bg-wide', `url("${next}")`);
void getComputedStyle(layer, '::after').opacity;    // flush
delete html.dataset.bgFade;                          // transition to opaque
Enter fullscreen mode Exit fullscreen mode

Why tests missed it at first. My production walk sampled the layer's opacity every frame in both engines and caught it the day it shipped (WebKit only). The fix shipped in the next patch. Sampling a value across frames is what found it; a screenshot at the end would have looked fine.

4. "mai" instead of "Phô mai"

Symptom. On a 1024 px wide screen, one sauce button under the woks read "mai". The sauce is Phô mai (cheese). In English, "Tamarind" was squeezed onto two lines.

The sauce row at 1024 px: before, the cheese label reads

Alt text: The sauce row at 1024 px: before, the cheese label reads "mai" and Tamarind breaks into "Tama- / rind" behind the bottle; after, every name on one line

(The "before" rows are the 1.36 rule restored on today's page, to reproduce it.)

Cause. The wide layout's rule for those labels said one line, 11 px:

.cvc .sauce-btn > span { grid-row: 2; font-size: 11px; }
Enter fullscreen mode Exit fullscreen mode

But an older base rule, written for phones, allowed two lines with a line clamp, and it had higher specificity:

.cvc .sauce-btn span:last-child { white-space: normal; -webkit-line-clamp: 2; /* … */ }
Enter fullscreen mode Exit fullscreen mode

span:last-child beats > span. At 1024 px the button is 47 px wide and "Phô mai" needs 40, so the text wrapped. The label row is 14 px tall and aligned to the bottom, so the second line showed and the first sat hidden behind the bottle picture. It had been like that in the release before mine too.

Why tests missed it. My walk checked for clipped labels by comparing scrollWidth to clientWidth. A label that wraps is never too wide. It is too tall, and the overflow went upward, behind another element.

Fix and guard. A wide-only rule with enough specificity (span:last-child) keeps the label on one line, and lets a name a pixel wider than its box run into the button's padding instead of wrapping. The walk now also measures every one-line label's height against its line height and fails if it wrapped. The same check found a second case, an order card's "Sốt mayonnaise" cut off next to the drink tile, which now uses the short name from the bottle label ("Mayo").

What the four have in common

Each bug was invisible to the kind of check I was running:

  • Headless rendering isn't real rendering. Backdrop blur and some compositing simply aren't drawn. Keep a real-browser look in the loop for visual effects.
  • Interaction bugs need interaction. The runaway button only exists while a pointer moves.
  • Time-based bugs need sampling over time. One frame at the end of a fade proves nothing.
  • "Clipped" has more than one direction. Measure height against line height, not only width.

And when a bug gets through, the fix isn't finished until there's a check that would have caught it.

The game is free at cavienchien.net. Earlier posts in this series cover the Astro + Cloudflare setup, the invisible layout shifts and the phone redesign.

Top comments (0)