ADR 006: Screenshots as a bundle source
Status: Accepted — 2026-09-13
Context
Reviewing a web app with a model means handing it the rendered page, not only the code. page.screenshot() is not enough on its own:
- Tall pages become unreadable. Vision models downscale every image to a fixed pixel budget, so a 1440×9000 page reaches them as a thumbnail.
- The page isn't finished when
loadfires. A full-page screenshot doesn't move the viewport, so lazy images and IntersectionObserver content stay blank. - Dev chrome pollutes the image. Framework toolbars and cookie banners get reviewed as if they were the design.
And the workflow should be srcpack's: define once, rerun by name. A bundle stops meaning "sources → one text file" and becomes a named context packet — text, images, or both.
Decision
screenshot is a bundle source key, the same shape as linear:
bundles: {
home: { screenshot: "http://localhost:5173/", onDemand: true },
pricing: {
include: "src/pages/pricing/**/*",
screenshot: { url: "http://localhost:5173/pricing", viewport: "mobile" },
},
}A top-level bundle url would drag viewport and hide onto every bundle, where on a text bundle they would do nothing. prompt, index and outfile describe the text file, so on a screenshot-only bundle they are config errors — which is why index is no longer defaulted in the schema.
Not generalizing to sources: [{ provider }]. ADR 003 predicted that shape once a third source arrived. This is the third source, but include and linear produce entries in one text file while screenshot produces sibling binary files. A common provider abstraction would hide that difference rather than simplify it.
Output is a flat numbered family, <outDir>/<name>-NN.png. 00 is the whole page and 01… are detail slices top to bottom, so lexical order is review order. Each image carries a logical index: an overview omitted for size leaves 01… in place rather than renumbering a detail slice into 00. There is no outfile, so a family always lives in outDir and removing stale images never reaches a directory srcpack doesn't own.
A family is a claim on a directory plus a name prefix. Its directory resolves fully — it is an ancestor of every PNG, unlike a text output's last component, which rename replaces (ADR 004). Families collide when their folded names match (Web/web); a text outfile collides with a family when it resolves into the family's directory under one of its numbered names, the same bundle's family included. Stale-image cleanup matches exactly, never folded, since folding could only widen a deletion.
Configured and active writers are checked separately. Configured bundles are checked against each other on every run, so a config error doesn't depend on what was asked for. The bundles a run writes — ad-hoc included — are checked against configured bundles of a different name, so --screenshot shadows a configured bundle named screenshot but cannot overwrite another bundle's file.
Capture settles the page before it slices it. It scrolls one viewport at a time, re-reading the page height so sections that load taller than their placeholders are followed, capped at 50 steps and 15 seconds with a warning. At each apparent bottom it waits for the network to go quiet and scrolls on if a response appended content, since growth measured but never visited is sliced with its lazy content blank. The wait uses its own in-flight request tracker: Playwright's networkidle load state resolves immediately once it has fired, which misses exactly the requests scrolling started. Slices are clipped captures of at most 2,200 device px: the fewest that cover the page with at least 160 px overlap, shrunk to share it evenly so a page just over the limit doesn't become two near-identical images; the overview is best-effort.
Playwright is an optional peer, resolved from the project first (playwright, then @playwright/test), then srcpack's own install. Under npx, srcpack runs from the npm cache where a bare import never sees the project's copy. The peer range is *: a project that never takes a screenshot shouldn't have its install judged against its Playwright version. Compatibility (1.41 up to 2) is checked at load instead, which also covers a copy found in the project, where a peer range constrains nothing. Without Playwright's Chromium, the system Chrome is used.
Images stay local. Drive uploads find files by name and update in place, so a page that shrank would leave old slices there with nothing to remove them. A mixed bundle's text file still uploads.
--dry-run launches no browser. Unlike Linear, there is no cheap way to answer "what would this produce" short of the capture itself, and a plain fetch would be a second code path that can pass where the capture then fails. The preview lists URL, viewport and destination; config and collision errors still surface.
Alternatives
- A top-level
urlon bundles — rejected above. sources: [{ provider }]— hides the text-versus-files difference it claims to unify.- Stitching one tall image, or an image library for slicing — clipped Playwright captures need no dependency, and a stitched image is exactly what downscaling ruins.
- Several URLs per bundle — numbering across pages hides which image is which page;
srcpack home pricingalready composes. - Uploading images — needs Drive-side cleanup of shrunk pages first.
Consequences
srcpack can now require a browser, but only for bundles that ask for one; nobody else downloads anything. Screenshot bundles need a running server, which is what onDemand (ADR 005) is for, and the unreachable-URL error in a full run says so.
Captured images are held in memory until every bundle resolves, so a failure anywhere leaves the previous run's files in place. As with text, each file is replaced atomically but a run is not. Authenticated pages, custom viewport sizes and dark mode are deferred.