---
title: "Let the agent show me the UI - screenshot galleries"
description: "How I capture every UI state with Playwright, and the tiny HTML layer I want to add so an agent can hand me one gallery instead of a folder of PNGs."
pubDate: 'July 16, 2026'
tags: ['AI', 'Frontend']
---
Another spin-off from [the box and the loop](/blog/how-i-build-small-products-autonomously-hetzner-swarm-and-agent-loops). I called this "my favorite little trick" over there and then moved on. It deserves more than a sentence.
[I posted a quick example of it on X.](https://x.com/Kuba_Szw/status/2075219506171883704)
## The problem with reviewing UI from a diff
When an agent changes UI, the PR tells you *what changed in the code*. It does not tell you what the screen now looks like - and worse, it doesn't show you the five states you didn't think to check. Empty. Filled. Accepted. Expired. The dead-end where the link is invalid.
Reading a diff and imagining the rendered result is slow and I'm bad at it. So I stopped. Now the agent shows me.
## Step one: a script that walks every state
The real workhorse is a plain script that drives the running app with Playwright and screenshots each meaningful state. Here's the shape I use - a tiny `shot()` helper plus a flat list of the states I care about:
```js
// Condensed from the real script - setup and data helpers omitted.
async function shot(name, url, prep) {
const page = await browser.newPage({ viewport: VP, deviceScaleFactor: 2 });
await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" });
if (prep) await prep(page); // fill a form, expand a tree, etc.
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true });
await page.close();
}
await shot("01-request-empty", "/t/demo/request");
await shot("02-request-filled", "/t/demo/request", fillTheForm);
await shot("03-confirmation", "/t/demo/request", submitIt);
await shot("04-quote-sent", quoteUrl("DEMO-04"));
await shot("05-quote-accepted", quoteUrl("DEMO-05"));
await shot("06-quote-declined", quoteUrl("DEMO-06"));
await shot("07-quote-expired", quoteUrl("DEMO-08"));
await shot("07b-quote-sent-past-expiry", quoteUrl("DEMO-07"));
await shot("08-quote-cancelled", quoteUrl("DEMO-09"));
await shot("09-quote-unavailable", quoteUrl("DEMO-09", "bad-token"));
```
The value isn't the screenshot mechanics - Playwright makes that trivial. The value is that the list is exhaustive **for this customer-facing lifecycle**. Every decision state, every dead end, captured in one run. `BASE` defaults to the [local gateway](/blog/one-caddy-gateway-for-all-your-local-docker-stacks) hostname and can be overridden with an environment variable when the script runs somewhere else.
A couple of things I learned the boring way:
- `deviceScaleFactor: 2` and `fullPage: true`, or the shots look cheap and cut off.
- Sometimes you have to seed known dev-only state first (staged tokens, demo rows) so the "expired" and "cancelled" states actually exist to screenshot.
- Playwright's actionability checks race open animations. For flaky accordions I just click through the DOM directly in `page.evaluate` and move on.
## Step two: an HTML page over the folder
The real script currently stops at a directory of PNGs. That's not much of a review surface. I couldn't find the bigger gallery wrapper I remembered, so this is the obvious next layer: a dozen lines that turn the folder into one skimmable page.
```js
import fs from "node:fs";
const shots = fs.readdirSync(OUT).filter((f) => f.endsWith(".png")).sort();
const cards = shots
.map((f) => `${f}`)
.join("\n");
fs.writeFileSync(`${OUT}/index.html`, `
${cards}`);
```
Add that block to the capture script, open `index.html`, and the whole lifecycle is on one screen. That's the gallery.
## Where it really pays off: comparing concepts
The single-run gallery would already be good for review. The extension I actually want is pointing it at **variants**.
Have the agent render two or three ideas for a component, group the same state into columns, and you get a side-by-side lookbook - concept A, B, C, same states, same page. Deciding stops being an argument in prose ("I think the second one feels cleaner?") and becomes a glance. You pick with your eyes, in seconds, the way you'd choose between photo prints on a table. This grouping layer isn't in the current script yet; now I know exactly what the reusable skill still needs.
## The point
This is the direction I want for the visual half of the [autonomous loop](/blog/how-i-build-small-products-autonomously-hetzner-swarm-and-agent-loops). Today the agent can hand me ordered screenshots for every state. One tiny HTML index turns those into a page I can skim; a variant-aware index turns it into a decision tool. "Show, don't tell" turns out to be a solid engineering principle too.