no manual screenshots ever again.

The screenshot in the /ai prototype post is four screenshots presented as one that automatically swaps to match the website’s theme. For this post I ran:

bin/capture-themed-screenshots /ai/ \
  static/posts/themed-screenshots-with-playwright-cli/ai-route-app \
  --selector '[data-ai-app]' \
  --seed-ai-demo
/ai app screenshot selected when the current site theme is aurora /ai app screenshot selected when the current site theme is boring /ai app screenshot selected when the current site theme is code-y /ai app screenshot selected when the current site theme is dreamy
The first tab follows the active site theme. The other tabs show the generated files in alpha order.

The generated artifacts are:

piecepath
wrapperbin/capture-themed-screenshots
postcontent/posts/themed-screenshots-with-playwright-cli.md
aura screenshotstatic/posts/themed-screenshots-with-playwright-cli/ai-route-app-aura-dark.png
boring screenshotstatic/posts/themed-screenshots-with-playwright-cli/ai-route-app-boring-dark.png
code-y screenshotstatic/posts/themed-screenshots-with-playwright-cli/ai-route-app-code-y-dark.png
dreamy screenshotstatic/posts/themed-screenshots-with-playwright-cli/ai-route-app-dreamy-dark.png
tab stylessass/style.scss
tab behaviortemplates/base.html

The wrapper script runs bin/build, starts a local static server if one is not already listening, reads config.extra.site_themes, and loops over the configured theme slugs. For each theme it writes a Playwright storage-state file with:

  • site-theme=<theme slug>
  • theme=dark
  • optional demo state for routes that need restored browser data

That last part is why /ai has --seed-ai-demo. The app stores threads, active chat, and layout in localStorage. A screenshot of the empty app would prove the route exists, which is not the same thing as showing the prototype. The seed writes a fixed local thread and a fixed active-chat pointer before Chromium opens the page.

The storage file is:

{
  "origins": [
    {
      "origin": "http://127.0.0.1:4173",
      "localStorage": [
        { "name": "site-theme", "value": "aura" },
        { "name": "theme", "value": "dark" },
        { "name": "dkdc.ai.threads.v1", "value": "[...]" },
        { "name": "dkdc.ai.activeThread.v1", "value": "ai-route-prototype-thread" }
      ]
    }
  ]
}

The actual capture call is just Playwright CLI:

npx --yes playwright screenshot \
  --browser chromium \
  --viewport-size "$VIEWPORT" \
  --color-scheme dark \
  --load-storage "$state" \
  --wait-for-selector "$SELECTOR" \
  --wait-for-timeout "$WAIT_MS" \
  "$BASE_URL$PATH_ARG" \
  "$output"

The display markup has five tabs. The first panel reuses the normal .theme-screenshot classes, so it follows the live site theme. The other four panels point straight at the generated PNGs:

<figure class="theme-screenshot-tabs" data-theme-screenshot-tabs>
  <button data-theme-screenshot-tab="current">current theme</button>
  <button data-theme-screenshot-tab="aura">aura</button>
  <button data-theme-screenshot-tab="boring">boring</button>
  <button data-theme-screenshot-tab="code-y">code-y</button>
  <button data-theme-screenshot-tab="dreamy">dreamy</button>

  <div data-theme-screenshot-panel="current">
    <div class="theme-screenshot">
      <img class="theme-screenshot__image theme-screenshot__image--theme-aura" src="...-aura-dark.png" alt="...">
      <img class="theme-screenshot__image theme-screenshot__image--theme-boring" src="...-boring-dark.png" alt="...">
      <img class="theme-screenshot__image theme-screenshot__image--theme-code-y" src="...-code-y-dark.png" alt="...">
      <img class="theme-screenshot__image theme-screenshot__image--theme-dreamy" src="...-dreamy-dark.png" alt="...">
    </div>
  </div>

  <div data-theme-screenshot-panel="aura" hidden>
    <img src="...-aura-dark.png" alt="...">
  </div>
  <figcaption>...</figcaption>
</figure>

CSS hides the current-theme images, then shows the one matching html[data-site-theme]. The boring image stays as the fallback because it is the default theme and because CSS should degrade to something visible.

This is the kind of automation I want. If I add a theme, config.toml changes and the command emits another file. If the app route changes, the selector or seed changes. The screenshot stops being an artisanal artifact and becomes another checked-in receipt.