The bug our agents couldn't see
A scroll jitter only iOS Safari could see, a fleet of agents that couldn't, and the single line of CSS that made the difference.
Our agents check their own UI work before anyone looks at it. Until this bug, that meant starting the app on our dev boxes, driving it in a headless browser, taking screenshots and comparing what they saw with what they meant to build. That loop caught most things. This post is about a bug it couldn’t catch, and why our agents now test in whatever environment the app actually runs in: a position: sticky section that jittered in Safari on iPhone while scrolling, held perfectly still everywhere else, and turned out to be one overflow-x: clip on an ancestor.
How our agents verified UI
Every change that touched the interface got driven, not just compiled. The agent opened the page in a headless browser on a dev box, scrolled, clicked, resized, read the DOM, the console and the network, and sent screenshots of every affected state with its report. A change nobody has seen isn’t done. (More on how the work is organised in Inside an agent-run dev shop.)
So when we built the “How we work” section of our marketing site, it had been checked many times over. It’s a scroll track: a stage one viewport tall scrolls in, pins to the top with position: sticky; top: 0, holds while you scroll through one page per principle, then lets go. At every width the agents tried, it was solid.
The bug the boxes couldn’t see
In Safari on an iPhone, on a fresh load, the pinned stage shook up and down as you scrolled. Not a slow drift: a visible jitter, as if the browser kept re-deciding where the stage belonged. After the on-screen keyboard had been opened once, it stopped.
That narrowed it down. In Safari on iPhone, the toolbars collapse and expand as you scroll, and the viewport resizes with them. The jitter only appeared while that was happening, and it disappeared once the keyboard had been opened. A headless desktop browser has none of this. Its viewport is exactly the size you set, it never moves under your finger, and there is no keyboard to open. The agents could scroll that page all day and never see a single frame of jitter.
We could have handed the problem to whoever had an iPhone in their pocket, and turned the agents into people relaying screenshots (a person’s device pass has its place, as a last check). That doesn’t scale, and it throws away the part they’re good at: trying something, looking, trying the next thing.
Giving the agents an iPhone
So we brought up a macOS box for one purpose: running iOS simulators. The agents can reach it from the dev boxes, start a simulated iPhone, open the page in Safari, scroll, open the keyboard and take screenshots, the same way they already drive a headless browser.
Safari on the simulator showed the bug exactly as reported: on a fresh load, the toolbars resized the viewport mid-scroll and the stage jittered; after the keyboard had been opened once, it held still. With a device they could drive themselves, the agents could do what they couldn’t before: reproduce the bug, and then take it apart.
Bisecting a layout bug on a phone
You can’t easily attach a debugger to a jitter, so the agents made the page report on itself. A dev-only ?probe query parameter showed the viewport’s numbers live: inner and visual viewport, scrollY, the pinned stage’s offset, and counts of resize and scroll events. A screenshot before and after the keyboard then said exactly what changed. On a fresh load, one short scroll produced three resizes, with jitter. After the keyboard: no resizes, no jitter.
The first fix was the obvious one: nothing should re-lay the page when only the viewport’s height changes. They dropped min-height: 100vh from body and the page frame, removed the stage’s lvh/svh fallbacks, and made the scripts that re-measure on resize skip a height-only change. Good hygiene. The jitter stayed.
So they bisected. A second parameter, ?jx=a,b, switched on one suspected trigger at a time, combinable:
nosticky: the stage asposition: relative, to prove sticky was involved at allfit: a stage no taller than the visible height, in case an overconstrained sticky was the issuenoclip: move the frame’soverflow-x: clipoff the stage’s ancestorslayer:translateZ(0)andbackface-visibilityon the stage, the classic compositing nudge
Next to it sat a bare page of long plain text on the same origin, to tell a jitter our page caused from one the browser causes on its own.
?jx=noclip was the one. The page frame had this, so decorative halos and full-width bands reaching past the column would never cause sideways scrolling:
.frame {
position: relative;
overflow-x: clip; /* the culprit */
}With that clip on any ancestor of the sticky stage, the stage jittered whenever the toolbars resized the viewport. Without it, the stage held still. The result was consistent across every combination of switches: clip, jitter; no clip, none. The probe, the switches and the bare page were deleted in the same pull request as the fix. Debug scaffolding that outlives its bug is how a codebase fills up with mystery flags.
The rule: nothing clips above the pinned element
The fix is one line removed. The work is everything that line was quietly covering for. If nothing may clip between the root and the pinned stage, then nothing may reach past the page sideways either. That’s now a written convention, with three consequences.
Full-bleed layers use the page’s real width, not 100vw. The hero’s animated dot field was width: 100vw. With classic scrollbars, 100vw includes the scrollbar, so the layer was wider than the page, and the clip had been hiding it. Now a few lines of script publish document.documentElement.clientWidth as a custom property whenever it changes:
.dots {
position: absolute;
left: calc(50% - var(--page-width, 100vw) / 2);
width: var(--page-width, 100vw);
}Without JavaScript it falls back to 100vw.
Bands bleed with paint, not overflow. Tinted section bands run edge to edge while their content stays in the column. They use a spread shadow and a clip-path, and neither creates scrollable overflow:
.section.is-band::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: var(--band);
box-shadow: 0 0 0 100vmax var(--band);
clip-path: inset(0 -100vmax);
}The shadow paints the tint far past the box; the clip-path cuts it back to the band’s own height while letting it run sideways. The page’s scroll width never notices. That’s the only way anything on the site goes full-bleed now.
Sizes come from script, in pixels. Every vh, svh or lvh value that follows the toolbars moves under the visitor’s finger. The stage’s height and the track’s scroll length are pixel values set by script instead, and on touch devices they hold until the width changes: a rotation, not a toolbar. The decision whether to pin at all holds the same way, which fixed a second bug for free: the keyboard used to shrink the viewport below the pin threshold and unpin the stage mid-form.
The check that keeps it fixed
Removing a clip is easy. Keeping it removed means proving nothing overflows, so any change to a full-bleed layer ends with the same check:
document.documentElement.scrollWidth === document.documentElement.clientWidthThe agents run it at widths from 320 to 1920 px, with and without classic scrollbars, with the stage pinned and the consent card up. This one the headless browsers can do perfectly well, which is the point: each kind of check runs where it’s cheapest and still true.
Give agents the devices your users have
The agents didn’t get smarter between not seeing this bug and fixing it. They got eyes on the right device. The same loop (reproduce, instrument, change one thing, look again) works on any bug they can actually observe, and fails on any bug they can’t.
So the lesson isn’t about sticky positioning. If your users are on iPhones, your agents need an iPhone. That’s how we work now: a UI change isn’t verified in the environment that’s convenient, but in the ones the app actually runs in. Give agents the devices your users have, and they can debug what your users actually see.