Skip to content
PolyFish CSS

The changelog filter never worked

A small bug with a useful lesson about :has(), and an examples section that finally reads like a directory.

· ~4 min read · 0.18.5 in the changelog

The changelog has had a filter since 0.14: radio buttons for Added, Changed, Fixed and Removed, driven entirely by :has(). It shipped, it validated, every check passed, and it did nothing at all.

Why

The selectors looked like this:

.cl:has(#cf-added:checked) .cl__items li:not(:has(.cl__tag--added)) {
  display: none;
}

Correct CSS. The problem was in the markup: the filter panel sat directly above the .cl container rather than inside it. :has() only looks inside the element you attach it to, so .cl:has(#cf-added:checked) could never match — the input was a sibling, not a descendant. A control panel placed "just above" the thing it controls is outside it.

The fix, and the check

Moving the panel inside was one line. The more useful change is a new build check: for every :has(#id:checked) rule, it finds the element carrying that scope class and confirms the control is inside it. Re-introducing the original bug now fails the build with a message naming the control and the scope.

The examples section was also regrouped by what each page is for — real-world sites, application screens, content pages — with a downloadable pack that renders straight off the filesystem.

Update: we shipped the same class of bug twice more

The failure in this post has a shape worth naming: correct CSS that never applies. Nothing is malformed, so nothing complains — the rule is simply never reached. Since writing it we have found two more instances, neither caught by any static check.

The first is the same mistake at a different scale. The module picker and the script builder are driven by :has() rules living in site.css, and neither page linked that file. The selectors were correct, scoped correctly, attached to the right element, and absent from the document. Both tools had been inert since they shipped, along with nine blog posts that had lost their code-block scrolling for the same reason.

The second came from cascade layers. A rule in an earlier layer loses to a rule in a later one regardless of specificity, so a 16px touch floor for form fields written in Core was overridden by Content's field styles for two minor versions, and iPhones zoomed on every focused field. Same shape again: valid, well-written, unreachable.

Each got a check, because that is the only thing that generalises. A page may not use a class whose stylesheet it does not load. A module that sets a field font size below 16px must restore it in its own coarse-pointer block. Neither would have been caught by validating markup or parsing CSS, which is also why the browser suite exists: a rendering engine is the only thing that knows whether a rule actually won.

What this post does not cover

  • Other :has() patterns. This is one failure, not a survey of the selector.
  • Browser support tables — the selector is Baseline, and the bug was ours.

Checked against sibling, radio buttons. 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

The fix is specific to a filter driven by sibling selectors. It says nothing about filters that need to match across a tree, which :has() can express but which get slow quickly, and nothing about pagination, sorting or anything else people expect from a filter once it works.

Sources: MDN on :has() and MDN on the subsequent-sibling combinator. The selector shown is reproduced verbatim from the changelog page that shipped broken.

:has() Selectors Debugging Documentation Release CSS