Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 146 additions & 6 deletions tools/docs-media/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* node capture.mjs --only adopt-modal
*/
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";

const arg = (n, d) => {
Expand All @@ -23,6 +23,18 @@ const BASE = arg("--base", process.env.PADDOCK_RIG_BASE || "http://127.0.0.1:400
const OUT = arg("--out", process.env.PADDOCK_SHOTS_OUT || "./shots");
const ONLY = arg("--only", null);

/**
* Appearance is per-BROWSER, not per-instance: three localStorage keys read by
* an inline pre-paint script in index.html. There is no server-side theme, so a
* capture has to pin them itself.
*
* Default is the out-of-the-box appearance — Foundation, dark, the theme's own
* accent, no tint. That is what a reader sees on first boot, which is the whole
* job of a documentation screenshot. Override for the theme quartet only.
*/
const SHOT_THEME = process.env.PADDOCK_SHOT_THEME || "foundation";
const SHOT_DARK = (process.env.PADDOCK_SHOT_MODE || "dark") === "dark";

mkdirSync(OUT, { recursive: true });

/**
Expand Down Expand Up @@ -98,7 +110,48 @@ async function assertClean(page, label) {
* an element shot of a 4-row chat list is ~40% empty black — which reads as a
* sloppy screenshot rather than as a short list.
*/
async function shoot(page, name, { selector = null, fitToLast = null, pad = 8 } = {}) {
/**
* Record WHAT WAS ON SCREEN beside the shot, as `<file>.png.json`.
*
* With four runtime themes and a free accent picker, "which theme is this?" is
* no longer answerable from the PNG — and that question is most of what made
* deciding this re-shoot expensive. A sidecar turns it into a file read.
*
* Everything here is OBSERVED from the live page, not restated from the config
* that was requested: the point is evidence that the intended appearance
* actually applied, so a shot taken with a silently-failed theme is detectable
* afterwards rather than only at capture time.
*/
async function provenance(page, name, viewport) {
return page.evaluate(
([shotName, vp]) => {
const root = document.documentElement;
const cs = getComputedStyle(root);
let stored = {};
try {
stored = JSON.parse(localStorage.getItem("paddock:appearance") || "{}");
} catch {}
// The instance stamps its own version into the sidebar; that is the
// build that is literally in the frame.
const v = (document.body.innerText || "").match(/\bv(\d+\.\d+\.\d+)\b/);
return {
shot: shotName,
route: location.pathname,
viewport: vp,
theme: stored.theme ?? null,
mode: root.classList.contains("dark") ? "dark" : "light",
hue: stored.hue ?? null,
tint: stored.tint ?? 0,
// Bare space-separated sRGB channels — the branding seam's format.
accent: cs.getPropertyValue("--accent").trim() || null,
appVersion: v ? v[1] : null,
};
},
[name, viewport],
);
}

async function shoot(page, name, { selector = null, fitToLast = null, pad = 8 } = {}, viewport) {
await mask(page);
await assertClean(page, name);
const file = path.join(OUT, `docs-${name}.png`);
Expand All @@ -120,7 +173,9 @@ async function shoot(page, name, { selector = null, fitToLast = null, pad = 8 }
} else {
await page.screenshot({ path: file, scale: "css" });
}
console.log(` ✓ ${file}`);
const meta = await provenance(page, name, viewport);
writeFileSync(`${file}.json`, JSON.stringify(meta, null, 2) + "\n");
console.log(` ✓ ${file} [${meta.theme}/${meta.mode} accent=${meta.accent} v${meta.appVersion}]`);
return file;
}

Expand Down Expand Up @@ -215,7 +270,7 @@ shot("root-home", { width: 1280, height: 800 }, async (page) => {
// 3 · using/creating-and-organizing-projects.md:405 — Promote to project.
// NB the opener is an unlabelled hover-only "+" on a root chat row, NOT a
// button reading "Promote to project" as the prose claims.
shot("promote-dialog", { width: 1180, height: 700 }, async (page) => {
shot("promote-to-project", { width: 1180, height: 700 }, async (page) => {
await page.goto(`${BASE}/chat`);
// The opener is opacity-0 until the chat row is hovered
// (SessionSidebar.tsx:411), so hover the row before clicking.
Expand All @@ -232,13 +287,66 @@ shot("promote-dialog", { width: 1180, height: 700 }, async (page) => {
// 6 · guides/agent-capabilities.md:159-178 — tool picker, Bash ticked, amber
// warning visible. The warning renders ONLY while Bash is ticked
// (TriggersPane.tsx:808), so ticking is the shot.
shot("trigger-bash-warning", { width: 900, height: 820 }, async (page) => {
shot("trigger-tool-picker-bash", { width: 900, height: 820 }, async (page) => {
await page.goto(`${BASE}/projects/tidepool/triggers`);
await page.getByTestId("add-trigger").click();
await page.getByRole("checkbox", { name: /^Bash/ }).check();
await page.getByText(/lets this trigger run arbitrary shell commands/).waitFor();
});

// 7 · configuration/config-file.md:712 — the project Settings tab. #768 rebuilt
// this screen STRUCTURALLY, not just repainted it, so the old frame is
// wrong about layout and not merely about colour. Framed on the form's
// scroll container rather than the window: the subject is the settings
// measure, and the sidebar beside it adds nothing at docs-column width.
shot(
"project-settings",
{ width: 1180, height: 900 },
async (page) => {
await page.goto(`${BASE}/projects/tidepool/settings`);
await page.getByText(/Summary|Domain|Model/).first().waitFor();
await page.waitForLoadState("networkidle");
},
{ selector: "main" },
);

// 8 · The Appearance section of /config (#780). NOTHING on the site illustrates
// the four themes or the accent picker — the feature is un-illustrated
// anywhere, which is why this is net-new rather than a re-shoot.
shot(
"appearance-panel",
{ width: 1100, height: 760 },
async (page) => {
await page.goto(`${BASE}/config`);
await page.getByRole("heading", { name: "Appearance" }).waitFor();
await page.getByText("The neutral base. Warm ground, terracotta accent.").waitFor();
},
{ selector: "section:has(h3:text-is('Appearance'))" },
);

// 9 · The theme quartet for the 0.67 entry. The SAME route in all four themes,
// driven by $PADDOCK_SHOT_THEME — four separate runs, four files. This is
// the ONE shot that must not be Foundation-only, because the subject is the
// choice itself.
//
// Four screenshots of one URL at one viewport is precisely the
// configuration that has produced byte-identical files before, so md5sum
// the four before believing you have four.
shot(`theme-${SHOT_THEME}`, { width: 1280, height: 800 }, async (page) => {
await page.goto(`${BASE}/projects/tidepool/settings`);
await page.waitForLoadState("networkidle");
await page.getByText("Tidepool").first().waitFor();
});

// 10 · /discover (#745/#802). 0.68 is the newest What's New entry and carries
// NO image at all. Discovery is also what an empty instance renders as its
// Home, so this doubles as the first-run screen.
shot("discover", { width: 1280, height: 800 }, async (page) => {
await page.goto(`${BASE}/discover`);
await page.waitForLoadState("networkidle");
await page.getByText(/Discover|scan|candidate/i).first().waitFor();
});

// ---------------------------------------------------------------------------

async function main() {
Expand All @@ -249,11 +357,43 @@ async function main() {
const s = SHOTS[name];
if (!s) throw new Error(`no such shot: ${name}`);
const ctx = await browser.newContext({ viewport: s.viewport, deviceScaleFactor: 2 });
// addInitScript, NOT page.evaluate after goto: the keys are read by a
// pre-paint inline script, so writing them after navigation gives you a
// flash of the wrong theme and, worse, a shot taken mid-swap. This runs
// before any page script, on every navigation.
await ctx.addInitScript(
([theme, dark]) => {
try {
localStorage.setItem("paddock:theme", dark ? "dark" : "light");
localStorage.setItem(
"paddock:appearance",
JSON.stringify({ theme, hue: null, tint: 0 }),
);
// Keyed <theme>:<dark|light>. A stale entry paints the PREVIOUS
// theme's solved accent before React boots, and a fast shot catches
// exactly that frame. Removing it is not optional.
localStorage.removeItem("paddock:appearance-cache");
} catch {}
},
[SHOT_THEME, SHOT_DARK],
);
const page = await ctx.newPage();
try {
console.log(`→ ${name}`);
await s.fn(page);
await shoot(page, name, s.opts);
// Assert the theme actually took rather than trusting it. Do not try to
// verify by grepping CSS: OKLCH serialises as `oklch(...)` and --accent
// is a bare RGB triple, so regex readers score a themed build zero.
const applied = await page.evaluate(() => ({
dark: document.documentElement.classList.contains("dark"),
accent: getComputedStyle(document.documentElement).getPropertyValue("--accent").trim(),
}));
if (applied.dark !== SHOT_DARK || !applied.accent) {
throw new Error(
`theme did not apply (dark=${applied.dark} want ${SHOT_DARK}, accent="${applied.accent}")`,
);
}
await shoot(page, name, s.opts, s.viewport);
} catch (e) {
failed++;
console.error(` ✗ ${name}: ${String(e).split("\n")[0]}`);
Expand Down
172 changes: 172 additions & 0 deletions tools/docs-media/drive.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!/usr/bin/env node
/**
* Drive real turns into the rig so the chat list has texture.
*
* seed.mjs creates projects and adoptable transcripts; it cannot create CHATS,
* because a chat is the product of a turn. This runs those turns through the
* fake `claude` on the rig's PATH, so it costs nothing and is deterministic:
* replies come from $PADDOCK_FAKE_SCRIPT (a prompt -> reply JSON map), which is
* how the on-camera text is authored rather than improvised.
*
* Env:
* PADDOCK_RIG_HOME required — same var as serve.sh (identity guard)
* PADDOCK_RIG_BASE instance URL (default http://127.0.0.1:4000)
*
* Usage: node drive.mjs [--base http://127.0.0.1:PORT]
*/
import WebSocket from "ws";

const RIG = process.env.PADDOCK_RIG_HOME;
if (!RIG) {
console.error("set PADDOCK_RIG_HOME (the rig scratch root — the same value serve.sh uses)");
process.exit(1);
}
const argBase = process.argv.indexOf("--base");
const BASE =
argBase > -1 ? process.argv[argBase + 1] : process.env.PADDOCK_RIG_BASE || "http://127.0.0.1:4000";
const API = `${BASE}/api`;
const WS = BASE.replace(/^http/, "ws") + "/ws";

// Same identity guard as seed.mjs. Driving turns into a stale squatter would
// write real chats into somebody else's instance — and on a `session` instance
// it would bill real money.
async function assertIsRig() {
const r = await fetch(`${API}/instance-config`);
if (!r.ok) throw new Error(`no instance at ${BASE} (${r.status})`);
const cfg = await r.json();
const field = (k) => cfg.groups?.flatMap((g) => g.fields ?? []).find((f) => f.key === k)?.value;
if (field("dataDir") !== `${RIG}/data`) {
throw new Error(`REFUSING TO DRIVE: ${BASE} reports dataDir=${field("dataDir")}, not ${RIG}/data`);
}
if (field("driveMode") !== "batch") {
throw new Error(`REFUSING TO DRIVE: driveMode=${field("driveMode")} (real credit risk)`);
}
console.log(`✓ verified ${BASE} is the rig`);
}

/**
* Send one message and resolve when the turn completes.
*
* `slug` is the WORKSPACE KEY, and the root workspace's key is the EMPTY
* STRING — so this takes it verbatim and never tests it for truthiness. A
* `if (!slug)` here would silently redirect every root chat to a project.
*/
function send(slug, message, sessionId = null) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(WS);
const timer = setTimeout(() => {
ws.close();
reject(new Error(`timeout waiting for chat:complete (${String(message).slice(0, 40)}…)`));
}, 120000);
ws.on("open", () =>
ws.send(JSON.stringify({ type: "chat:send", payload: { projectSlug: slug, sessionId, message } })),
);
ws.on("message", (buf) => {
let f;
try {
f = JSON.parse(buf.toString());
} catch {
return;
}
if (f.type === "chat:complete") {
clearTimeout(timer);
ws.close();
resolve(f.payload);
}
if (f.type === "chat:error") {
clearTimeout(timer);
ws.close();
reject(new Error(JSON.stringify(f.payload)));
}
});
ws.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
});
}

const patch = (slug, sessionId, body) =>
fetch(`${API}/${slug === "" ? "root" : `projects/${slug}`}/chats/${sessionId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});

// ---------------------------------------------------------------------------
// The script. Each entry is one chat: a workspace, the prompt to send, and the
// display name to give the resulting chat. The prompts are KEYS into
// fixtures.json, so the reply on camera is authored, not improvised.
// ---------------------------------------------------------------------------
const CHATS = [
["tidepool", "Why do cold starts take 40s on the ingest worker?", "Why cold starts take 40s on the ingest worker"],
["tidepool", "Add a staleness alert for silent gauges.", "Add a staleness alert for silent gauges"],
["tidepool", "Normalise the Dover sensor's timestamp drift.", "Normalise the Dover sensor's timestamp drift"],
["tidepool", "Backfill 2024 readings from the archive bucket.", "Backfill 2024 readings from the archive bucket"],
["lanternfish", "The overnight run missed its window again.", "Overnight run missed its window again"],
["lanternfish", "Split the batch queue by priority.", "Split the batch queue by priority"],
["harbour-notes", "Compare the two survey methods.", "Compare the two survey methods"],
["", "Compare the two survey methods.", "Compare the two survey methods"],
["", "Draft the release note for the tide model review.", "Notes from the tide model review"],
];

async function main() {
await assertIsRig();
const made = [];
for (const [slug, prompt, name] of CHATS) {
const where = slug === "" ? "(root)" : slug;
try {
const res = await send(slug, prompt);
const id = res?.sessionId;
if (id) {
made.push({ slug, id, name });
console.log(`✓ ${where}: turn complete`);
} else {
console.log(`! ${where}: completed without a sessionId`);
}
} catch (e) {
console.log(`✗ ${where}: ${String(e).split("\n")[0]}`);
}
}

// Rename in a SECOND PASS, after every turn has finished.
//
// Renaming immediately after `chat:complete` loses the race: the transcript's
// own title resolution (ai-title, else the first user message) lands after the
// turn completes and overwrites the custom name. The symptom is subtle — the
// chat is named the prompt you sent rather than the name you set, which looks
// like a name you chose badly rather than a write that was clobbered.
for (const m of made) {
const r = await patch(m.slug, m.id, { name: m.name });
console.log(`${r.ok ? "✓" : "✗"} named: ${m.name}`);
}

// Texture: one starred, one left unread. Both are shots in their own right
// (starred-chats, mark-unread) and both make the sidebar look like an
// instance somebody actually uses rather than a fresh seed.
const tide = made.filter((m) => m.slug === "tidepool");
if (tide[0]) {
// Starring is its OWN route (POST …/star), not a field on the rename PATCH
// — whose body schema accepts `name` only, so a `starred` key there is
// accepted and silently dropped.
await fetch(`${API}/projects/tidepool/chats/${tide[0].id}/star`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ starred: true }),
});
console.log(`✓ starred: ${tide[0].name}`);
}
if (tide[1]) {
await fetch(`${API}/projects/tidepool/chats/${tide[1].id}/unread`, { method: "POST" }).catch(
() => {},
);
console.log(`✓ marked unread: ${tide[1].name}`);
}

console.log(`\ndrove ${made.length} chats`);
}

main().catch((e) => {
console.error(String(e));
process.exit(1);
});
Loading