Every gate was green and the animations were gone
The test passed because it asserted the exact condition the failure produces.
That sentence took me most of a day to arrive at, and it is the only part of this worth remembering. The specific bug underneath it is a CSS minifier edge case that will be fixed upstream eventually and will matter to almost nobody. The shape of the failure is not specific to CSS at all.
I had seven scroll-driven animations on this site — the hero settling as it leaves, section
headings fading in, the fifteen rows of the DURA phase table cascading one at a time as you
scroll past. All of them written in plain CSS with animation-timeline, no
JavaScript, no library. I had a verification suite with eleven gates. Every one of them was
green. None of the animations had ever run in production.
What I actually shipped
The source was correct. Each rule looked like this:
animation: rise linear both;
animation-timeline: view(); The build ran that through lightningcss, which is Vite's default CSS minifier and is very good at its job. One of the things it does well is collapse adjacent longhand properties into their shorthand. So it wrote this:
animation: linear both rise view()
Which is not legal. animation-timeline was removed from the
animation shorthand by the CSS Working Group, specifically so that a timeline could not
be reset by an unrelated animation declaration. A browser reading that value does not take the parts
it understands and discard the rest — it discards the entire declaration. All seven animations computed
to animation-name: none in the shipped artifact.
The scroll progress bar was the visible casualty. Its rule sets display: block inside the same block that gives it its animation. The display survived
the minifier; the animation did not. So the deployed site had a two-pixel accent bar pinned across
the top of the viewport at full width, at every scroll position, permanently. The comment directly
above that rule in my stylesheet describes this exact outcome as the thing it was written to prevent.
Why nothing caught it
I had gates for this. Several. They checked that after scrolling to the bottom of the page, every section had settled at full opacity — that nothing was left invisible, stuck at the start of an animation that never fired. That is a real failure mode and worth testing.
Here is the assertion, more or less:
const opacities = sections.map((el) => getComputedStyle(el).opacity);
assert(opacities.every((o) => o > 0.99)); A working scroll animation ends at opacity 1. A dropped animation leaves the element at its base state, which I had deliberately authored as the final visible state so the page degrades correctly without scroll-timeline support. That is also opacity 1.
The test could not distinguish between the animation completing and the animation never existing. It was not a weak assertion or a flaky one. It was a precise assertion about the wrong property — one that the bug satisfied perfectly, every run, with total confidence.
The cross-engine gate could not save me either. It ran the same checks in Firefox, which has no
support for animation-timeline: view()
at all and is legitimately on the static fallback path. Firefox reporting "everything visible, no
animations" was the expected result. The gate that existed to prove the fallback worked was structurally
incapable of noticing that the fallback was all anyone was getting.
How it surfaced
Not from the suite. Somebody looked at the site and said the scroll animations were not doing anything.
My first explanation was that they had Reduce Motion enabled in macOS, which the site honours. That was a plausible, confident, and completely wrong diagnosis, and I held it long enough to be embarrassing. It took an adversarial review of the built output — not the source — to find the actual computed values.
The gap between "the source is correct" and "the artifact is correct" is where this lived the whole time. Every check I had ran against behaviour I assumed followed from the source. None ran against what the build actually emitted.
The fix, and the better fix
The fix was one line: switch Vite's CSS minifier to esbuild, which does not perform that particular collapse. Thirty seconds of work.
The better fix was a new gate that reads the computed animation off the built page, in an engine that supports the feature:
const cs = getComputedStyle(el);
ok: cs.animationName === expected && cs.animationTimeline !== 'auto'
Seven selectors, checked by name and by timeline. I verified it works the only way worth
verifying anything — by putting the bug back. Reverting the minifier makes all seven fail with name=none timeline=auto. Restoring it makes all seven pass. A gate I have not
watched fail is a gate I have not tested.
What I took from it
Not "test the built artifact", though I do that now. The narrower and more useful version:
When you write an assertion, ask what a total absence of the feature would produce, and check whether your assertion would pass on it.
Mine would have. A page with no animation code at all, no stylesheet, no script — every section sitting at opacity 1 — passes that suite cleanly. The test was measuring a property that the working case and the broken case happen to share, which means it was measuring nothing.
A suite full of assertions like that does not give you confidence. It gives you something worse, which is the feeling of confidence with none of the coverage, and it costs you the instinct to go and look.