@ryuxdsgn/design-intelligence 2.14.2 → 2.18.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 +7 -3
- package/dist/cli/hook.js +55 -0
- package/dist/cli/index.js +106 -26
- package/dist/cli/page-probe.js +228 -0
- package/dist/cli/page-scan.js +70 -0
- package/dist/cli/scan.js +160 -0
- package/dist/intelligence/content.js +51 -11
- package/dist/intelligence/guides.js +23 -11
- package/dist/render.js +9 -2
- package/dist/scan.js +343 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ npx @ryuxdsgn/design-intelligence # sta
|
|
|
22
22
|
npx @ryuxdsgn/design-intelligence setup # teach RYUX about the project (DESIGN.md)
|
|
23
23
|
npx @ryuxdsgn/design-intelligence setup --audience "..." --market "..." # same, without questions
|
|
24
24
|
npx @ryuxdsgn/design-intelligence check # health check (exit 1 on problems)
|
|
25
|
+
npx @ryuxdsgn/design-intelligence check --json # the same findings as JSON, for agents and CI
|
|
26
|
+
npx @ryuxdsgn/design-intelligence init --agent claude --dry-run # list what would change, write nothing
|
|
25
27
|
npx @ryuxdsgn/design-intelligence init --agent claude # install + empty project context block
|
|
26
28
|
npx @ryuxdsgn/design-intelligence install --agent claude,cursor,codex # non-interactive
|
|
27
29
|
npx @ryuxdsgn/design-intelligence install --agent all # every supported agent
|
|
@@ -35,12 +37,14 @@ npx @ryuxdsgn/design-intelligence remove --yes # rem
|
|
|
35
37
|
| Command | What it does |
|
|
36
38
|
| --- | --- |
|
|
37
39
|
| `setup` | Asks a few questions with plain choices, "Write my own", or Skip: product, current work, audience, market and locale, constraints, design intent, and UX, UI, and motion direction. Saves the answers to the `<!-- ryux-context -->` block in `DESIGN.md`, changing only the fields you answer. The answers are direction, not evidence. Without a terminal, set fields with `--product`, `--work`, `--audience`, `--market`, `--constraints`, `--intent`, `--ux`, `--ui`, `--motion`. |
|
|
38
|
-
| `init` | Installs RYUX, then adds a `<!-- ryux-context -->` block to `DESIGN.md` (product, audience, market and locale, design system, evidence sources, constraints), prefilled only with what it can detect. Re-running never overwrites what you wrote. |
|
|
39
|
-
| `install` | Writes the `ryux/` skill into each agent's folder and a short pointer block into `CLAUDE.md`, `GEMINI.md`, or `AGENTS.md`. Interactive installs then offer `setup`. |
|
|
40
|
-
| `check` (`doctor`) | Checks every install: all files present, frontmatter valid, version matches this CLI, every module reference resolves, no RYUX 1.x folders, pointer blocks, project context and how many setup fields are filled. Exits 1 on errors, so it works in CI
|
|
40
|
+
| `init` | Installs RYUX, then adds a `<!-- ryux-context -->` block to `DESIGN.md` (product, audience, market and locale, design system, evidence sources, constraints), prefilled only with what it can detect. Re-running never overwrites what you wrote. `--dry-run` lists what it would create or update and writes nothing. |
|
|
41
|
+
| `install` | Writes the `ryux/` skill into each agent's folder and a short pointer block into `CLAUDE.md`, `GEMINI.md`, or `AGENTS.md`. Interactive installs then offer `setup`. `--dry-run` lists the folders and blocks it would create or update and writes nothing. |
|
|
42
|
+
| `check` (`doctor`) | Checks every install: all files present, frontmatter valid, version matches this CLI, every module reference resolves, no RYUX 1.x folders, pointer blocks, project context and how many setup fields are filled. Each problem names the command that fixes it. Exits 1 on errors, so it works in CI; `--json` prints `{ ok, cli, scope, installs, findings: [{ level, where, message, fix }] }`. |
|
|
41
43
|
| `update` | Rewrites installs at this version and migrates RYUX 1.x. |
|
|
42
44
|
| `remove` | Removes the skill and the pointer blocks. Leaves your `DESIGN.md` context. |
|
|
43
45
|
|
|
46
|
+
Exit codes: 0 done or healthy, 1 a problem was found or the command failed, 2 the command was used wrongly (an unknown command or agent).
|
|
47
|
+
|
|
44
48
|
One version: `packages/cli/package.json`. The skill header, the plugin manifests, and
|
|
45
49
|
`ryux --version` all derive from it.
|
|
46
50
|
|
package/dist/cli/hook.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The opt-in Claude Code hook that runs `ryux scan --hook` after each Edit or Write. It lives in
|
|
2
|
+
// .claude/settings.json next to the user's own settings; only the RYUX entry is added, updated, or removed.
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CLI_CMD, VERSION } from "../product.js";
|
|
5
|
+
import { readIfExists, writeFileEnsured } from "./fsutil.js";
|
|
6
|
+
const SETTINGS = ".claude/settings.json";
|
|
7
|
+
const MATCHER = "Edit|MultiEdit|Write";
|
|
8
|
+
// Pinned to this version so every edit runs the same detectors; `update` moves the pin.
|
|
9
|
+
const command = () => `${CLI_CMD.replace("npx ", "npx -y ")}@${VERSION} scan --hook`;
|
|
10
|
+
const isOurs = (c) => typeof c === "string" && c.includes("@ryuxdsgn/design-intelligence") && c.includes("scan --hook");
|
|
11
|
+
async function read(root) {
|
|
12
|
+
const raw = await readIfExists(join(root, SETTINGS));
|
|
13
|
+
if (raw === null)
|
|
14
|
+
return {};
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(raw);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const ours = (s) => (s.hooks?.PostToolUse ?? []).filter((e) => e.hooks?.some((h) => isOurs(h.command)));
|
|
23
|
+
export async function hasHook(root) {
|
|
24
|
+
const s = await read(root);
|
|
25
|
+
return Boolean(s && ours(s).length);
|
|
26
|
+
}
|
|
27
|
+
/** Add the hook, or move an existing one to this version. Throws when settings.json is not valid JSON,
|
|
28
|
+
* so a file we cannot read is never overwritten. */
|
|
29
|
+
export async function installHook(root) {
|
|
30
|
+
const s = await read(root);
|
|
31
|
+
if (!s)
|
|
32
|
+
throw new Error(`${SETTINGS} is not valid JSON; fix it, then run this again`);
|
|
33
|
+
const existing = ours(s);
|
|
34
|
+
if (existing.some((e) => e.hooks.some((h) => h.command === command())))
|
|
35
|
+
return `${SETTINGS} (scan hook already present)`;
|
|
36
|
+
const post = (s.hooks?.PostToolUse ?? []).filter((e) => !existing.includes(e));
|
|
37
|
+
s.hooks = { ...s.hooks, PostToolUse: [...post, { matcher: MATCHER, hooks: [{ type: "command", command: command() }] }] };
|
|
38
|
+
await writeFileEnsured(join(root, SETTINGS), `${JSON.stringify(s, null, 2)}\n`);
|
|
39
|
+
return `${SETTINGS} (scan hook ${existing.length ? "updated" : "added"})`;
|
|
40
|
+
}
|
|
41
|
+
/** Remove the RYUX entry and any hook lists it leaves empty. True when something was removed. */
|
|
42
|
+
export async function removeHook(root) {
|
|
43
|
+
const s = await read(root);
|
|
44
|
+
if (!s || !ours(s).length)
|
|
45
|
+
return false;
|
|
46
|
+
const post = s.hooks.PostToolUse.filter((e) => !ours(s).includes(e));
|
|
47
|
+
if (post.length)
|
|
48
|
+
s.hooks.PostToolUse = post;
|
|
49
|
+
else
|
|
50
|
+
delete s.hooks.PostToolUse;
|
|
51
|
+
if (!Object.keys(s.hooks).length)
|
|
52
|
+
delete s.hooks;
|
|
53
|
+
await writeFileEnsured(join(root, SETTINGS), `${JSON.stringify(s, null, 2)}\n`);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -10,6 +10,8 @@ import { CLI_CMD, CONTEXT_END, CONTEXT_FILE, CONTEXT_START, MARK_START, MCP_STAT
|
|
|
10
10
|
import { validateBundle } from "../validate.js";
|
|
11
11
|
import { renderAgentsBlock, renderBundle, renderPointerBlock, SKILL_NAME } from "../render.js";
|
|
12
12
|
import { hasBlock, readIfExists, rel, removeBlock, removePath, upsertBlock, writeFileEnsured } from "./fsutil.js";
|
|
13
|
+
import { hasHook, installHook, removeHook } from "./hook.js";
|
|
14
|
+
import { runScan } from "./scan.js";
|
|
13
15
|
import { readFile, readdir } from "node:fs/promises";
|
|
14
16
|
const AGENT_IDS = [...AGENT_TARGETS.map((a) => a.id), AGENTS_MD_INLINE.id];
|
|
15
17
|
const targetDir = (a, scope) => join(scope.root, scope.global ? a.globalDir : a.dir);
|
|
@@ -73,6 +75,27 @@ async function install(agentIds, scope) {
|
|
|
73
75
|
}
|
|
74
76
|
return done;
|
|
75
77
|
}
|
|
78
|
+
/** What install would do, without writing: each skill folder and pointer block, created or updated. */
|
|
79
|
+
async function planInstall(agentIds, scope) {
|
|
80
|
+
const plan = [];
|
|
81
|
+
for (const dir of dirsFor(agentIds, scope)) {
|
|
82
|
+
const skillDir = join(dir, SKILL_NAME);
|
|
83
|
+
const old = OLD_SKILL_DIRS.filter((n) => existsSync(join(dir, n))).length;
|
|
84
|
+
plan.push(`${existsSync(skillDir) ? "update" : "create"} ${rel(skillDir)}/ (${Object.keys(renderBundle()).length} files)`);
|
|
85
|
+
if (old)
|
|
86
|
+
plan.push(`replace ${old} RYUX 1.x folder${old === 1 ? "" : "s"} in ${rel(dir)}`);
|
|
87
|
+
}
|
|
88
|
+
if (scope.global)
|
|
89
|
+
return plan;
|
|
90
|
+
const inline = agentIds.includes(AGENTS_MD_INLINE.id);
|
|
91
|
+
const files = pointersFor(agentIds).filter((f) => !(f === "AGENTS.md" && inline));
|
|
92
|
+
for (const file of [...files, ...(inline ? ["AGENTS.md"] : [])]) {
|
|
93
|
+
const content = await readIfExists(join(scope.root, file));
|
|
94
|
+
const what = file === "AGENTS.md" && inline ? "rules inline" : "pointer block";
|
|
95
|
+
plan.push(content === null ? `create ${file} with the ${what}` : hasBlock(content) ? `update the ${what} in ${file}` : `add the ${what} to ${file} (the rest of the file is kept)`);
|
|
96
|
+
}
|
|
97
|
+
return plan;
|
|
98
|
+
}
|
|
76
99
|
const hasInstall = (dir) => existsSync(join(dir, SKILL_NAME, "SKILL.md")) || existsSync(join(dir, "ryux-core")) || existsSync(join(dir, "ryux-rules"));
|
|
77
100
|
/** Skill folders that already hold a RYUX install (the single skill, or the RYUX 1.x folders). */
|
|
78
101
|
function detectInstalled(scope) {
|
|
@@ -122,7 +145,7 @@ async function runInstall(flags) {
|
|
|
122
145
|
showMcp = Boolean(flags.mcp);
|
|
123
146
|
if (!agents.length) {
|
|
124
147
|
console.error(`Invalid agent.\nAgents: ${AGENT_IDS.join(",")},all`);
|
|
125
|
-
process.exitCode =
|
|
148
|
+
process.exitCode = 2;
|
|
126
149
|
return;
|
|
127
150
|
}
|
|
128
151
|
}
|
|
@@ -153,7 +176,27 @@ async function runInstall(flags) {
|
|
|
153
176
|
console.log(pc.yellow("agents-md is project-only; skipped for --global."));
|
|
154
177
|
agents = agents.filter((x) => x !== AGENTS_MD_INLINE.id);
|
|
155
178
|
}
|
|
179
|
+
let hook = Boolean(flags.hook);
|
|
180
|
+
if (!nonInteractive && agents.includes("claude")) {
|
|
181
|
+
const h = await p.confirm({ message: "Run ryux scan on each file Claude Code edits? (adds a hook to .claude/settings.json)", initialValue: false });
|
|
182
|
+
if (p.isCancel(h))
|
|
183
|
+
return cancel();
|
|
184
|
+
hook = h;
|
|
185
|
+
}
|
|
186
|
+
if (hook && !agents.includes("claude")) {
|
|
187
|
+
console.log(pc.yellow("--hook works with Claude Code only for now; skipped."));
|
|
188
|
+
hook = false;
|
|
189
|
+
}
|
|
190
|
+
if (flags["dry-run"]) {
|
|
191
|
+
const plan = await planInstall(agents, scope);
|
|
192
|
+
if (hook)
|
|
193
|
+
plan.push(`${(await hasHook(scope.root)) ? "update" : "add"} the scan hook in .claude/settings.json (runs ryux scan after each edit)`);
|
|
194
|
+
console.log(`Dry run: nothing was written. ${CLI_CMD} ${flags.init ? "init" : "install"} would:\n${plan.map((l) => ` ${l}`).join("\n")}`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
156
197
|
const written = await install(agents, scope);
|
|
198
|
+
if (hook)
|
|
199
|
+
written.push(await installHook(scope.root));
|
|
157
200
|
const summary = written.map((w) => pc.green("✓ ") + w).join("\n");
|
|
158
201
|
if (nonInteractive)
|
|
159
202
|
console.log(summary);
|
|
@@ -196,6 +239,8 @@ async function runUpdate(flags) {
|
|
|
196
239
|
const done = [];
|
|
197
240
|
for (const dir of dirs)
|
|
198
241
|
done.push(...(await writeSkills(dir, scope.root)));
|
|
242
|
+
if (await hasHook(scope.root))
|
|
243
|
+
done.push(await installHook(scope.root));
|
|
199
244
|
if (!scope.global) {
|
|
200
245
|
for (const file of ["CLAUDE.md", "GEMINI.md", "AGENTS.md"]) {
|
|
201
246
|
const path = join(scope.root, file);
|
|
@@ -215,7 +260,7 @@ async function runRemove(flags) {
|
|
|
215
260
|
const pointerFiles = scope.global ? [] : ["CLAUDE.md", "GEMINI.md", "AGENTS.md"];
|
|
216
261
|
const hasBlocks = (await Promise.all(pointerFiles.map(async (f) => (await readIfExists(join(scope.root, f)))?.includes(MARK_START)))).some(Boolean);
|
|
217
262
|
const hasLegacyCursor = existsSync(join(scope.root, LEGACY_CURSOR_RULES_DIR));
|
|
218
|
-
if (!hasSkills && !hasBlocks && !hasLegacyCursor) {
|
|
263
|
+
if (!hasSkills && !hasBlocks && !hasLegacyCursor && !(await hasHook(scope.root))) {
|
|
219
264
|
console.log("No RYUX install found.");
|
|
220
265
|
return;
|
|
221
266
|
}
|
|
@@ -238,6 +283,8 @@ async function runRemove(flags) {
|
|
|
238
283
|
for (const f of pointerFiles)
|
|
239
284
|
if (await removeBlock(join(scope.root, f)))
|
|
240
285
|
removed.push(`${f} (block)`);
|
|
286
|
+
if (await removeHook(scope.root))
|
|
287
|
+
removed.push(".claude/settings.json (scan hook)");
|
|
241
288
|
console.log(removed.length ? removed.map((r) => pc.red("− ") + r).join("\n") : "No files removed.");
|
|
242
289
|
}
|
|
243
290
|
/** Read every file of an installed skill folder, keyed by its path inside the folder. */
|
|
@@ -253,53 +300,63 @@ async function readBundle(skillDir) {
|
|
|
253
300
|
}
|
|
254
301
|
async function runCheck(flags) {
|
|
255
302
|
const scope = scopeOf(flags);
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
process.exitCode = 1;
|
|
303
|
+
const findings = [];
|
|
304
|
+
const installs = [];
|
|
305
|
+
const add = (level, where, message, fix) => {
|
|
306
|
+
findings.push({ level, where, message, ...(fix ? { fix: `${CLI_CMD} ${fix}` } : {}) });
|
|
261
307
|
};
|
|
262
308
|
const dirs = detectInstalled(scope);
|
|
263
|
-
if (!dirs.length)
|
|
264
|
-
fail
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
309
|
+
if (!dirs.length)
|
|
310
|
+
add("fail", scope.global ? "~" : ".", `RYUX is not installed ${scope.global ? "in your home directory" : "in this project"}`, "init");
|
|
267
311
|
for (const dir of dirs) {
|
|
268
312
|
const where = rel(dir);
|
|
269
313
|
const skillDir = join(dir, SKILL_NAME);
|
|
270
314
|
const leftovers = OLD_SKILL_DIRS.filter((n) => existsSync(join(dir, n)));
|
|
271
315
|
if (leftovers.length)
|
|
272
|
-
fail
|
|
316
|
+
add("fail", where, `RYUX 1.x folders remain (${leftovers.length})`, "update");
|
|
273
317
|
if (!existsSync(join(skillDir, "SKILL.md"))) {
|
|
274
|
-
fail
|
|
318
|
+
add("fail", where, `no ${SKILL_NAME}/ skill`, "update");
|
|
275
319
|
continue;
|
|
276
320
|
}
|
|
277
321
|
const report = validateBundle(await readBundle(skillDir));
|
|
322
|
+
installs.push({ dir: `${where}/${SKILL_NAME}`, version: report.version ?? null });
|
|
278
323
|
for (const e of report.errors)
|
|
279
|
-
fail
|
|
324
|
+
add("fail", `${where}/${SKILL_NAME}`, e, "update");
|
|
280
325
|
for (const w of report.warnings)
|
|
281
|
-
warn
|
|
326
|
+
add("warn", `${where}/${SKILL_NAME}`, w);
|
|
282
327
|
if (report.version && report.version !== VERSION)
|
|
283
|
-
warn
|
|
328
|
+
add("warn", where, `installed RYUX ${report.version}, this CLI is ${VERSION}`, "update");
|
|
284
329
|
if (!report.errors.length)
|
|
285
|
-
ok
|
|
330
|
+
add("ok", `${where}/${SKILL_NAME}`, `complete, RYUX ${report.version}`);
|
|
286
331
|
}
|
|
287
|
-
if (!scope.global) {
|
|
332
|
+
if (dirs.length && !scope.global) {
|
|
288
333
|
const agentsHere = agentsAt(scope, true);
|
|
289
334
|
for (const file of pointersFor(agentsHere)) {
|
|
290
335
|
if (hasBlock(await readIfExists(join(scope.root, file))))
|
|
291
|
-
ok
|
|
336
|
+
add("ok", file, "RYUX pointer block present");
|
|
292
337
|
else
|
|
293
|
-
warn
|
|
338
|
+
add("warn", file, "no RYUX pointer block", "update");
|
|
294
339
|
}
|
|
295
340
|
const context = contextBody(await readIfExists(join(scope.root, CONTEXT_FILE)));
|
|
296
341
|
if (context !== null) {
|
|
297
|
-
ok
|
|
342
|
+
add("ok", CONTEXT_FILE, `project context present, ${filledCount(context)} of ${ASKED_FIELDS.length} setup fields filled (blanks are treated as unknown)`);
|
|
298
343
|
}
|
|
299
344
|
else
|
|
300
|
-
warn
|
|
345
|
+
add("warn", CONTEXT_FILE, "no RYUX project context", "setup");
|
|
301
346
|
}
|
|
302
|
-
if (
|
|
347
|
+
if (await hasHook(scope.root))
|
|
348
|
+
add("ok", ".claude/settings.json", "scan hook present");
|
|
349
|
+
const ok = !findings.some((f) => f.level === "fail");
|
|
350
|
+
if (!ok)
|
|
351
|
+
process.exitCode = 1;
|
|
352
|
+
if (flags.json) {
|
|
353
|
+
console.log(JSON.stringify({ ok, cli: VERSION, scope: scope.global ? "global" : "project", installs, findings }, null, 2));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const mark = { ok: pc.green("✓"), warn: pc.yellow("!"), fail: pc.red("✗") };
|
|
357
|
+
for (const f of findings)
|
|
358
|
+
console.log(`${mark[f.level]} ${f.where}: ${f.message}${f.fix ? `. Run: ${f.fix}` : ""}`);
|
|
359
|
+
if (ok)
|
|
303
360
|
console.log(pc.green("\nRYUX is ready."));
|
|
304
361
|
}
|
|
305
362
|
/** Facts init can detect without guessing: the project name and where design tokens live. */
|
|
@@ -660,9 +717,13 @@ async function runInit(flags) {
|
|
|
660
717
|
const scope = scopeOf(flags);
|
|
661
718
|
const path = join(scope.root, CONTEXT_FILE);
|
|
662
719
|
const had = hasBlock(await readIfExists(path), CONTEXT_MARKS);
|
|
663
|
-
await runInstall(flags);
|
|
720
|
+
await runInstall({ ...flags, init: true });
|
|
664
721
|
if (process.exitCode)
|
|
665
722
|
return;
|
|
723
|
+
if (flags["dry-run"]) {
|
|
724
|
+
console.log(` ${had ? `keep the project context in ${CONTEXT_FILE} as you wrote it` : `add the project context block to ${CONTEXT_FILE}`}`);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
666
727
|
if (had) {
|
|
667
728
|
console.log(`${pc.green("✓ ")}${CONTEXT_FILE}: project context already present (left as you wrote it). Edit it with: ${CLI_CMD} setup`);
|
|
668
729
|
}
|
|
@@ -685,6 +746,9 @@ Commands:
|
|
|
685
746
|
setup Teach RYUX about this project: a few skippable questions, saved to DESIGN.md.
|
|
686
747
|
init Install RYUX and add the DESIGN.md project context block.
|
|
687
748
|
check Check the install: files, version, references, pointers, project context.
|
|
749
|
+
scan Check source files for rules a machine can see (Rupiah format, alt text, zoom, focus, ...).
|
|
750
|
+
No LLM, no network. Default path: this folder. Give a URL to measure the rendered page
|
|
751
|
+
(sideways scroll, contrast, target size, colliding text); that needs Playwright.
|
|
688
752
|
update Update an install to this version (also migrates RYUX 1.x folders).
|
|
689
753
|
remove Remove RYUX and the marked blocks.
|
|
690
754
|
help Show this help.
|
|
@@ -698,6 +762,12 @@ Options:
|
|
|
698
762
|
--global install into your home directory instead of this project
|
|
699
763
|
--mcp print the ryux MCP connect command
|
|
700
764
|
--yes skip the confirmation (remove)
|
|
765
|
+
--dry-run list what install or init would create or update, and write nothing
|
|
766
|
+
--json print check's or scan's findings as JSON (for agents and CI)
|
|
767
|
+
--hook install: run ryux scan after each Claude Code edit (opt-in)
|
|
768
|
+
--list-rules scan: list what each check looks for
|
|
769
|
+
--ignore <ids> scan: skip rules, e.g. RX-DS-03 (or a ryux-ignore RX-.. comment in the file)
|
|
770
|
+
--widths <list> scan <url>: viewport widths in CSS px (default 320,390,1280)
|
|
701
771
|
--product, --work, --audience, --market, --constraints, --intent, --ux, --ui, --motion <text>
|
|
702
772
|
set project context fields without questions (setup)
|
|
703
773
|
|
|
@@ -711,7 +781,15 @@ Examples:
|
|
|
711
781
|
${CLI_CMD} install --agent all
|
|
712
782
|
${CLI_CMD} install --agent claude --global
|
|
713
783
|
${CLI_CMD} update
|
|
714
|
-
${CLI_CMD} remove --yes
|
|
784
|
+
${CLI_CMD} remove --yes
|
|
785
|
+
${CLI_CMD} init --agent claude --dry-run
|
|
786
|
+
${CLI_CMD} check --json
|
|
787
|
+
${CLI_CMD} scan src
|
|
788
|
+
${CLI_CMD} scan http://localhost:3000/checkout
|
|
789
|
+
${CLI_CMD} scan --list-rules
|
|
790
|
+
${CLI_CMD} install --agent claude --hook
|
|
791
|
+
|
|
792
|
+
Exit codes: 0 done or healthy, 1 a problem was found (for scan: a fail) or the command failed, 2 the command was used wrongly.`);
|
|
715
793
|
}
|
|
716
794
|
async function main() {
|
|
717
795
|
const argv = process.argv.slice(2);
|
|
@@ -730,6 +808,8 @@ async function main() {
|
|
|
730
808
|
case "check":
|
|
731
809
|
case "doctor":
|
|
732
810
|
return runCheck(flags);
|
|
811
|
+
case "scan":
|
|
812
|
+
return runScan(argv.slice(1));
|
|
733
813
|
case "init":
|
|
734
814
|
return runInit(flags);
|
|
735
815
|
case "setup":
|
|
@@ -741,7 +821,7 @@ async function main() {
|
|
|
741
821
|
default:
|
|
742
822
|
console.error(`Unknown command: ${cmd}\n`);
|
|
743
823
|
help();
|
|
744
|
-
process.exitCode =
|
|
824
|
+
process.exitCode = 2;
|
|
745
825
|
}
|
|
746
826
|
}
|
|
747
827
|
main().catch((err) => {
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// @ts-nocheck: this function runs inside the page (Playwright serializes it), where the DOM exists;
|
|
2
|
+
// the package compiles for Node without DOM types.
|
|
3
|
+
// Measures a rendered page at the current viewport: horizontal scroll, text contrast, text size,
|
|
4
|
+
// target size, and text that collides. Returns plain findings; the caller adds the width and URL.
|
|
5
|
+
export function probe() {
|
|
6
|
+
const out = [];
|
|
7
|
+
const vw = document.documentElement.clientWidth;
|
|
8
|
+
const selector = (el) => {
|
|
9
|
+
const parts = [];
|
|
10
|
+
for (let e = el; e && e !== document.body && parts.length < 3; e = e.parentElement) {
|
|
11
|
+
let s = e.tagName.toLowerCase();
|
|
12
|
+
if (e.id) {
|
|
13
|
+
parts.unshift(`${s}#${e.id}`);
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
const cls = typeof e.className === "string" ? e.className.trim().split(/\s+/)[0] : "";
|
|
17
|
+
if (cls)
|
|
18
|
+
s += `.${cls}`;
|
|
19
|
+
parts.unshift(s);
|
|
20
|
+
}
|
|
21
|
+
return parts.join(" > ");
|
|
22
|
+
};
|
|
23
|
+
const style = (el) => getComputedStyle(el);
|
|
24
|
+
const shown = (el) => {
|
|
25
|
+
for (let e = el; e; e = e.parentElement) {
|
|
26
|
+
const cs = style(e);
|
|
27
|
+
if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0")
|
|
28
|
+
return false;
|
|
29
|
+
// Visually hidden (the sr-only pattern): clipped, or inside a box of 1px or less that hides overflow.
|
|
30
|
+
if ((cs.clip && cs.clip !== "auto") || /inset\(50%\)/.test(cs.clipPath))
|
|
31
|
+
return false;
|
|
32
|
+
const box = e.getBoundingClientRect();
|
|
33
|
+
if (cs.overflow !== "visible" && (box.width <= 1 || box.height <= 1))
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const r = el.getBoundingClientRect();
|
|
37
|
+
return r.width > 1 && r.height > 1;
|
|
38
|
+
};
|
|
39
|
+
const disabled = (el) => Boolean(el.closest("[disabled],[aria-disabled='true'],[inert]"));
|
|
40
|
+
const quote = (s) => (s.length > 40 ? `"${s.slice(0, 37)}..."` : `"${s}"`);
|
|
41
|
+
// Horizontal scroll (RX-RD-01): name the outermost elements that reach past the viewport.
|
|
42
|
+
if (document.documentElement.scrollWidth > vw + 1) {
|
|
43
|
+
const culprits = [...document.body.querySelectorAll("*")].filter((el) => {
|
|
44
|
+
const r = el.getBoundingClientRect();
|
|
45
|
+
const p = el.parentElement?.getBoundingClientRect();
|
|
46
|
+
return r.right > vw + 1 && r.width > 0 && (!p || p.right <= vw + 1) && shown(el);
|
|
47
|
+
});
|
|
48
|
+
const names = culprits.slice(0, 3).map((el) => `${selector(el)} (to ${Math.round(el.getBoundingClientRect().right)}px)`);
|
|
49
|
+
out.push({
|
|
50
|
+
rule: "RX-RD-01",
|
|
51
|
+
level: "fail",
|
|
52
|
+
selector: culprits[0] ? selector(culprits[0]) : "html",
|
|
53
|
+
message: `the page scrolls sideways: ${document.documentElement.scrollWidth}px of content in ${vw}px${names.length ? `; widest: ${names.join(", ")}` : ""}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// Text: every visible element with its own text.
|
|
57
|
+
const texts = [];
|
|
58
|
+
for (const el of document.body.querySelectorAll("*")) {
|
|
59
|
+
if (/^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|OPTION)$/.test(el.tagName) || el.closest("svg"))
|
|
60
|
+
continue;
|
|
61
|
+
const nodes = [...el.childNodes].filter((n) => n.nodeType === 3 && n.textContent.trim());
|
|
62
|
+
if (!nodes.length || !shown(el))
|
|
63
|
+
continue;
|
|
64
|
+
texts.push({ el, nodes, text: nodes.map((n) => n.textContent).join(" ").replace(/\s+/g, " ").trim() });
|
|
65
|
+
}
|
|
66
|
+
const rgba = (c) => {
|
|
67
|
+
const m = c.match(/rgba?\(([^)]+)\)/);
|
|
68
|
+
if (!m)
|
|
69
|
+
return null;
|
|
70
|
+
const [r, g, b, a = 1] = m[1].split(/[\s,/]+/).filter(Boolean).map(Number);
|
|
71
|
+
return [r, g, b, a];
|
|
72
|
+
};
|
|
73
|
+
const over = (top, bottom) => {
|
|
74
|
+
const a = top[3];
|
|
75
|
+
return [0, 1, 2].map((i) => top[i] * a + bottom[i] * (1 - a)).concat(1);
|
|
76
|
+
};
|
|
77
|
+
// The solid color behind an element, or null when an image, gradient, or blend makes it unknown.
|
|
78
|
+
const background = (el) => {
|
|
79
|
+
const layers = [];
|
|
80
|
+
for (let e = el; e; e = e.parentElement) {
|
|
81
|
+
const cs = style(e);
|
|
82
|
+
if (cs.backgroundImage !== "none" || cs.mixBlendMode !== "normal" || (cs.filter && cs.filter !== "none"))
|
|
83
|
+
return null;
|
|
84
|
+
const c = rgba(cs.backgroundColor);
|
|
85
|
+
if (c && c[3] > 0) {
|
|
86
|
+
layers.push(c);
|
|
87
|
+
if (c[3] >= 1)
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
let color = [255, 255, 255, 1];
|
|
92
|
+
for (const layer of layers.reverse())
|
|
93
|
+
color = over(layer, color);
|
|
94
|
+
return color;
|
|
95
|
+
};
|
|
96
|
+
const luminance = ([r, g, b]) => [r, g, b]
|
|
97
|
+
.map((v) => v / 255)
|
|
98
|
+
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
99
|
+
.reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
|
|
100
|
+
// True when something other than the element's own ancestors sits under its text (an image or a
|
|
101
|
+
// positioned sibling), so the ancestor background is not what the reader sees.
|
|
102
|
+
const coveredBy = (t) => {
|
|
103
|
+
t.el.scrollIntoView({ block: "center" });
|
|
104
|
+
const range = document.createRange();
|
|
105
|
+
range.setStartBefore(t.nodes[0]);
|
|
106
|
+
range.setEndAfter(t.nodes[t.nodes.length - 1]);
|
|
107
|
+
const r = range.getBoundingClientRect();
|
|
108
|
+
const stack = document.elementsFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
|
109
|
+
const below = stack.find((e) => !t.el.contains(e));
|
|
110
|
+
return Boolean(below && !below.contains(t.el));
|
|
111
|
+
};
|
|
112
|
+
const hex = (c) => `#${c.slice(0, 3).map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
|
|
113
|
+
// Contrast (RX-A11Y-01) and size (RX-A11Y-02), grouped so one style used 40 times is one finding.
|
|
114
|
+
const contrast = new Map();
|
|
115
|
+
const small = new Map();
|
|
116
|
+
for (const t of texts) {
|
|
117
|
+
const cs = style(t.el);
|
|
118
|
+
const size = Number.parseFloat(cs.fontSize);
|
|
119
|
+
if (size < 12) {
|
|
120
|
+
const g = small.get(size) ?? { t, count: 0 };
|
|
121
|
+
g.count++;
|
|
122
|
+
small.set(size, g);
|
|
123
|
+
}
|
|
124
|
+
if (disabled(t.el) || cs.webkitTextFillColor === "rgba(0, 0, 0, 0)")
|
|
125
|
+
continue;
|
|
126
|
+
const bg = background(t.el);
|
|
127
|
+
const fgRaw = rgba(cs.color);
|
|
128
|
+
if (!bg || !fgRaw)
|
|
129
|
+
continue;
|
|
130
|
+
const fg = fgRaw[3] < 1 ? over(fgRaw, bg) : fgRaw;
|
|
131
|
+
const [hi, lo] = [luminance(fg), luminance(bg)].sort((a, b) => b - a);
|
|
132
|
+
const ratio = (hi + 0.05) / (lo + 0.05);
|
|
133
|
+
const large = size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 700);
|
|
134
|
+
const need = large ? 3 : 4.5;
|
|
135
|
+
if (ratio >= need || coveredBy(t))
|
|
136
|
+
continue;
|
|
137
|
+
const key = `${hex(fg)}|${hex(bg)}|${need}`;
|
|
138
|
+
const g = contrast.get(key) ?? { t, ratio, fg, bg, need, count: 0 };
|
|
139
|
+
g.count++;
|
|
140
|
+
contrast.set(key, g);
|
|
141
|
+
}
|
|
142
|
+
for (const g of contrast.values()) {
|
|
143
|
+
out.push({
|
|
144
|
+
rule: "RX-A11Y-01",
|
|
145
|
+
level: "fail",
|
|
146
|
+
selector: selector(g.t.el),
|
|
147
|
+
message: `${quote(g.t.text)}: contrast ${g.ratio.toFixed(2)}:1 (${hex(g.fg)} on ${hex(g.bg)}), needs ${g.need}:1${g.count > 1 ? `; ${g.count - 1} more with the same colors` : ""}`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
for (const [size, g] of small) {
|
|
151
|
+
out.push({
|
|
152
|
+
rule: "RX-A11Y-02",
|
|
153
|
+
level: "warn",
|
|
154
|
+
selector: selector(g.t.el),
|
|
155
|
+
message: `${quote(g.t.text)}: ${size}px text${g.count > 1 ? ` (${g.count} elements)` : ""}; body text is 14 to 16px on mobile, below 12px is hard to read`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
window.scrollTo(0, 0);
|
|
159
|
+
// Target size (RX-A11Y-02): under 24px fails unless spaced as WCAG 2.5.8 allows; inline links in a
|
|
160
|
+
// sentence are exempt.
|
|
161
|
+
const controls = [
|
|
162
|
+
...document.body.querySelectorAll("a[href],button,input:not([type=hidden]),select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=switch],[role=tab],[role=menuitem]"),
|
|
163
|
+
].filter((el) => shown(el) && !disabled(el));
|
|
164
|
+
const rects = controls.map((el) => el.getBoundingClientRect());
|
|
165
|
+
const inSentence = (el) => {
|
|
166
|
+
if (el.tagName !== "A" || style(el).display !== "inline")
|
|
167
|
+
return false;
|
|
168
|
+
const block = el.parentElement;
|
|
169
|
+
return Boolean(block && block.textContent.replace(el.textContent, "").trim().length > 0);
|
|
170
|
+
};
|
|
171
|
+
const center = (r) => [r.left + r.width / 2, r.top + r.height / 2];
|
|
172
|
+
const undersized = controls.map((el, i) => (rects[i].width < 24 || rects[i].height < 24) && !inSentence(el));
|
|
173
|
+
controls.forEach((el, i) => {
|
|
174
|
+
if (!undersized[i])
|
|
175
|
+
return;
|
|
176
|
+
const [cx, cy] = center(rects[i]);
|
|
177
|
+
const crowded = rects.some((r, j) => {
|
|
178
|
+
if (j === i || el.contains(controls[j]) || controls[j].contains(el))
|
|
179
|
+
return false;
|
|
180
|
+
if (undersized[j]) {
|
|
181
|
+
const [x, y] = center(r);
|
|
182
|
+
return Math.hypot(cx - x, cy - y) < 24;
|
|
183
|
+
}
|
|
184
|
+
const dx = Math.max(r.left - cx, 0, cx - r.right);
|
|
185
|
+
const dy = Math.max(r.top - cy, 0, cy - r.bottom);
|
|
186
|
+
return Math.hypot(dx, dy) < 12;
|
|
187
|
+
});
|
|
188
|
+
const size = `${Math.round(rects[i].width)}x${Math.round(rects[i].height)}px`;
|
|
189
|
+
const name = quote((el.getAttribute("aria-label") || el.textContent || el.tagName.toLowerCase()).trim().replace(/\s+/g, " "));
|
|
190
|
+
out.push(crowded
|
|
191
|
+
? { rule: "RX-A11Y-02", level: "fail", selector: selector(el), message: `${name}: target ${size}, under 24x24 and crowded by its neighbours; 44x44 is the preferred hit area` }
|
|
192
|
+
: { rule: "RX-A11Y-02", level: "warn", selector: selector(el), message: `${name}: target ${size}, under 24x24 (spaced enough to pass WCAG 2.5.8); 44x44 is the preferred hit area` });
|
|
193
|
+
});
|
|
194
|
+
// Colliding text (RX-QA-03): the text boxes of two unrelated elements overlap.
|
|
195
|
+
const boxes = texts.slice(0, 1500).map((t) => {
|
|
196
|
+
const range = document.createRange();
|
|
197
|
+
range.setStartBefore(t.nodes[0]);
|
|
198
|
+
range.setEndAfter(t.nodes[t.nodes.length - 1]);
|
|
199
|
+
return { t, r: range.getBoundingClientRect() };
|
|
200
|
+
});
|
|
201
|
+
// Content that scrolls under a fixed or sticky bar is not a collision.
|
|
202
|
+
const pinned = (el) => {
|
|
203
|
+
for (let e = el; e; e = e.parentElement)
|
|
204
|
+
if (/^(fixed|sticky)$/.test(style(e).position))
|
|
205
|
+
return true;
|
|
206
|
+
return false;
|
|
207
|
+
};
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
210
|
+
for (let j = i + 1; j < boxes.length; j++) {
|
|
211
|
+
const a = boxes[i];
|
|
212
|
+
const b = boxes[j];
|
|
213
|
+
if (a.t.el.contains(b.t.el) || b.t.el.contains(a.t.el) || pinned(a.t.el) !== pinned(b.t.el))
|
|
214
|
+
continue;
|
|
215
|
+
const w = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left);
|
|
216
|
+
const h = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top);
|
|
217
|
+
if (w <= 2 || h <= 2)
|
|
218
|
+
continue;
|
|
219
|
+
const smaller = Math.min(a.r.width * a.r.height, b.r.width * b.r.height);
|
|
220
|
+
if (w * h < 0.3 * smaller || seen.has(a.t.el) || seen.has(b.t.el))
|
|
221
|
+
continue;
|
|
222
|
+
seen.add(a.t.el);
|
|
223
|
+
seen.add(b.t.el);
|
|
224
|
+
out.push({ rule: "RX-QA-03", level: "warn", selector: selector(a.t.el), message: `${quote(a.t.text)} overlaps ${quote(b.t.text)} (${selector(b.t.el)})` });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// `ryux scan <url>`: render a page with Playwright at each width and measure it (./page-probe.ts).
|
|
2
|
+
// Playwright is optional: it is loaded from the user's project (or a global install) and never
|
|
3
|
+
// bundled, so installing RYUX stays light.
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { probe } from "./page-probe.js";
|
|
8
|
+
export const PLAYWRIGHT_MISSING = "Scanning a URL needs Playwright, which RYUX does not install for you. In this project run:\n npm i -D playwright && npx playwright install chromium";
|
|
9
|
+
async function loadChromium() {
|
|
10
|
+
const fromProject = createRequire(join(process.cwd(), "noop.js"));
|
|
11
|
+
for (const name of ["playwright", "playwright-core", "@playwright/test"]) {
|
|
12
|
+
try {
|
|
13
|
+
const mod = (await import(pathToFileURL(fromProject.resolve(name)).href));
|
|
14
|
+
const chromium = mod.chromium ?? mod.default?.chromium;
|
|
15
|
+
if (chromium)
|
|
16
|
+
return chromium;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// not installed under this name; try the next
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/** Render each URL at each width and return the findings. Throws PLAYWRIGHT_MISSING when there is no
|
|
25
|
+
* Playwright, and a launch error when its browser is not installed. */
|
|
26
|
+
export async function scanPages(urls, widths) {
|
|
27
|
+
const chromium = await loadChromium();
|
|
28
|
+
if (!chromium)
|
|
29
|
+
throw new Error(PLAYWRIGHT_MISSING);
|
|
30
|
+
let browser;
|
|
31
|
+
try {
|
|
32
|
+
browser = await chromium.launch();
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
throw new Error(`Playwright could not start Chromium. Run: npx playwright install chromium\n${err instanceof Error ? err.message.split("\n")[0] : err}`);
|
|
36
|
+
}
|
|
37
|
+
const findings = [];
|
|
38
|
+
try {
|
|
39
|
+
for (const url of urls) {
|
|
40
|
+
for (const width of widths) {
|
|
41
|
+
// Reduced motion lets animations settle, so the page is measured in its final state.
|
|
42
|
+
const ctx = await browser.newContext({ viewport: { width, height: width <= 480 ? 844 : 900 }, reducedMotion: "reduce" });
|
|
43
|
+
const page = await ctx.newPage();
|
|
44
|
+
const errors = [];
|
|
45
|
+
page.on("pageerror", (e) => errors.push(e.message.split("\n")[0]));
|
|
46
|
+
const add = (f) => {
|
|
47
|
+
findings.push({ ...f, file: url, line: 0, column: 0, width });
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }).catch(() => page.goto(url, { waitUntil: "load", timeout: 30_000 }));
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
add({ rule: "RX-QA-08", level: "fail", selector: "page", message: `the page did not load: ${err instanceof Error ? err.message.split("\n")[0] : err}` });
|
|
54
|
+
await ctx.close();
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
await page.waitForTimeout(300);
|
|
58
|
+
(await page.evaluate(probe)).forEach(add);
|
|
59
|
+
for (const message of [...new Set(errors)]) {
|
|
60
|
+
add({ rule: "RX-QA-08", level: "warn", selector: "page", message: `the page threw "${message}" while loading; check that the primary task still completes` });
|
|
61
|
+
}
|
|
62
|
+
await ctx.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await browser.close();
|
|
68
|
+
}
|
|
69
|
+
return findings;
|
|
70
|
+
}
|