Appearance
How I trust what an AI builds
An agent hands me a finished feature. The code reads well. The tests are green. The PR is tidy. And I still have no idea whether the thing actually works.
That gap is the whole problem. Agents write all the code in this project now, and they write it fast. So the bottleneck was never going to be whether the machine can build something. The bottleneck is me, on my phone, deciding whether to trust what came back.
Here's what I've learned: relying on reading the code to trust it doesn't scale, not at the volume an agent produces. So instead, I'm moving towards trusting the proof.
And the proof isn't the code. It's the artifact sitting above it: the feature actually running, the screenshot of the screen, the PR that says what changed and why. I review the result, not the diff. I'm not the first to land here:
I don't read changes in the code. I read changes in the artifacts.
The code is the agent's, the way it belongs to the engineers in any company and not the CEO. My job is the layer on top.
That layer on top is a stack of automated checks, and it all runs before I ever look. Most of the ways an agent can be wrong get caught by a machine long before I do. The last piece, and the one that took longest to get right, is visual regression. This post walks that whole net layer by layer, building to the piece I most recently cracked.

The workflow at a glance
Every change walks the same path. Most of it happens without me:
| # | Stage | Done by |
|---|---|---|
| 1 | Build the feature | 🤖 AI |
| 2 | Review its own code, run the local checks: Pest tests, coverage, mutation, browser tests | 🤖 AI |
| 3 | Open the PR with a readable title and body | 🤖 AI |
| 4 | Re-run the whole gate on a clean machine | ⚙️ CI |
| 5 | Screenshot every UI state at phone + desktop width, diff against the right baseline, publish the report to Cloudflare, tag the PR | ⚙️ CI |
| 6 | Open one link on a phone, then approve or bounce | 🧑 Me |
By the time anything reaches me, it has already been tested, measured, mutated, and screenshotted. Stage six is the only part that needs a human. The entire design goal is to keep shrinking it.
The bottleneck moved
Agents build fast. Getting working code back from one is no longer the hard part. The bottleneck has moved. It moved onto me, the person who has to decide whether what came back is any good.
And bottlenecks have a habit: whatever you make cheaper, you do more of. When review is expensive, you ration it. You skim the diff, decide the code looks right, and merge on faith. Each bit of friction is a small tax, and paid enough times it quietly lowers the bar.
So I'm really chasing two things, and the whole system is built around them:
The two jobs
- Catch as much as possible automatically, so less needs a human at all.
- Make the bit that's left seamless, so easy it gets done every time, even from a phone.
The long-term bet is that the first job keeps eating into the second. Stack enough proof below the human line, and what reaches me is only the part that genuinely needs judgement.
The net that catches it before I look
Most of trust isn't the human look. It's everything that runs before it. Each layer guards a different way for things to go wrong, and together they mean an agent's mistakes mostly get caught by a machine, not by me.
| Layer | What it proves | How it keeps the AI honest |
|---|---|---|
| Pest tests | The code does what it should | Encodes the intended behaviour; a regression flips a test red |
| Code coverage | Which code actually ran under test | Shows the AI the paths it forgot to test |
| Mutation testing | The tests would catch a bug | Breaks the code on purpose; a test that stays green is a real gap |
| Browser tests | The real UI and flows work | Drives an actual browser: clicks, types, asserts what renders |
| Pattern library | Each component in each state | Fixed states from props, no data to bootstrap, doubles as docs |
| Visual regression | Nothing changed that shouldn't | Screenshots flag unintended ripples across the rest of the app |
A few of these are worth slowing down on, because the why isn't obvious.
Tests encode intent. Coverage and mutation keep them honest. Here's the trap: a test suite an agent wrote can be all green and still prove nothing. It can assert nothing meaningful and pass every single time. Coverage catches the obvious version of that, code that no test even ran. Mutation testing catches the sneaky version. It breaks the code on purpose and checks that a test screams. A mutant that survives is a test that looks like it's working and isn't. Sometimes it turns up a real bug in the code while it's at it. Together they stop the agent from marking its own homework with a blank page.
The pattern library is the unlock for UI. This one is Storybook. A story is a single component rendered with a fixed set of props: empty, normal, long title, error. That one idea solves three problems at once:
- 🧩 No data to bootstrap. I don't seed a database and click through the app to reach the empty state. I describe it with props and it renders instantly.
- 📖 It's documentation. The set of stories is the catalogue of how each component is meant to look.
- 📸 It makes visual regression stable. Same props in, same pixels out. A diff only fires on a real change, not noise.
Screenshots catch the blast radius. The thing I worry about with an agent change isn't the part it meant to touch. It's the part it didn't. A visual diff across every component is the only way an accidental ripple three screens away ever shows up.
Making the PR itself easy to read
Before any of the UI review, there's the PR itself. And a PR I can't read quickly is just more friction. So the agent writes them to a fixed, plain shape: an imperative title with no feat(scope): noise, a two-line lead in plain words, emoji-anchored sections, a callout for any real decision, and the deep detail folded into collapsible blocks. Same shape every time, so I always know where to look.


This isn't cosmetics. The first one gets skimmed. The second gets read. And a PR that gets read properly is a PR where I actually catch things. Cheap to read is the same lever as cheap to QA.
The one step that stays human
Even with all that automation, one thing stays mine. Looking at a screen and deciding it's right. No test asserts this looks good. So the goal for that last step was simple: drive its cost as close to zero as I could.
Playwright screenshots every Storybook story twice: once at my phone's width, once at a desktop one. The app is mobile-first and I review it from a phone, so a layout that only breaks at one size still has to get caught. Here's the same screen at both:


Then reg-suit does the bookkeeping that makes a diff trustworthy. It keys every snapshot to the commit that produced it, keeps them in Cloudflare R2, and picks each PR's baseline by walking git history back to the commit it branched from. Two PRs off the same point never bleed into each other's pictures. reg-cli does the pixel diff underneath, into a self-contained report. Cloudflare Pages hosts it, and a comment drops the link straight onto the PR, with a VRT label so I know a report is waiting before I even open it.

Open that link and the report is right there. Changed components float to the top with the difference highlighted; unchanged ones drop into a passed list I can ignore. Here's what it looks like. Tap any shot to blow it up:



It's the thing GitHub's own image view could never give me, which is the natural place to talk about what I tried first.
The options I turned down
I didn't land here on the first try. Every obvious path broke on contact.
Just review the images in GitHub's diff
The cheapest instinct: commit the screenshots and use GitHub's image diff in the "Files changed" tab. Free, already there, no new tools.
It falls apart on a phone. The GitHub mobile app renders committed images as binary files are not rendered, so you get nothing at all. The mobile browser does a little better. It shows the two-up view, then clips the images, refuses to zoom, and ignores taps on the swipe and onion-skin modes. The data was sitting right there in the repo and I still couldn't review it from where I work.
The bot-token inline-screenshot trick
For a while I inlined screenshots straight into the PR body through a bot account, since private repos only render images uploaded as attachments. When it worked, it was lovely. Then the bot's session token would expire, roughly every fortnight, and the inline step would fail in silence. A review surface that quietly stops working is worse than one that's obviously broken. You stop trusting all of them.
Pay for a hosted visual-regression service
Chromatic, Percy and Argos all do exactly this, and their review apps work beautifully on mobile. The catch is the pricing curve. Free tiers sit around 5,000 to 7,000 snapshots a month, and this project burns through snapshots fast (every component, times every state, times every push). Past that you're looking at $100+ a month to look at pictures of your own app. That was the one thing I most wanted to avoid.
Histoire
I looked at Histoire as the Vue-native option, but its development had stalled, and I didn't want to build on a foundation that wasn't moving. Storybook had just shipped first-class Vite and Vue 3 support and is very much alive. So that's where I went.
Privacy, and a link you can try
The report sits on the public internet for convenience, but locked behind Cloudflare Access. Opening it asks for a one-time code to my email first. No one else sees screenshots of the app, and I still get a one-tap link.
Try it yourself, for free
Both halves cost nothing at this scale. Stand up Cloudflare Pages for the hosting, then put Cloudflare Access in front for the private URL: no server to run, no bill to pay. Want it wired up for you? Open the prompt below and paste it into your AI.
🪄 View the prompt (paste it into your AI)
Stay safe from prompt injection
Manually type out the prompt yourself, or at least read it before pasting into your agent.
text
Help me set up a private visual-regression report I can review from my phone, for free.
I want:
1. Screenshots of every component and page state. I use Storybook + Playwright — adapt if I tell you I use something else.
2. A diff report built in CI with reg-suit and reg-cli, with each pull request's baseline picked from the commit it branched off.
3. Hosting on Cloudflare Pages, published fresh on every pull request.
4. Privacy via Cloudflare Access in front of it: opening the report asks for a one-time email code, and only my address is allowed.
5. A comment on each PR that drops the report link, plus a label so I can see which PRs have a report waiting.
Gotchas I hit the hard way, so get these right from the start:
- Playwright fullPage never crops below viewport height. With Storybook's centered layout, short stories capture with dead space. Measure each story's real #storybook-root height and shrink the viewport height to it before the shot, but leave the width alone so horizontal overflow still gets caught. Don't use an element screenshot of #storybook-root: it clips horizontal overflow.
- Capture with standalone Playwright against the static Storybook build, not pest-browser (it can't reach static files).
- Pin device scale factor to 1 and pin viewport widths for determinism, and wait for fonts before shooting.
- Add staticDirs: ['../public'] in Storybook, or absolute-path assets render as broken images, silently.
- A page's <Teleport> target leaks into the capture: supply the sink in the decorator as display: none.
- The reg-suit report is a tree keyed by file path, so nest snapshots in folders to group, and rename files to rename items.
- Set enableAntialias: true even with thresholdRate: 0, to absorb the anti-aliasing wobble from Tailwind reordering its CSS.
- Baselines key off the git commit by merge-base: use concurrency: cancel-in-progress on push-to-main so burst-merges don't mis-attribute baselines.
- Baseline every main commit, not just visual-touching ones (don't path-filter the push-to-main baseline job), or a PR forking off a skipped commit has no merge-base to diff against and marks every story new. Bound the accumulating snapshots with a retention sweep (expire R2 snapshots past ~30 days).
- Gitignore the reg.json that reg-cli drops.
- R2 needs forcePathStyle: true and region: "auto". Bucket name as a repo variable; endpoint and keys as secrets.
- Cloudflare Pages preview deployments never expire, so add a scheduled prune from day one.
- Keep the private report and any public site as separate Pages projects: per-project access policy is what controls public vs private.
- Path-scope the visual CI lanes so a docs-only change doesn't trigger a screenshot run.
- Build Storybook in dev mode if you gate on a console smoke check (prod builds strip Vue's __DEV__ warnings).
Ask me for my Cloudflare account, the email to allow, and any tokens you need. Then give me the exact CI workflow and config, and walk me through wiring up Cloudflare Pages and Access one step at a time.The gotchas, if you replicate this
The prompt above gets you most of the way. But the glue is where the bodies are buried, and almost none of this was in a README. I found it the slow way: a screenshot padded with dead space, a baseline that quietly attached to the wrong commit, an image that rendered broken and told no one. Here's the list I wish someone had handed me first. It's folded away because it only matters if you're building the same thing.
🪤 The traps I hit, grouped by where they bit
📸 Screenshots: the fiddly bit
Playwright's fullPage never crops below the viewport height. With Storybook's layout: 'centered', every short story comes out padded with dead space above and below. The fix is to measure each story's real #storybook-root height and shrink the viewport to it before the shot. Leave the width alone, so responsive layout stays untouched and fullPage still catches anything overflowing sideways. The tempting shortcut, an element screenshot of #storybook-root, is the wrong call: it clips horizontal overflow, so a week strip running past phone width just vanishes.
Capture against the static Storybook build with standalone Playwright, not pest-browser. pest-browser can't reach static files. This is a separate toolchain from the app's own test suite, and trying to make one serve both is a wasted afternoon.
Pin the device scale factor to 1, not your phone's real DPR of 3, and pin the viewport widths. Determinism beats realism here. And wait for fonts before you shoot, or you'll diff against half-loaded text.
Storybook serves nothing from public/ without staticDirs: ['../public']. Any component with an absolute asset path renders as a broken image, in the Storybook UI and in every capture, with no error anywhere. The broken image is the only tell you get.
A page's <Teleport> target leaks into the capture. If a page teleports its header actions to #header-actions, the decorator has to supply that sink. But it has to be display: none, or the teleported menu renders at the bottom of your otherwise chrome-free shot.
🌳 Baselines: the diff engine
The report is a directory tree keyed by file path. The path literally is the label: there's no display-name field anywhere. So to group the report you nest snapshots in folders, and to rename an item you rename its file. The .png always shows and you can't strip it. I Title-Case and CamelCase-split the Storybook title into the path, like Components/Alert Error/Dark (Desktop).png. Spaces and parens are valid S3 keys and load fine.
Set enableAntialias: true even with thresholdRate: 0. Adding a story reshuffles Tailwind's CSS order, which throws sub-pixel anti-aliasing wobble onto unrelated components: a flood of false "1 story changed" positives. Antialias-awareness kills them without loosening the exact-match threshold one bit.
Baselines are keyed by git commit, picked by merge-base. That bites the day you burst-merge several visual PRs at once: lean on concurrency: cancel-in-progress on the push: [main] runs so only the final run publishes the cumulative baseline. Otherwise the mid-merges race each other and mis-attribute the pictures.
Baseline every commit on main, even one that changes nothing visual. I path-filtered the push: [main] baseline job to fire only when the diff touched visual files. It looked frugal. Then a PR forked off a main commit that had skipped its snapshot, found nothing at its merge-base to diff against, and marked every single story NEW: a useless report instead of a real diff. Drop the filter so the exact merge-base always exists. The snapshots then pile up, so pair it with a retention sweep (I expire R2 snapshots past 30 days) to stay inside the free tier. My runner is unmetered, so capturing every commit costs only time I don't pay for.
reg-cli drops a reg.json in the working directory. Gitignore it, or it nags on every dirty-tree check you run.
🎨 Storybook authoring
Stories set props directly. No database, no Todoist, no clock to seed. That's the entire point: a state is reachable for review without standing up a data spine behind it.
Some states aren't authorable, though. My Inertia form stub hard-codes { errors: {}, processing: false }, so the error and submitting states simply can't be storied. Don't burn time fighting it. Cover those in feature tests instead.
The net catches real render bugs, not just visual drift. A story is often the very first time a component renders in isolation, and that alone surfaced a latent 2FA crash (a temporal-dead-zone ordering bug) and a broken logo that the normal app flows had been quietly masking. A story that won't render is a bug found, not a story to skip.
Build the Storybook bundle in dev mode if you gate on a console smoke check. Vue strips its __DEV__ warnings out of a production build, so a strict console check against a prod bundle proves nothing at all.
☁️ Cloudflare and infra
Two separate concerns share one Cloudflare account: R2 holds the snapshots, Pages hosts the report. R2 needs forcePathStyle: true and region: "auto" in the S3 SDK options. Keep the split clean: bucket name as a repo variable, endpoint and access keys as secrets.
Reuse one Cloudflare API token across deploys. Once the report's Pages deploy is proven, pointing a second site at the same setup is tiny and needs no new provisioning. I moved the whole wiki onto it that way.
Per-project access policy is the lever for public versus private. The report sits behind Cloudflare Access; the wiki is a separate Pages project with no Access. Don't try to serve both audiences from one project.
Pages preview deployments never expire, and one accrues on every push. Plan a scheduled prune from day one, or they pile up forever.
⚙️ CI and process
Path-scope everything visual. The VRT and Storybook lanes only run when the diff touches the visual surface, and the mutation gate no-ops fast on a frontend-only change. Don't let a docs edit kick off a full screenshot run.
A markdown-linked Closes [#666](…) in a PR body does not trigger GitHub's native auto-close. The keyword parser wants a plain Closes #666. I learned this watching three siblings half-close: my own sprint automation caught one, GitHub's parser caught neither, and the last one I closed by hand. Want both the mobile-friendly link and the auto-close? Put a plain Closes #N line somewhere in the body too.
Surface the artifact, don't make people read the diff. A PR that changes the visual surface gets the marker label and a 📸 alert in the body pointing straight at the report. That's the same instinct as the rest of this post, applied to the review itself: read the result, not the diff.
Owning only the glue
Add it all up and the pipeline is Storybook, Playwright, reg-suit with reg-cli underneath, and Cloudflare Pages over R2. I wrote none of them. They're stitched together by one CI workflow file and a short capture script. That split was the whole point.
The whole bill: £0
Storybook, Playwright, reg-suit and reg-cli are open source. Cloudflare Pages, R2 and Access all sit inside their free tiers at this volume. The visual-review pipeline costs nothing to run: what I spend is attention, keeping a dozen lines of glue alive, not money.
The principle has a name I keep coming back to: minimise the cost of ownership. Every line of infrastructure you write is something you own forever. You maintain it. You debug it at 11pm. You carry it when the framework underneath shifts. The four big pieces here are bought, not in money but in attention, since they're free. Someone else maintains Storybook's renderer and Cloudflare's hosting. I own only the glue, because the glue is the only part that's specific to me. When this breaks, it's a dozen lines of YAML to fix, not a diffing engine. That's also why I walked past the paid tools. Good as they are, at $100+ a month the cost just moves from maintenance to rent.
Where this is heading
The shape of it is a net that keeps getting denser. Pest proves the behaviour. Coverage and mutation prove the tests. Browser tests prove the flows. The pattern library pins the states. Visual regression catches the ripples. By the time a change reaches me, most of the ways it could have been wrong are already ruled out. What's left is one private link, one minute, from wherever I happen to be.
And because that step is cheap, I take it on every PR instead of saving it up. Which means I catch more, not less, and the bar goes up rather than sliding on the days I'm busy. The goal was never to remove the human. It's to stack enough proof underneath that the human only looks at the part that actually needs a human. The agent writes the code. The net decides whether it's any good. The one judgement left is mine, and it's the only one I want to be making.
What's next
Everything above is the net around the code. But the net is something I built too, and it's the part I now spend most of my time on: the rules, the skills, the gates an agent clears before anything reaches me. Which surfaces the one question I still can't answer with proof. When I change the harness, how do I know I made it better?
I'm increasingly sure that's the question worth obsessing over, because the harness is where the leverage is. Addy Osmani points at two public numbers that make it concrete. On Terminal Bench 2.0, one team moved a coding agent from outside the top 30 into the top 5 by changing only the harness, the same model underneath. A LangChain experiment added 13.7 points on the same benchmark by changing only the prompt, tools and middleware around a fixed model. Neither touched the model. His takeaway is the one I've come to live by:
So when an agent does something dumb, I've learned to debug the harness first. Usually it's a missing tool, a rule I wrote too loosely, a guardrail I forgot, or a context window full of junk. Most agent failures are configuration failures.
That's the encouraging part. Configuration is the thing I can fix today, with no better model required. But it cuts both ways: if the harness is the lever, I'd better know which way I'm pulling it.
Today I eyeball it. I adjust a rule, watch the next few PRs, form an impression. That's the exact trap this post is about, moved up a level: an impression isn't proof.
So the next layer is evals. Measure the harness the way the tests measure the code: does it do what I expect, and is a given change a real improvement or just a change? Garrett Lord makes the case that evals are the strategic IP that will define the next era of AI, and I think he's right. The model is rented. What you own is how you know it's working. The whole discipline fits in five words:
Set the bar at the eval, not the demo.
After that, more loops. Right now I'm in the loop on purpose: the tooling is young, so I watch it run, catch where it's wrong, and grow it by hand. That's the right place to stand while the thing is still forming. But the trajectory is the same one this whole post describes. Every layer of proof I stack lets me step further back, until more of the work runs itself and less of it waits on me. The handwork now is what buys the automation later.
One thing I will never trade for speed is quality. Using AI to build faster is not a licence to ship worse, and if anything it's the reverse: this entire net exists so the bar goes up, not down. The standard I hold the work to does not move one inch because a machine wrote the first draft.
Here's the honest constraint under both: cost. Evals mean running the harness over and over to score it. More loops mean more agent runs with nobody watching the meter. And I'm one developer on a personal project, paying for every token myself. So I can't just throw compute at it. Each step has to buy more than it costs.
Which is why the last piece is about which models, not just more of them. The pattern I'm moving toward splits the work by what it's worth: lean on the strongest reasoning model for the decisions that matter (planning, reviewing, judging), and hand the cheaper, faster models the code execution underneath. I do a version of this today with Opus as the planner, and it's solid. But Fable 5 has been on another level for me at exactly those reasoning, planning and review steps, the moments where one better decision saves ten wasted runs. Spend the expensive thinking where it changes the outcome. Buy the rest cheap.
None of this is built yet, and writing it down is how I keep myself honest about the order. The net I have today tells me whether the code is good. The next one has to tell me whether the harness is. And the day I trust that answer the way I already trust a green test, I get to step back a little further and let the machine catch a little more.
The app screenshots and the reg-viz report above are real output from the live pipeline. The three GitHub-interface sketches (the PR body, the "binary files not rendered" screen, the PR comment) are representative. And yes, this post was drafted by an agent and reviewed before publishing, which is fittingly the same arrangement it describes.