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.
- package/AGENTS.md +24 -0
- package/CLAUDE.md +12 -75
- package/README.md +23 -16
- package/agents/builder.md +9 -21
- package/agents/planner.md +8 -0
- package/agents/verifier.md +8 -0
- package/agents/visual-evaluator.md +132 -0
- package/bin/cli.js +54 -18
- package/bin/install.js +369 -29
- package/bin/qualia-ui.js +208 -1
- package/bin/slop-detect.mjs +5 -0
- package/bin/state.js +34 -1
- package/docs/install-redesign-builder-prompt.md +290 -0
- package/docs/install-redesign-pilot.md +234 -0
- package/docs/playwright-loop-builder-prompt.md +185 -0
- package/docs/playwright-loop-design-notes.md +108 -0
- package/docs/playwright-loop-pilot-results.md +170 -0
- package/docs/playwright-loop-tester-prompt.md +213 -0
- package/docs/polish-loop-supervised-run.md +111 -0
- package/docs/reviews/matt-pocock-skills-analysis.md +300 -0
- package/guide.md +9 -5
- package/hooks/env-empty-guard.js +74 -0
- package/hooks/pre-compact.js +19 -9
- package/hooks/pre-deploy-gate.js +8 -2
- package/hooks/pre-push.js +26 -12
- package/hooks/supabase-destructive-guard.js +62 -0
- package/hooks/vercel-account-guard.js +91 -0
- package/package.json +2 -1
- package/rules/design-brand.md +4 -0
- package/rules/design-laws.md +4 -0
- package/rules/design-product.md +4 -0
- package/rules/design-rubric.md +4 -0
- package/rules/grounding.md +4 -0
- package/skills/qualia-build/SKILL.md +40 -46
- package/skills/qualia-discuss/SKILL.md +51 -68
- package/skills/qualia-handoff/SKILL.md +1 -0
- package/skills/qualia-hook-gen/SKILL.md +206 -0
- package/skills/qualia-issues/SKILL.md +151 -0
- package/skills/qualia-map/SKILL.md +78 -35
- package/skills/qualia-new/REFERENCE.md +139 -0
- package/skills/qualia-new/SKILL.md +45 -121
- package/skills/qualia-optimize/REFERENCE.md +265 -0
- package/skills/qualia-optimize/SKILL.md +92 -232
- package/skills/qualia-plan/SKILL.md +58 -65
- package/skills/qualia-polish-loop/REFERENCE.md +265 -0
- package/skills/qualia-polish-loop/SKILL.md +201 -0
- package/skills/qualia-polish-loop/fixtures/broken.html +117 -0
- package/skills/qualia-polish-loop/fixtures/clean.html +196 -0
- package/skills/qualia-polish-loop/scripts/loop.mjs +323 -0
- package/skills/qualia-polish-loop/scripts/playwright-capture.mjs +206 -0
- package/skills/qualia-polish-loop/scripts/score.mjs +176 -0
- package/skills/qualia-prd/SKILL.md +199 -0
- package/skills/qualia-report/SKILL.md +141 -200
- package/skills/qualia-research/SKILL.md +28 -33
- package/skills/qualia-road/SKILL.md +103 -0
- package/skills/qualia-ship/SKILL.md +1 -0
- package/skills/qualia-task/SKILL.md +1 -1
- package/skills/qualia-test/SKILL.md +50 -2
- package/skills/qualia-triage/SKILL.md +152 -0
- package/skills/qualia-verify/SKILL.md +63 -104
- package/skills/qualia-zoom/SKILL.md +51 -0
- package/skills/zoho-workflow/SKILL.md +1 -1
- package/templates/CONTEXT.md +36 -0
- package/templates/decisions/ADR-template.md +30 -0
- package/tests/bin.test.sh +598 -7
- 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
|
+
});
|