@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 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
 
@@ -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 = 1;
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 ok = (m) => console.log(`${pc.green("✓")} ${m}`);
257
- const warn = (m) => console.log(`${pc.yellow("!")} ${m}`);
258
- const fail = (m) => {
259
- console.log(`${pc.red("✗")} ${m}`);
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(`RYUX is not installed ${scope.global ? "in your home directory" : "in this project"}. Run: ${CLI_CMD} init`);
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(`${where}: RYUX 1.x folders remain (${leftovers.length}). Run: ${CLI_CMD} update`);
316
+ add("fail", where, `RYUX 1.x folders remain (${leftovers.length})`, "update");
273
317
  if (!existsSync(join(skillDir, "SKILL.md"))) {
274
- fail(`${where}: no ${SKILL_NAME}/ skill. Run: ${CLI_CMD} update`);
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(`${where}/${SKILL_NAME}: ${e}`);
324
+ add("fail", `${where}/${SKILL_NAME}`, e, "update");
280
325
  for (const w of report.warnings)
281
- warn(`${where}/${SKILL_NAME}: ${w}`);
326
+ add("warn", `${where}/${SKILL_NAME}`, w);
282
327
  if (report.version && report.version !== VERSION)
283
- warn(`${where}: installed RYUX ${report.version}, this CLI is ${VERSION}. Run: ${CLI_CMD} update`);
328
+ add("warn", where, `installed RYUX ${report.version}, this CLI is ${VERSION}`, "update");
284
329
  if (!report.errors.length)
285
- ok(`${where}/${SKILL_NAME}: complete, RYUX ${report.version}`);
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(`${file}: RYUX pointer block present`);
336
+ add("ok", file, "RYUX pointer block present");
292
337
  else
293
- warn(`${file}: no RYUX pointer block. Run: ${CLI_CMD} update`);
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(`${CONTEXT_FILE}: project context present, ${filledCount(context)} of ${ASKED_FIELDS.length} setup fields filled (blanks are treated as unknown)`);
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(`${CONTEXT_FILE}: no RYUX project context. Run: ${CLI_CMD} setup`);
345
+ add("warn", CONTEXT_FILE, "no RYUX project context", "setup");
301
346
  }
302
- if (!process.exitCode)
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 = 1;
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
+ }