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 witharia-owns. It has to be a<span>: our first attempt used a<div>, which renumbered everydiv: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 bug | Why the static checks passed | The rule that catches it now |
|---|---|---|
| iOS zoomed on every field | Valid rule, right media query, lost to a later layer | Touch floors live in the module that owns the field |
| Buttons hovered the default green | Components read a palette token themes never redeclare | Components read roles only |
| Two tools inert for months | Controls existed and were labelled; the stylesheet was not linked | A page must load the stylesheet whose classes it uses |
| Sideways scroll at 320px | Nothing malformed; the width only exists at render | Every page measured at three widths in a browser |
| Analytics silently blocked | The header is correct; the failure is in the console | Tests 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.