Skip to content

feat: screenshot bundles and on-demand runs - #12

Merged
koistya merged 12 commits into
mainfrom
feat/screenshots
Sep 13, 2026
Merged

koistya merged 12 commits into
mainfrom
feat/screenshots

Conversation

@koistya

@koistya koistya commented Sep 13, 2026

Copy link
Copy Markdown
Member

A bundle can now capture a rendered web page as images a vision model can actually read, with the same "define once, rerun by name" workflow as source code. Bundles marked onDemand are skipped by a full run and built when named, so a bundle that needs a running dev server doesn't break srcpack.

bundles: {
  code: "src/**/*",
  home: { screenshot: "http://localhost:5173/", onDemand: true },
  pricing: {
    include: "src/pages/pricing/**/*",
    screenshot: { url: "http://localhost:5173/pricing", viewport: "mobile" },
    onDemand: true,
  },
}
$ srcpack home
  home  4 images  page 1440×5,486  → .srcpack/home-00.png … home-03.png

$ srcpack --screenshot localhost:5173 --viewport mobile   # no config needed

What changed

  • Capture (src/screenshot.ts): -00 is the whole page; -01… are overlapping detail slices of at most 2,200 device px, since models shrink each image to a fixed budget and a tall page otherwise arrives as a thumbnail. Before capture the page is scrolled so lazy and observer-driven content renders; at each apparent bottom it waits for in-flight requests and keeps scrolling if they added content. Astro and Nuxt dev toolbars are hidden; nextjs-portal is not, because it also shows Next's error overlay.
  • Output planning (src/plan.ts): destinations are derived once into PlannedBundle, and collisions (text files, image families, symlinked or case-folded aliases) fail before any source resolves or a browser launches. Stale images are removed by exact match only.
  • CLI (src/args.ts): parsing moves to node:util.parseArgs with strict options, adding --screenshot and --viewport; a bare -- and repeated valued flags are rejected.
  • Playwright is an optional peer (*), resolved from the project before srcpack's own copy (needed under npx) and version-checked at load (1.41 to <2). Without Playwright's Chromium, system Chrome is used.
  • Images stay local: uploads send only text, since Drive updates by filename and couldn't remove slices of a page that shrank.
  • Version bumped to 1.0.0.

Decisions are recorded in ADR 005 (on-demand selection; emptying unchanged) and ADR 006 (why screenshot is its own source key rather than the sources provider shape ADR 003 anticipated, image-family collision rules, local-only images).

Verification

  • bun run check, prettier --check ., bun run build, bun run docs:build
  • bun test tests/unit/ tests/e2e/: 326 tests, including 10 real-Chromium e2e tests (slices and pixel sampling, growing pages, late-appended content, delayed requests, mobile UA, overlays, stale cleanup, unreachable/404 failures, ad-hoc capture, offline dry run)
  • CI now installs Chromium, and the Node tarball smoke test previews and runs a screenshot bundle without Playwright installed (checked locally)
  • Manually checked against Playwright 1.41.2 and against the docs site on desktop and mobile

Notes

  • Pages behind a login, virtualized lists, and apps that scroll inside their own container aren't fully captured yet; this is documented.
  • Only Node 24 and Playwright 1.63 run in CI; the Node 22.18 and Playwright 1.41 minimums are not CI-tested.

Slow, remote or situational bundles (Linear today, screenshots next) either slowed every run or failed it. onDemand: true skips a bundle in a full run and builds it when named; a full run still empties outDir, so emptying stays one rule instead of a list of exceptions (ADR 005).
A hand-rolled flag scan needed a sinceValueIndex exception to keep --since's value out of the bundle names, and every future valued flag (--screenshot, --viewport) would need another. parseCliArgs is pure and strict: unknown flags, a bare --, and a repeated --since fail on their own, and parseArgs errors map to the existing messages. --opt=value spellings are now accepted.
Destinations were re-derived from config in each phase (collisions, own-output exclusion, reporting, upload), and all of them assumed one text file per bundle. PlannedBundle derives where a bundle writes once; ResolvedBundle carries what it produced. Collisions now also cover an ad-hoc bundle writing over a configured bundle of a different name, and writeBundle becomes writeFileAtomic so binary output can share the symlink-safe replace.

index is no longer defaulted in the schema, so a set value is distinguishable from an absent one.
Adds the screenshot bundle key (URL shorthand or { url, viewport, hide }), with prompt, index and outfile rejected on a screenshot-only bundle since it writes no text file. A scheme-less URL is called out by name: URL would parse localhost:5173 as protocol localhost:.

screenshot.ts holds the pure pieces the capture pipeline needs — overlapping slice planning in device pixels, numbered image names and family matching — and the Playwright loader. Playwright is an optional peer, resolved from the project before srcpack's own copy because npx runs srcpack from its cache, and version-checked at load since the peer range can't constrain a project's copy.
A bundle can now capture a rendered page as numbered PNGs a vision model can read: srcpack home, or srcpack --screenshot localhost:5173 --viewport mobile with no config. Tall pages are sliced into overlapping device-pixel parts that survive a model's downscaling, plus a whole-page overview for layout.

The capture scrolls the page first, following sections that grow as they load, so lazy and IntersectionObserver content is rendered; it waits on its own in-flight request tracker because Playwright's networkidle resolves at once after it first fires. Dev toolbars and configured selectors are hidden through the screenshot stylesheet rather than by mutating the page.

Images are an output family (<outDir>/<name>-NN.png) planned alongside text outputs: families collide by folded physical directory and name, a text outfile collides with a family it would land in, and stale images are removed by exact match only. Captures resolve before outDir is emptied, so an unreachable dev server or a 404 leaves the previous run in place. Images stay local; a mixed bundle's text still uploads.
Adds a Screenshots section (setup, filenames, options, viewports, notes), --screenshot and --viewport in the CLI reference, a README example, and ADR 006 recording why screenshot is its own source key rather than a sources provider, how image families claim destinations, and why images stay local.
- --screenshot adds http:// unless the URL starts with a scheme; a nested URL in the query no longer counts
- Height is measured on the scrolling element settle actually scrolls, so an app scrolling inside its own container isn't sliced into blank images
- Capture warnings print even when a later bundle fails
- watchNetwork takes its quiet window and cap, so its tests no longer depend on timer cadence or real 5 s waits
- e2e samples a detail slice, not only the overview; CI's Node smoke test runs a screenshot bundle without Playwright
- ADR 005 and the docs say on-demand output is removed only where outDir is emptied
- Each text entry path resolves once; summary counting moves out of main()
- Stop auto-hiding nextjs-portal: it also hosts Next's build and runtime error overlay, and a clean page over a broken app is false evidence
- Relax the optional Playwright peer to * so installs that never capture aren't judged by their Playwright version; compatibility (1.41 up to 2) stays a runtime check
- Rethrow a system Chrome that exists but fails to start instead of reporting it missing
- Shrink slices to share the page evenly, so a page just over one slice becomes two half-page images rather than two near-copies
- Cover scroll → observer → delayed request → capture with a browser test
- Move pathKey to fs.ts, rename CapturedImages to CapturedPage, and name the 20 MB overview cutoff as ChatGPT's
- Document that virtualized lists may not capture every row
Settling returned to the top as soon as scrolling stopped moving and only then waited for the network. A response that appended content after that was measured and sliced but never scrolled into view, so its own lazy content captured blank. Settling now waits for the network at each apparent bottom and scrolls on if the page grew, returning to the top only once a wait brings no growth; an overall 15 s deadline keeps a page that keeps appending from stacking network waits.

Adds a browser regression for late appended content, and checks that nextjs-portal stays visible unless listed in hide.
A full run no longer builds every bundle, and uploads carry only text, but the README, CLI reference, getting-started and upload pages still said otherwise. Also spells out the network wait numbers, that --emptyOutDir applies to one-off flags, which flags combine, and that collision checks cover skipped bundles; trims comments that restated the code.
--no-emptyOutDir skips directory-wide clearing but bundles still replace their own outputs and stale images, and one-off flags empty only with --emptyOutDir; the help text, README and CLI reference said 'keep existing files'. Also documents that one-off flags reuse the config's root and outDir and shadow a bundle of the same name, and tightens comments to state intent without repeating ADR rationale.
Condenses comments that restated ADR 004 rationale, and documents two behaviors the docs omitted: image filenames widen their zero-padding past index 99, and outDir is excluded from bundling only when it doesn't contain the project root.
@koistya
koistya merged commit ab6b3b8 into main Sep 13, 2026
1 check passed
@koistya
koistya deleted the feat/screenshots branch September 13, 2026 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant