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]>
215 lines
7.2 KiB
JavaScript
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`);
|