@brunosps00/dev-workflow 1.0.5 → 1.1.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/README.md +17 -13
- package/bin/dev-workflow.js +5 -0
- package/lib/constants.js +6 -4
- package/lib/install-deps.js +88 -3
- package/lib/setup-wsl-browser.js +149 -0
- package/package.json +1 -1
- package/scaffold/en/agent-instructions.md +7 -5
- package/scaffold/en/commands/dw-analyze-project.md +22 -0
- package/scaffold/en/commands/dw-autopilot.md +158 -275
- package/scaffold/en/commands/dw-bugfix.md +3 -2
- package/scaffold/en/commands/dw-functional-doc.md +11 -3
- package/scaffold/en/commands/dw-generate-pr.md +2 -2
- package/scaffold/en/commands/dw-goal.md +136 -0
- package/scaffold/en/commands/dw-help.md +9 -7
- package/scaffold/en/commands/dw-plan.md +9 -3
- package/scaffold/en/commands/dw-qa.md +1 -1
- package/scaffold/en/commands/dw-redesign-ui.md +2 -2
- package/scaffold/en/commands/dw-review.md +7 -5
- package/scaffold/en/commands/dw-secure-audit.md +72 -15
- package/scaffold/en/templates/constitution-template.md +8 -3
- package/scaffold/en/templates/functional-doc/playwright.flow.mjs.tpl +17 -0
- package/scaffold/pt-br/agent-instructions.md +7 -5
- package/scaffold/pt-br/commands/dw-analyze-project.md +22 -0
- package/scaffold/pt-br/commands/dw-autopilot.md +161 -278
- package/scaffold/pt-br/commands/dw-bugfix.md +3 -2
- package/scaffold/pt-br/commands/dw-functional-doc.md +11 -3
- package/scaffold/pt-br/commands/dw-generate-pr.md +2 -2
- package/scaffold/pt-br/commands/dw-goal.md +136 -0
- package/scaffold/pt-br/commands/dw-help.md +9 -7
- package/scaffold/pt-br/commands/dw-plan.md +9 -3
- package/scaffold/pt-br/commands/dw-qa.md +1 -1
- package/scaffold/pt-br/commands/dw-redesign-ui.md +2 -2
- package/scaffold/pt-br/commands/dw-review.md +7 -5
- package/scaffold/pt-br/commands/dw-secure-audit.md +72 -15
- package/scaffold/pt-br/templates/constitution-template.md +8 -3
- package/scaffold/pt-br/templates/functional-doc/playwright.flow.mjs.tpl +17 -0
- package/scaffold/scripts/cdp-relay/Cargo.toml +16 -0
- package/scaffold/scripts/cdp-relay/src/main.rs +49 -0
- package/scaffold/scripts/functional-doc/generate-dossier.mjs +7 -9
- package/scaffold/scripts/functional-doc/run-playwright-flow.mjs +192 -183
- package/scaffold/scripts/lib/capture-screenshots.mjs +133 -0
- package/scaffold/scripts/lib/resolve-browser.mjs +405 -0
- package/scaffold/scripts/lib/ui-slop-detect.mjs +102 -0
- package/scaffold/skills/dw-testing-discipline/references/playwright-recipes.md +50 -0
- package/scaffold/skills/dw-ui-discipline/SKILL.md +5 -0
- package/scaffold/skills/dw-ui-discipline/references/color-oklch.md +60 -0
- package/scaffold/skills/dw-ui-discipline/references/motion.md +52 -0
- package/scaffold/skills/dw-ui-discipline/references/responsive.md +52 -0
- package/scaffold/skills/dw-ui-discipline/references/type-scale.md +48 -0
- package/scaffold/skills/dw-ui-discipline/references/ux-writing.md +52 -0
- package/scaffold/skills/security-review/SKILL.md +19 -0
- package/scaffold/skills/security-review/references/sast.md +67 -0
- package/scaffold/skills/security-review/references/secrets.md +46 -0
- package/scaffold/en/templates/functional-doc/playwright.spec.ts.tpl +0 -19
- package/scaffold/pt-br/templates/functional-doc/playwright.spec.ts.tpl +0 -19
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Resolves which browser to drive for dev-workflow's browser-automation flows
|
|
4
|
+
// (dw-qa, dw-functional-doc, dw-redesign-ui), with WSL resilience.
|
|
5
|
+
//
|
|
6
|
+
// Resolution order: env BROWSER_TEST -> .dw/config.json (browserTest) -> auto-detect.
|
|
7
|
+
// On WSL with no explicit config, auto-detects a Windows browser (Edge first, then
|
|
8
|
+
// Chrome) and launches it in remote-debugging mode so flows connect over CDP instead
|
|
9
|
+
// of opening WSLg / losing layout fidelity in the bundled Linux Chromium.
|
|
10
|
+
//
|
|
11
|
+
// Returns one of:
|
|
12
|
+
// { mode: "cdp", endpoint, source, cleanup } // connect over CDP (browser ready)
|
|
13
|
+
// { mode: "launch", launchOptions, source } // let Playwright launch the browser
|
|
14
|
+
//
|
|
15
|
+
// DEFAULT (no BROWSER_TEST): the full Playwright Chromium in headless via
|
|
16
|
+
// channel:"chromium", which uses the new headless mode (desktop-faithful rendering,
|
|
17
|
+
// not the lighter headless-shell) and never opens WSLg. This is the robust default on
|
|
18
|
+
// WSL. Set BROWSER_TEST to opt into a Windows browser.
|
|
19
|
+
//
|
|
20
|
+
// NOTE on WSL networking: the Chromium debug port binds Windows loopback only. In mirrored
|
|
21
|
+
// networking mode WSL shares that loopback, so 127.0.0.1 connects directly. In NAT mode the
|
|
22
|
+
// helper starts the prebuilt cdp-relay.exe on Windows (0.0.0.0:<fixed port> -> 127.0.0.1:debug)
|
|
23
|
+
// and connects via the Windows host gateway. That needs the one-time setup performed by
|
|
24
|
+
// `npx @brunosps00/dev-workflow setup-wsl-browser` (builds the relay + adds the Hyper-V firewall
|
|
25
|
+
// rule). Without it, the helper falls back to the local headless Chromium.
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import net from "node:net";
|
|
29
|
+
import http from "node:http";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
32
|
+
|
|
33
|
+
// Full Chromium in new-headless mode: desktop-faithful rendering, no WSLg.
|
|
34
|
+
const BUNDLED_LAUNCH = { channel: "chromium" };
|
|
35
|
+
|
|
36
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
const dedupe = (items) => [...new Set(items.filter(Boolean))];
|
|
38
|
+
|
|
39
|
+
export function detectWsl() {
|
|
40
|
+
if (process.env.WSL_DISTRO_NAME) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return /microsoft/i.test(fs.readFileSync("/proc/version", "utf8"));
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// WSL2 mirrored networking creates a `loopback0` interface and shares Windows' loopback, so
|
|
51
|
+
// 127.0.0.1 reaches a Windows browser's debug port directly. NAT mode has no loopback0 and
|
|
52
|
+
// needs the Windows-side relay instead. Used to choose between the two connection strategies.
|
|
53
|
+
export function isMirroredNetworking() {
|
|
54
|
+
return fs.existsSync("/sys/class/net/loopback0");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readConfigValue(projectRoot) {
|
|
58
|
+
if (process.env.BROWSER_TEST && process.env.BROWSER_TEST.trim()) {
|
|
59
|
+
return { value: process.env.BROWSER_TEST.trim(), source: "env:BROWSER_TEST" };
|
|
60
|
+
}
|
|
61
|
+
if (projectRoot) {
|
|
62
|
+
try {
|
|
63
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, ".dw", "config.json"), "utf8"));
|
|
64
|
+
if (cfg && typeof cfg.browserTest === "string" && cfg.browserTest.trim()) {
|
|
65
|
+
return { value: cfg.browserTest.trim(), source: ".dw/config.json" };
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// no config / unreadable -> fall through to auto-detect
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { value: undefined, source: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function classifyValue(value) {
|
|
75
|
+
if (/^(https?:\/\/|cdp:\/\/)/i.test(value)) {
|
|
76
|
+
return "cdp";
|
|
77
|
+
}
|
|
78
|
+
if (/\.exe$/i.test(value) || value.startsWith("/mnt/")) {
|
|
79
|
+
return "exe";
|
|
80
|
+
}
|
|
81
|
+
if (["chrome", "msedge", "chromium"].includes(value.toLowerCase())) {
|
|
82
|
+
return "channel";
|
|
83
|
+
}
|
|
84
|
+
return value.includes("/") ? "exe" : "channel";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getFreePort() {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const server = net.createServer();
|
|
90
|
+
server.unref();
|
|
91
|
+
server.on("error", reject);
|
|
92
|
+
server.listen(0, "127.0.0.1", () => {
|
|
93
|
+
const { port } = server.address();
|
|
94
|
+
server.close(() => resolve(port));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function windowsHostIp() {
|
|
100
|
+
// In WSL2 NAT mode the Windows host is the default-route gateway (the vEthernet
|
|
101
|
+
// adapter), NOT the /etc/resolv.conf nameserver (which can be a DNS-tunneling proxy
|
|
102
|
+
// like 10.255.255.254 that does not forward arbitrary TCP ports). Prefer the gateway.
|
|
103
|
+
try {
|
|
104
|
+
const out = spawnSync("ip", ["route", "show", "default"], { encoding: "utf8" });
|
|
105
|
+
const match = out.stdout?.match(/default via ([0-9.]+)/);
|
|
106
|
+
if (match) {
|
|
107
|
+
return match[1];
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// ignore
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const match = fs.readFileSync("/etc/resolv.conf", "utf8").match(/nameserver\s+([0-9.]+)/);
|
|
114
|
+
if (match) {
|
|
115
|
+
return match[1];
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// ignore
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function httpGetJson(url, timeoutMs = 1000) {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const request = http.get(url, { timeout: timeoutMs }, (response) => {
|
|
126
|
+
let data = "";
|
|
127
|
+
response.on("data", (chunk) => (data += chunk));
|
|
128
|
+
response.on("end", () => {
|
|
129
|
+
try {
|
|
130
|
+
resolve(JSON.parse(data));
|
|
131
|
+
} catch {
|
|
132
|
+
resolve(null);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
request.on("error", () => resolve(null));
|
|
137
|
+
request.on("timeout", () => {
|
|
138
|
+
request.destroy();
|
|
139
|
+
resolve(null);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function findReachableCdp(port, extraHosts = []) {
|
|
145
|
+
const hosts = dedupe(["127.0.0.1", "localhost", ...extraHosts, windowsHostIp()]);
|
|
146
|
+
for (const host of hosts) {
|
|
147
|
+
const base = `http://${host}:${port}`;
|
|
148
|
+
const info = await httpGetJson(`${base}/json/version`);
|
|
149
|
+
if (info) {
|
|
150
|
+
return { base, host, info };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function waitForCdp(hosts, port, { timeoutMs = 12000, intervalMs = 250 } = {}) {
|
|
157
|
+
const deadline = Date.now() + timeoutMs;
|
|
158
|
+
while (Date.now() < deadline) {
|
|
159
|
+
for (const host of dedupe(hosts)) {
|
|
160
|
+
const base = `http://${host}:${port}`;
|
|
161
|
+
const info = await httpGetJson(`${base}/json/version`);
|
|
162
|
+
if (info) {
|
|
163
|
+
return { base, host, info };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
await sleep(intervalMs);
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A Windows path for the launched browser (it runs on Windows). Returns { winPath, wslPath }.
|
|
172
|
+
function windowsTempDir() {
|
|
173
|
+
const rand = `dw-browser-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
174
|
+
let winTemp = null;
|
|
175
|
+
try {
|
|
176
|
+
winTemp = spawnSync("cmd.exe", ["/c", "echo %TEMP%"], { encoding: "utf8" }).stdout?.split(/\r?\n/)[0]?.trim() || null;
|
|
177
|
+
} catch {
|
|
178
|
+
winTemp = null;
|
|
179
|
+
}
|
|
180
|
+
if (!winTemp || !/^[A-Za-z]:\\/.test(winTemp)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const winPath = `${winTemp}\\${rand}`;
|
|
184
|
+
let wslPath = null;
|
|
185
|
+
try {
|
|
186
|
+
wslPath = spawnSync("wslpath", ["-u", winPath], { encoding: "utf8" }).stdout?.trim() || null;
|
|
187
|
+
} catch {
|
|
188
|
+
wslPath = null;
|
|
189
|
+
}
|
|
190
|
+
return wslPath ? { winPath, wslPath } : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Fixed port the Windows-side relay listens on, so a single Hyper-V firewall rule covers it.
|
|
194
|
+
const DEFAULT_RELAY_PORT = 39222;
|
|
195
|
+
|
|
196
|
+
function relayPort(projectRoot) {
|
|
197
|
+
const fromEnv = Number(process.env.DW_RELAY_PORT);
|
|
198
|
+
if (fromEnv) return fromEnv;
|
|
199
|
+
if (projectRoot) {
|
|
200
|
+
try {
|
|
201
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, ".dw", "config.json"), "utf8"));
|
|
202
|
+
if (Number(cfg?.relayPort)) return Number(cfg.relayPort);
|
|
203
|
+
} catch {
|
|
204
|
+
// ignore
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return DEFAULT_RELAY_PORT;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The prebuilt Rust relay (cdp-relay.exe), built on Windows by `setup-wsl-browser`. Order:
|
|
211
|
+
// .dw/config.json relayExe -> %LOCALAPPDATA%\dev-workflow\cdp-relay.exe. Returns { winPath, wslPath } or null.
|
|
212
|
+
function findRelayExe(projectRoot) {
|
|
213
|
+
const candidates = [];
|
|
214
|
+
if (projectRoot) {
|
|
215
|
+
try {
|
|
216
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, ".dw", "config.json"), "utf8"));
|
|
217
|
+
if (typeof cfg?.relayExe === "string" && cfg.relayExe.trim()) candidates.push(cfg.relayExe.trim());
|
|
218
|
+
} catch {
|
|
219
|
+
// ignore
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const lad = spawnSync("cmd.exe", ["/c", "echo %LOCALAPPDATA%"], { encoding: "utf8" }).stdout?.split(/\r?\n/)[0]?.trim();
|
|
224
|
+
if (lad && /^[A-Za-z]:\\/.test(lad)) candidates.push(`${lad}\\dev-workflow\\cdp-relay.exe`);
|
|
225
|
+
} catch {
|
|
226
|
+
// ignore
|
|
227
|
+
}
|
|
228
|
+
for (const winPath of candidates) {
|
|
229
|
+
let wslPath = null;
|
|
230
|
+
try {
|
|
231
|
+
wslPath = spawnSync("wslpath", ["-u", winPath], { encoding: "utf8" }).stdout?.trim() || null;
|
|
232
|
+
} catch {
|
|
233
|
+
// ignore
|
|
234
|
+
}
|
|
235
|
+
if (wslPath && fs.existsSync(wslPath)) return { winPath, wslPath };
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Launches the Windows browser in remote-debugging mode and returns a CDP descriptor.
|
|
241
|
+
// - mirrored networking: WSL shares the Windows loopback (Hyper-V LoopbackEnabled), connect 127.0.0.1.
|
|
242
|
+
// - NAT networking: the prebuilt cdp-relay.exe bridges 0.0.0.0:<relayPort> -> 127.0.0.1:<debug> on the
|
|
243
|
+
// Windows side, and WSL connects via the Windows host gateway. This needs a one-time Hyper-V firewall
|
|
244
|
+
// inbound rule for <relayPort> (added by `setup-wsl-browser`), because WSL's Hyper-V firewall defaults
|
|
245
|
+
// inbound to Block. The browser echoes the request Host into webSocketDebuggerUrl, so the ws stays
|
|
246
|
+
// reachable through the relay.
|
|
247
|
+
// --remote-allow-origins=* is required or Chromium drops the DevTools ws (anti-DNS-rebinding).
|
|
248
|
+
async function launchWindowsBrowser(exePath, { headless, projectRoot }) {
|
|
249
|
+
const mirrored = isMirroredNetworking();
|
|
250
|
+
const tmp = windowsTempDir();
|
|
251
|
+
if (tmp) {
|
|
252
|
+
try {
|
|
253
|
+
fs.mkdirSync(tmp.wslPath, { recursive: true });
|
|
254
|
+
} catch {
|
|
255
|
+
// best effort
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const debugPort = await getFreePort();
|
|
260
|
+
const args = [
|
|
261
|
+
`--remote-debugging-port=${debugPort}`,
|
|
262
|
+
"--remote-allow-origins=*",
|
|
263
|
+
"--no-first-run",
|
|
264
|
+
"--no-default-browser-check",
|
|
265
|
+
];
|
|
266
|
+
if (tmp) args.push(`--user-data-dir=${tmp.winPath}\\profile`);
|
|
267
|
+
if (headless) args.push("--headless=new");
|
|
268
|
+
args.push("about:blank");
|
|
269
|
+
|
|
270
|
+
const browserChild = spawn(exePath, args, { detached: true, stdio: "ignore" });
|
|
271
|
+
browserChild.unref();
|
|
272
|
+
|
|
273
|
+
const cleanups = [() => browserChild.kill()];
|
|
274
|
+
if (tmp) cleanups.push(() => fs.rmSync(tmp.wslPath, { recursive: true, force: true }));
|
|
275
|
+
const cleanup = async () => {
|
|
276
|
+
for (const fn of cleanups) {
|
|
277
|
+
try {
|
|
278
|
+
await fn();
|
|
279
|
+
} catch {
|
|
280
|
+
// ignore
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
let hosts;
|
|
286
|
+
let port;
|
|
287
|
+
if (mirrored) {
|
|
288
|
+
hosts = ["127.0.0.1", "localhost"];
|
|
289
|
+
port = debugPort;
|
|
290
|
+
} else {
|
|
291
|
+
const relay = findRelayExe(projectRoot);
|
|
292
|
+
if (!relay) {
|
|
293
|
+
await cleanup();
|
|
294
|
+
throw new Error(
|
|
295
|
+
"WSL is in NAT mode; reaching the Windows browser needs cdp-relay.exe. Run " +
|
|
296
|
+
"`npx @brunosps00/dev-workflow setup-wsl-browser` (builds the relay on Windows and adds the " +
|
|
297
|
+
"Hyper-V firewall rule). Alternatively enable mirrored networking or set BROWSER_TEST to a CDP URL.",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const rport = relayPort(projectRoot);
|
|
301
|
+
const relayChild = spawn(relay.wslPath, [String(rport), String(debugPort)], { detached: true, stdio: "ignore" });
|
|
302
|
+
relayChild.unref();
|
|
303
|
+
cleanups.unshift(() => relayChild.kill());
|
|
304
|
+
hosts = [windowsHostIp(), "127.0.0.1"].filter(Boolean);
|
|
305
|
+
port = rport;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const found = await waitForCdp(hosts, port);
|
|
309
|
+
if (!found) {
|
|
310
|
+
await cleanup();
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Launched ${path.basename(exePath)} but its CDP endpoint was not reachable (${hosts.join("/")}:${port}). ` +
|
|
313
|
+
"The Hyper-V firewall rule may be missing — run `npx @brunosps00/dev-workflow setup-wsl-browser`.",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { mode: "cdp", endpoint: found.base, source: `launched:${path.basename(exePath)}${mirrored ? "" : "+relay"}`, cleanup };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const noopCleanup = async () => {};
|
|
321
|
+
|
|
322
|
+
function bundledDescriptor(headless, source = "bundled") {
|
|
323
|
+
return { mode: "launch", launchOptions: { ...BUNDLED_LAUNCH, headless }, source };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// When an explicit BROWSER_TEST cannot be honored, degrade to the headless default
|
|
327
|
+
// (resilient) unless allowFallback is false (then throw, e.g. for diagnostics).
|
|
328
|
+
function degradeOrThrow(message, { headless, allowFallback }) {
|
|
329
|
+
if (allowFallback) {
|
|
330
|
+
process.stderr.write(`resolve-browser: ${message} Falling back to headless Chromium.\n`);
|
|
331
|
+
return bundledDescriptor(headless, "fallback");
|
|
332
|
+
}
|
|
333
|
+
throw new Error(message);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function resolveBrowser({ projectRoot, headless = true, allowFallback = true } = {}) {
|
|
337
|
+
const { value, source } = readConfigValue(projectRoot);
|
|
338
|
+
|
|
339
|
+
if (!value) {
|
|
340
|
+
return bundledDescriptor(headless);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const kind = classifyValue(value);
|
|
344
|
+
|
|
345
|
+
if (kind === "cdp") {
|
|
346
|
+
const url = new URL(value.replace(/^cdp:\/\//i, "http://"));
|
|
347
|
+
const port = Number(url.port) || 9222;
|
|
348
|
+
const found = await findReachableCdp(port, [url.hostname]);
|
|
349
|
+
if (!found) {
|
|
350
|
+
return degradeOrThrow(
|
|
351
|
+
`BROWSER_TEST endpoint '${value}' is not reachable (start the browser with --remote-debugging-port=${port}).`,
|
|
352
|
+
{ headless, allowFallback },
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return { mode: "cdp", endpoint: found.base, source, cleanup: noopCleanup };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (kind === "exe") {
|
|
359
|
+
if (!fs.existsSync(value)) {
|
|
360
|
+
return degradeOrThrow(`BROWSER_TEST points to '${value}', but that file does not exist.`, {
|
|
361
|
+
headless,
|
|
362
|
+
allowFallback,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
return await launchWindowsBrowser(value, { headless, projectRoot });
|
|
367
|
+
} catch (error) {
|
|
368
|
+
return degradeOrThrow(error.message, { headless, allowFallback });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// channel: chrome | msedge | chromium
|
|
373
|
+
const channel = value.toLowerCase();
|
|
374
|
+
return { mode: "launch", launchOptions: { channel, headless }, source };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// CLI probe: `node resolve-browser.mjs [--project-root <dir>] [--headed] [--keep-open]`
|
|
378
|
+
// Prints the resolved descriptor as JSON (cleans up any launched browser unless --keep-open).
|
|
379
|
+
function parseProbeArgs(argv) {
|
|
380
|
+
const out = {};
|
|
381
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
382
|
+
const token = argv[i];
|
|
383
|
+
if (token === "--headed") out.headed = true;
|
|
384
|
+
else if (token === "--keep-open") out.keepOpen = true;
|
|
385
|
+
else if (token === "--project-root") out.projectRoot = argv[(i += 1)];
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith("resolve-browser.mjs");
|
|
391
|
+
if (invokedDirectly) {
|
|
392
|
+
const args = parseProbeArgs(process.argv.slice(2));
|
|
393
|
+
resolveBrowser({ projectRoot: args.projectRoot ?? process.cwd(), headless: !args.headed })
|
|
394
|
+
.then(async (descriptor) => {
|
|
395
|
+
const { cleanup, ...printable } = descriptor;
|
|
396
|
+
process.stdout.write(`${JSON.stringify(printable, null, 2)}\n`);
|
|
397
|
+
if (typeof cleanup === "function" && !args.keepOpen) {
|
|
398
|
+
await cleanup();
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
.catch((error) => {
|
|
402
|
+
process.stderr.write(`resolve-browser: ${error.message}\n`);
|
|
403
|
+
process.exitCode = 1;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Thin wrapper around `impeccable detect` — the deterministic UI design-slop detector by
|
|
4
|
+
// Paul Bakaus (https://github.com/pbakaus/impeccable, Apache-2.0, derived from Anthropic's
|
|
5
|
+
// frontend-design skill). dev-workflow does NOT vendor it; npx fetches it on demand, so
|
|
6
|
+
// there is no extra dependency to install.
|
|
7
|
+
//
|
|
8
|
+
// Purpose: turn "the LLM might notice slop" into a deterministic gate. /dw-review (UI diffs)
|
|
9
|
+
// and /dw-verify run this and block on findings at or above --fail-on severity.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// node .dw/scripts/lib/ui-slop-detect.mjs [paths...] [--fail-on error] [--fast]
|
|
13
|
+
// (defaults: paths=".", --fail-on=error)
|
|
14
|
+
//
|
|
15
|
+
// Output: normalized JSON { failOn, summary: { bySeverity }, blocking, findings }.
|
|
16
|
+
// Exit: 1 if any finding >= failOn; 0 otherwise (including when impeccable cannot run — a
|
|
17
|
+
// missing detector should not hard-fail a review, only warn).
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
const SEVERITY_RANK = { info: 0, warning: 1, error: 2, critical: 3 };
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const paths = [];
|
|
25
|
+
const flags = { failOn: "error", fast: false };
|
|
26
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
27
|
+
const token = argv[index];
|
|
28
|
+
if (token === "--fast") {
|
|
29
|
+
flags.fast = true;
|
|
30
|
+
} else if (token === "--fail-on") {
|
|
31
|
+
flags.failOn = String(argv[(index += 1)] ?? "error").toLowerCase();
|
|
32
|
+
} else if (token.startsWith("--fail-on=")) {
|
|
33
|
+
flags.failOn = token.slice("--fail-on=".length).toLowerCase();
|
|
34
|
+
} else if (!token.startsWith("--")) {
|
|
35
|
+
paths.push(token);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (paths.length === 0) {
|
|
39
|
+
paths.push(".");
|
|
40
|
+
}
|
|
41
|
+
return { paths, flags };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rank(severity) {
|
|
45
|
+
return SEVERITY_RANK[String(severity).toLowerCase()] ?? 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function main() {
|
|
49
|
+
const { paths, flags } = parseArgs(process.argv.slice(2));
|
|
50
|
+
const detectArgs = ["-y", "impeccable@latest", "detect", "--json"];
|
|
51
|
+
if (flags.fast) {
|
|
52
|
+
detectArgs.push("--fast");
|
|
53
|
+
}
|
|
54
|
+
detectArgs.push(...paths);
|
|
55
|
+
|
|
56
|
+
const result = spawnSync("npx", detectArgs, { encoding: "utf8", timeout: 180000 });
|
|
57
|
+
if (result.error || typeof result.stdout !== "string") {
|
|
58
|
+
process.stderr.write(
|
|
59
|
+
`ui-slop-detect: could not run impeccable (${result.error?.message ?? "no output"}). ` +
|
|
60
|
+
"Skipping the deterministic UI gate — install Node/npx or run `npx impeccable detect` manually.\n",
|
|
61
|
+
);
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let findings;
|
|
66
|
+
try {
|
|
67
|
+
findings = JSON.parse(result.stdout);
|
|
68
|
+
if (!Array.isArray(findings)) {
|
|
69
|
+
throw new Error("unexpected output shape");
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
process.stderr.write(`ui-slop-detect: could not parse impeccable output (${error.message}).\n`);
|
|
73
|
+
process.stdout.write(result.stdout);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const bySeverity = {};
|
|
78
|
+
for (const finding of findings) {
|
|
79
|
+
const severity = String(finding.severity ?? "warning").toLowerCase();
|
|
80
|
+
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const threshold = rank(flags.failOn);
|
|
84
|
+
const blocking = findings.filter((finding) => rank(finding.severity) >= threshold);
|
|
85
|
+
|
|
86
|
+
process.stdout.write(
|
|
87
|
+
`${JSON.stringify(
|
|
88
|
+
{
|
|
89
|
+
failOn: flags.failOn,
|
|
90
|
+
summary: { total: findings.length, bySeverity },
|
|
91
|
+
blocking: blocking.length,
|
|
92
|
+
findings,
|
|
93
|
+
},
|
|
94
|
+
null,
|
|
95
|
+
2,
|
|
96
|
+
)}\n`,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
process.exit(blocking.length > 0 ? 1 : 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
main();
|
|
@@ -272,6 +272,56 @@ Tests now start authenticated. No login loop in every test.
|
|
|
272
272
|
- Some authentication flows (Google Sign-In, MFA hardware keys) cannot be automated; use test-mode bypasses with a dedicated test account.
|
|
273
273
|
- Visual regression tests are sensitive to font rendering across OSes; pin to a CI runner OS.
|
|
274
274
|
|
|
275
|
+
## Browser on WSL (resilient resolution)
|
|
276
|
+
|
|
277
|
+
WSL makes browser automation fragile: the Playwright MCP may be blocked, headed runs open
|
|
278
|
+
WSLg, and the bundled headless can lose fidelity. dev-workflow ships `.dw/scripts/lib/resolve-browser.mjs`
|
|
279
|
+
to pick a browser deterministically. Both `.dw/scripts/functional-doc/run-playwright-flow.mjs`
|
|
280
|
+
(video, via `page.screencast`) and `.dw/scripts/lib/capture-screenshots.mjs` (responsive shots)
|
|
281
|
+
use it, so the same rules apply everywhere.
|
|
282
|
+
|
|
283
|
+
Resolution order: `BROWSER_TEST` env → `.dw/config.json` (`browserTest`) → default.
|
|
284
|
+
|
|
285
|
+
- **Default (no config):** full Chromium in new-headless mode (`channel: "chromium"`) — desktop-faithful
|
|
286
|
+
rendering, never opens WSLg. This is the recommended default on WSL.
|
|
287
|
+
- **`BROWSER_TEST` = CDP URL** (e.g. `http://localhost:9222`): connects with `chromium.connectOverCDP`.
|
|
288
|
+
Tries `127.0.0.1` first, then the Windows host (default-route gateway) for NAT.
|
|
289
|
+
- **`BROWSER_TEST` = Windows exe** (e.g. `/mnt/c/.../msedge.exe`): launches it with a temp profile in
|
|
290
|
+
remote-debugging mode (`--remote-allow-origins=*`) and connects over CDP. **Works in both WSL2 modes:**
|
|
291
|
+
in mirrored networking it connects to `127.0.0.1` directly; in NAT mode it starts the prebuilt
|
|
292
|
+
**`cdp-relay.exe`** (a tiny Rust binary, no runtime) on Windows — `0.0.0.0:39222` → `127.0.0.1:<debug>` —
|
|
293
|
+
and connects via the Windows host gateway. Falls back to headless Chromium if the relay/rule is absent.
|
|
294
|
+
- **`BROWSER_TEST` = channel** (`chrome` | `msedge` | `chromium`): launches that channel locally.
|
|
295
|
+
|
|
296
|
+
NAT mode needs a one-time setup (the WSL **Hyper-V firewall** defaults inbound to Block, and Chromium
|
|
297
|
+
binds its debug port to loopback only):
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
npx @brunosps00/dev-workflow setup-wsl-browser
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
This builds `cdp-relay.exe` with the Windows Rust toolchain (`rustup` on Windows) and adds a Hyper-V
|
|
304
|
+
firewall inbound allow rule for TCP 39222 (one UAC prompt). After that, the *real* Windows browser is
|
|
305
|
+
driven from WSL — including `page.screencast` video — under NAT. The launched browser and relay use a
|
|
306
|
+
throwaway profile and are killed on exit.
|
|
307
|
+
|
|
308
|
+
Two env vars, two jobs — keep them distinct:
|
|
309
|
+
- `BROWSER` — opens URLs for a human (used by `/dw-generate-pr`).
|
|
310
|
+
- `BROWSER_TEST` — the automation target for QA / functional-doc / redesign capture.
|
|
311
|
+
|
|
312
|
+
To drive the real Windows browser, set `BROWSER_TEST` to its exe path (NAT works as long as `node` is on
|
|
313
|
+
the Windows PATH; mirrored works too). To force the faithful local-headless path instead, leave
|
|
314
|
+
`BROWSER_TEST` unset or set it to `chromium`.
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
# Probe what would be used (cleans up any launched browser):
|
|
318
|
+
node .dw/scripts/lib/resolve-browser.mjs --project-root .
|
|
319
|
+
|
|
320
|
+
# Responsive screenshots fallback when the Playwright MCP is unavailable:
|
|
321
|
+
node .dw/scripts/lib/capture-screenshots.mjs --url http://localhost:3000/path \
|
|
322
|
+
--out QA/evidence/ui --widths 375,1440 --slug home
|
|
323
|
+
```
|
|
324
|
+
|
|
275
325
|
## Cross-skill integration
|
|
276
326
|
|
|
277
327
|
When running these recipes, the doctrine in this skill applies:
|
|
@@ -92,6 +92,11 @@ Without this sentence, defaults take over: light mode, default contrasts, animat
|
|
|
92
92
|
| Designing state coverage | `references/state-matrix.md` |
|
|
93
93
|
| Interactive widget (button, form, modal, anything clickable) | `references/accessibility-floor.md` |
|
|
94
94
|
| No design authority exists in the project | `references/curated-defaults.md` (palettes / fonts / scales) |
|
|
95
|
+
| Choosing or reviewing a palette / dark mode | `references/color-oklch.md` (OKLCH, tinted neutrals, contrast) |
|
|
96
|
+
| Fonts, sizing, hierarchy, line length | `references/type-scale.md` (modular scale, pairing, measure) |
|
|
97
|
+
| Adding animation or transitions | `references/motion.md` (easing, duration, reduced-motion) |
|
|
98
|
+
| Cross-device / breakpoints / touch | `references/responsive.md` (mobile-first, container queries) |
|
|
99
|
+
| Any user-facing copy (labels, errors, empty states) | `references/ux-writing.md` |
|
|
95
100
|
|
|
96
101
|
## The 14 visual-slop patterns (full catalog in `references/visual-slop.md`)
|
|
97
102
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Color & contrast — OKLCH, tinted neutrals, dark mode
|
|
2
|
+
|
|
3
|
+
> Patterns adapted from [impeccable](https://github.com/pbakaus/impeccable) (Apache-2.0, derived from
|
|
4
|
+
> Anthropic's frontend-design skill). Read when building or reviewing a palette.
|
|
5
|
+
|
|
6
|
+
## Work in OKLCH, not hex/HSL
|
|
7
|
+
|
|
8
|
+
OKLCH (`oklch(L C H)`) is perceptually uniform: equal lightness steps *look* equally spaced, and changing
|
|
9
|
+
hue does not shift perceived brightness (HSL's big flaw — HSL yellow at 50% L looks far lighter than HSL
|
|
10
|
+
blue at 50% L). Modern browsers support `oklch()` natively.
|
|
11
|
+
|
|
12
|
+
- **L** (lightness) 0–100% — drive your scale off this.
|
|
13
|
+
- **C** (chroma) 0–~0.37 — saturation. Keep neutrals low (0.005–0.03), accents moderate (0.1–0.2).
|
|
14
|
+
- **H** (hue) 0–360.
|
|
15
|
+
|
|
16
|
+
## Tinted neutrals (the single highest-leverage move)
|
|
17
|
+
|
|
18
|
+
Pure grays (`#777`, `oklch(50% 0 0)`) look dead. Tint every neutral slightly toward one hue — usually the
|
|
19
|
+
brand hue or its complement — so the UI feels intentional.
|
|
20
|
+
|
|
21
|
+
```css
|
|
22
|
+
/* Cool, slightly blue-tinted neutral scale */
|
|
23
|
+
--n-50: oklch(98% 0.005 250);
|
|
24
|
+
--n-100: oklch(96% 0.006 250);
|
|
25
|
+
--n-300: oklch(87% 0.010 250);
|
|
26
|
+
--n-500: oklch(62% 0.014 250);
|
|
27
|
+
--n-700: oklch(42% 0.014 250);
|
|
28
|
+
--n-900: oklch(22% 0.012 250);
|
|
29
|
+
--n-950: oklch(14% 0.010 250);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Pure `#000` / `#fff` are anti-patterns (the detector flags `pure-black-white`). Use `oklch(14% 0.01 H)`
|
|
33
|
+
for "black" surfaces and `oklch(98% 0.005 H)` for "white".
|
|
34
|
+
|
|
35
|
+
## Building a scale
|
|
36
|
+
|
|
37
|
+
Pick the hue + chroma, then step lightness on a roughly even curve (98 / 96 / 92 / 87 / 78 / 68 / 62 / 52 /
|
|
38
|
+
42 / 32 / 22 / 14). Keep chroma roughly constant for neutrals; for an accent, let chroma peak in the mid
|
|
39
|
+
lightness range (most vivid at 55–65% L) and fall off at the extremes.
|
|
40
|
+
|
|
41
|
+
## Accent & semantic colors
|
|
42
|
+
|
|
43
|
+
- One primary accent carries the brand. Avoid the training-data default blue `#3B82F6` — shift the hue or
|
|
44
|
+
darken it (`oklch(55% 0.18 255)` reads more deliberate).
|
|
45
|
+
- Semantic set: success (green ~145H), warning (amber ~75H), danger (red ~25H), info (blue ~250H). Keep
|
|
46
|
+
their lightness consistent with each other so badges feel like a family.
|
|
47
|
+
|
|
48
|
+
## Contrast (non-negotiable, see accessibility-floor.md)
|
|
49
|
+
|
|
50
|
+
- Body text ≥ 4.5:1; large text (≥24px or ≥18.7px bold) and UI component boundaries ≥ 3:1.
|
|
51
|
+
- Don't rely on hue alone to convey state — pair color with icon/text (color-blind users).
|
|
52
|
+
- Test the *actual* foreground/background pair, not the token names.
|
|
53
|
+
|
|
54
|
+
## Dark mode
|
|
55
|
+
|
|
56
|
+
Dark mode is not "invert lightness". Re-tune:
|
|
57
|
+
- Raise surface lightness in steps as elements come forward (`oklch(16%)` page → `18%` card → `22%`
|
|
58
|
+
popover) instead of borders everywhere.
|
|
59
|
+
- Reduce accent chroma slightly in dark mode — vivid colors vibrate on dark backgrounds.
|
|
60
|
+
- Keep body text around `oklch(90% 0.01 H)`, not pure white, to cut glare.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Motion — easing, duration, stagger, reduced motion
|
|
2
|
+
|
|
3
|
+
> Patterns adapted from [impeccable](https://github.com/pbakaus/impeccable) (Apache-2.0, derived from
|
|
4
|
+
> Anthropic's frontend-design skill). Read when adding animation or transitions.
|
|
5
|
+
|
|
6
|
+
## Motion conveys state, not decoration
|
|
7
|
+
|
|
8
|
+
Animate to explain a change: where did this come from, what just happened, where did it go. If a motion
|
|
9
|
+
doesn't answer one of those, cut it. Decorative motion is a visual-slop pattern.
|
|
10
|
+
|
|
11
|
+
## Easing — avoid bounce/elastic
|
|
12
|
+
|
|
13
|
+
Real objects decelerate smoothly. Bounce and elastic easing feel dated and tacky (the detector flags
|
|
14
|
+
`bounce-easing` / spring overshoot like `cubic-bezier(0.68,-0.55,0.27,1.55)`).
|
|
15
|
+
|
|
16
|
+
- **Enter** (element appears): ease-out — fast start, soft landing. `cubic-bezier(0.16, 1, 0.3, 1)`
|
|
17
|
+
(ease-out-expo) or `ease-out`.
|
|
18
|
+
- **Exit** (element leaves): ease-in — accelerate away. `cubic-bezier(0.4, 0, 1, 1)`.
|
|
19
|
+
- **Move/resize on screen:** ease-in-out (standard) `cubic-bezier(0.4, 0, 0.2, 1)`.
|
|
20
|
+
- Never linear for UI (only for continuous things like spinners/marquees).
|
|
21
|
+
|
|
22
|
+
## Duration
|
|
23
|
+
|
|
24
|
+
- Micro (hover, small fades): 100–150ms.
|
|
25
|
+
- Standard (dropdowns, toggles, cards): 200–300ms.
|
|
26
|
+
- Large (page/route, full-screen sheets): 300–500ms.
|
|
27
|
+
- Over ~500ms feels sluggish; under ~80ms is imperceptible (just snap with no transition).
|
|
28
|
+
|
|
29
|
+
## Stagger
|
|
30
|
+
|
|
31
|
+
When several items enter together, stagger them 20–50ms apart so the eye follows the sequence — but cap
|
|
32
|
+
total stagger (don't make the 12th list item wait 600ms). Stagger entrances, not exits.
|
|
33
|
+
|
|
34
|
+
## What to animate (performance)
|
|
35
|
+
|
|
36
|
+
Animate only `transform` and `opacity` — they run on the compositor and don't trigger layout/paint.
|
|
37
|
+
Animating `width`, `height`, `top`, `left`, `margin` janks. Use `transform: translate/scale` instead.
|
|
38
|
+
|
|
39
|
+
## `prefers-reduced-motion` (required)
|
|
40
|
+
|
|
41
|
+
Honor it. Replace movement with an instant or opacity-only change.
|
|
42
|
+
|
|
43
|
+
```css
|
|
44
|
+
@media (prefers-reduced-motion: reduce) {
|
|
45
|
+
*, *::before, *::after {
|
|
46
|
+
animation-duration: 0.01ms !important;
|
|
47
|
+
transition-duration: 0.01ms !important;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Don't remove *feedback* entirely — a state change still needs to be visible, just not kinetic.
|