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

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 +17 -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 +157 -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,2 @@
1
+ <!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Test platform</title><style>body{font-family:sans-serif;margin:0}</style></head>
2
+ <body><div id="root"></div><script src="/app.js"></script></body></html>
@@ -0,0 +1,12 @@
1
+ // Tiny static server for the example app (SPA fallback to index.html). Usage: node serve.mjs [port]
2
+ import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url";
3
+ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "public");
4
+ const port = Number(process.argv[2] || 4321);
5
+ const types = { ".html": "text/html", ".js": "text/javascript", ".json": "application/json" };
6
+ http.createServer((req, res) => {
7
+ let p;
8
+ try { p = path.resolve(root, "." + decodeURIComponent(new URL(req.url, "http://x").pathname)); }
9
+ catch { res.writeHead(400); res.end("Invalid URL"); return; }
10
+ if (!p.startsWith(root + path.sep) || !fs.existsSync(p) || fs.statSync(p).isDirectory()) p = path.join(root, "index.html");
11
+ res.writeHead(200, { "content-type": types[path.extname(p)] || "application/octet-stream" }); fs.createReadStream(p).pipe(res);
12
+ }).listen(port, "127.0.0.1", () => console.log(`Glitchy example app: http://localhost:${port}/dashboard`));
@@ -0,0 +1,157 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { randomUUID } from "node:crypto";
7
+ import { createRequire } from "node:module";
8
+ const require = createRequire(import.meta.url);
9
+
10
+ const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
+ const optionalText = (description: string) => Type.Optional(Type.String({ description }));
12
+ const number = (description: string, max: number) => Type.Optional(Type.Integer({ minimum: 1, maximum: max, description }));
13
+ const scope = {
14
+ config: optionalText("Matrix config; default .ux/matrix.json"),
15
+ cell: optionalText("Persona cell; omit for all configured cells"),
16
+ interact: Type.Optional(Type.Boolean({ description: "User authorized browser interactions in a disposable test environment. Never infer this permission." })),
17
+ allowWrites: Type.Optional(Type.Boolean({ description: "User explicitly authorized writes, including login, typing, forms and repeated actions. Requires interact=true." })),
18
+ out: optionalText("New output directory; existing directories are rejected"),
19
+ };
20
+ const flag = (args: string[], name: string, value: unknown) => {
21
+ if (value === undefined || value === false || value === "") return;
22
+ if (typeof value === "string" && value.startsWith("--")) throw new Error(`Invalid value for ${name}: cannot be another CLI flag`);
23
+ args.push(`--${name}`);
24
+ if (value !== true) args.push(String(value));
25
+ };
26
+ const target = (args: string[], p: any) => {
27
+ if (p.url) flag(args, "url", p.url);
28
+ else { flag(args, "config", p.config || ".ux/matrix.json"); flag(args, p.cell ? "cell" : "all", p.cell || true); }
29
+ };
30
+ const output = (p: any, kind: string) => p.out || path.join(".ux", kind, `${Date.now()}-${randomUUID().slice(0, 8)}`);
31
+ const result = (text: string, ok: boolean, details: any = {}, images: any[] = []) => ({
32
+ content: [{ type: "text" as const, text: text.slice(0, 10000) + (text.length > 10000 ? "\n…truncated; read the output files." : "") }, ...images],
33
+ details: { ...details, ok }, isError: !ok,
34
+ });
35
+
36
+ export default function (pi: ExtensionAPI) {
37
+ async function authorize(p: any, signal: AbortSignal | undefined, ctx: any) {
38
+ signal?.throwIfAborted();
39
+ if (p.allowWrites && !p.interact) throw new Error("allowWrites requires interact=true");
40
+ if (p.interact && ctx.hasUI && !(await ctx.ui.confirm("Glitch Lab interaction approval", p.allowWrites
41
+ ? "Allow writes and repeated browser interactions against the approved disposable test environment? No rollback is provided."
42
+ : "Allow browser interactions against the approved test environment? HTTP writes are blocked, but GET handlers and page scripts can still have side effects.", { signal }))) throw new Error("Interaction approval declined");
43
+ }
44
+ async function run(script: string, args: string[], signal: AbortSignal | undefined, ctx: any) {
45
+ signal?.throwIfAborted();
46
+ const r = await pi.exec(process.execPath, [path.join(PKG, "scripts", script), ...args], { cwd: ctx.cwd, signal, timeout: 20 * 60_000, maxBuffer: 1024 * 1024 });
47
+ return { ...r, ok: r.code === 0 && !r.killed && !r.truncated };
48
+ }
49
+ function policy(args: string[], p: any) { flag(args, "interact", p.interact); flag(args, "allow-writes", p.allowWrites); }
50
+
51
+ pi.registerTool({
52
+ name: "glitch_hunt", label: "Glitch hunt", executionMode: "sequential",
53
+ description: "Detect visual flicker, remounts and layout shifts using frame/DOM/React sensors. Navigation/journey modes require explicit interaction authorization. Reports sensor limits and incomplete runs, not universal visual correctness. Output text limited to 10k characters.",
54
+ promptSnippet: "glitch_hunt: evidence-backed flicker, remount and layout-shift testing",
55
+ promptGuidelines: ["Use glitch_hunt for flicker or unstable UI only on approved test targets; never infer interaction/write permission or claim exhaustive coverage."],
56
+ parameters: Type.Object({ ...scope, url: optionalText("Starting URL, instead of config"),
57
+ mode: Type.Optional(Type.Union([Type.Literal("nav"), Type.Literal("idle"), Type.Literal("loads"), Type.Literal("journey")])),
58
+ journey: optionalText("Journey ID"), items: optionalText("Comma-separated navigation link labels"),
59
+ fullMatrix: Type.Optional(Type.Boolean()), repeat: number("Runs per scenario; default 2", 5), cpu: number("CPU slowdown; default 4", 10),
60
+ paint: Type.Optional(Type.Boolean()), idleMs: number("Idle watch milliseconds", 60000), viewport: optionalText("desktop, laptop, iphone, android or tablet"),
61
+ attachImages: Type.Optional(Type.Integer({ minimum: 0, maximum: 4 })),
62
+ }),
63
+ async execute(_id, p, signal, _update, ctx) {
64
+ await authorize(p, signal, ctx);
65
+ const out = output(p, "glitch"), args = [p.mode || "nav"];
66
+ target(args, p); policy(args, p);
67
+ for (const k of ["journey", "items", "repeat", "cpu", "paint", "viewport"]) flag(args, k, p[k as keyof typeof p]);
68
+ flag(args, "mode", p.fullMatrix ? "full" : undefined); flag(args, "idle-ms", p.idleMs); flag(args, "out", out);
69
+ const r = await run("glitch_lab.mjs", args, signal, ctx);
70
+ if (!r.ok) return result(`Glitch run failed or incomplete. ${out}\n${r.stderr || r.stdout}`, false, { out });
71
+ const index = JSON.parse(fs.readFileSync(path.resolve(ctx.cwd, out, "index.json"), "utf8"));
72
+ const summaries: string[] = [], images: any[] = [];
73
+ for (const cell of index) {
74
+ const dir = path.resolve(ctx.cwd, cell.dir);
75
+ summaries.push(fs.readFileSync(path.join(dir, "summary.md"), "utf8"));
76
+ const data = JSON.parse(fs.readFileSync(path.join(dir, "findings.json"), "utf8"));
77
+ for (const finding of data.findings.filter((f: any) => f.level === "high")) {
78
+ const image = finding.paintImage || finding.images?.[0];
79
+ if (image && images.length < (p.attachImages ?? 2)) images.push({ type: "image", data: fs.readFileSync(path.join(dir, image)).toString("base64"), mimeType: "image/png" });
80
+ }
81
+ }
82
+ return result(summaries.join("\n\n") + `\nEvidence: ${out}`, index.length > 0 && index.every((c: any) => c.ok), { out }, images);
83
+ },
84
+ });
85
+
86
+ pi.registerTool({
87
+ name: "ux_affordance_scan", label: "UI inventory and interaction coverage", executionMode: "sequential",
88
+ description: "Inventory rendered controls and UX candidates. Default observation only. With probeClicks + interact, explore bounded menu/tab/dialog/navigation states, recording responded/no-observable-response/error/blocked/skipped and before/after evidence. Does not establish business correctness. No automatic form filling. 10k-character output limit.",
89
+ promptSnippet: "ux_affordance_scan: rendered controls, UX candidates and bounded interaction coverage",
90
+ promptGuidelines: ["Use ux_affordance_scan observation first; ask for explicit disposable-environment permission before probeClicks/interact. Report skipped controls and bounds, never 'every button passed'."],
91
+ parameters: Type.Object({ ...scope, url: optionalText("Single starting URL"), probeClicks: Type.Optional(Type.Boolean()),
92
+ viewport: optionalText("desktop, laptop, iphone, android or tablet"), maxActions: number("Maximum attempted actions, default 40", 200),
93
+ maxStates: number("Maximum explored states, default 12", 50), maxPages: number("Maximum visited URLs, default 5", 25),
94
+ maxDepth: number("Maximum state depth, default 4", 8), seconds: number("Exploration budget seconds, default 120", 600),
95
+ observeMs: number("Observation window per action, default 1000ms", 5000),
96
+ }),
97
+ async execute(_id, p, signal, _update, ctx) {
98
+ await authorize(p, signal, ctx);
99
+ const out = output(p, "scan"), args: string[] = [];
100
+ target(args, p); policy(args, p); flag(args, "out", out); flag(args, "probe-clicks", p.probeClicks); flag(args, "viewport", p.viewport);
101
+ for (const [k, v] of Object.entries({ "max-actions": p.maxActions, "max-states": p.maxStates, "max-pages": p.maxPages, "max-depth": p.maxDepth, seconds: p.seconds, "observe-ms": p.observeMs })) flag(args, k, v);
102
+ const r = await run("affordance_scan.mjs", args, signal, ctx);
103
+ return result(`${r.ok ? "Scan completed (not UAT acceptance)." : "Scan failed/incomplete."}\n${r.stdout}\n${r.ok ? "" : r.stderr}\nEvidence and coverage: ${out}`, r.ok, { out });
104
+ },
105
+ });
106
+
107
+ pi.registerTool({
108
+ name: "ux_journeys", label: "Scripted UAT journeys", executionMode: "sequential",
109
+ description: "Execute explicit acceptance journeys without AI: navigation, authorized input, reload/persistence and text/URL/visibility/value/checked/enabled/count/attribute assertions. Failed steps, missing journeys, missing baselines and regressions fail. Baseline/output must be new directories. 10k-character output limit.",
110
+ promptSnippet: "ux_journeys: deterministic acceptance journeys and regression comparisons",
111
+ parameters: Type.Object({ ...scope, journey: optionalText("Journey ID; omit for configured journeys"),
112
+ baseline: optionalText("Create baseline in this new directory"), compare: optionalText("Existing baseline to compare against"),
113
+ }),
114
+ async execute(_id, p, signal, _update, ctx) {
115
+ await authorize(p, signal, ctx);
116
+ const out = output(p, "journeys"), args: string[] = [];
117
+ target(args, p); policy(args, p); flag(args, "journey", p.journey);
118
+ flag(args, "baseline", p.baseline); flag(args, "compare", p.compare); flag(args, "out", out);
119
+ const r = await run("journey_runner.mjs", args, signal, ctx);
120
+ return result(`${r.ok ? "Journeys passed their scripted assertions." : "Journeys failed/incomplete; do not report acceptance."}\n${r.stdout}\n${r.ok ? "" : r.stderr}\nEvidence: ${p.baseline || out}`, r.ok, { out: p.baseline || out, code: r.code });
121
+ },
122
+ });
123
+
124
+ pi.registerTool({
125
+ name: "ux_report", label: "Build UX report", executionMode: "sequential",
126
+ description: "Build an HTML evidence report, including interaction coverage and UAT failures. Treat reports as private; screenshots may contain application data.",
127
+ parameters: Type.Object({ runDir: Type.String(), findings: optionalText("Written findings JSON"), out: optionalText("Output HTML path"), embed: Type.Optional(Type.Boolean()) }),
128
+ async execute(_id, p, signal, _update, ctx) {
129
+ const args = [p.runDir]; flag(args, "findings", p.findings); flag(args, "out", p.out); flag(args, "embed", p.embed !== false);
130
+ const r = await run("report.mjs", args, signal, ctx);
131
+ return result(r.stdout + r.stderr, r.ok);
132
+ },
133
+ });
134
+
135
+ async function doctor(install: boolean, signal: AbortSignal | undefined, ctx: any) {
136
+ signal?.throwIfAborted();
137
+ if (install) {
138
+ const r = await pi.exec(process.execPath, [path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js"), "install", "chromium"], { cwd: PKG, signal, timeout: 180000 });
139
+ if (r.code !== 0 || r.killed || r.truncated) return result(r.stderr || r.stdout, false);
140
+ }
141
+ const r = await run("doctor.mjs", [], signal, ctx);
142
+ return result(r.stdout + r.stderr, r.ok);
143
+ }
144
+ pi.registerTool({
145
+ name: "glitch_lab_doctor", label: "Glitch Lab doctor",
146
+ description: "Check browser availability. install=true explicitly downloads Playwright Chromium; never install it without user permission.",
147
+ parameters: Type.Object({ install: Type.Optional(Type.Boolean()) }),
148
+ execute: (_id, p, signal, _update, ctx) => doctor(!!p.install, signal, ctx),
149
+ });
150
+ pi.registerCommand("glitch-lab-doctor", {
151
+ description: "Check browser availability; add install to download Chromium",
152
+ handler: async (args, ctx) => {
153
+ const r = await doctor(args.trim() === "install", undefined, ctx);
154
+ ctx.ui.notify(r.content[0].text, r.isError ? "warning" : "info");
155
+ },
156
+ });
157
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@stacknyu/pi-glitch-lab",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.5.1",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "registry": "https://registry.npmjs.org/"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/stacknyu/stack-pi-plugins.git",
11
+ "directory": "packages/pi-glitch-lab"
12
+ },
13
+ "homepage": "https://github.com/stacknyu/stack-pi-plugins/tree/main/packages/pi-glitch-lab#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/stacknyu/stack-pi-plugins/issues"
16
+ },
17
+ "description": "Visual QA for Pi agents: finds flicker, remounting components, self-refreshing UI, layout jumps, fake/hidden buttons and persona gating problems in running web apps, with frame, React, DOM, network and paint-flashing sensors.",
18
+ "type": "module",
19
+ "keywords": [
20
+ "pi-package",
21
+ "qa",
22
+ "ux",
23
+ "usability",
24
+ "visual-regression",
25
+ "flicker",
26
+ "react",
27
+ "playwright"
28
+ ],
29
+ "license": "MIT",
30
+ "pi": {
31
+ "extensions": [
32
+ "./extensions/index.ts"
33
+ ],
34
+ "skills": [
35
+ "./skills"
36
+ ]
37
+ },
38
+ "files": [
39
+ "extensions",
40
+ "scripts",
41
+ "skills",
42
+ "examples",
43
+ "README.md",
44
+ "CHANGELOG.md"
45
+ ],
46
+ "dependencies": {
47
+ "playwright": "1.63.0"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "typebox": "*"
52
+ },
53
+ "engines": {
54
+ "node": ">=22.19.0"
55
+ }
6
56
  }
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * affordance_scan.mjs — find visual affordance problems on rendered screens.
4
+ *
5
+ * node affordance_scan.mjs --config .ux/matrix.json --cell teen-applicant@brightpath --out .ux/runs/2026-09-23
6
+ * node affordance_scan.mjs --url http://localhost:3000/chat --viewport mobile --out .ux/runs/adhoc
7
+ * options: --all (every cell) --probe-clicks --max-clicks 20 --auth-dir .ux/auth
8
+ *
9
+ * Categories (box colors in *_annotated.png):
10
+ * control (green) real control that also looks like one
11
+ * fake (red) looks clickable, but nothing happens (not a control)
12
+ * hidden (orange) is clickable, but looks like plain text/decoration
13
+ * unlabeled(purple) icon-only control, no visible text or accessible name
14
+ * small (yellow) touch target below 24px (44px recommended on touch devices)
15
+ * These are CANDIDATES. Review the screenshots and judge as the persona.
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { parseArgs, loadPlaywright, launchBrowser, loadConfig, resolveCells, newContext, login, throttle,
20
+ attachListeners, slugify, ensureDir, tpl, VIEWPORTS, freshOutput, requireInteraction } from "./lib.mjs";
21
+ import { inventory, explore } from "./interactions.mjs";
22
+
23
+ const args = parseArgs();
24
+ const OUT = args.out || ".ux/runs/adhoc";
25
+ if (args["probe-clicks"]) requireInteraction(args);
26
+ freshOutput(OUT);
27
+ const { chromium } = await loadPlaywright();
28
+
29
+ let cells;
30
+ if (args.url) {
31
+ const u = new URL(args.url);
32
+ cells = [{ id: "adhoc", slug: "adhoc", base: u.origin, screens: [u.pathname + u.search], viewport: args.viewport || "desktop",
33
+ network: args.network || "fast", unsafe: [], persona: {}, partner: {}, mask: [] }];
34
+ } else {
35
+ const cfg = loadConfig(args.config);
36
+ cells = resolveCells(cfg, args.cell, args.all);
37
+ }
38
+
39
+ // ---------- in-page classifier ----------
40
+ function classify(opts) {
41
+ const { touch } = opts;
42
+ const INTERACTIVE_ROLES = new Set(["button", "link", "tab", "menuitem", "menuitemcheckbox", "menuitemradio",
43
+ "checkbox", "switch", "option", "radio", "combobox", "treeitem", "slider"]);
44
+ const vis = (el, cs) => {
45
+ const r = el.getBoundingClientRect();
46
+ return r.width >= 4 && r.height >= 4 && cs.visibility !== "hidden" && cs.display !== "none" && +cs.opacity > 0.05;
47
+ };
48
+ const isSemantic = (el) => {
49
+ const t = el.tagName.toLowerCase();
50
+ if (t === "a" && el.hasAttribute("href")) return true;
51
+ if (["button", "select", "textarea", "summary"].includes(t)) return true;
52
+ if (t === "input" && el.type !== "hidden") return true;
53
+ if (t === "label" && el.htmlFor) return true;
54
+ const role = el.getAttribute("role");
55
+ if (role && INTERACTIVE_ROLES.has(role)) return true;
56
+ if (el.hasAttribute("onclick") || el.isContentEditable) return true;
57
+ const ti = el.getAttribute("tabindex");
58
+ return ti !== null && +ti >= 0;
59
+ };
60
+ const isPointerRoot = (el, cs) => cs.cursor === "pointer" && (!el.parentElement || getComputedStyle(el.parentElement).cursor !== "pointer");
61
+ const parseRGB = (c) => { const m = c.match(/[\d.]+/g); return m ? m.map(Number) : [0, 0, 0, 0]; };
62
+ const alpha = (c) => { const v = parseRGB(c); return v.length > 3 ? v[3] : 1; };
63
+ const effBg = (el) => { for (let e = el; e; e = e.parentElement) { const b = getComputedStyle(e).backgroundColor; if (alpha(b) > 0.1) return b; } return "rgb(255,255,255)"; };
64
+ const dist = (a, b) => { const x = parseRGB(a), y = parseRGB(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
65
+ const hueSat = (c) => {
66
+ let [r, g, b] = parseRGB(c).map((v) => v / 255);
67
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
68
+ let h = 0; if (d) h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
69
+ return { h: (h * 60 + 360) % 360, s: mx ? d / mx : 0, v: mx };
70
+ };
71
+ const text = (el) => (el.innerText || "").replace(/\s+/g, " ").trim();
72
+ const accName = (el) => (el.getAttribute("aria-label") || el.getAttribute("title") ||
73
+ (el.getAttribute("aria-labelledby") && document.getElementById(el.getAttribute("aria-labelledby"))?.innerText) ||
74
+ el.labels?.[0]?.innerText || el.getAttribute("placeholder") || el.querySelector("img[alt]")?.alt || el.querySelector("svg title")?.textContent || "").trim();
75
+ const selectorFor = (el) => {
76
+ const parts = [];
77
+ for (let e = el; e && e !== document.body; e = e.parentElement) {
78
+ if (e.id) { parts.unshift(`#${CSS.escape(e.id)}`); break; }
79
+ parts.unshift(`${e.tagName.toLowerCase()}:nth-child(${[...e.parentElement.children].indexOf(e) + 1})`);
80
+ }
81
+ return parts.join(" > ") || "body";
82
+ };
83
+ const bodyColor = getComputedStyle(document.body).color;
84
+
85
+ const looksLikeButton = (el, cs, r, t) => {
86
+ const words = t ? t.split(" ").length : 0;
87
+ const bgDiff = alpha(cs.backgroundColor) > 0.1 && dist(cs.backgroundColor, effBg(el.parentElement || el)) > 40;
88
+ const bordered = parseFloat(cs.borderTopWidth) >= 1 && cs.borderTopStyle !== "none" && parseFloat(cs.borderRadius) >= 3;
89
+ const rounded = parseFloat(cs.borderRadius) >= 3;
90
+ const padded = parseFloat(cs.paddingLeft) >= 6 && parseFloat(cs.paddingTop) >= 3;
91
+ return words >= 1 && words <= 5 && r.width < 360 && r.height >= 18 && r.height <= 72 && padded && ((bgDiff && rounded) || bordered);
92
+ };
93
+ const looksLikeLink = (el, cs, t) => {
94
+ if (!t || t.split(" ").length > 8 || el.children.length > 1) return false;
95
+ if (cs.textDecorationLine.includes("underline")) return true;
96
+ const hs = hueSat(cs.color);
97
+ return hs.h > 190 && hs.h < 250 && hs.s > 0.45 && hs.v > 0.35 && dist(cs.color, bodyColor) > 80;
98
+ };
99
+ const looksLikeIconControl = (el, r, t) => !t && r.width >= 14 && r.width <= 56 && r.height >= 14 && r.height <= 56 &&
100
+ el.querySelector("svg,img") && el.children.length <= 2;
101
+ const looksPlain = (el, cs, t) => {
102
+ const bgDiff = alpha(cs.backgroundColor) > 0.1 && dist(cs.backgroundColor, effBg(el.parentElement || el)) > 25;
103
+ const border = parseFloat(cs.borderTopWidth) >= 1 && cs.borderTopStyle !== "none";
104
+ const shadow = cs.boxShadow && cs.boxShadow !== "none";
105
+ return t && !bgDiff && !border && !shadow && !cs.textDecorationLine.includes("underline") && dist(cs.color, bodyColor) < 40;
106
+ };
107
+
108
+ const all = [...document.querySelectorAll("body *")];
109
+ const results = [];
110
+ const interactiveSet = new Set();
111
+ for (const el of all) {
112
+ if (el.closest("[data-ux-overlay]")) continue;
113
+ const cs = getComputedStyle(el);
114
+ if (!vis(el, cs)) continue;
115
+ if (isSemantic(el) || isPointerRoot(el, cs)) interactiveSet.add(el);
116
+ }
117
+ const hasInteractiveAncestor = (el) => { for (let e = el.parentElement; e; e = e.parentElement) if (interactiveSet.has(e)) return true; return false; };
118
+ const labels = {};
119
+ let idx = 0;
120
+ for (const el of all) {
121
+ if (el.closest("[data-ux-overlay]") || ["svg", "path", "g", "script", "style"].includes(el.tagName.toLowerCase())) continue;
122
+ const cs = getComputedStyle(el);
123
+ if (!vis(el, cs)) continue;
124
+ const r = el.getBoundingClientRect();
125
+ const t = text(el).slice(0, 80);
126
+ const interactive = interactiveSet.has(el);
127
+ const nested = hasInteractiveAncestor(el);
128
+ const isField = ["input", "select", "textarea"].includes(el.tagName.toLowerCase()) || el.isContentEditable;
129
+ const reasons = [];
130
+ let cat = null;
131
+ if (interactive && !nested) {
132
+ const name = t || accName(el);
133
+ if (!t && !isField && !accName(el)) { cat = "unlabeled"; reasons.push("icon-only control with no text or accessible name"); }
134
+ else if (!isField && looksPlain(el, cs, t) && !(cs.cursor === "pointer" && el.closest("nav,header,[role=navigation],[role=menu],[role=tablist]"))) {
135
+ cat = "hidden"; reasons.push("clickable but styled like body text (no color, underline, border or background)");
136
+ } else cat = "control";
137
+ const min = touch ? 44 : 24;
138
+ if (!isField && (r.width < min || r.height < min)) { reasons.push(`target ${Math.round(r.width)}×${Math.round(r.height)}px < ${min}px`); if (cat === "control") cat = "small"; }
139
+ if (!isField && name) (labels[name.toLowerCase()] ||= []).push(el.tagName.toLowerCase() === "a" ? el.getAttribute("href") : "(action)");
140
+ if (/^(click here|here|submit|ok|go|more|details|continue|next|button)$/i.test(name)) reasons.push(`vague label "${name}"`);
141
+ } else if (!interactive && !nested) {
142
+ // leaf-ish, non-interactive element that looks interactive
143
+ const direct = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()) || !el.children.length || el.querySelector(":scope > svg, :scope > img");
144
+ if (direct) {
145
+ if (looksLikeButton(el, cs, r, t)) { cat = "fake"; reasons.push("styled like a button (filled/bordered, padded, short label) but not clickable"); }
146
+ else if (looksLikeLink(el, cs, t)) { cat = "fake"; reasons.push("styled like a link (blue or underlined) but not clickable"); }
147
+ else if (looksLikeIconControl(el, r, t) && !el.closest("p,li,td,h1,h2,h3,h4")) { cat = "fake-icon"; reasons.push("standalone icon sized like a control but not clickable (verify: may be decorative)"); }
148
+ }
149
+ }
150
+ if (!cat) continue;
151
+ el.setAttribute("data-ux-id", String(idx));
152
+ const selector = selectorFor(el), name = accName(el);
153
+ const inView = r.top >= 0 && r.left >= 0 && r.bottom <= innerHeight && r.right <= innerWidth;
154
+ const hit = inView ? document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) : null;
155
+ results.push({ id: idx++, key: JSON.stringify([selector, el.tagName, el.getAttribute("href"), t, name]), selector,
156
+ disabled: el.matches(":disabled,[aria-disabled=true]"), focusable: el.tabIndex >= 0,
157
+ covered: inView && !!hit && !el.contains(hit), offscreen: !inView,
158
+ expanded: el.getAttribute("aria-expanded"), selected: el.getAttribute("aria-selected"), checked: el.checked ?? el.getAttribute("aria-checked"),
159
+ cat, tag: el.tagName.toLowerCase(), role: el.getAttribute("role"), text: t, name,
160
+ href: el.getAttribute("href"), x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height),
161
+ reasons, field: isField });
162
+ }
163
+ const dupes = Object.entries(labels).filter(([, v]) => new Set(v).size > 1 && v.length > 1).map(([k, v]) => ({ label: k, count: v.length, targets: [...new Set(v)].slice(0, 5) }));
164
+ return { results, dupes, page: { w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight, title: document.title } };
165
+ }
166
+
167
+ function drawOverlay(items) {
168
+ const COLORS = { control: "#16a34a", fake: "#dc2626", "fake-icon": "#dc2626", hidden: "#ea580c", unlabeled: "#9333ea", small: "#ca8a04" };
169
+ const layer = document.createElement("div");
170
+ layer.setAttribute("data-ux-overlay", "1");
171
+ Object.assign(layer.style, { position: "absolute", left: 0, top: 0, width: "100%", height: document.documentElement.scrollHeight + "px", pointerEvents: "none", zIndex: 2147483647 });
172
+ for (const it of items) {
173
+ const c = COLORS[it.cat] || "#000";
174
+ const b = document.createElement("div");
175
+ Object.assign(b.style, { position: "absolute", left: it.x + "px", top: it.y + "px", width: it.w + "px", height: it.h + "px",
176
+ outline: `${it.cat === "control" ? 1 : 3}px solid ${c}`, background: it.cat === "control" ? "transparent" : c + "22" });
177
+ if (it.cat !== "control") {
178
+ const tag = document.createElement("div");
179
+ tag.textContent = it.id;
180
+ Object.assign(tag.style, { position: "absolute", left: "-2px", top: "-16px", font: "bold 11px/14px sans-serif", color: "#fff", background: c, padding: "0 4px", borderRadius: "3px" });
181
+ b.appendChild(tag);
182
+ }
183
+ layer.appendChild(b);
184
+ }
185
+ document.body.appendChild(layer);
186
+ }
187
+
188
+ // ---------- main ----------
189
+ const browser = await launchBrowser(chromium, args);
190
+
191
+ try {
192
+ for (const cell of cells) {
193
+ const context = await newContext(browser, cell, args);
194
+ await login(context, cell);
195
+ const dir = ensureDir(path.join(OUT, cell.slug, "affordance"));
196
+ const screens = cell.screens || ["/"];
197
+ const touch = !!(VIEWPORTS[cell.viewport] || {}).hasTouch;
198
+ const summary = [];
199
+ for (const screen of screens) {
200
+ const page = await context.newPage();
201
+ const sink = { console: [], failed: [] };
202
+ attachListeners(page, sink);
203
+ await throttle(context, page, cell);
204
+ const url = cell.base + tpl(screen, cell);
205
+ const t0 = Date.now();
206
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
207
+ if (cell.login?.authenticated) await page.locator(cell.login.authenticated).waitFor();
208
+ await page.waitForLoadState("networkidle", { timeout: 20000 }).catch(() => {});
209
+ const loadMs = Date.now() - t0;
210
+ await page.waitForTimeout(500);
211
+ const name = slugify(screen);
212
+ await page.screenshot({ path: path.join(dir, `${name}_clean.png`), fullPage: true, mask: cell.mask.map((s) => page.locator(s)) });
213
+ const res = await inventory(page, classify, touch);
214
+ res.page.horizontalOverflow = res.page.w > page.viewportSize().width + 1;
215
+ await page.evaluate(drawOverlay, res.results);
216
+ await page.screenshot({ path: path.join(dir, `${name}_annotated.png`), fullPage: true, mask: cell.mask.map((s) => page.locator(s)) });
217
+ await page.evaluate(() => document.querySelector('[data-ux-overlay]')?.remove());
218
+ const coverage = args["probe-clicks"] ? await explore(page, url, res, { ...cell, touch }, args, classify, dir, name) : null;
219
+ const clicks = coverage?.actions || [];
220
+ const counts = res.results.reduce((a, r) => ((a[r.cat] = (a[r.cat] || 0) + 1), a), {});
221
+ const data = { cell: cell.id, screen, url, viewport: cell.viewport, network: cell.network, loadMs, title: res.page.title,
222
+ counts, duplicateLabels: res.dupes, items: res.results, clicks, coverage,
223
+ scrollTruncated: res.scrollTruncated, limitations: res.limitations, horizontalOverflow: res.page.horizontalOverflow,
224
+ blockedRequests: context._uxBlocked,
225
+ consoleErrors: sink.console.slice(0, 20), failedRequests: sink.failed.slice(0, 20) };
226
+ fs.writeFileSync(path.join(dir, `${name}_affordance.json`), JSON.stringify(data, null, 2));
227
+ summary.push({ screen, loadMs, ...counts, coverage: coverage && { ...coverage.counts, states: coverage.states.length, limited: coverage.limited }, errors: sink.console.length });
228
+ console.log(`[${cell.id}] ${screen}: load ${loadMs}ms · fake ${(counts.fake || 0) + (counts["fake-icon"] || 0)} · hidden ${counts.hidden || 0} · unlabeled ${counts.unlabeled || 0} · small ${counts.small || 0}` +
229
+ (coverage ? ` · ${coverage.exercised}/${coverage.discovered} control-state pairs exercised, ${coverage.states.length} states · ${JSON.stringify(coverage.counts)}${coverage.limited ? " · LIMITED" : ""}` : " · observation only"));
230
+ await page.close();
231
+ }
232
+ fs.writeFileSync(path.join(dir, "summary.json"), JSON.stringify({ cell: cell.id, viewport: cell.viewport, network: cell.network, screens: summary }, null, 2));
233
+ await context.close();
234
+ }
235
+ } finally { await browser.close(); }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ // doctor.mjs — can Glitch Lab start a browser here?
3
+ import { loadPlaywright, launchBrowser, parseArgs } from "./lib.mjs";
4
+ const args = parseArgs();
5
+ const { chromium } = await loadPlaywright();
6
+ try {
7
+ const b = await launchBrowser(chromium, args);
8
+ const p = await b.newPage(); await p.setContent("<p>ok</p>");
9
+ console.log(`browser: OK (${b.browserType().name()} ${b.version()})`);
10
+ await b.close();
11
+ } catch (e) {
12
+ console.log(`browser: FAILED — ${e.message}`);
13
+ process.exit(1);
14
+ }