Skip to content
PolyFish CSS

What a real browser found that no checker could

The first browser run returned 55 failures. Forty were our own test server; the rest led to nine framework bugs, two shipping since 0.17.

· ~11 min read · 0.20.0 in the changelog

PolyFish has been verified statically since the first release: a checker that parses every module and every page, an HTML validator, a jsdom suite for the optional script. All of it passed, continuously, while the framework shipped bugs that made form fields zoom on iPhones and pages scroll sideways on a phone. This is the write-up of what happened when the suite finally ran in real rendering engines, why each bug survived the static checks, and what we changed so that the next one does not.

Forty of the fifty-five failures were ours

The first run came back with 55 failures across five device profiles, which looked catastrophic and was mostly noise. The test config served the site with python3 -m http.server. That server has no idea that /docs/components means docs/components.html — nginx does, through try_files, but the dev server does not. Forty failures were its 404 page being tested for contrast, landmarks and layout.

The fix was to stop pretending a static file server is the production server. The suite now starts tools/serve.py, which resolves URLs the way nginx does and sends the headers parsed out of nginx/snippets/security-headers.conf. That second part matters more than it sounds: a Content-Security-Policy violation now fails a test instead of being discovered by a reader whose browser refused to run something.

The cascade-layer bug we shipped twice

Two of the remaining failures had the same root cause, and it is worth understanding properly because it is a trap specific to the thing we chose in 0.17.

Cascade layers beat specificity. That is the whole point: it is why your unlayered CSS wins against the framework without !important. It also means a rule in an earlier layer loses to a rule in a later layer no matter how specific it is. Core sits in polyfish.core. Content sits in polyfish.content, which comes after.

So this, which lived in Core, did nothing at all:

@media (pointer: coarse) {
  .pf-input,
  .pf-select,
  .pf-textarea { font-size: 16px; }
}

Content declares .pf-input { font-size: var(--pf-text-sm) } in a later layer, so the 16px floor was overridden on every touch device from 0.17 onward. Below 16px, iOS Safari zooms the page when a field takes focus and does not zoom back. Every static check passed: the rule was there, correctly written, in the right media query, and completely inert.

The same mechanism broke .pf-invert. It flips a panel to the opposite end of the palette, and its paint rule was a doubled class — specific enough to beat .pf-card, except that .pf-card's background is in a later layer. The result was a card that flipped its text to dark and kept its dark background: 2.5:1, unreadable, on a documentation page about theming.

The rule we now follow is that a touch floor, or any override of a component, belongs in the module that owns that component. Where an exception is genuinely needed, the declaration says so out loud — the invert flip is written from Core but wrapped in @layer polyfish.utilities, with a comment explaining why. And the checker now fails the build if a module sets a field font size below 16px without restoring it in that same module's own coarse-pointer block.

Grids that grow past the screen

Ten pages scrolled sideways at 320px. The culprit was not exotic: a grid item's automatic minimum width is its longest unbreakable content, so a single-column grid grows to fit the longest word in it rather than clipping or wrapping. .pf-stack is a single-column grid. Put a long identifier in a stack, and the stack — and the page — gets wider than the phone.

Three things were feeding it. Inline code was white-space: nowrap, so every long token was one unbreakable box. Display headings like "Documentation" are wider than a 320px screen on their own. And twenty-six single-column grids across six modules let their children set the column width. All three are fixed: inline code wraps and breaks inside a token only when that token cannot fit a line on its own, large headings hyphenate below 30em, and the grids let their children shrink. All 54 pages now fit at 320px — including the 41 the suite was never pointed at, which is why the overflow test now enumerates every page on disk instead of a hand-written list of thirteen.

A colour system that only worked on one theme

The framework's central claim is that components read roles, never palette values. About sixty declarations quietly did not. The visible consequence: on five of the six shipped themes, the primary button hovered to the default green, because the hover colour read --pf-hue-1-deep and a theme that redeclares --pf-accent has no reason to redeclare that. Focus rings were a hard-coded green rgba. So were link underlines and the mobile tap highlight.

Rather than fix sixty lines and hope, the hover, wash, line and ring colours are now derived from the accent, so a theme can be one declaration:

:root { --pf-accent: #d9734e; }

Two details worth stating. First, the hover mixes toward --pf-text, not toward black. Mixing with black cut button-text contrast from 11.3:1 to 6.7:1 on slate and from 10.2:1 to 6.0:1 on dusk, because those themes put dark text on a light accent; text always sits on the opposite side of the scale from the ink, so mixing toward it can only improve the pairing. Second, custom properties resolve where they are declared, so a derived token declared on :root will not follow an accent set on some section deeper in the page. That is what .pf-retint is for, and it is the honest answer rather than a leaky abstraction.

The checker now fails on any component rule that reads a palette token or a literal copy of a default accent colour, and on any custom property declared outside :root that reads the palette — which is how we found --pf-btn-fg: var(--pf-base-950), the reason buttons inside an inverted panel had dark text on a dark fill.

Measured, not asserted

Contrast figures in the documentation used to come from a script we ran by hand. They now come from the browser, in a test, on every palette, including everything color-mix() resolves at runtime. Writing it surfaced four failures that the hand-run script had never covered: cream's accent at 3.96:1 (below AA, for links and button text), cream's second accent at 3.6:1, dusk's second accent at 6.95:1, and the default light scheme's links at 5.4:1 — in the default theme, on a site that claimed AAA throughout.

All of them are fixed by moving the colour along its own hue until it measures at least 7:1, and the claim on the home page and the accessibility page now states what was actually measured: AAA for the default theme in both schemes, with muted text on recessed surfaces at AA on some of the example themes. A smaller claim that is true beats a larger one that is not.

The same applies to layout shift. The metric-matched fallback fonts added in 0.18.0 exist to hold CLS near zero, and until this week nobody had measured whether they did. On a throttled phone profile, every page tested reports a cumulative layout shift of 0.0000, with LCP under 1.2s.

What axe-core found after all that

With the layout and colour bugs fixed, an automated accessibility scan across all 54 pages returned eight violation types. The instructive ones:

  • A focusable control that did nothing. Above the 860px breakpoint the burger label is display: none, but the checkbox it toggles was merely invisible. Keyboard users on every desktop page could tab onto an unlabelled checkbox with no effect.
  • An invalid tablist. The optional script put role="tablist" on the tabs container, whose children are radio inputs and panels — which ARIA forbids. The markup is deliberately flat because the no-script version drives panels with sibling selectors, so moving elements was not an option. The script now inserts an empty element that claims the labels with aria-owns. It has to be a <span>: our first attempt used a <div>, which renumbered every div:nth-of-type() in the panel rules and showed the wrong panel.
  • Scroll containers no keyboard could reach. Table wrappers and code blocks scroll horizontally; without tabindex="0" that content is mouse-only.
  • An accent footer that never flipped its text roles, leaving muted text at 1.19:1 on the accent background.

Three types remain, none critical or serious: role="tab" on a <label>, which the alternative would cost more than it is worth; and an announcement bar outside a landmark on five example pages, which is a best-practice rule rather than a WCAG one.

Twelve pages that never loaded their stylesheet

Not every finding was subtle. Nine blog posts, the examples index, the module picker and the script builder linked the framework modules but not site.css. The two interactive tools are driven by :has() rules that live in that file, so they had been inert since they shipped. The checker's "interactive controls" test had confirmed the controls existed in the markup; nothing had confirmed the CSS that runs them was loaded. It does now: any page using a class that only site.css defines must load it.

The bugWhy the static checks passedThe rule that catches it now
iOS zoomed on every fieldValid rule, right media query, lost to a later layerTouch floors live in the module that owns the field
Buttons hovered the default greenComponents read a palette token themes never redeclareComponents read roles only
Two tools inert for monthsControls existed and were labelled; the stylesheet was not linkedA page must load the stylesheet whose classes it uses
Sideways scroll at 320pxNothing malformed; the width only exists at renderEvery page measured at three widths in a browser
Analytics silently blockedThe header is correct; the failure is in the consoleTests are served through the production headers

What we changed about checking

Every bug above got a check, because a bug you fix without a check is a bug you will ship again: components may not read the palette, a token read must be declared somewhere, custom properties outside :root may not read palette values, touch floors must live in the owning module, scroll containers need tabindex, pages must load the stylesheet whose classes they use, and contrast floors are enforced per palette in a real browser. Along the way the physical-property guard learned about border-left-color and corner radii, which is how we found three alert variants colouring the wrong edge under dir="rtl".

The suite currently passes on Chromium desktop and Android Chrome: 370 tests, no failures, with screenshot baselines committed. Firefox, WebKit and iPhone still have to run before 1.0, along with a screen-reader pass. We would rather say that plainly than claim a green suite we have not seen.

What this post does not cover

  • Firefox, WebKit and iOS. The suite has only run on Chromium desktop and Android Chrome.
  • Performance beyond layout shift: no Lighthouse run, no long-task profiling.
  • Screen-reader behaviour, which no automated check in this post can speak to.

Checked against Cascade layers, minimum width, cumulative layout shift, axe-core. Code blocks are reproduced from the PolyFishCSS source files, not reconstructed from description. Figures are measured from the build they describe.

What this doesn’t cover

These results are Chromium desktop and Android Chrome. Firefox, WebKit and iOS have not run against the fixes, and until they do this is a partial picture. Screenshot baselines catch layout changes, not ugly ones — a page can regress into something worse and still match, provided the geometry holds.

Sources: MDN on @layer, MDN on COEP, web.dev on CLS and axe-core. Both code blocks are reproduced from the shipped source and the test config; every figure is measured from that run.

Testing Playwright Cascade Layers Accessibility Release CSS Tooling