qualia-framework 4.5.0 → 5.3.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 (66) hide show
  1. package/AGENTS.md +24 -0
  2. package/CLAUDE.md +12 -75
  3. package/README.md +23 -16
  4. package/agents/builder.md +9 -21
  5. package/agents/planner.md +8 -0
  6. package/agents/verifier.md +8 -0
  7. package/agents/visual-evaluator.md +132 -0
  8. package/bin/cli.js +54 -18
  9. package/bin/install.js +369 -29
  10. package/bin/qualia-ui.js +208 -1
  11. package/bin/slop-detect.mjs +5 -0
  12. package/bin/state.js +34 -1
  13. package/docs/install-redesign-builder-prompt.md +290 -0
  14. package/docs/install-redesign-pilot.md +234 -0
  15. package/docs/playwright-loop-builder-prompt.md +185 -0
  16. package/docs/playwright-loop-design-notes.md +108 -0
  17. package/docs/playwright-loop-pilot-results.md +170 -0
  18. package/docs/playwright-loop-tester-prompt.md +213 -0
  19. package/docs/polish-loop-supervised-run.md +111 -0
  20. package/docs/reviews/matt-pocock-skills-analysis.md +300 -0
  21. package/guide.md +9 -5
  22. package/hooks/env-empty-guard.js +74 -0
  23. package/hooks/pre-compact.js +19 -9
  24. package/hooks/pre-deploy-gate.js +8 -2
  25. package/hooks/pre-push.js +26 -12
  26. package/hooks/supabase-destructive-guard.js +62 -0
  27. package/hooks/vercel-account-guard.js +91 -0
  28. package/package.json +2 -1
  29. package/rules/design-brand.md +4 -0
  30. package/rules/design-laws.md +4 -0
  31. package/rules/design-product.md +4 -0
  32. package/rules/design-rubric.md +4 -0
  33. package/rules/grounding.md +4 -0
  34. package/skills/qualia-build/SKILL.md +40 -46
  35. package/skills/qualia-discuss/SKILL.md +51 -68
  36. package/skills/qualia-handoff/SKILL.md +1 -0
  37. package/skills/qualia-hook-gen/SKILL.md +206 -0
  38. package/skills/qualia-issues/SKILL.md +151 -0
  39. package/skills/qualia-map/SKILL.md +78 -35
  40. package/skills/qualia-new/REFERENCE.md +139 -0
  41. package/skills/qualia-new/SKILL.md +45 -121
  42. package/skills/qualia-optimize/REFERENCE.md +265 -0
  43. package/skills/qualia-optimize/SKILL.md +92 -232
  44. package/skills/qualia-plan/SKILL.md +58 -65
  45. package/skills/qualia-polish-loop/REFERENCE.md +265 -0
  46. package/skills/qualia-polish-loop/SKILL.md +201 -0
  47. package/skills/qualia-polish-loop/fixtures/broken.html +117 -0
  48. package/skills/qualia-polish-loop/fixtures/clean.html +196 -0
  49. package/skills/qualia-polish-loop/scripts/loop.mjs +323 -0
  50. package/skills/qualia-polish-loop/scripts/playwright-capture.mjs +206 -0
  51. package/skills/qualia-polish-loop/scripts/score.mjs +176 -0
  52. package/skills/qualia-prd/SKILL.md +199 -0
  53. package/skills/qualia-report/SKILL.md +141 -200
  54. package/skills/qualia-research/SKILL.md +28 -33
  55. package/skills/qualia-road/SKILL.md +103 -0
  56. package/skills/qualia-ship/SKILL.md +1 -0
  57. package/skills/qualia-task/SKILL.md +1 -1
  58. package/skills/qualia-test/SKILL.md +50 -2
  59. package/skills/qualia-triage/SKILL.md +152 -0
  60. package/skills/qualia-verify/SKILL.md +63 -104
  61. package/skills/qualia-zoom/SKILL.md +51 -0
  62. package/skills/zoho-workflow/SKILL.md +1 -1
  63. package/templates/CONTEXT.md +36 -0
  64. package/templates/decisions/ADR-template.md +30 -0
  65. package/tests/bin.test.sh +598 -7
  66. package/tests/state.test.sh +58 -0
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * loop.mjs — orchestrator state-machine for /qualia-polish-loop.
4
+ *
5
+ * Claude (the parent session) drives the loop by issuing CLI commands. This
6
+ * script keeps the deterministic state — iteration counter, regression
7
+ * fingerprints, token budget, success/kill verdict — out of the LLM context.
8
+ *
9
+ * loop.mjs init --url X --brief PATH --max 8 --budget 100000 --state STATE.json
10
+ * loop.mjs record --state STATE.json --eval EVAL.json
11
+ * loop.mjs status --state STATE.json # → JSON: continue|success|killed|out_of_budget
12
+ * loop.mjs commit-fix --state STATE.json --file PATH --slug SLUG
13
+ * loop.mjs report --state STATE.json # → final markdown to stdout
14
+ *
15
+ * Eval JSON contract (what the vision evaluator returns):
16
+ * {
17
+ * "iteration": 1,
18
+ * "viewport_results": [{ viewport, scores, top_issues }],
19
+ * "aggregate_scores": { typography, color, spatial, layout, shadow, motion, microcopy, container },
20
+ * "top_issues": [{ dim, severity, description, likely_file, fix }],
21
+ * "tokens_used": 14500
22
+ * }
23
+ *
24
+ * State JSON shape (written by this script, read by Claude):
25
+ * {
26
+ * "url", "brief_path", "max_iterations", "token_budget", "tokens_used",
27
+ * "iteration", "verdict", "iterations": [...], "fingerprints": {...}, "fixes": [...]
28
+ * }
29
+ */
30
+
31
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
32
+ import { dirname } from "node:path";
33
+ import { argv, exit } from "node:process";
34
+ import { spawnSync } from "node:child_process";
35
+
36
+ const DIMS = ["typography", "color", "spatial", "layout", "shadow", "motion", "microcopy", "container"];
37
+
38
+ // ── helpers ──────────────────────────────────────────────────────────────
39
+ function loadState(p) {
40
+ if (!existsSync(p)) { console.error(`state file not found: ${p}`); exit(2); }
41
+ try { return JSON.parse(readFileSync(p, "utf8")); }
42
+ catch (e) { console.error(`state file unreadable: ${e.message}`); exit(2); }
43
+ }
44
+ function saveState(p, s) {
45
+ if (!existsSync(dirname(p))) mkdirSync(dirname(p), { recursive: true });
46
+ writeFileSync(p, JSON.stringify(s, null, 2) + "\n");
47
+ }
48
+ function flag(name, def = null) {
49
+ const i = argv.indexOf(name);
50
+ if (i < 0) return def;
51
+ return argv[i + 1] || def;
52
+ }
53
+ function flagInt(name, def) { const v = flag(name); return v == null ? def : parseInt(v, 10); }
54
+
55
+ function fingerprintIssue(issue) {
56
+ const dim = String(issue.dim || "").toLowerCase().replace(/\W+/g, "_");
57
+ const file = String(issue.likely_file || issue.file || "_unknown").split("/").pop().replace(/\W+/g, "_");
58
+ const kw = String(issue.description || "").toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 32);
59
+ return `${dim}__${file}__${kw}`;
60
+ }
61
+
62
+ // ── commands ─────────────────────────────────────────────────────────────
63
+ function cmdInit() {
64
+ const statePath = flag("--state");
65
+ if (!statePath) { console.error("--state required"); exit(2); }
66
+ const routesFlag = flag("--routes");
67
+ const urlFlag = flag("--url");
68
+ if (!routesFlag && !urlFlag) { console.error("--url or --routes required"); exit(2); }
69
+ const urls = routesFlag
70
+ ? routesFlag.split(",").map((s) => s.trim()).filter(Boolean)
71
+ : [urlFlag];
72
+ const max = flagInt("--max", 8);
73
+ const budget = flagInt("--budget", 100000);
74
+ const state = {
75
+ url: urls[0], // primary URL (backward compat with single-route SKILL.md)
76
+ urls, // full list — multi-route mode when length > 1
77
+ brief_path: flag("--brief", null),
78
+ reference_path: flag("--ref", null),
79
+ reduced_motion: argv.includes("--reduced-motion"),
80
+ max_iterations: max,
81
+ token_budget: budget,
82
+ tokens_used: 0,
83
+ iteration: 0,
84
+ verdict: "pending",
85
+ iterations: [],
86
+ fingerprints: {},
87
+ fixes: [],
88
+ started_at: new Date().toISOString(),
89
+ };
90
+ saveState(statePath, state);
91
+ console.log(JSON.stringify({ ok: true, state_path: statePath, state }, null, 2));
92
+ }
93
+
94
+ function cmdRecord() {
95
+ const statePath = flag("--state");
96
+ const evalPath = flag("--eval");
97
+ if (!statePath || !evalPath) { console.error("--state and --eval required"); exit(2); }
98
+ const state = loadState(statePath);
99
+ const ev = JSON.parse(readFileSync(evalPath, "utf8"));
100
+
101
+ state.iteration = (ev.iteration ?? state.iteration + 1);
102
+ const scores = ev.aggregate_scores || {};
103
+ const failingDims = DIMS.filter((d) => (scores[d] ?? 3) < 3);
104
+ const total = DIMS.reduce((a, d) => a + (scores[d] ?? 3), 0);
105
+ const pass = failingDims.length === 0;
106
+ const issues = Array.isArray(ev.top_issues) ? ev.top_issues.slice(0, 3) : [];
107
+
108
+ // regression tracking — update fingerprints
109
+ const seenThisIter = new Set();
110
+ for (const it of issues) {
111
+ const fp = fingerprintIssue(it);
112
+ seenThisIter.add(fp);
113
+ if (!state.fingerprints[fp]) state.fingerprints[fp] = { iterations: [], description: it.description, dim: it.dim };
114
+ if (!state.fingerprints[fp].iterations.includes(state.iteration)) {
115
+ state.fingerprints[fp].iterations.push(state.iteration);
116
+ }
117
+ }
118
+ // Reset fingerprints that did NOT recur this iteration so we count CONSECUTIVE recurrences.
119
+ // The kill criterion is "same issue recurred 3x consecutively" — non-consecutive doesn't kill.
120
+ for (const fp of Object.keys(state.fingerprints)) {
121
+ if (!seenThisIter.has(fp)) {
122
+ const fpRec = state.fingerprints[fp];
123
+ const last = fpRec.iterations[fpRec.iterations.length - 1];
124
+ if (last !== state.iteration && last !== state.iteration - 1) {
125
+ // gap → reset run length but keep history
126
+ fpRec.iterations = [];
127
+ }
128
+ }
129
+ }
130
+
131
+ const regression = Object.entries(state.fingerprints).find(([, v]) => {
132
+ const its = v.iterations.slice(-3);
133
+ return its.length >= 3 && its.every((n, i, arr) => i === 0 || n === arr[i - 1] + 1);
134
+ });
135
+
136
+ state.tokens_used += parseInt(ev.tokens_used || 0, 10) || 0;
137
+
138
+ state.iterations.push({
139
+ iteration: state.iteration,
140
+ scores: { ...scores },
141
+ aggregate: total,
142
+ pass,
143
+ failing_dims: failingDims,
144
+ top_issues: issues,
145
+ tokens_used: ev.tokens_used || 0,
146
+ timestamp: new Date().toISOString(),
147
+ });
148
+
149
+ // verdict logic — order matters
150
+ if (pass) state.verdict = "success";
151
+ else if (regression) {
152
+ state.verdict = "killed_regression";
153
+ state.kill_reason = `LOOP_REGRESSION_DETECTED: ${regression[0]} recurred in 3 consecutive iterations`;
154
+ state.kill_fingerprint = regression[0];
155
+ } else if (state.tokens_used > state.token_budget) {
156
+ state.verdict = "out_of_budget";
157
+ state.kill_reason = `OUT_OF_BUDGET: ${state.tokens_used} > ${state.token_budget}`;
158
+ } else if (state.iteration >= state.max_iterations) {
159
+ state.verdict = "killed_max_iterations";
160
+ state.kill_reason = `MAX_ITERATIONS_REACHED: ${state.iteration} >= ${state.max_iterations}`;
161
+ } else {
162
+ state.verdict = "continue";
163
+ }
164
+
165
+ saveState(statePath, state);
166
+ console.log(JSON.stringify({
167
+ iteration: state.iteration,
168
+ pass,
169
+ verdict: state.verdict,
170
+ failing_dims: failingDims,
171
+ aggregate: total,
172
+ tokens_used: state.tokens_used,
173
+ token_budget: state.token_budget,
174
+ issues_to_fix: state.verdict === "continue" ? issues : [],
175
+ kill_reason: state.kill_reason || null,
176
+ }, null, 2));
177
+ // exit codes: 0 = success, 1 = continue, 2 = error, 3 = killed
178
+ if (state.verdict === "success") exit(0);
179
+ if (state.verdict === "continue") exit(1);
180
+ exit(3);
181
+ }
182
+
183
+ function cmdStatus() {
184
+ const statePath = flag("--state");
185
+ if (!statePath) { console.error("--state required"); exit(2); }
186
+ const state = loadState(statePath);
187
+ console.log(JSON.stringify({
188
+ verdict: state.verdict,
189
+ iteration: state.iteration,
190
+ max_iterations: state.max_iterations,
191
+ tokens_used: state.tokens_used,
192
+ token_budget: state.token_budget,
193
+ fixes_applied: state.fixes.length,
194
+ last_aggregate: state.iterations.length ? state.iterations[state.iterations.length - 1].aggregate : null,
195
+ }, null, 2));
196
+ }
197
+
198
+ function cmdCommitFix() {
199
+ const statePath = flag("--state");
200
+ const file = flag("--file");
201
+ const slug = flag("--slug", "fix");
202
+ if (!statePath || !file) { console.error("--state and --file required"); exit(2); }
203
+ const state = loadState(statePath);
204
+
205
+ // slop-detect gate — block on critical findings
206
+ const slopBin = process.env.SLOP_DETECT_BIN || "node";
207
+ const slopScript = process.env.SLOP_DETECT_SCRIPT || `${process.env.HOME}/.claude/bin/slop-detect.mjs`;
208
+ if (existsSync(slopScript)) {
209
+ const r = spawnSync(slopBin, [slopScript, file], { encoding: "utf8" });
210
+ if (r.status === 1) {
211
+ console.log(JSON.stringify({ ok: false, gate: "slop-detect", file, output: r.stdout }, null, 2));
212
+ exit(2);
213
+ }
214
+ }
215
+
216
+ // Stage + commit
217
+ const safeSlug = String(slug).toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 48);
218
+ const msg = `qpl-${state.iteration}: ${safeSlug}`;
219
+ const add = spawnSync("git", ["add", file], { encoding: "utf8" });
220
+ if (add.status !== 0) { console.error(add.stderr); exit(2); }
221
+ const commit = spawnSync("git", ["commit", "-m", msg], { encoding: "utf8" });
222
+ if (commit.status !== 0) {
223
+ // empty diff → nothing to commit; not fatal
224
+ if (/nothing to commit/i.test(commit.stdout + commit.stderr)) {
225
+ console.log(JSON.stringify({ ok: true, committed: false, reason: "no-op" }));
226
+ return;
227
+ }
228
+ console.error(commit.stderr); exit(2);
229
+ }
230
+ const sha = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
231
+ state.fixes.push({ iteration: state.iteration, file, slug: safeSlug, sha, ts: new Date().toISOString() });
232
+ saveState(statePath, state);
233
+ console.log(JSON.stringify({ ok: true, committed: true, sha, msg }, null, 2));
234
+ }
235
+
236
+ function cmdReport() {
237
+ const statePath = flag("--state");
238
+ if (!statePath) { console.error("--state required"); exit(2); }
239
+ const state = loadState(statePath);
240
+ const lines = [];
241
+ lines.push(`# Visual-Polish Loop Report`);
242
+ lines.push("");
243
+ if (Array.isArray(state.urls) && state.urls.length > 1) {
244
+ lines.push(`- **URLs (${state.urls.length}):** ${state.urls.join(", ")}`);
245
+ } else {
246
+ lines.push(`- **URL:** ${state.url}`);
247
+ }
248
+ lines.push(`- **Brief:** ${state.brief_path || "_(none)_"}`);
249
+ if (state.reduced_motion) lines.push(`- **Reduced motion:** forced`);
250
+ lines.push(`- **Started:** ${state.started_at}`);
251
+ lines.push(`- **Final verdict:** ${state.verdict.toUpperCase()}${state.kill_reason ? ` — ${state.kill_reason}` : ""}`);
252
+ lines.push(`- **Iterations:** ${state.iteration} / ${state.max_iterations}`);
253
+ lines.push(`- **Tokens used:** ${state.tokens_used} / ${state.token_budget}`);
254
+ lines.push(`- **Fixes committed:** ${state.fixes.length}`);
255
+ lines.push("");
256
+ lines.push("## Iteration log");
257
+ for (const it of state.iterations) {
258
+ lines.push("");
259
+ lines.push(`### Iteration ${it.iteration}`);
260
+ const dims = DIMS.map((d) => `${d.slice(0, 4)}=${it.scores[d] ?? "?"}`).join(" ");
261
+ lines.push(`- Scores: ${dims}`);
262
+ lines.push(`- Aggregate: ${it.aggregate}/40 (avg ${(it.aggregate / 8).toFixed(2)})`);
263
+ lines.push(`- Pass: ${it.pass ? "YES" : "NO"} ${it.failing_dims.length ? `(failing: ${it.failing_dims.join(", ")})` : ""}`);
264
+ if (it.top_issues && it.top_issues.length) {
265
+ lines.push(`- Top issues:`);
266
+ for (const iss of it.top_issues) {
267
+ lines.push(` - **${iss.dim}** [${iss.severity}] ${iss.description} → ${iss.likely_file || "_(file?)_"}`);
268
+ }
269
+ }
270
+ }
271
+ if (state.fixes.length) {
272
+ lines.push("");
273
+ lines.push("## Fix commits (revertable)");
274
+ for (const f of state.fixes) lines.push(`- \`${f.sha}\` qpl-${f.iteration}: ${f.slug} — ${f.file}`);
275
+ }
276
+ if (Object.keys(state.fingerprints).length) {
277
+ lines.push("");
278
+ lines.push("## Issue fingerprints (regression tracker)");
279
+ for (const [fp, rec] of Object.entries(state.fingerprints)) {
280
+ lines.push(`- \`${fp}\` — iterations ${JSON.stringify(rec.iterations)} — ${rec.description || ""}`);
281
+ }
282
+ }
283
+ console.log(lines.join("\n"));
284
+ }
285
+
286
+ // ── dispatch ─────────────────────────────────────────────────────────────
287
+ const cmd = argv[2];
288
+ switch (cmd) {
289
+ case "init": cmdInit(); break;
290
+ case "record": cmdRecord(); break;
291
+ case "status": cmdStatus(); break;
292
+ case "commit-fix": cmdCommitFix(); break;
293
+ case "report": cmdReport(); break;
294
+ case "--help":
295
+ case "-h":
296
+ case undefined:
297
+ console.log(`loop.mjs — orchestrator for /qualia-polish-loop
298
+
299
+ Commands:
300
+ init --state PATH (--url URL | --routes URL1,URL2,...) [--brief PATH] [--ref PATH] [--max 8] [--budget 100000] [--reduced-motion]
301
+ record --state PATH --eval PATH
302
+ status --state PATH
303
+ commit-fix --state PATH --file PATH --slug TEXT
304
+ report --state PATH > report.md
305
+
306
+ Multi-route mode (v5.2):
307
+ --routes wins over --url. State stores both state.url (first, backward
308
+ compat) and state.urls (full list). Orchestrator drives capture+eval
309
+ per URL; aggregate scores are min across URLs and viewports.
310
+
311
+ Reduced-motion mode (v5.2):
312
+ --reduced-motion is recorded in state.reduced_motion. Capture script
313
+ is invoked with --reduced-motion which forces prefers-reduced-motion.
314
+ Vision evaluator scores motion on CSS-declaration quality only.
315
+
316
+ Exit codes (record):
317
+ 0 = success (all dims >= 3) 1 = continue (more iterations needed)
318
+ 2 = invocation error 3 = killed (regression / budget / max)`);
319
+ exit(cmd ? 0 : 2);
320
+ default:
321
+ console.error(`unknown command: ${cmd}`);
322
+ exit(2);
323
+ }
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * playwright-capture.mjs — screenshot capture for the visual-polish loop.
4
+ *
5
+ * Prefers Playwright when the package is import-resolvable (project has it as a
6
+ * dep, or the user ran `npm i -g playwright`). Falls back to driving a cached
7
+ * Chromium / Google Chrome binary directly via `--headless=new --screenshot`.
8
+ * Either path produces equivalent PNG output for the vision evaluator.
9
+ *
10
+ * Usage:
11
+ * node playwright-capture.mjs --url http://localhost:3000 --out /tmp/qpl-1
12
+ * node playwright-capture.mjs --url http://localhost:3000 --viewports 375,768,1440 --out /tmp/qpl-1 --wait 1500
13
+ *
14
+ * Output (one PNG per viewport, in --out):
15
+ * mobile-375.png tablet-768.png desktop-1440.png
16
+ *
17
+ * Stdout: a JSON envelope with per-capture status. Exit 0 on full success,
18
+ * 1 if any viewport failed, 2 on invocation error.
19
+ */
20
+
21
+ import { existsSync, mkdirSync, statSync, readdirSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { spawnSync } from "node:child_process";
24
+ import { argv, exit } from "node:process";
25
+ import { homedir } from "node:os";
26
+
27
+ // ── Arg parsing ──────────────────────────────────────────────────────────
28
+ function parseArgs() {
29
+ const args = { url: null, out: null, viewports: [375, 768, 1440], wait: 1500, reducedMotion: false };
30
+ for (let i = 2; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === "--url" && argv[i + 1]) args.url = argv[++i];
33
+ else if (a === "--out" && argv[i + 1]) args.out = argv[++i];
34
+ else if (a === "--viewports" && argv[i + 1]) {
35
+ args.viewports = argv[++i].split(",").map((s) => parseInt(s, 10)).filter((n) => Number.isFinite(n) && n > 0);
36
+ } else if (a === "--wait" && argv[i + 1]) args.wait = parseInt(argv[++i], 10);
37
+ else if (a === "--reduced-motion") args.reducedMotion = true;
38
+ else if (a === "--help" || a === "-h") {
39
+ console.log(`playwright-capture.mjs — Screenshot capture for /qualia-polish-loop
40
+
41
+ Usage:
42
+ node playwright-capture.mjs --url <url> --out <dir> [--viewports 375,768,1440] [--wait 1500] [--reduced-motion]
43
+
44
+ Flags:
45
+ --reduced-motion Force prefers-reduced-motion: reduce in the captured page.
46
+ Use when the brief explicitly opts out of motion (a11y mode).
47
+
48
+ Backend selection (auto):
49
+ 1. Playwright — import('playwright') if installed
50
+ 2. Chromium — ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome
51
+ 3. Google Chrome — google-chrome on PATH
52
+ 4. Chromium — chromium on PATH`);
53
+ exit(0);
54
+ }
55
+ }
56
+ if (!args.url) { console.error("--url is required"); exit(2); }
57
+ if (!args.out) { console.error("--out is required"); exit(2); }
58
+ return args;
59
+ }
60
+
61
+ // ── Viewport name mapping ────────────────────────────────────────────────
62
+ function viewportName(width) {
63
+ if (width <= 480) return "mobile";
64
+ if (width <= 900) return "tablet";
65
+ return "desktop";
66
+ }
67
+ function viewportHeight(width) {
68
+ if (width <= 480) return 812;
69
+ if (width <= 900) return 1024;
70
+ return 900;
71
+ }
72
+
73
+ // ── Backend: Playwright (preferred when available) ──────────────────────
74
+ async function captureViaPlaywright(args) {
75
+ let chromium;
76
+ try {
77
+ ({ chromium } = await import("playwright"));
78
+ } catch {
79
+ try { ({ chromium } = await import("playwright-core")); } catch { return null; }
80
+ }
81
+ const results = [];
82
+ let browser = null;
83
+ try {
84
+ browser = await chromium.launch({ headless: true });
85
+ for (const width of args.viewports) {
86
+ const name = viewportName(width);
87
+ const height = viewportHeight(width);
88
+ const file = join(args.out, `${name}-${width}.png`);
89
+ try {
90
+ const ctxOpts = { viewport: { width, height }, deviceScaleFactor: 1 };
91
+ if (args.reducedMotion) ctxOpts.reducedMotion = "reduce";
92
+ const ctx = await browser.newContext(ctxOpts);
93
+ const page = await ctx.newPage();
94
+ await page.goto(args.url, { waitUntil: "networkidle", timeout: 30000 });
95
+ if (args.wait > 0) await page.waitForTimeout(args.wait);
96
+ await page.screenshot({ path: file, fullPage: false });
97
+ await ctx.close();
98
+ results.push({ viewport: name, width, height, file, ok: true, backend: "playwright", reducedMotion: !!args.reducedMotion });
99
+ } catch (err) {
100
+ results.push({ viewport: name, width, height, file, ok: false, backend: "playwright", error: err.message });
101
+ }
102
+ }
103
+ } finally {
104
+ if (browser) await browser.close();
105
+ }
106
+ return results;
107
+ }
108
+
109
+ // ── Backend: Chrome/Chromium headless via spawn ─────────────────────────
110
+ function findChromeBinary() {
111
+ // 1. Playwright cached chromium (newest first)
112
+ try {
113
+ const pwCache = join(homedir(), ".cache", "ms-playwright");
114
+ if (existsSync(pwCache)) {
115
+ const dirs = readdirSync(pwCache)
116
+ .filter((d) => d.startsWith("chromium-"))
117
+ .sort((a, b) => parseInt(b.split("-")[1], 10) - parseInt(a.split("-")[1], 10));
118
+ for (const d of dirs) {
119
+ for (const sub of ["chrome-linux64/chrome", "chrome-linux/chrome", "chrome-mac/Chromium.app/Contents/MacOS/Chromium", "chrome-win/chrome.exe"]) {
120
+ const p = join(pwCache, d, sub);
121
+ if (existsSync(p)) return p;
122
+ }
123
+ }
124
+ }
125
+ } catch {}
126
+
127
+ // 2. PATH lookups
128
+ for (const cmd of ["google-chrome", "chromium", "chromium-browser", "chrome"]) {
129
+ const r = spawnSync("which", [cmd], { encoding: "utf8" });
130
+ if (r.status === 0 && r.stdout) return r.stdout.trim();
131
+ }
132
+ return null;
133
+ }
134
+
135
+ function captureViaChromeBinary(args, binary) {
136
+ const results = [];
137
+ for (const width of args.viewports) {
138
+ const name = viewportName(width);
139
+ const height = viewportHeight(width);
140
+ const file = join(args.out, `${name}-${width}.png`);
141
+ const flags = [
142
+ "--headless=new",
143
+ "--no-sandbox",
144
+ "--disable-gpu",
145
+ "--disable-dev-shm-usage",
146
+ "--hide-scrollbars",
147
+ `--window-size=${width},${height}`,
148
+ `--screenshot=${file}`,
149
+ `--virtual-time-budget=${Math.max(args.wait + 1000, 3000)}`,
150
+ ];
151
+ if (args.reducedMotion) flags.push("--force-prefers-reduced-motion");
152
+ flags.push(args.url);
153
+ const r = spawnSync(binary, flags, { encoding: "utf8", timeout: 30000 });
154
+ let ok = r.status === 0 && existsSync(file);
155
+ let size = 0;
156
+ if (ok) {
157
+ try { size = statSync(file).size; } catch { ok = false; }
158
+ if (size < 100) ok = false; // empty/zero PNG → treat as failure
159
+ }
160
+ results.push({
161
+ viewport: name, width, height, file, ok,
162
+ backend: "chrome-binary", binary,
163
+ reducedMotion: !!args.reducedMotion,
164
+ ...(ok ? {} : { error: r.stderr ? r.stderr.split("\n").slice(0, 3).join(" / ") : `exit ${r.status}` }),
165
+ });
166
+ }
167
+ return results;
168
+ }
169
+
170
+ // ── Main ─────────────────────────────────────────────────────────────────
171
+ async function main() {
172
+ const args = parseArgs();
173
+ if (!existsSync(args.out)) mkdirSync(args.out, { recursive: true });
174
+
175
+ let captures = await captureViaPlaywright(args);
176
+ let backendUsed = "playwright";
177
+ if (captures === null) {
178
+ const bin = findChromeBinary();
179
+ if (!bin) {
180
+ console.error(JSON.stringify({
181
+ ok: false,
182
+ error: "no_browser_backend",
183
+ hint: "Install Playwright (`npm i playwright && npx playwright install chromium`) or ensure google-chrome / chromium is on PATH.",
184
+ }, null, 2));
185
+ exit(2);
186
+ }
187
+ captures = captureViaChromeBinary(args, bin);
188
+ backendUsed = "chrome-binary";
189
+ }
190
+
191
+ const failed = captures.filter((c) => !c.ok).length;
192
+ console.log(JSON.stringify({
193
+ url: args.url,
194
+ output_dir: args.out,
195
+ backend: backendUsed,
196
+ captures,
197
+ total: captures.length,
198
+ failed,
199
+ }, null, 2));
200
+ exit(failed > 0 ? 1 : 0);
201
+ }
202
+
203
+ main().catch((e) => {
204
+ console.error(JSON.stringify({ ok: false, error: e.message, stack: e.stack }, null, 2));
205
+ exit(2);
206
+ });