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]>
This commit is contained in:
Omar Sobh
2026-06-12 08:00:16 -05:00
co-authored by Claude Opus 4.8
parent 23a4bad07d
commit 355a6f23b9
4 changed files with 275 additions and 0 deletions
+3
View File
@@ -23,6 +23,9 @@ const eslintConfig = defineConfig([
"out/**", "out/**",
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
// Forensic capture of our own deployed app — minified assets + one-off
// scripts, not part of the product source.
"tools/wc-capture/**",
]), ]),
]); ]);
+5
View File
@@ -0,0 +1,5 @@
# Captured reference material + session — never commit (it's a snapshot of the
# live app, and auth.json is a live session token).
out/
# One-off forensic scripts (hardcoded session/claw URLs) — keep local, not in repo
grab*.mjs
+53
View File
@@ -0,0 +1,53 @@
# wc-capture — forensic front-end capture of our WorkClaw app
Pulls our own deployed app's compiled front-end (CSS, JS chunks, fonts, SVGs,
images) plus the rendered DOM and computed styles of each screen, so we can
reassemble the styled interface locally and match Clawmates to it exactly.
Your password is never entered here: you log in by hand once and the session
is reused. `out/` (including the session token) is gitignored.
## Run
From `frontend/` (so Playwright resolves).
**Recommended for our emailed-code login** — log in and capture in one session:
```bash
WC_BASE_URL="https://www.workclaw.com" node tools/wc-capture/capture.mjs live
# browser opens → log in by hand (enter the emailed code) → press Enter →
# it captures all ROUTES + mirrors assets in that same authenticated session
```
Alternative (only if the session survives a save/reload — i.e. cookie/
localStorage token, not sessionStorage):
```bash
node tools/wc-capture/capture.mjs login # log in by hand, saves out/auth.json
node tools/wc-capture/capture.mjs run # headless; walks ROUTES
node tools/wc-capture/capture.mjs browse # headed; mirror assets as you click
```
Adjust `BASE`, `ROUTES`, and `SELECTORS` at the top of `capture.mjs` to the
live UI (the defaults mirror Clawmates' role-based selectors).
## Output (`out/`)
| dir | what |
|---|---|
| `assets/<host>/…` | mirrored CSS, JS, **fonts**, **SVGs**, images — preserved paths |
| `pages/<screen>.html` | rendered `outerHTML` per screen |
| `shots/<screen>.png` | reference screenshot |
| `styles/<screen>.json` | `getComputedStyle` + bounding rects for the parity selectors |
| `auth.json` | persisted session (gitignored — treat as a live credential) |
## View the static snapshot locally
```bash
npx serve tools/wc-capture/out/assets # serves mirrored assets
# open a pages/*.html to inspect styling next to Clawmates in devtools
```
Note: this is a **static styling reference**, not a working app — the JS is
minified and hydration/API calls hit prod. Use it to read exact values
(`styles/*.json`) and lift static assets (fonts/SVGs), then apply to Clawmates.
+214
View File
@@ -0,0 +1,214 @@
// 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`);