Files
clawmates/frontend/tools/wc-capture/capture.mjs
T
Omar SobhandClaude Opus 4.8 355a6f23b9 Add wc-capture: forensic front-end capture of our deployed app
Playwright tool to mirror our own WorkClaw app's compiled front-end (CSS,
JS, fonts, SVGs) and dump each screen's DOM + computed styles, for matching
Clawmates to the real thing instead of guessing from screenshots.

Auth is by hand (you log in, incl. the emailed code, it reuses the session);
`live` mode does login + capture in one go for OTP logins. out/ + the one-off
grab scripts are gitignored; eslint ignores the captured minified assets.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-12 08:00:16 -05:00

215 lines
7.2 KiB
JavaScript

// Forensic front-end capture of our own WorkClaw app, for reassembling the
// styled interface locally and getting ground-truth styles to match Clawmates.
//
// Auth never touches this script: you log in by hand once, it persists the
// session, headless runs reuse it.
//
// node capture.mjs live headed; you log in by hand (incl. the emailed
// code), press Enter, and it captures everything in
// that same session — best for code/OTP logins
// node capture.mjs login headed; you log in manually; saves out/auth.json
// (only persists if the token lives in a cookie or
// localStorage — not sessionStorage)
// node capture.mjs browse headed + saved session; mirrors assets as you
// click around; close the window to stop
// node capture.mjs run headless + saved session; walks ROUTES, saving
// each screen's HTML, screenshot, computed styles
//
// Config via env: WC_BASE_URL (default below). Outputs land in ./out (gitignored).
import { chromium } from "playwright";
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import readline from "node:readline";
const BASE = process.env.WC_BASE_URL ?? "https://www.workclaw.com";
const LOGIN = process.env.WC_LOGIN_URL ?? new URL("/sign-in", BASE).href;
const HERE = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(HERE, "out");
const AUTH = path.join(OUT, "auth.json");
const HOST = new URL(BASE).host;
const mode = process.argv[2] ?? "run";
// Screens to capture in `run` mode. `actions(page)` runs after navigation —
// e.g. enter a claw and open the Computer panel. Selectors mirror Clawmates'
// (role-based); adjust if the live WorkClaw UI differs.
const ROUTES = [
{ name: "workspace-home", url: "/" },
{
name: "computer-home",
url: "/",
actions: async (page) => {
await page.getByRole("link", { name: /claw|chat|scout/i }).first().click();
await page.waitForURL(/chat/i, { timeout: 8000 }).catch(() => {});
await page.getByRole("button", { name: /computer/i }).click();
await page.waitForTimeout(900);
},
},
];
// Elements we want exact computed styles for (the parity unknowns).
const SELECTORS = [
'[aria-label="Dock"]',
'[aria-label="Dock"] button',
'[role="complementary"]',
'[role="complementary"] .grid, [aria-label$="apps"]',
];
const CAPTURE_TYPES = new Set([
"stylesheet",
"script",
"font",
"image",
"document",
"manifest",
]);
async function saveResponse(resp) {
try {
const url = new URL(resp.url());
if (url.host !== HOST) return; // same-origin only
if (!CAPTURE_TYPES.has(resp.request().resourceType())) return;
const body = await resp.body().catch(() => null);
if (!body) return;
let rel = url.pathname;
if (rel.endsWith("/")) rel += "index.html";
const file = path.join(OUT, "assets", url.host, rel);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, body);
} catch {
/* ignore individual asset failures */
}
}
function waitForEnter(prompt) {
return new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin });
rl.question(prompt, () => {
rl.close();
resolve();
});
});
}
async function login() {
const browser = await chromium.launch({ headless: false });
const ctx = await browser.newContext();
await ctx.newPage().then((p) => p.goto(LOGIN));
await waitForEnter(
"\n>>> Log in by hand in the browser window. When the app is fully loaded, press Enter here.\n",
);
await mkdir(OUT, { recursive: true });
await ctx.storageState({ path: AUTH });
console.log(`Saved session → ${AUTH}`);
await browser.close();
}
function requireAuth() {
if (!existsSync(AUTH)) {
console.error("No session yet — run `node capture.mjs login` first.");
process.exit(1);
}
}
async function browse() {
requireAuth();
const browser = await chromium.launch({ headless: false });
const ctx = await browser.newContext({ storageState: AUTH });
const page = await ctx.newPage();
page.on("response", saveResponse);
await page.goto(BASE);
console.log("Mirroring assets as you browse. Close the window to finish.");
await new Promise((r) => browser.on("disconnected", r));
console.log(`Assets saved under ${path.join(OUT, "assets")}`);
}
async function captureScreens(page) {
for (const dir of ["pages", "shots", "styles"]) {
await mkdir(path.join(OUT, dir), { recursive: true });
}
for (const route of ROUTES) {
try {
await page.goto(new URL(route.url, BASE).href, {
waitUntil: "networkidle",
});
if (route.actions) await route.actions(page);
await page.waitForTimeout(500);
await writeFile(
path.join(OUT, "pages", `${route.name}.html`),
await page.content(),
);
await page.screenshot({
path: path.join(OUT, "shots", `${route.name}.png`),
});
const styles = await page.evaluate((sels) => {
const props = [
"width",
"height",
"margin",
"padding",
"gap",
"justifyContent",
"alignItems",
"borderRadius",
"boxShadow",
"backgroundColor",
"backdropFilter",
"fontFamily",
"color",
];
const dump = {};
for (const sel of sels) {
dump[sel] = [...document.querySelectorAll(sel)]
.slice(0, 4)
.map((el) => {
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
const o = { rect: { w: r.width, h: r.height, x: r.x, y: r.y } };
for (const p of props) o[p] = cs[p];
return o;
});
}
return dump;
}, SELECTORS);
await writeFile(
path.join(OUT, "styles", `${route.name}.json`),
JSON.stringify(styles, null, 2),
);
console.log(`captured ${route.name}`);
} catch (e) {
console.warn(`! ${route.name}: ${e.message}`);
}
}
console.log(`\nDone. See ${OUT}/{assets,pages,shots,styles}`);
}
async function run() {
requireAuth();
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ storageState: AUTH });
const page = await ctx.newPage();
page.on("response", saveResponse);
await captureScreens(page);
await browser.close();
}
// Combined login + capture in one live session — robust to OTP logins and
// sessionStorage tokens that wouldn't survive a save/reload.
async function live() {
const browser = await chromium.launch({ headless: false });
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.on("response", saveResponse); // mirror assets from the first paint
await page.goto(LOGIN);
await waitForEnter(
"\n>>> Log in by hand (enter the emailed code). When the app is fully loaded, press Enter here.\n",
);
await captureScreens(page);
await browser.close();
}
await { live, login, browse, run }[mode]?.() ??
console.error(`Unknown mode "${mode}" — use live | login | browse | run`);