Files
paperclip-docs/scripts/screenshots/capture.mjs
631a6a0c53 Release docs for paperclip v2026.817.0 (content + screenshot overhaul) (#95)
Ships the v2026.817.0 docs to live: the doc content that was authored
and verified
earlier, plus a full screenshot overhaul.

The content half (`cdf81e2` and the nightly commits behind it) has
already been
reviewed. **The screenshots are the part that needs eyes** — `f24d734`,
342 PNGs.

## Screenshots

Everything was recaptured against the release commit
`213dabab4f8e1f3bb1803a2924c0fea1289fcd4c`
(`upstream/candidate/release-2026.817.0`). The previous shots were 375
parent-commits stale (v2026.720.0), with 228 of 310 entries flagged
pending. All
342 registry entries now carry that SHA.

> ⚠️ Worth knowing: the release commit is **not** an ancestor of the
parent's
> `master`, and local `master` was four days *older* than it. Capturing
against
> `master` would have both missed release UI and leaked 93 post-tag
commits' worth
> of UI into the release docs.

### New coverage (34 entries)

Three guides shipped with **no images at all** despite being 115–136
lines. They
now have them:

| Guide | Shots |
|---|---|
| `guides/day-to-day/decisions.md` | queue, an agent-proposed decision
with its options, triage strip, filter panel, named queue, Decided
curtain, empty state |
| `experimental/status-cards.md` | board, New card dialog, and all four
drawer tabs |
| `experimental/task-chat.md` | thread, composer mode picker, side pane
|
| `reference/api/secrets.md` | the board-side Proposals tab |

Chat-Style Tasks is captured in a **separate pass**:
`enableTaskChatRedesign` is
instance-wide, so leaving it on would have turned every classic
issue/task/work-mode
shot into the redesigned chat.

## Two defects that were already live

Both were shipping in the docs before this PR:

1. **`issues/detail-chat.png` and the `tasks/*` shots were mostly plugin
debug
output.** The Kitchen Sink example plugin mounts a panel into the issue
page
that dumps the raw issue JSON, and the seed installed it. Those guides
were
illustrating a debug panel instead of the task page. The seed no longer
   installs it.
2. **The seeded task thread was a monologue** — four board-user messages
in a row,
because the board actor cannot post agent comments. It is now a real
two-sided
   exchange.

## Targets repointed because the UI moved

| Target | Why |
|---|---|
| `activity/*` | `Activity.tsx` deleted; `/activity` renders
`pages/audit/CompanyActivity.tsx` |
| `experimental/cloud-upstream` | `CloudUpstream.tsx` deleted; the route
now redirects to `/company/export` |
| `settings/*`, `plugins/*`, `adapters/*` | `/instance/settings/*` are
legacy redirects now |
| `dashboard/stale-tasks-panel` | panel removed from `Dashboard.tsx` —
target dropped |

Plus eight targets with **silent selector drift**. A failed interaction
step falls
back to "whatever state was reached", so these had been quietly
producing wrong
images — a plain inbox where the doc promised a mode picker, a whole
dashboard
where it promised one panel. Full table in
`docs/user-guides/screenshots/SCREENSHOTS_PLAN_v2026.817.0.md` §C2.

## Pipeline

Decisions, status-card summaries and secret proposals are all
**agent-authored**,
and the board actor the seed runs as is refused by every one of them
(`"Agent run context required"`, `"Secret proposals require a verified
run-bound
agent token"`). Rather than insert rows behind the API,
`seed-new-surfaces.mjs`
borrows the two doors a real run already has — a board-minted agent API
key, and a
locally signed agent JWT — so every write goes through the production
code path.

Also:
- `capture.mjs` records a real `captured_against` on a detached checkout
instead of
the literal string `"HEAD"` (which is what every pre-2026.817 entry
recorded), and
  gains a `scrollTo` step for content below the fold.
- `run.mjs` gains `--serve` for iterating on the seed, the task-chat
phase, and a
keeper that holds the demo board's issue statuses steady — the demo
`process`
agents finish runs without declaring a disposition, so the stalled-work
sweep
re-blocks issues every few minutes over a capture that runs far longer.

## Reviewing

Light and dark both captured at 1440×900 @2x. The new surfaces are the
ones to
scrutinise; the rest is a like-for-like refresh.

### One leftover

Dropping `dashboard/stale-tasks-panel` leaves
`{light,dark}/dashboard/stale-tasks-panel.png`
tracked but unused — they need deleting by hand.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: DevRel Gemini <[email protected]>
Co-authored-by: Paperclip <[email protected]>
Co-authored-by: Dotta <[email protected]>
Co-authored-by: ClaudeCoder <[email protected]>
2026-08-18 12:43:56 +02:00

404 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* capture.mjs — Playwright-based screenshot capture engine.
*
* Default export: async function capture(opts) — programmatic API.
* Also runnable as a CLI.
*
* Theme handling:
* 1. colorScheme emulation via Playwright newContext() so CSS media queries work.
* 2. addInitScript seeds localStorage("paperclip.theme") and toggles the
* document.documentElement "dark" class BEFORE the page inline script runs.
* This mirrors exactly what /ui/index.html does at parse time.
*
* CLI flags:
* --all Recapture every target regardless of staleness.
* --only <substr> Only capture targets whose name contains <substr>.
* --theme <light|dark|both> Which theme to capture (default: both).
* --stale <comma-file-list> Capture only these registry file paths.
* --base-url <url> Override BASE_URL (default: config.BASE_URL).
* --keep Do not clean up scratch resources when run standalone.
*
* Omitting --all / --only / --stale captures everything (same as --all).
*/
import { chromium } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import {
BASE_URL,
VIEWPORT,
DEVICE_SCALE,
SHOTS_DIR,
REGISTRY_PATH,
SEED_IDS_PATH,
PARENT_REPO,
} from "./config.mjs";
import { CAPTURE_TARGETS, resolveRoute } from "./routes.mjs";
const execFileAsync = promisify(execFile);
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Read seed-ids.json; returns {} if file does not exist. */
async function loadSeedIds() {
try {
const raw = await readFile(SEED_IDS_PATH, "utf8");
return JSON.parse(raw);
} catch {
return {};
}
}
/** Get the current HEAD SHA of the parent repo. */
async function parentHeadSha() {
try {
const { stdout } = await execFileAsync("git", ["-C", PARENT_REPO, "rev-parse", "HEAD"]);
return stdout.trim();
} catch {
return null;
}
}
/** Run a git command in the parent repo; returns trimmed stdout, or "" on failure. */
async function parentGit(args) {
try {
const { stdout } = await execFileAsync("git", ["-C", PARENT_REPO, ...args]);
return stdout.trim();
} catch {
return "";
}
}
/**
* Human-meaningful name for the parent revision being captured.
*
* Release captures run against a *detached* checkout of the release commit, and
* `rev-parse --abbrev-ref HEAD` answers the literal string "HEAD" in that state —
* which is what every pre-2026.817 registry entry recorded, making it impossible
* to tell from the registry alone which release a shot belongs to. So: use the
* branch name when we are on one, and otherwise resolve the most descriptive ref
* that contains the commit (tag → describe → containing branch), falling back to
* the short SHA. `captured_sha` still pins the exact commit either way.
*/
async function parentRefName() {
const branch = await parentGit(["symbolic-ref", "--quiet", "--short", "HEAD"]);
if (branch) return branch;
// Detached. A tag pointing exactly at HEAD is the best answer.
const tag = (await parentGit(["tag", "--points-at", "HEAD"])).split("\n")[0];
if (tag) return tag;
// Otherwise the nearest tag-ish description (e.g. "beta/v2026.811.0-beta.0-4-g213dabab4").
const described = await parentGit(["describe", "--tags", "--always"]);
// A release candidate usually lives only on a release branch — prefer that name
// when one contains the commit, since it identifies the release.
const containing = await parentGit([
"branch",
"--all",
"--contains",
"HEAD",
"--format=%(refname:short)",
])
.then((out) =>
out
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.includes("HEAD detached")),
);
const release = containing.find((ref) => /release|candidate/.test(ref)) ?? containing[0];
if (release) return release;
return described || (await parentGit(["rev-parse", "--short", "HEAD"])) || null;
}
/**
* Playwright addInitScript source that seeds localStorage and the dark class
* before the page-level inline script runs. This exactly mirrors the
* document.documentElement manipulation in ui/index.html.
*
* @param {"light"|"dark"} theme
* @returns {string}
*/
function themeInitScript(theme) {
const isDark = theme === "dark";
return `(() => {
try {
localStorage.setItem("paperclip.theme", ${JSON.stringify(theme)});
} catch {}
const isDark = ${JSON.stringify(isDark)};
document.documentElement.classList.toggle("dark", isDark);
document.documentElement.style.colorScheme = isDark ? "dark" : "light";
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", isDark ? "#18181b" : "#ffffff");
})();`;
}
// ── Interaction step engine ───────────────────────────────────────────────────
/**
* Resolve a selector descriptor to a Playwright Locator.
* Supported keys (first match wins): testid, role+name, label, title,
* placeholder, text, css. Always `.first()` to avoid strict-mode violations.
*
* @param {import('@playwright/test').Page} page
* @param {object} sel
*/
function locate(page, sel) {
if (sel.testid) return page.getByTestId(sel.testid).first();
if (sel.role) return page.getByRole(sel.role, sel.name ? { name: sel.name } : {}).first();
if (sel.label) return page.getByLabel(sel.label).first();
if (sel.title) return page.getByTitle(sel.title).first();
if (sel.placeholder) return page.getByPlaceholder(sel.placeholder).first();
if (sel.text) return page.getByText(sel.text).first();
if (sel.css) return page.locator(sel.css).first();
throw new Error(`unknown selector: ${JSON.stringify(sel)}`);
}
/**
* Run a target's interaction steps. Every step is best-effort: a failing step
* is logged and skipped so we still screenshot whatever state was reached
* (a partial capture beats no capture, and surfaces selector drift in the log).
*
* Step shapes: { click }, { fill, value }, { waitFor }, { waitMs }, { press },
* { scrollTo } (bring an element into view — long pages like issue detail put
* the interesting section below the fold, and a 1440×900 shot of the top of the
* page misses it entirely).
*
* @param {import('@playwright/test').Page} page
* @param {Array<object>} steps
* @param {string} label
*/
async function runSteps(page, steps, label) {
for (const [i, stepObj] of steps.entries()) {
try {
if (stepObj.waitMs != null) {
await page.waitForTimeout(stepObj.waitMs);
} else if (stepObj.waitFor) {
await locate(page, stepObj.waitFor).waitFor({ state: "visible", timeout: 8_000 });
} else if (stepObj.scrollTo) {
await locate(page, stepObj.scrollTo).scrollIntoViewIfNeeded({ timeout: 8_000 });
} else if (stepObj.click) {
await locate(page, stepObj.click).click({ timeout: 8_000 });
} else if (stepObj.fill) {
await locate(page, stepObj.fill).fill(String(stepObj.value ?? ""), { timeout: 8_000 });
} else if (stepObj.press) {
await page.keyboard.press(stepObj.press);
}
} catch (err) {
console.warn(`capture: step ${i} of ${label} failed (continuing): ${err.message.split("\n")[0]}`);
}
}
}
// ── Core capture function ─────────────────────────────────────────────────────
/**
* @typedef {Object} CaptureOpts
* @property {boolean} [all] - Recapture everything (default when no filters given).
* @property {string} [only] - Name substring filter.
* @property {"light"|"dark"|"both"} [theme] - Which theme(s). Default "both".
* @property {string[]} [staleFiles] - Registry file list to restrict capture.
* @property {string} [baseUrl] - Base URL override.
* @property {"pre-seed"|"post-seed"} [phase] - Which capture phase to run.
* Targets default to "post-seed"; targets marked `phase: "pre-seed"` in
* routes.mjs are shot against the company-less instance (before seed.mjs
* runs) so onboarding-wizard states are reachable. Default "post-seed".
*/
/**
* Capture screenshots for all matching targets.
*
* @param {CaptureOpts} opts
*/
export default async function capture(opts = {}) {
const {
only,
theme: themeFilter = "both",
staleFiles,
baseUrl = BASE_URL,
phase = "post-seed",
} = opts;
const seedIds = await loadSeedIds();
const sha = await parentHeadSha();
const refName = await parentRefName();
// Determine which themes to capture.
const themes =
themeFilter === "both" ? ["light", "dark"] : [themeFilter];
// Build the set of files we need to (re)capture.
const staleSet = staleFiles ? new Set(staleFiles) : null;
// Filter targets.
let targets = CAPTURE_TARGETS.filter((t) => (t.phase ?? "post-seed") === phase);
if (only) {
targets = targets.filter((t) => t.name.includes(only));
}
// Expand to (target, theme) pairs and apply staleness filter.
const work = [];
for (const target of targets) {
const targetThemes = (target.themes ?? ["light", "dark"]).filter((t) =>
themes.includes(t),
);
for (const theme of targetThemes) {
const file = `${theme}/${target.name}.png`;
if (staleSet && !staleSet.has(file)) continue;
work.push({ target, theme, file });
}
}
if (work.length === 0) {
console.log("capture: nothing to do.");
return;
}
console.log(`capture: capturing ${work.length} screenshot(s)…`);
// Load registry for stamping.
let registry = null;
try {
registry = JSON.parse(await readFile(REGISTRY_PATH, "utf8"));
} catch {
// Registry missing or unreadable — stamp step will be skipped.
}
// Track which registry entries we stamped this run.
const stampedFiles = new Set();
const browser = await chromium.launch();
try {
for (const { target, theme, file } of work) {
const route = resolveRoute(target, seedIds);
if (!route) {
console.warn(`capture: skipping ${file} — required id missing from seed-ids.json`);
continue;
}
const url = baseUrl + route;
const outPath = resolve(SHOTS_DIR, file);
await mkdir(resolve(SHOTS_DIR, theme, target.name.split("/")[0]), { recursive: true });
// Create a fresh context per capture for full isolation.
const context = await browser.newContext({
viewport: VIEWPORT,
deviceScaleFactor: DEVICE_SCALE,
colorScheme: theme, // CSS @media (prefers-color-scheme) emulation
});
// Seed the localStorage key and toggle the html class BEFORE any page
// script runs. This is equivalent to the inline script in ui/index.html.
await context.addInitScript(themeInitScript(theme));
const page = await context.newPage();
const wait = target.wait ?? 1200;
// The pipeline serves the UI through vite dev middleware, which compiles
// route chunks lazily on first request. `networkidle` can fire before the
// SPA has mounted, yielding a pure-white blank. Navigate, then poll until
// the app has actually rendered real content; reload-and-retry if not.
for (let attempt = 0; attempt < 4; attempt++) {
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 20_000 });
} catch {
// Timeout or navigation error — best-effort; the content poll decides.
}
await page.waitForTimeout(wait);
let contentLen = 0;
try {
contentLen = await page.evaluate(
() => (document.body?.innerText || "").trim().length,
);
} catch {
// evaluate can race a navigation; treat as not-yet-rendered.
}
if (contentLen > 40) break;
if (attempt < 3) {
console.warn(
`capture: [${theme}] ${target.name} rendered blank (len=${contentLen}); retry ${attempt + 1}/3`,
);
}
}
// Optional interaction steps (open a dialog, switch a tab, fill a field…).
if (Array.isArray(target.steps) && target.steps.length) {
await runSteps(page, target.steps, target.name);
await page.waitForTimeout(target.postStepWait ?? 600);
}
// `clip` captures a single element (e.g. one dashboard panel) instead of
// the whole page. Falls back to a full-page shot if the element is missing.
let clipped = false;
if (target.clip) {
try {
await locate(page, target.clip).screenshot({ path: outPath, timeout: 8_000 });
clipped = true;
} catch (err) {
console.warn(`capture: clip for ${target.name} failed, full page instead: ${err.message.split("\n")[0]}`);
}
}
if (!clipped) {
await page.screenshot({ path: outPath });
}
console.log(`capture: ok [${theme}] ${target.name} → ${file}`);
stampedFiles.add(file);
await context.close();
}
} finally {
await browser.close();
}
// ── Stamp registry entries ────────────────────────────────────────────────
if (registry && sha && stampedFiles.size > 0) {
registry.entries = registry.entries.map((entry) => {
if (!stampedFiles.has(entry.file)) return entry;
return {
...entry,
captured_against: refName,
captured_sha: sha,
};
});
try {
await writeFile(REGISTRY_PATH, JSON.stringify(registry, null, 2) + "\n", "utf8");
console.log(`capture: stamped ${stampedFiles.size} registry entries (sha=${sha}).`);
} catch (err) {
console.warn("capture: could not write registry.json:", err.message);
}
}
}
// ── CLI entry point ───────────────────────────────────────────────────────────
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
const args = process.argv.slice(2);
const get = (flag) => {
const idx = args.indexOf(flag);
return idx !== -1 ? args[idx + 1] : undefined;
};
const has = (flag) => args.includes(flag);
const staleRaw = get("--stale");
const opts = {
all: has("--all"),
only: get("--only"),
theme: get("--theme") ?? "both",
staleFiles: staleRaw ? staleRaw.split(",") : undefined,
baseUrl: get("--base-url") ?? BASE_URL,
keep: has("--keep"),
phase: get("--phase") ?? "post-seed",
};
capture(opts).catch((err) => {
console.error("capture failed:", err);
process.exit(1);
});
}