mirror of
https://github.com/paperclipai/paperclip-docs.git
synced 2026-11-07 06:16:07 -10:00
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]>
404 lines
15 KiB
JavaScript
404 lines
15 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|
||
}
|