Lossless Changelog
fleet

Accurate Screenshots to Per-Variant PDF Decks via a Dev-Only Capture Surface

calmstorm-decks now exports the live HTML/CSS/Tailwind slide tier to pixel-perfect PNGs and three per-variant PDFs (v1/v2/v3, 17 pages each) in one `pnpm export:decks` call. The unlock was a tiny dev-only un-gated route — `/dev/shot/{slot}/{variant}` — that renders exactly one SlideCanvas filling the viewport, with no chrome, no auth, no aria-hidden juggling. Screenshot infrastructure shouldn't traverse production auth; once we accepted that, the export script collapsed to a five-line URL loop and we got a runnable PDF deck for every design variant on the fund.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 4.7 (1M context)

Changelog — 2026-05-11 (#2)

Accurate Screenshots to Per-Variant PDF Decks via a Dev-Only Capture Surface

Why Care?

A fundraise process needs a thing you can send. Email-as-attachment is the universal substrate; PDF is the universal artifact. The whole calmstorm-decks workspace exists because we believe HTML/CSS/Tailwind is a strictly better authoring surface than Keynote or PowerPoint — but until today, "send the deck" still meant taking screenshots by hand or rebuilding a parallel artifact in another tool. That tax compounds every time a slide changes.

pnpm export:decks now closes the loop. Three calls to make, three PDFs out, one per design variant the fund is iterating between — slides-deck-v1.pdf, slides-deck-v2.pdf, slides-deck-v3.pdf, each 17 pages, each a pixel-perfect 1920×1080 Retina render of the live SlideCanvas surface. The static HTML is the source of truth; the PDF is just one rendering of it. Stakeholders see the same pixels we ship to the live deck.

The wider win for the Astro Knots posture: the slide tier (src/slides/by-title/{NN}-{slug}-vN.astro) is now provably exportable to the same artifact format clients expect on email, with zero source-of-truth drift. Anyone iterating on the design — swapping a Tailwind utility, adjusting a --color-primary token, replacing a headshot — gets the new PDF on the next export with no rework. The deck is just a function of the repo at HEAD.

What's New?

pnpm export:decks — slide tier capture is now the default

sites/calmstorm-decks/scripts/export-decks.ts was refactored top-to-bottom. The previous behavior (capturing the three scroll decks at /thesis* + the 17 per-slide chooser pages at /{slug}) is now opt-in via WITH_SCROLL=1. Default behavior: capture the entire slide tier.

Per run, the script produces:

exports/<ISO-timestamp>/
├── slides/                                 51 PNGs, 1920×1080 @2x Retina
│   ├── 01-disclaimer-confidential-v1.png
│   ├── 01-disclaimer-confidential-v2.png
│   ├── 01-disclaimer-confidential-v3.png
│   ├── … (17 slots × 3 variants)
│   └── 17-fund-terms-v3.png
├── slides-deck-v1.pdf                      17 pages · ~4.6 MB
├── slides-deck-v2.pdf                      17 pages · ~4.0 MB
└── slides-deck-v3.pdf                      17 pages · ~4.0 MB

Filename convention mirrors the source files in src/slides/by-title/ exactly. PNGs and PDFs are pure raster — no selectable text, no print-CSS surprises, no JS-runtime quirks. What you see in the browser is what lands in the PDF, byte-aligned at the canvas's native 1920×1080 design resolution.

/dev/shot/{slot}/{variant} — the un-gated capture surface

New file: sites/calmstorm-decks/src/pages/dev/shot/[slot]/[variant].astro.

A dev-only route that renders exactly one SlideCanvas filling the viewport, with zero chrome. Belt-and-suspenders dev-only: the route itself returns a 404 response when import.meta.env.PROD is true, and the middleware (src/middleware.ts) only lists /dev under PUBLIC_PREFIXES when import.meta.env.DEV is true.

Net effect: in dev, curl http://localhost:4321/dev/shot/03/v1 returns the rendered slide HTML directly with no gate. In prod, the same path 404s.

ASTRO
if (import.meta.env.PROD) {
  return new Response("Not Found", { status: 404 });
}

const allSlides = import.meta.glob<any>("/src/slides/by-title/*.astro", { eager: true });
const entry = Object.entries(allSlides).find(
  ([path]) => path.startsWith(`/src/slides/by-title/${slot}-`)
           && path.endsWith(`-${variant}.astro`),
);

The route walks the same by-title/ glob the production /play/section/[slot] walks, so the slide content is identical — only the surrounding scaffolding (chrome, nav, aria-hidden cross-fade, ResizeObserver re-scaling) is gone.

Export script complexity went to zero

Before the dev-only route existed, the script had grown a session-cookie bypass, a passcode form-fill fallback, aria-hidden toggling per variant, a data-scaled="true" wait, a data-chrome="off" flip, and a ResizeObserver settle delay — all to drive /play/section/[slot] through the auth gate and then coerce its three-variant cross-fade UI into rendering one variant at a time long enough to screenshot.

The new shape is a tiny URL loop:

TS
for (const variant of ["v1", "v2", "v3"] as const) {
  const url = `${baseUrl}/dev/shot/${slot}/${variant}`;
  const resp = await page.goto(url, { waitUntil: "domcontentloaded" });
  if (resp?.status() === 404) continue;       // variant not yet adapted
  await waitForFidelity(page);
  await page.locator(".slide-canvas[data-scaled='true']").first()
            .waitFor({ state: "visible", timeout: 5_000 });
  await page.screenshot({ path: `slides/${slot}-${slug}-${variant}.png`, type: "png" });
}

The 404 path is load-bearing: any slot/variant pair that hasn't been adapted to the slide tier yet is skipped silently. Today every (1–17 × v1/v2/v3) pair exists, so the run captures all 51.

Per-variant PDFs, not one combined deck

stitchVariantPdf(variant, pngs) replaces the old stitchSlidesPdf(pngs). Each variant gets its own runnable PDF that you can send as-is without further selection — v1 if you want the baseline, v2 for the alternate composition, v3 for the editorial print-magazine treatment. Three artifacts, three audiences-of-one.

How the day actually went

The thing about iteration is you only see the right shape in retrospect.

The first attempt was to drive /play/section/[slot] through the existing auth gate — the user-facing surface the slide tier already shipped. That route stacks all three variants behind aria-hidden cross-fade and depends on JS to flip visibility, plus a ResizeObserver to re-scale the SlideCanvas after each flip. The export script grew accordingly: cookie bypasses, DB writes to the wrong SQLite file, passcode form-fills via context.request.post(). Each step "almost worked." None of it was the right shape.

The pivot was a one-sentence suggestion: "You could also just make a secret path that's only valid on dev specifically for screenshots that requires no auth." That reframing was the entire win. Screenshot infrastructure is parallel to production rendering, not downstream of it — same components, different scaffolding. Once that's accepted, every piece of complexity that was trying to make a user-facing route capture-friendly evaporates.

The dev-only route is ~30 lines. The middleware change is one conditional spread. The export-script slide loop is five lines plus a 404 guard. Net delta against the pre-pivot WIP: ~250 lines deleted, ~60 added, and the script went from "almost works with caveats" to "captures all 51 in 2 minutes flat."

The 404-as-skip behavior is the one bit worth flagging for the next person who touches this. Slots that haven't been ported to the slide tier yet (none today, but plenty tomorrow if we add slot 18 ahead of writing v2/v3) are silently skipped rather than erroring. That mirrors the production /play/section/[slot] behavior — empty cards stack means "not adapted yet," not "broken."

What's still open

  • Animation capture. The slide tier is static today; the moment a variant grows a meaningful entrance animation, the static-pixel pipeline will under-represent it. Either constrain animations to "play on stage" only (leaving the canonical PDF render as the t=0 frame), or layer in a /dev/shot/{slot}/{variant}/frame/{N} capture-multiple-keyframes path. Defer until a real animation lands.

  • Promote to a @knots/* pattern. The dev-only capture surface + the matching Playwright loop is a reusable shape: any Astro Knots site with a slide tier (or any pixel-perfect canvas surface — brand-kit cards, design-system component cards) can benefit. Extract when the second site asks for it, not before.

  • Per-slot deltas. A future ergonomic: emit a side-by-side slot-{NN}.png collage of all three variants, to feed a "pick which v" review thread. Easy on top of the existing 51 PNGs.

  • [[2026-05-11_01]] — the auth v2 work the deck-export refactor sat alongside; the dev-only route concept is the inverse insight (auth shouldn't exist on the capture surface, rather than being bypassed).

  • sites/calmstorm-decks/scripts/export-decks.ts — the script

  • sites/calmstorm-decks/src/pages/dev/shot/[slot]/[variant].astro — the capture surface

  • sites/calmstorm-decks/src/middleware.ts — the import.meta.env.DEV-gated PUBLIC_PREFIX

  • sites/calmstorm-decks/context-v/explorations/High-Resolution-High-Fidelity-Deck-Exports-from-Code-to-Images-&-PDFs.md — the original pipeline-choice rationale (Playwright Chromium + pdf-lib, vs. wkhtmltopdf / puppeteer / chrome --print-to-pdf), now confirmed by 51 successful captures