Every bug in this post typechecked. Every one passed lint, passed tests, and shipped a page that was wrong.
In my last post I described migrating a production app from Angular to React with an agent team doing most of the typing, and mentioned in a single line that each phase got "a visual pass". That line is doing more work than it looks. Backend work has a property frontend doesn't: the machine can check it. A wrong enum breaks a test. A bad query throws. But a layout that is eleven pixels too wide, a modal that flies in from off-screen, a scroll lock that stops the mouse wheel and not the keyboard — none of that is visible in a diff, and none of it is visible to the model that wrote it. It writes CSS the way you'd write a letter to someone who will read it in a room you've never been in.
This is what that has actually looked like, in this site's history.
402 pixels in a 390-pixel viewport
The experiences timeline used grid-cols-[20px_1fr] — a marker column and a content column. Perfectly reasonable code, and the kind any of us would write.
1fr carries an implicit min-width: auto, which refuses to shrink the track below its content's min-content width. At a 390px viewport the document came out around 402px wide, and the fixed w-full header then stretched to match, compounding it. Twelve pixels of horizontal scroll on every phone.
The fix was minmax(0, 1fr) on the content column, min-w-0 on the wrappers, and a defensive overflow-x: clip on html/body — clip rather than hidden, because hidden creates a scroll container and would have broken every position: sticky descendant on the site.
Nothing in that paragraph is exotic. The point is that no amount of reading the code tells you the number is 402. You have to open a browser at 390px and measure.
The modal that flew in
Tailwind v4 centers a dialog using the modern translate property. My open animation was a keyframe on transform.
Both apply. They stack. The modal was displaced off-screen for the entire duration of the animation and then snapped into place at the end — which looks, to a user, like the site is broken, and looks, in review, like a perfectly ordinary fade-and-scale keyframe.
Animating the individual translate and scale properties instead overrides the utility cleanly for the duration. Ten characters of difference. Invisible in a diff; unmissable on screen.
The scroll lock that leaked
This is my favourite, because it is the one where every automated check was not just silent but actively reassuring.
The site uses Radix, which uses react-remove-scroll to lock background scrolling behind a modal. That library sets body { overflow: hidden } and blocks wheel and touch events in JavaScript. Standard, battle-tested, thousands of production apps.
The overflow-x: clip I'd added to fix the mobile overflow above stopped the body's overflow from propagating to the viewport — so the real page scroller was never the body at all, and overflow: hidden on it locked nothing. The JS half still blocked the wheel and touch, so the bug was invisible to anyone testing the way people test: open the modal, spin the mouse wheel, see that nothing moves.
The page scrolled fine if you dragged the scrollbar. Or pressed the down arrow.
One fix I made in February caused a bug that only appeared through two input methods nobody thinks to try, in a library I hadn't written, three months later. The lock now applies to the root element while the body carries data-scroll-locked — using hidden, not clip, because hidden preserves the scroll offset — and it covers every consumer of that library at once: dialogs, selects, menus.
The page nobody could see
Sections on this site fade in as you scroll: they start at opacity: 0, and an IntersectionObserver adds .is-visible when they enter the viewport.
With JavaScript disabled — or on any request where the observer never runs — the content stayed invisible. Permanently. The HTML was there, correct, semantic, indexed; the pixels were not. Every automated check I had said the page was fine, because by every measure a machine can take, it was.
The fix stamps <html class="js"> from an inline script in the <head> before first paint, and the CSS forces the reveal elements visible when that class is absent. A descendant selector, so it outweighs the utilities without !important and never matches once JS is present.
Note what class of bug this is. It isn't wrong logic. It's a state the browser can be in that nobody involved — me or the model — was thinking about.
So I stopped reading diffs and started driving the browser
The loop I ended up with is unglamorous: playwright-core against the system Chrome, no downloads, driven by a throwaway script that asserts numbers rather than taking my word for anything.
Not screenshots for a human to squint at. Measurements. scrollWidth == viewport at 390 and 360, with a count of offending elements. getComputedStyle(panel).opacity before and after focus. The rail's right edge against the frame's right edge at four viewport widths.
Recently I added a reading indicator to these posts — the column of ticks in the right margin, one per paragraph. It worked. It also drifted sideways every time a tick lit up, and I only know that because I sampled the panel's left edge on every animation frame across ninety scroll steps: twenty-four distinct positions, 6.53 pixels of travel. The tick column had no fixed width, so it re-measured itself mid-animation while one tick grew and another shrank. Pinning it to the width of its widest tick took one class. After: one position, 0.00 pixels.
I would never have caught that by looking. I did notice something felt off — but "felt off" is not a bug report, and it is certainly not a fix.
The harness lies too
Then you discover your verification environment has its own blind spots.
Headless Chrome on macOS uses overlay scrollbars, which occupy zero layout width. Which means the entire class of scrollbar-gutter and scroll-lock layout-shift bugs — the ones where the page jumps sideways as a modal opens — cannot be reproduced there naturally. The harness reports success because the harness is not the browser your reader has.
You have to simulate it: set the scrollbar-size custom property and the body padding by hand, then compare the frame rails in the header against the ones in the section below and assert they line up to the pixel.
That gotcha now lives in a skill file in the repo, so the next agent that touches this site starts with it instead of rediscovering it. Which is the actual subject of the second half of this post.
What the practice looks like
I use Claude Code. The engineering skills mostly come from Matt Pocock's skills repo — TDD, triage, breaking a plan into independently-grabbable issues, domain modelling, and a grilling skill that interviews me about a decision until the thing I actually want falls out. The last one is why the reading indicator lists section headings instead of paragraph previews: I asked for a copy of ChatGPT's conversation rail, got interrogated about what a reader would actually navigate to, and changed my mind before any code existed.
The repo-specific ones I write myself. verify is fifteen lines telling an agent how to run this site and which browser lies it will hit. It exists because I was tired of explaining it.
What has changed is not that I type less. It's what gets left behind. Decisions live in docs/adr/, so "why isn't every tick clickable?" has an answer that outlives me caring. Vocabulary lives in CONTEXT.md, so "block", "section" and "case study" mean one thing each in code, in issues, and in conversation. Work is an issue, then a PR, then a review — sixteen issues and twenty-one pull requests on a personal site nobody asked me to run this way.
What it costs
A working week moves roughly a billion tokens through the harness for me. That number is meaningless without its shape: about 97% of it is cache reads — the same context being re-read turn after turn, billed at a fraction. The generated output is about five million tokens a week, and around one million on a heavy day.
I mention it because "AI wrote it" implies a slot machine, and the real cost profile is the opposite. Most of what you pay for is the model re-reading the context you took the trouble to build.
Where I got stricter, and what proves it
Working this way made me more careful, not less, and I only trust that claim where there's an artifact:
Accessibility. The JS-disabled fix above. The reading rail is
aria-hiddenand unclickable on purpose — a screen reader navigates a post by heading, and twenty-four clickable ticks would be twenty-four unlabelled tab stops. Its section panel opens on:focus-withinas well as hover, faded withopacityrather thandisplay: none, because bothdisplay: noneandvisibility: hiddenremove links from the tab order, which would make the thing focus is supposed to reveal unreachable. That reasoning is written down in an ADR, not in my head.SEO. Sitemap and robots generated, canonical and
hreflangalternates emitted per locale, and anllms.txtwhose locale list is derived from the routing config rather than hardcoded — because the hardcoded one had already drifted.UX and motion. Every transition on this site is
motion-safe; a reader who asked their OS for less motion gets position and state without animation.Tests. A Vitest suite wired into CI. The reading-indicator work took it from 67 to 86, and the shape matters: the scroll maths and the slug generation are pure functions living apart from the components precisely so a test can reach them without a browser.
Security. Links authored in the CMS pass through a sanitiser before they reach an
href. The contact form sits behind Turnstile and degrades to a clear message when the mail key is missing, instead of pretending to send. A dependency CVE got bumped the day it landed.
None of this is exotic either. All of it is the stuff that quietly doesn't happen at 11pm on a personal project.
Two evenings, four months apart
February, before any of this. Four hours of work, and here is the entire record of it:
improves app's performance improves ux/ui improvemets in ui improves security seo improvements new commit fixes build errors
This week. One hour and forty minutes, from a screenshot I pasted in to two pull requests:
feat: mark reading position with a tick per block feat: anchor the reading rail to the post column, low right fix: stop the reading rail at the end of the post feat: open a section list from the reading rail fix: stop the section panel drifting as ticks animate
Each with a body explaining what was wrong and why the fix is the fix. Nineteen new tests. One architecture decision record. Two of those five commits are bugs the agent introduced and measurement caught — which is the point, not an embarrassment.
Same developer. Same repo. The difference isn't that I got faster, though I did. It's that an evening now leaves behind something a stranger could review.
I already cared about security and SEO in February — "improves security" is right there in the log. What I couldn't do was prove it, to you or to myself.
The honest part
None of this makes the model able to see. It makes the gap survivable.
If you take one thing from this: the value isn't in the code being written for you. It's in being forced to say, out loud and in writing, what "done" means — and then building something that can check it. I'd have written better software ten years ago if I'd worked this way, and no LLM was required to do it.
It's just that now, something asks me every time.