@stacknyu/pi-glitch-lab 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -2
  4. package/examples/glitchy-app/README.md +14 -0
  5. package/examples/glitchy-app/THIRD_PARTY_NOTICES.md +27 -0
  6. package/examples/glitchy-app/app.jsx +62 -0
  7. package/examples/glitchy-app/public/api/credits.json +1 -0
  8. package/examples/glitchy-app/public/api/me.json +1 -0
  9. package/examples/glitchy-app/public/api/messages.json +1 -0
  10. package/examples/glitchy-app/public/app.js +14749 -0
  11. package/examples/glitchy-app/public/index.html +2 -0
  12. package/examples/glitchy-app/serve.mjs +12 -0
  13. package/extensions/index.ts +155 -0
  14. package/package.json +53 -3
  15. package/scripts/affordance_scan.mjs +235 -0
  16. package/scripts/doctor.mjs +14 -0
  17. package/scripts/glitch/analyze.mjs +274 -0
  18. package/scripts/glitch_lab.mjs +519 -0
  19. package/scripts/interactions.mjs +172 -0
  20. package/scripts/journey_runner.mjs +260 -0
  21. package/scripts/lib.mjs +260 -0
  22. package/scripts/report.mjs +144 -0
  23. package/scripts/sensors/dom_observer.mjs +96 -0
  24. package/scripts/sensors/react_sensor.mjs +167 -0
  25. package/skills/ai-agentic-usability-testing/SKILL.md +68 -0
  26. package/skills/ai-agentic-usability-testing/assets/default-personas.md +96 -0
  27. package/skills/ai-agentic-usability-testing/assets/findings.example.json +56 -0
  28. package/skills/ai-agentic-usability-testing/assets/matrix.default.json +746 -0
  29. package/skills/ai-agentic-usability-testing/assets/matrix.partners.example.json +73 -0
  30. package/skills/ai-agentic-usability-testing/assets/matrix.web.example.json +32 -0
  31. package/skills/ai-agentic-usability-testing/assets/personas-template.md +45 -0
  32. package/skills/ai-agentic-usability-testing/references/latency-reliability.md +41 -0
  33. package/skills/ai-agentic-usability-testing/references/regression-pipeline.md +41 -0
  34. package/skills/ai-agentic-usability-testing/references/tenant-persona-matrix.md +61 -0
  35. package/skills/ai-agentic-usability-testing/references/visual-affordance-guide.md +97 -0
  36. package/skills/ai-agentic-usability-testing/references/visual-glitches.md +83 -0
  37. package/skills/glitch-hunt/SKILL.md +59 -0
@@ -0,0 +1,260 @@
1
+ // Shared helpers for affordance_scan.mjs and journey_runner.mjs
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { execSync } from "node:child_process";
5
+ import { createRequire } from "node:module";
6
+
7
+ export function parseArgs(argv = process.argv.slice(2)) {
8
+ const out = {};
9
+ for (let i = 0; i < argv.length; i++) {
10
+ const a = argv[i];
11
+ if (!a.startsWith("--")) continue;
12
+ const k = a.slice(2);
13
+ const v = argv[i + 1];
14
+ if (v === undefined || v.startsWith("--")) out[k] = true;
15
+ else { out[k] = v; i++; }
16
+ if (["interact", "allow-writes", "probe-clicks", "all", "paint", "embed", "headed"].includes(k)) {
17
+ if (![true, "true", "false"].includes(out[k])) throw new Error(`--${k} must be true or false`);
18
+ out[k] = out[k] === true || out[k] === "true";
19
+ }
20
+ }
21
+ return out;
22
+ }
23
+
24
+ export async function loadPlaywright() {
25
+ try { return await import("playwright"); } catch {}
26
+ try {
27
+ const root = execSync("npm root -g", { encoding: "utf8" }).trim();
28
+ const req = createRequire(path.join(root, "noop.js"));
29
+ return req("playwright");
30
+ } catch {}
31
+ console.error("Playwright not found. Install: npm i -D playwright && npx playwright install chromium");
32
+ process.exit(1);
33
+ }
34
+
35
+ /** Launch Chromium; falls back to installed Google Chrome if Playwright's browser isn't downloaded. */
36
+ export async function launchBrowser(chromium, args = {}) {
37
+ const opts = { headless: !args.headed };
38
+ const channel = args.channel || process.env.GLITCH_LAB_CHANNEL;
39
+ try {
40
+ return await chromium.launch(channel ? { ...opts, channel } : opts);
41
+ } catch (e) {
42
+ if (!channel) { try { return await chromium.launch({ ...opts, channel: "chrome" }); } catch {} }
43
+ throw new Error("Could not launch a browser. Run `npx playwright install chromium` in the package folder, or pass --channel chrome. " + String(e.message).split("\n")[0]);
44
+ }
45
+ }
46
+
47
+ export function loadConfig(p) {
48
+ if (!p || !fs.existsSync(p)) {
49
+ console.error(`Config not found: ${p}. Start from assets/matrix.example.json`);
50
+ process.exit(1);
51
+ }
52
+ return JSON.parse(fs.readFileSync(p, "utf8"));
53
+ }
54
+
55
+ export const VIEWPORTS = {
56
+ mobile: { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2 },
57
+ tablet: { viewport: { width: 820, height: 1180 }, isMobile: true, hasTouch: true },
58
+ desktop: { viewport: { width: 1440, height: 900 } },
59
+ android: { viewport: { width: 360, height: 800 }, isMobile: true, hasTouch: true, deviceScaleFactor: 3 }, // typical Indian mid-range Android
60
+ iphone: { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 3 },
61
+ laptop: { viewport: { width: 1366, height: 768 } }, // common budget laptop / Chromebook
62
+ "laptop-zoom125": { viewport: { width: 1093, height: 614 }, deviceScaleFactor: 1.25 }, // 1366×768 at 125% browser zoom
63
+ "desktop-zoom150": { viewport: { width: 960, height: 600 }, deviceScaleFactor: 1.5 }, // 1440×900 at 150% browser zoom
64
+ };
65
+ export const NETWORKS = {
66
+ fast: null,
67
+ fast3g: { offline: false, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8, latency: 150 },
68
+ slow3g: { offline: false, downloadThroughput: (500 * 1024) / 8, uploadThroughput: (500 * 1024) / 8, latency: 400 },
69
+ };
70
+
71
+ /**
72
+ * Resolve cells into full objects with merged settings.
73
+ * A cell without "partner" (or partner "direct") tests the platform directly, as a root user
74
+ * with no partner; it uses cfg.platform for name/url.
75
+ */
76
+ export function resolveCells(cfg, cellArg, all) {
77
+ const cells = (cfg.cells || []).map((c) => ({ ...c, partner: c.partner || "direct" }));
78
+ const ids = cells.map((c) => `${c.persona}@${c.partner}`), slugs = cells.map((c) => `${c.persona}__${c.partner}`.replace(/[^a-z0-9_-]+/gi, "_"));
79
+ if (new Set(ids).size !== ids.length || new Set(slugs).size !== slugs.length) throw new Error("Cell identities/output slugs must be unique");
80
+ const key = (c) => `${c.persona}@${c.partner}`;
81
+ const want = cellArg && !String(cellArg).includes("@") ? `${cellArg}@direct` : cellArg;
82
+ const pick = all ? cells : cells.filter((c) => key(c) === want);
83
+ if (!pick.length) {
84
+ console.error(`No cell matches "${cellArg}". Available: ${cells.map(key).join(", ")}`);
85
+ process.exit(1);
86
+ }
87
+ return pick.map((c) => {
88
+ const screens = c.screens || [];
89
+ if (new Set(screens.map(slugify)).size !== screens.length) throw new Error("Screen output names collide; split into separate cells/runs");
90
+ const persona = cfg.personas?.[c.persona] || {};
91
+ const direct = c.partner === "direct";
92
+ const partner = direct ? { name: cfg.platform?.name || "", url: cfg.platform?.url, direct: true } : cfg.partners?.[c.partner] || {};
93
+ const base = (c.baseUrl || partner.url || cfg.platform?.url || cfg.baseUrl || "").replace(/\/$/, "");
94
+ return {
95
+ ...c,
96
+ id: key(c),
97
+ slug: `${c.persona}__${c.partner}`.replace(/[^a-z0-9_-]+/gi, "_"),
98
+ persona, partner, base,
99
+ viewport: c.viewport || persona.viewport || "desktop",
100
+ network: c.network || persona.network || "fast",
101
+ locale: c.locale || persona.locale,
102
+ timezone: c.timezone || persona.timezone,
103
+ login: c.login || (direct ? cfg.platform?.login : partner.login) || c.login,
104
+ unsafe: [...(cfg.unsafeWords || []), ...(partner.unsafeWords || [])],
105
+ mask: [...(cfg.mask || []), ...(c.mask || [])],
106
+ };
107
+ });
108
+ }
109
+
110
+ /** Find an element the way a user would: by visible text/role, label, placeholder, or selector. */
111
+ export function locatorFor(page, spec, cell) {
112
+ if (typeof spec === "string") {
113
+ const t = tpl(spec, cell);
114
+ return page.getByRole("button", { name: t, exact: true })
115
+ .or(page.getByRole("link", { name: t, exact: true }))
116
+ .or(page.getByRole("tab", { name: t, exact: true }))
117
+ .or(page.getByRole("menuitem", { name: t, exact: true }));
118
+ }
119
+ if (spec.selector) return page.locator(spec.selector);
120
+ if (spec.role) return page.getByRole(spec.role, { name: tpl(spec.name, cell), exact: true });
121
+ if (spec.label) return page.getByLabel(tpl(spec.label, cell), { exact: true });
122
+ if (spec.placeholder) return page.getByPlaceholder(tpl(spec.placeholder, cell), { exact: true });
123
+ if (spec.text) return page.getByText(tpl(spec.text, cell), { exact: true });
124
+ throw new Error("Unknown target " + JSON.stringify(spec));
125
+ }
126
+
127
+ /** Fill {{partner.name}}, {{persona.name}} etc. */
128
+ export function tpl(str, cell) {
129
+ if (typeof str !== "string") return str;
130
+ return str.replace(/\{\{\s*(partner|persona)\.(\w+)\s*\}\}/g, (_, o, k) => {
131
+ const value = cell[o]?.[k];
132
+ if (value === undefined || value === null || String(value).trim() === "") throw new Error(`Missing template value: ${o}.${k}`);
133
+ return value;
134
+ });
135
+ }
136
+
137
+ export async function newContext(browser, cell, args = {}) {
138
+ const opts = { ...(VIEWPORTS[cell.viewport] || VIEWPORTS.desktop), serviceWorkers: "block" };
139
+ if (cell.locale) opts.locale = cell.locale;
140
+ if (cell.timezone) opts.timezoneId = cell.timezone;
141
+ const context = await browser.newContext(opts);
142
+ context.setDefaultTimeout(10000);
143
+ context.setDefaultNavigationTimeout(30000);
144
+ await protectContext(context, cell, args);
145
+ return context;
146
+ }
147
+
148
+ export const UNSAFE = /delete|remove|pay|purchase|buy|billing|send|submit|logout|log.out|sign.out|upgrade|subscribe|invite|publish|deactivate|reset/i;
149
+ export function requireInteraction(args, writes = false) {
150
+ if (args.interact !== true) throw new Error("Interaction requires --interact: authorize a disposable test environment first");
151
+ if (writes && args["allow-writes"] !== true) throw new Error("This action requires explicit --allow-writes authorization");
152
+ }
153
+
154
+ export async function protectContext(context, cell, args) {
155
+ if (args["allow-writes"]) requireInteraction(args);
156
+ const origins = new Set([new URL(cell.base).origin, ...(cell.allowedOrigins || []).map((u) => new URL(u).origin)]);
157
+ context._uxArgs = args;
158
+ context._uxBlocked = [];
159
+ await context.exposeBinding("__uxHistoryVisit", (_source, url) => { context._uxPageBudget?.urls.add(url); });
160
+ await context.route("**/*", async (route) => {
161
+ const req = route.request(), url = new URL(req.url());
162
+ const write = !["GET", "HEAD", "OPTIONS"].includes(req.method());
163
+ const budget = context._uxPageBudget;
164
+ if (req.isNavigationRequest() && budget) {
165
+ // Never evaluate in a frame paused for navigation: it waits for this route and deadlocks.
166
+ const prior = req.frame().url();
167
+ if (prior && prior !== "about:blank") budget.urls.add(prior);
168
+ }
169
+ const overBudget = req.isNavigationRequest() && budget && !budget.urls.has(req.url()) && budget.urls.size >= budget.limit;
170
+ if (overBudget) budget.hit = true;
171
+ const denied = overBudget || (req.isNavigationRequest() && !origins.has(url.origin)) ||
172
+ (write && (!args["allow-writes"] || !origins.has(url.origin))) ||
173
+ (!args["allow-writes"] && UNSAFE.test(url.pathname));
174
+ if (denied) {
175
+ context._uxBlocked.push({ method: req.method(), url: url.origin + url.pathname });
176
+ await route.abort("blockedbyclient");
177
+ } else if (req.isNavigationRequest() || write) {
178
+ if (req.isNavigationRequest() && budget) budget.urls.add(req.url());
179
+ // Route handlers do not re-check a browser redirect chain. Fail closed before following it.
180
+ try {
181
+ const response = await route.fetch({ maxRedirects: 0, timeout: 15000 });
182
+ if (response.status() >= 300 && response.status() < 400 && response.headers().location) {
183
+ context._uxBlocked.push({ method: req.method(), url: url.origin + url.pathname, reason: "HTTP redirect requires a direct target URL" });
184
+ await route.abort("blockedbyclient");
185
+ } else await route.fulfill({ response });
186
+ } catch (e) {
187
+ context._uxBlocked.push({ method: req.method(), url: url.origin + url.pathname, reason: e.message });
188
+ await route.abort("failed").catch(() => {});
189
+ }
190
+ } else await route.continue();
191
+ });
192
+ await context.routeWebSocket("**/*", (socket) => {
193
+ const url = new URL(socket.url()), origin = url.origin.replace(/^ws/, "http");
194
+ if (args["allow-writes"] === true && origins.has(origin)) socket.connectToServer();
195
+ else { context._uxBlocked.push({ method: "WEBSOCKET", url: origin + url.pathname }); socket.close(); }
196
+ });
197
+ }
198
+
199
+ export async function checkTarget(loc, cell, args, automatic = false) {
200
+ requireInteraction(args);
201
+ const target = await loc.evaluate((el) => ({ text: [el.textContent, el.getAttribute("aria-label"), el.getAttribute("href")].join(" "),
202
+ href: el.closest("a[href]")?.href, submit: el.matches('button:not([type]),button[type=submit],input[type=submit],input[type=image]') && !!el.closest("form") }));
203
+ if (target.href) {
204
+ const url = new URL(target.href);
205
+ if (![new URL(cell.base).origin, ...(cell.allowedOrigins || []).map((u) => new URL(u).origin)].includes(url.origin)) throw new Error("Destination origin is not allowed");
206
+ if (!["http:", "https:"].includes(url.protocol)) throw new Error("Non-web destination skipped");
207
+ }
208
+ const unsafe = UNSAFE.test(target.text) || (cell.unsafe || []).some((w) => target.text.toLowerCase().includes(w.toLowerCase()));
209
+ if ((target.submit || unsafe) && (automatic || !args["allow-writes"])) throw new Error("Potential write/destructive control skipped; use an explicitly authorized journey");
210
+ }
211
+
212
+ export function freshOutput(dir) {
213
+ fs.mkdirSync(path.dirname(path.resolve(dir)), { recursive: true });
214
+ fs.mkdirSync(dir); // never overwrite a prior run or baseline
215
+ }
216
+
217
+ export function bounded(value, fallback, max) {
218
+ const n = value === undefined ? fallback : Number(value);
219
+ if (!Number.isInteger(n) || n < 1 || n > max) throw new Error(`Expected integer between 1 and ${max}`);
220
+ return n;
221
+ }
222
+
223
+ export async function throttle(context, page, cell) {
224
+ const n = NETWORKS[cell.network];
225
+ if (!n) return;
226
+ try {
227
+ const cdp = await context.newCDPSession(page);
228
+ await cdp.send("Network.enable");
229
+ await cdp.send("Network.emulateNetworkConditions", n);
230
+ } catch { /* non-chromium */ }
231
+ }
232
+
233
+ export async function login(context, cell) {
234
+ const l = cell.login;
235
+ if (!l) return;
236
+ requireInteraction(context._uxArgs || {}, true);
237
+ if (!l.authenticated && (!l.waitFor || l.waitFor === "/")) throw new Error("Login needs an authenticated selector or a specific waitFor pathname (not /)");
238
+ const page = await context.newPage();
239
+ const timeout = bounded(l.timeout, 15000, 30000);
240
+ await page.goto(cell.base + tpl(l.url, cell), { waitUntil: "domcontentloaded" });
241
+ for (const [sel, val] of Object.entries(l.fields || {})) {
242
+ const v = String(val).startsWith("env:") ? process.env[String(val).slice(4)] : String(val);
243
+ if (!v) throw new Error(`Missing login value for ${sel}`);
244
+ await page.fill(sel, v);
245
+ }
246
+ await page.click(l.submit || "button[type=submit]");
247
+ if (l.waitFor) await page.waitForURL((u) => u.origin === new URL(cell.base).origin && u.pathname === l.waitFor, { timeout });
248
+ if (l.authenticated) await page.locator(l.authenticated).waitFor({ state: "visible", timeout });
249
+ await page.close();
250
+ }
251
+
252
+ export function attachListeners(page, sink) {
253
+ page.on("console", (m) => { if (m.type() === "error") sink.console.push(m.text().slice(0, 300)); });
254
+ page.on("pageerror", (e) => sink.console.push("pageerror: " + e.message.slice(0, 300)));
255
+ page.on("requestfailed", (r) => sink.failed.push({ url: r.url().slice(0, 200), error: r.failure()?.errorText }));
256
+ page.on("response", (r) => { if (r.status() >= 400) sink.failed.push({ url: r.url().slice(0, 200), status: r.status() }); });
257
+ }
258
+
259
+ export const slugify = (s) => (s === "/" ? "home" : String(s).replace(/[^a-z0-9]+/gi, "_").replace(/^_|_$/g, "").slice(0, 60) || "page");
260
+ export const ensureDir = (d) => (fs.mkdirSync(d, { recursive: true }), d);
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * report.mjs — one shareable HTML report from Glitch Lab runs, journeys, affordance scans and Claude's findings.
4
+ *
5
+ * node report.mjs <run-dir> [--findings .ux/findings.json] [--out .ux/report/index.html] [--embed] [--title "..."]
6
+ *
7
+ * <run-dir> may contain any mix of: glitch lab cells (findings.json + summary.md), journey results
8
+ * (<cell>/journeys/<id>/result.json) and affordance scans (<cell>/affordance/*_affordance.json).
9
+ * --embed inlines images (one self-contained file to share).
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { parseArgs } from "./lib.mjs";
14
+
15
+ const argv = process.argv.slice(2);
16
+ const runDir = path.resolve(argv.find((a) => !a.startsWith("--")) || ".ux");
17
+ const args = parseArgs(argv);
18
+ const out = path.resolve(args.out || path.join(runDir, "report.html"));
19
+ const outDir = path.dirname(out);
20
+ fs.mkdirSync(outDir, { recursive: true });
21
+ const embed = !!args.embed;
22
+ const human = args.findings && fs.existsSync(args.findings) ? JSON.parse(fs.readFileSync(args.findings, "utf8")) : {};
23
+
24
+ const e = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
25
+ const walk = (dir, test, acc = []) => {
26
+ if (!fs.existsSync(dir)) return acc;
27
+ for (const n of fs.readdirSync(dir)) {
28
+ const p = path.join(dir, n); const st = fs.lstatSync(p);
29
+ if (st.isSymbolicLink()) continue;
30
+ if (st.isDirectory()) walk(p, test, acc); else if (test(p)) acc.push(p);
31
+ }
32
+ return acc.sort();
33
+ };
34
+ const img = (p, cls = "shot") => {
35
+ if (!p || !fs.existsSync(p)) return "";
36
+ const src = embed ? `data:image/png;base64,${fs.readFileSync(p).toString("base64")}` : path.relative(outDir, p);
37
+ return `<a href="${e(src)}" target="_blank" rel="noopener"><img class="${e(cls)}" loading="lazy" src="${e(src)}"></a>`;
38
+ };
39
+ const SEV = { S1: "#b91c1c", S2: "#c2410c", S3: "#a16207", S4: "#4b5563", high: "#b91c1c", medium: "#c2410c", low: "#4b5563" };
40
+ const pill = (t) => `<span class="pill" style="background:${SEV[t] || "#555"}">${e(t)}</span>`;
41
+
42
+ const P = [];
43
+ P.push(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
44
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
45
+ <title>${e(human.title || args.title || "UX & glitch audit")}</title><style>
46
+ :root{--bg:#fafaf9;--fg:#1c1917;--mut:#57534e;--card:#fff;--line:#e7e5e4}
47
+ @media (prefers-color-scheme: dark){:root{--bg:#1c1917;--fg:#f5f5f4;--mut:#a8a29e;--card:#292524;--line:#44403c}}
48
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
49
+ main{max-width:1200px;margin:0 auto;padding:28px 20px 80px}h1{margin:0 0 4px;font-size:28px}h2{margin:40px 0 12px;border-bottom:1px solid var(--line);padding-bottom:6px}
50
+ .mut{color:var(--mut)}.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:12px 0}
51
+ .pill{display:inline-block;padding:1px 8px;border-radius:99px;color:#fff;font-size:12px;font-weight:600;margin-right:6px;text-transform:uppercase}
52
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px}
53
+ .shot{width:100%;border:1px solid var(--line);border-radius:6px;background:#fff}.big{max-width:100%;border:1px solid var(--line);border-radius:6px;margin-top:8px;background:#fff}
54
+ table{border-collapse:collapse;width:100%;font-size:14px}td,th{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}
55
+ .wrap{overflow-x:auto}.ok{color:#15803d;font-weight:600}.bad{color:#b91c1c;font-weight:600}blockquote{margin:8px 0;padding:6px 12px;border-left:3px solid var(--line);color:var(--mut);font-style:italic}
56
+ code{font-size:13px}</style></head><body><main>
57
+ <h1>${e(human.title || args.title || "UX & glitch audit")}</h1><div class="mut">${e(human.subtitle || "")} · generated ${new Date().toISOString().slice(0, 10)}</div>`);
58
+ if (human.summary) P.push(`<div class="card">${e(human.summary)}</div>`);
59
+
60
+ if (human.scorecard?.length) {
61
+ const icon = { smooth: "✅ Smooth", friction: "⚠️ Friction", blocked: "❌ Blocked" };
62
+ P.push(`<h2>Persona × journey scorecard</h2><div class="wrap"><table><tr><th>Persona</th><th>Journey</th><th>Result</th><th>Issues</th></tr>`);
63
+ for (const r of human.scorecard) P.push(`<tr><td>${e(r.cell)}</td><td>${e(r.journey)}</td><td>${icon[r.result] || e(r.result)}</td><td>${e((r.issues || []).join(", "))}</td></tr>`);
64
+ P.push(`</table></div>`);
65
+ }
66
+ if (human.patterns?.length) {
67
+ P.push(`<h2>Patterns to fix first</h2>`);
68
+ for (const p of human.patterns) P.push(`<div class="card"><b>${e(p.title)}</b> <span class="mut">· ${e((p.findings || []).join(", "))}</span><div>${e(p.description)}</div><div><b>Recommendation:</b> ${e(p.recommendation)}</div></div>`);
69
+ }
70
+ if (human.findings?.length) {
71
+ P.push(`<h2>Findings</h2>`);
72
+ const order = { S1: 0, S2: 1, S3: 2, S4: 3 };
73
+ for (const f of [...human.findings].sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9))) {
74
+ const shots = (f.screenshots || []).map((s) => img(path.isAbsolute(s) ? s : path.join(runDir, s), "big")).join("");
75
+ P.push(`<div class="card" id="${e(f.id)}">${pill(f.severity || "S3")}<b>${e(f.id)} · ${e(f.title)}</b> <span class="mut">· ${e(f.category)} · ${e((f.cells || []).join(", "))} · ${e(f.screen || "")}</span>
76
+ ${f.personaVoice ? `<blockquote>${e(f.personaVoice)}</blockquote>` : ""}<div>${e(f.observation || "")}</div>
77
+ ${f.evidence ? `<div><b>Evidence:</b> ${e(f.evidence)}</div>` : ""}<div><b>Recommendation:</b> ${e(f.recommendation || "")}</div>${shots}</div>`);
78
+ }
79
+ }
80
+
81
+ // Glitch Lab
82
+ const glitchFiles = walk(runDir, (p) => path.basename(p) === "findings.json" && path.resolve(p) !== path.resolve(args.findings || "") && fs.existsSync(path.join(path.dirname(p), "summary.md")));
83
+ if (glitchFiles.length) {
84
+ P.push(`<h2>Visual glitches (Glitch Lab)</h2><div class="mut">Detected with frame-by-frame capture, React render tracking, DOM and network sensors, repeated runs on a slowed CPU. Green in repaint frames = area the browser repainted.</div>`);
85
+ for (const gf of glitchFiles) {
86
+ const g = JSON.parse(fs.readFileSync(gf, "utf8")); const d = path.dirname(gf);
87
+ P.push(`<div class="card"><b>${e(g.cell)}</b> <span class="mut">· ${e(g.mode)} · ${g.repeat} runs · CPU ${g.cpu}× · ${e(g.viewport)} · React sensor ${g.meta?.react ? "on" : "off"}</span>`);
88
+ if (g.meta?.errors?.length) P.push(`<div class="bad">INCOMPLETE: ${e(g.meta.errors.join("; "))}</div>`);
89
+ for (const f of (g.findings || []).filter((x) => x.level !== "low")) {
90
+ P.push(`<div style="margin-top:14px">${pill(f.level)}<b>${e(f.id)} · ${e(f.what)}</b> <span class="mut">· ${e(f.seen)}${f.visibleBlink ? " · visible blink" : ""}</span>
91
+ ${f.note ? `<div class="mut">${e(f.note)}</div>` : ""}${f.why ? `<div>Why it rendered: <code>${e([].concat(f.why).join(", "))}</code></div>` : ""}
92
+ ${f.network?.length ? `<div>Right after: <code>${e(f.network.join("; "))}</code></div>` : ""}<div class="mut">Where: ${e((f.scenarios || []).slice(0, 5).join("; "))}</div>
93
+ ${(f.images || []).slice(0, 1).map((i) => img(path.join(d, i), "big")).join("")}${f.paintImage ? img(path.join(d, f.paintImage), "big") : ""}</div>`);
94
+ }
95
+ const low = (g.findings || []).filter((x) => x.level === "low").length;
96
+ if (low) P.push(`<div class="mut" style="margin-top:10px">${low} low-priority item(s) not shown.</div>`);
97
+ if (g.meta?.paint && fs.existsSync(g.meta.paint)) P.push(`<div class="mut">Paint-flashing video: <code>${e(g.meta.paint)}</code></div>`);
98
+ P.push(`</div>`);
99
+ }
100
+ }
101
+
102
+ // Journeys
103
+ const journeys = walk(runDir, (p) => p.endsWith(`${path.sep}result.json`) && p.includes(`${path.sep}journeys${path.sep}`));
104
+ if (journeys.length) {
105
+ P.push(`<h2>Journeys</h2>`);
106
+ for (const rp of journeys) {
107
+ const r = JSON.parse(fs.readFileSync(rp, "utf8")); const d = path.dirname(rp);
108
+ P.push(`<div class="card"><b>${e(r.cell)} — ${e(r.title || r.journey)}</b> ${r.passed ? '<span class="ok">PASS</span>' : '<span class="bad">FAIL</span>'} <span class="mut">· ${e(r.viewport)} · ${e(r.network)} · ${((r.totalMs || 0) / 1000).toFixed(1)}s</span>`);
109
+ if (r.regressions?.length) P.push(`<div class="bad">Regressions: ${e(r.regressions.map((g) => `${g.type} at step ${g.step ?? "-"}${g.error ? ` (${g.error})` : ""}`).join("; "))}</div>`);
110
+ P.push(`<div class="wrap"><table><tr><th>#</th><th>Step</th><th>Result</th><th>Time</th><th>Notes</th></tr>`);
111
+ for (const s of r.steps || []) {
112
+ let note = s.error || "";
113
+ if (s.response) note = `first output ${s.response.firstOutputMs} ms, complete ${s.response.completeMs} ms`;
114
+ if (s.visualDiff) note += ` · visual change ${(s.visualDiff.ratio * 100).toFixed(2)}%`;
115
+ const arg = typeof s.arg === "string" ? s.arg : JSON.stringify(s.arg);
116
+ P.push(`<tr><td>${s.i}</td><td>${e(s.kind)} ${e(String(arg).slice(0, 90))}</td><td>${s.skipped ? "skipped" : s.ok ? "✅" : "❌"}</td><td>${s.ms ?? ""} ms</td><td>${e(note)}</td></tr>`);
117
+ }
118
+ P.push(`</table></div><div class="grid" style="margin-top:10px">`);
119
+ for (const s of r.steps || []) if (s.shot) P.push(`<div><div class="mut">#${s.i} ${e(s.kind)}</div>${img(path.join(d, s.shot))}</div>`);
120
+ P.push(`</div></div>`);
121
+ }
122
+ }
123
+
124
+ // Affordance
125
+ const scans = walk(runDir, (p) => p.endsWith("_affordance.json"));
126
+ if (scans.length) {
127
+ P.push(`<h2>Visual affordance scans</h2><div class="mut">Green = real control · Red = looks clickable but isn't · Orange = clickable but doesn't look it · Purple = unlabeled icon · Yellow = small target</div>`);
128
+ for (const sp of scans) {
129
+ const s = JSON.parse(fs.readFileSync(sp, "utf8")); const c = s.counts || {};
130
+ const clicks = s.clicks || []; const dead = clicks.filter((x) => x.result === "no-observable-response"); const slow = clicks.filter((x) => x.slow);
131
+ P.push(`<div class="card"><b>${e(s.cell)} — ${e(s.screen)}</b> <span class="mut">· ${e(s.viewport)} · load ${s.loadMs} ms · fake ${(c.fake || 0) + (c["fake-icon"] || 0)} · hidden ${c.hidden || 0} · unlabeled ${c.unlabeled || 0} · small ${c.small || 0}${clicks.length ? ` · no observable response ${dead.length}/${clicks.length} · slow ${slow.length}` : ""}</span>
132
+ ${img(sp.replace("_affordance.json", "_annotated.png"), "big")}</div>`);
133
+ if (s.coverage) {
134
+ const coverage = s.coverage;
135
+ P.push(`<div class="card"><b>Interaction coverage — ${e(s.screen)}</b><div>${e(coverage.exercised)}/${e(coverage.discovered)} control-state pairs exercised across ${e(coverage.states.length)} states; ${e(coverage.actualClicks)} actual click attempts including ${e(coverage.replays?.length || 0)} replays. ${coverage.limited ? "LIMIT REACHED — incomplete exploration." : "Bounded exploration finished."}</div><div>${e(coverage.limitations)}</div><div>${e(JSON.stringify(coverage.counts))}</div><div class="wrap"><table><tr><th>State/control</th><th>Outcome</th><th>Notes</th><th>Evidence</th></tr>`);
136
+ for (const action of coverage.actions) P.push(`<tr><td>${e(action.state)} · ${e(action.text || action.selector)}</td><td>${e(action.result)}</td><td>${e(action.reason || action.errors?.join("; ") || "")}</td><td>${[action.before, action.after].filter(Boolean).map((f) => img(path.join(path.dirname(sp), f))).join("")}</td></tr>`);
137
+ P.push(`</table></div></div>`);
138
+ } else P.push(`<div class="mut">Observation only; no controls exercised.</div>`);
139
+ P.push(`<div class="mut">${e(s.limitations)} ${s.scrollTruncated ? "Scroll limit reached." : ""} ${s.horizontalOverflow ? "Horizontal overflow detected." : ""}</div>`);
140
+ }
141
+ }
142
+ P.push(`</main></body></html>`);
143
+ fs.writeFileSync(out, P.join("\n"));
144
+ console.log(`Wrote ${out}`);
@@ -0,0 +1,96 @@
1
+ /** In-page DOM observer: remounts of DOM elements, text flaps, layout shifts, running animations. Installed before app scripts. */
2
+ export function DOM_OBSERVER() {
3
+ if (window.__gl) return;
4
+ const G = (window.__gl = { events: [], shifts: [], anim: [] });
5
+ // hide the blinking text caret so it isn't reported as flicker
6
+ const hideCaret = () => { const st = document.createElement("style"); st.textContent = "*{caret-color:transparent!important}"; (document.head || document.documentElement).appendChild(st); };
7
+ if (document.head) hideCaret(); else document.addEventListener("DOMContentLoaded", hideCaret, { once: true });
8
+ const now = () => Math.round(performance.now());
9
+ const desc = (el) => {
10
+ if (!el || !el.tagName) return "?";
11
+ let s = el.tagName.toLowerCase();
12
+ if (el.id) s += "#" + el.id;
13
+ const tid = el.getAttribute("data-testid") || el.getAttribute("aria-label") || el.getAttribute("role");
14
+ if (tid) s += `[${tid}]`;
15
+ else if (typeof el.className === "string" && el.className.trim()) s += "." + el.className.trim().split(/\s+/).slice(0, 2).join(".");
16
+ const t = (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 50);
17
+ return t ? `${s} "${t}"` : s;
18
+ };
19
+ const sig = (el) => el.tagName + "|" + (typeof el.className === "string" ? el.className : "") + "|" + (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 60);
20
+ const rect = (el) => { try { const r = el.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }; } catch { return null; } };
21
+ const removed = []; // {sig, t, desc, rect}
22
+ const lastRect = new WeakMap();
23
+ const textHist = new WeakMap();
24
+ const start = () => {
25
+ const mo = new MutationObserver((muts) => {
26
+ const t = now();
27
+ for (const m of muts) {
28
+ if (m.type === "childList") {
29
+ m.removedNodes.forEach((n) => {
30
+ if (n.nodeType !== 1) return;
31
+ const r = lastRect.get(n);
32
+ if (!r || r.w < 40 || r.h < 16) return;
33
+ removed.push({ sig: sig(n), t, desc: desc(n), rect: r });
34
+ });
35
+ m.addedNodes.forEach((n) => {
36
+ if (n.nodeType !== 1) return;
37
+ requestAnimationFrame(() => {
38
+ const r = rect(n); if (!r || r.w < 40 || r.h < 16) return;
39
+ lastRect.set(n, r);
40
+ n.querySelectorAll?.("*").forEach((c) => { const cr = rect(c); if (cr && cr.w >= 40 && cr.h >= 16) lastRect.set(c, cr); });
41
+ const s = sig(n);
42
+ const hit = removed.findLast?.((x) => x.sig === s && t - x.t < 1500);
43
+ if (hit) G.events.push({ type: "remount", t, desc: desc(n), rect: r, gapMs: t - hit.t });
44
+ });
45
+ });
46
+ }
47
+ const el = m.type === "characterData" ? m.target.parentElement : m.target.nodeType === 1 ? m.target : null;
48
+ if (el && (m.type === "characterData" || m.type === "childList")) {
49
+ const txt = el.innerText || "";
50
+ if (txt.length > 300) continue;
51
+ const h = textHist.get(el) || [];
52
+ if (!h.length) {
53
+ // seed with the value before this mutation, so the first change is visible in history
54
+ let old = null;
55
+ if (m.type === "characterData") old = m.oldValue;
56
+ else if ([...m.removedNodes].every((n) => n.nodeType === 3) && [...m.addedNodes].every((n) => n.nodeType === 3) && m.removedNodes.length)
57
+ old = txt.replace([...m.addedNodes].map((n) => n.textContent).join(""), [...m.removedNodes].map((n) => n.textContent).join(""));
58
+ if (old !== null && old !== txt) h.push({ v: old.slice(0, 300), t: t - 1 });
59
+ }
60
+ const changed = !h.length || h[h.length - 1].v !== txt;
61
+ if (changed) h.push({ v: txt, t });
62
+ while (h.length > 6) h.shift();
63
+ textHist.set(el, h);
64
+ const n = h.length;
65
+ if (changed && n >= 3 && h[n - 1].v === h[n - 3].v && h[n - 2].v !== h[n - 1].v && t - h[n - 2].t < 2000 && (h[n - 1].v || h[n - 2].v))
66
+ G.events.push({ type: "text-flap", t, visibleMs: t - h[n - 2].t, desc: desc(el), rect: rect(el), sequence: [h[n - 3].v, h[n - 2].v, h[n - 1].v].map((s) => s.slice(0, 60)) });
67
+ }
68
+ }
69
+ });
70
+ mo.observe(document.documentElement, { childList: true, subtree: true, characterData: true, characterDataOldValue: true });
71
+ // seed rects for existing elements periodically so removals can be sized
72
+ const seed = () => {
73
+ document.querySelectorAll("body *").forEach((c) => { const r = rect(c); if (r && r.w >= 40 && r.h >= 16) lastRect.set(c, r); });
74
+ // remember where intentional animations run (spinners, typing dots, pulses)
75
+ try {
76
+ for (const a of document.getAnimations()) {
77
+ const el = a.effect?.target; if (!el || a.playState !== "running") continue;
78
+ const r = rect(el); if (!r || !r.w) continue;
79
+ G.anim.push({ ...r, t: now(), desc: desc(el) });
80
+ if (G.anim.length > 1000) G.anim.shift();
81
+ }
82
+ } catch {}
83
+ };
84
+ setInterval(seed, 250);
85
+ try {
86
+ new PerformanceObserver((l) => {
87
+ for (const e of l.getEntries()) {
88
+ if (e.hadRecentInput) continue;
89
+ G.shifts.push({ t: Math.round(e.startTime), value: +e.value.toFixed(4),
90
+ sources: (e.sources || []).map((s) => ({ desc: desc(s.node), from: s.previousRect && { x: s.previousRect.x, y: s.previousRect.y, w: s.previousRect.width, h: s.previousRect.height }, to: s.currentRect && { x: s.currentRect.x, y: s.currentRect.y, w: s.currentRect.width, h: s.currentRect.height } })) });
91
+ }
92
+ }).observe({ type: "layout-shift", buffered: true });
93
+ } catch {}
94
+ };
95
+ if (document.documentElement) start(); else document.addEventListener("readystatechange", start, { once: true });
96
+ }