@brunosps00/dev-workflow 1.0.6 → 1.1.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 (55) hide show
  1. package/README.md +51 -29
  2. package/bin/dev-workflow.js +6 -0
  3. package/lib/constants.js +2 -2
  4. package/lib/init.js +13 -3
  5. package/lib/install-deps.js +88 -3
  6. package/lib/migrate-gsd.js +1 -1
  7. package/lib/post-update.js +153 -0
  8. package/lib/setup-wsl-browser.js +149 -0
  9. package/lib/uninstall.js +1 -1
  10. package/package.json +1 -1
  11. package/scaffold/en/agent-instructions.md +1 -1
  12. package/scaffold/en/commands/dw-analyze-project.md +22 -0
  13. package/scaffold/en/commands/dw-autopilot.md +3 -1
  14. package/scaffold/en/commands/dw-functional-doc.md +11 -3
  15. package/scaffold/en/commands/dw-generate-pr.md +2 -2
  16. package/scaffold/en/commands/dw-help.md +29 -14
  17. package/scaffold/en/commands/dw-qa.md +1 -1
  18. package/scaffold/en/commands/dw-redesign-ui.md +2 -2
  19. package/scaffold/en/commands/dw-review.md +7 -5
  20. package/scaffold/en/commands/dw-secure-audit.md +72 -15
  21. package/scaffold/en/commands/dw-update.md +16 -1
  22. package/scaffold/en/templates/constitution-template.md +8 -3
  23. package/scaffold/en/templates/functional-doc/playwright.flow.mjs.tpl +17 -0
  24. package/scaffold/pt-br/agent-instructions.md +1 -1
  25. package/scaffold/pt-br/commands/dw-analyze-project.md +22 -0
  26. package/scaffold/pt-br/commands/dw-autopilot.md +3 -1
  27. package/scaffold/pt-br/commands/dw-functional-doc.md +11 -3
  28. package/scaffold/pt-br/commands/dw-generate-pr.md +2 -2
  29. package/scaffold/pt-br/commands/dw-help.md +29 -14
  30. package/scaffold/pt-br/commands/dw-qa.md +1 -1
  31. package/scaffold/pt-br/commands/dw-redesign-ui.md +2 -2
  32. package/scaffold/pt-br/commands/dw-review.md +7 -5
  33. package/scaffold/pt-br/commands/dw-secure-audit.md +72 -15
  34. package/scaffold/pt-br/commands/dw-update.md +16 -1
  35. package/scaffold/pt-br/templates/constitution-template.md +8 -3
  36. package/scaffold/pt-br/templates/functional-doc/playwright.flow.mjs.tpl +17 -0
  37. package/scaffold/scripts/cdp-relay/Cargo.toml +16 -0
  38. package/scaffold/scripts/cdp-relay/src/main.rs +49 -0
  39. package/scaffold/scripts/functional-doc/generate-dossier.mjs +7 -9
  40. package/scaffold/scripts/functional-doc/run-playwright-flow.mjs +192 -183
  41. package/scaffold/scripts/lib/capture-screenshots.mjs +133 -0
  42. package/scaffold/scripts/lib/resolve-browser.mjs +405 -0
  43. package/scaffold/scripts/lib/ui-slop-detect.mjs +102 -0
  44. package/scaffold/skills/dw-testing-discipline/references/playwright-recipes.md +50 -0
  45. package/scaffold/skills/dw-ui-discipline/SKILL.md +5 -0
  46. package/scaffold/skills/dw-ui-discipline/references/color-oklch.md +60 -0
  47. package/scaffold/skills/dw-ui-discipline/references/motion.md +52 -0
  48. package/scaffold/skills/dw-ui-discipline/references/responsive.md +52 -0
  49. package/scaffold/skills/dw-ui-discipline/references/type-scale.md +48 -0
  50. package/scaffold/skills/dw-ui-discipline/references/ux-writing.md +52 -0
  51. package/scaffold/skills/security-review/SKILL.md +19 -0
  52. package/scaffold/skills/security-review/references/sast.md +67 -0
  53. package/scaffold/skills/security-review/references/secrets.md +46 -0
  54. package/scaffold/en/templates/functional-doc/playwright.spec.ts.tpl +0 -19
  55. 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.