@ryuxdsgn/design-intelligence 2.15.0 → 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/dist/cli/hook.js +55 -0
- package/dist/cli/index.js +39 -3
- 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 +35 -8
- package/dist/intelligence/guides.js +8 -5
- package/dist/render.js +8 -1
- package/dist/scan.js +343 -0
- package/package.json +1 -1
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);
|
|
@@ -174,12 +176,27 @@ async function runInstall(flags) {
|
|
|
174
176
|
console.log(pc.yellow("agents-md is project-only; skipped for --global."));
|
|
175
177
|
agents = agents.filter((x) => x !== AGENTS_MD_INLINE.id);
|
|
176
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
|
+
}
|
|
177
190
|
if (flags["dry-run"]) {
|
|
178
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)`);
|
|
179
194
|
console.log(`Dry run: nothing was written. ${CLI_CMD} ${flags.init ? "init" : "install"} would:\n${plan.map((l) => ` ${l}`).join("\n")}`);
|
|
180
195
|
return;
|
|
181
196
|
}
|
|
182
197
|
const written = await install(agents, scope);
|
|
198
|
+
if (hook)
|
|
199
|
+
written.push(await installHook(scope.root));
|
|
183
200
|
const summary = written.map((w) => pc.green("✓ ") + w).join("\n");
|
|
184
201
|
if (nonInteractive)
|
|
185
202
|
console.log(summary);
|
|
@@ -222,6 +239,8 @@ async function runUpdate(flags) {
|
|
|
222
239
|
const done = [];
|
|
223
240
|
for (const dir of dirs)
|
|
224
241
|
done.push(...(await writeSkills(dir, scope.root)));
|
|
242
|
+
if (await hasHook(scope.root))
|
|
243
|
+
done.push(await installHook(scope.root));
|
|
225
244
|
if (!scope.global) {
|
|
226
245
|
for (const file of ["CLAUDE.md", "GEMINI.md", "AGENTS.md"]) {
|
|
227
246
|
const path = join(scope.root, file);
|
|
@@ -241,7 +260,7 @@ async function runRemove(flags) {
|
|
|
241
260
|
const pointerFiles = scope.global ? [] : ["CLAUDE.md", "GEMINI.md", "AGENTS.md"];
|
|
242
261
|
const hasBlocks = (await Promise.all(pointerFiles.map(async (f) => (await readIfExists(join(scope.root, f)))?.includes(MARK_START)))).some(Boolean);
|
|
243
262
|
const hasLegacyCursor = existsSync(join(scope.root, LEGACY_CURSOR_RULES_DIR));
|
|
244
|
-
if (!hasSkills && !hasBlocks && !hasLegacyCursor) {
|
|
263
|
+
if (!hasSkills && !hasBlocks && !hasLegacyCursor && !(await hasHook(scope.root))) {
|
|
245
264
|
console.log("No RYUX install found.");
|
|
246
265
|
return;
|
|
247
266
|
}
|
|
@@ -264,6 +283,8 @@ async function runRemove(flags) {
|
|
|
264
283
|
for (const f of pointerFiles)
|
|
265
284
|
if (await removeBlock(join(scope.root, f)))
|
|
266
285
|
removed.push(`${f} (block)`);
|
|
286
|
+
if (await removeHook(scope.root))
|
|
287
|
+
removed.push(".claude/settings.json (scan hook)");
|
|
267
288
|
console.log(removed.length ? removed.map((r) => pc.red("− ") + r).join("\n") : "No files removed.");
|
|
268
289
|
}
|
|
269
290
|
/** Read every file of an installed skill folder, keyed by its path inside the folder. */
|
|
@@ -323,6 +344,8 @@ async function runCheck(flags) {
|
|
|
323
344
|
else
|
|
324
345
|
add("warn", CONTEXT_FILE, "no RYUX project context", "setup");
|
|
325
346
|
}
|
|
347
|
+
if (await hasHook(scope.root))
|
|
348
|
+
add("ok", ".claude/settings.json", "scan hook present");
|
|
326
349
|
const ok = !findings.some((f) => f.level === "fail");
|
|
327
350
|
if (!ok)
|
|
328
351
|
process.exitCode = 1;
|
|
@@ -723,6 +746,9 @@ Commands:
|
|
|
723
746
|
setup Teach RYUX about this project: a few skippable questions, saved to DESIGN.md.
|
|
724
747
|
init Install RYUX and add the DESIGN.md project context block.
|
|
725
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.
|
|
726
752
|
update Update an install to this version (also migrates RYUX 1.x folders).
|
|
727
753
|
remove Remove RYUX and the marked blocks.
|
|
728
754
|
help Show this help.
|
|
@@ -737,7 +763,11 @@ Options:
|
|
|
737
763
|
--mcp print the ryux MCP connect command
|
|
738
764
|
--yes skip the confirmation (remove)
|
|
739
765
|
--dry-run list what install or init would create or update, and write nothing
|
|
740
|
-
--json print check's findings as JSON (for agents and CI)
|
|
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)
|
|
741
771
|
--product, --work, --audience, --market, --constraints, --intent, --ux, --ui, --motion <text>
|
|
742
772
|
set project context fields without questions (setup)
|
|
743
773
|
|
|
@@ -754,8 +784,12 @@ Examples:
|
|
|
754
784
|
${CLI_CMD} remove --yes
|
|
755
785
|
${CLI_CMD} init --agent claude --dry-run
|
|
756
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
|
|
757
791
|
|
|
758
|
-
Exit codes: 0 done or healthy, 1 a problem was found or the command failed, 2 the command was used wrongly.`);
|
|
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.`);
|
|
759
793
|
}
|
|
760
794
|
async function main() {
|
|
761
795
|
const argv = process.argv.slice(2);
|
|
@@ -774,6 +808,8 @@ async function main() {
|
|
|
774
808
|
case "check":
|
|
775
809
|
case "doctor":
|
|
776
810
|
return runCheck(flags);
|
|
811
|
+
case "scan":
|
|
812
|
+
return runScan(argv.slice(1));
|
|
777
813
|
case "init":
|
|
778
814
|
return runInit(flags);
|
|
779
815
|
case "setup":
|
|
@@ -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
|
+
}
|
package/dist/cli/scan.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// `ryux scan`: run the deterministic detectors (../scan.ts) over files and folders, measure rendered
|
|
2
|
+
// pages given as URLs (./page-scan.ts), or check the file an agent just edited (--hook, fed by a
|
|
3
|
+
// Claude Code PostToolUse hook on stdin).
|
|
4
|
+
import { existsSync, statSync } from "node:fs";
|
|
5
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import { RULES } from "../intelligence/content.js";
|
|
9
|
+
import { VERSION } from "../product.js";
|
|
10
|
+
import { DETECTORS, RENDER_CHECKS, SCAN_EXTS, scanSources, toSource } from "../scan.js";
|
|
11
|
+
import { scanPages } from "./page-scan.js";
|
|
12
|
+
const DEFAULT_WIDTHS = [320, 390, 1280];
|
|
13
|
+
const isUrl = (t) => /^(?:https?|file):\/\//.test(t);
|
|
14
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "out", "coverage", "vendor", ".next", ".nuxt", ".svelte-kit", ".output"]);
|
|
15
|
+
const MAX_BYTES = 512 * 1024;
|
|
16
|
+
const scannable = (path) => SCAN_EXTS.includes(path.slice(path.lastIndexOf(".") + 1).toLowerCase()) && !/\.(?:min\.\w+|d\.ts)$/.test(path);
|
|
17
|
+
async function walk(dir, out) {
|
|
18
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
19
|
+
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
|
|
20
|
+
continue;
|
|
21
|
+
const full = join(dir, entry.name);
|
|
22
|
+
if (entry.isDirectory())
|
|
23
|
+
await walk(full, out);
|
|
24
|
+
else if (entry.isFile() && scannable(full))
|
|
25
|
+
out.push(full);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function scanPaths(paths, skip) {
|
|
29
|
+
const files = [];
|
|
30
|
+
let folder = false;
|
|
31
|
+
for (const p of paths) {
|
|
32
|
+
if (statSync(p).isDirectory()) {
|
|
33
|
+
folder = true;
|
|
34
|
+
await walk(p, files);
|
|
35
|
+
}
|
|
36
|
+
else if (scannable(p))
|
|
37
|
+
files.push(p);
|
|
38
|
+
}
|
|
39
|
+
const sources = [];
|
|
40
|
+
for (const f of files) {
|
|
41
|
+
if (statSync(f).size > MAX_BYTES)
|
|
42
|
+
continue;
|
|
43
|
+
sources.push(toSource(relative(process.cwd(), f) || f, await readFile(f, "utf8")));
|
|
44
|
+
}
|
|
45
|
+
return { files: sources.length, findings: scanSources(sources, { folder, skip }) };
|
|
46
|
+
}
|
|
47
|
+
const title = (id) => RULES.find((r) => r.id === id)?.title ?? "";
|
|
48
|
+
function listRules(json) {
|
|
49
|
+
if (json) {
|
|
50
|
+
const entry = (d, kind) => ({ rule: d.rule, title: title(d.rule), level: d.level, kind, looksFor: d.looksFor, folderOnly: Boolean(d.folder) });
|
|
51
|
+
console.log(JSON.stringify([...DETECTORS.map((d) => entry(d, "source")), ...RENDER_CHECKS.map((d) => entry(d, "render"))], null, 2));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const row = (d) => {
|
|
55
|
+
console.log(`${d.level === "fail" ? pc.red("fail") : pc.yellow("warn")} ${d.rule.padEnd(11)} ${title(d.rule)}`);
|
|
56
|
+
console.log(` ${d.looksFor}${d.folder ? " (folder scans only)" : ""}`);
|
|
57
|
+
};
|
|
58
|
+
console.log(`ryux scan v${VERSION}: checks for rules a machine can see. No LLM.\n\nSource files (${DETECTORS.length}):`);
|
|
59
|
+
DETECTORS.forEach(row);
|
|
60
|
+
console.log(`\nRendered pages, for a URL (${RENDER_CHECKS.length}; needs Playwright in the project; widths ${DEFAULT_WIDTHS.join(", ")} unless --widths):`);
|
|
61
|
+
RENDER_CHECKS.forEach(row);
|
|
62
|
+
console.log(`\nfail: wrong wherever it appears, exit 1. warn: worth a look, exit 0.
|
|
63
|
+
Not checked: a judgment call, which belongs to Critique.`);
|
|
64
|
+
}
|
|
65
|
+
/** Read the PostToolUse payload Claude Code sends on stdin and return the edited file, if any. */
|
|
66
|
+
async function hookFile() {
|
|
67
|
+
let input = "";
|
|
68
|
+
for await (const chunk of process.stdin)
|
|
69
|
+
input += chunk;
|
|
70
|
+
try {
|
|
71
|
+
const payload = JSON.parse(input);
|
|
72
|
+
return payload.tool_input?.file_path ?? null;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function report(findings, files, pages = 0) {
|
|
79
|
+
const mark = { fail: pc.red("✗"), warn: pc.yellow("!") };
|
|
80
|
+
const lines = findings.map((f) => f.width ? `${mark[f.level]} ${f.file} @${f.width}px ${f.rule} ${f.message} [${f.selector}]` : `${mark[f.level]} ${f.file}:${f.line}:${f.column} ${f.rule} ${f.message}`);
|
|
81
|
+
const fails = findings.filter((f) => f.level === "fail").length;
|
|
82
|
+
const warns = findings.length - fails;
|
|
83
|
+
const what = [files || !pages ? `${files} file${files === 1 ? "" : "s"}` : "", pages ? `${pages} page${pages === 1 ? "" : "s"}` : ""].filter(Boolean).join(" and ");
|
|
84
|
+
lines.push(`${lines.length ? "\n" : ""}${what} scanned: ${fails} fail, ${warns} warn.`);
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
87
|
+
export async function runScan(argv) {
|
|
88
|
+
const paths = [];
|
|
89
|
+
const skip = new Set();
|
|
90
|
+
const opts = { json: false, hook: false, list: false };
|
|
91
|
+
let widths = DEFAULT_WIDTHS;
|
|
92
|
+
for (let i = 0; i < argv.length; i++) {
|
|
93
|
+
const a = argv[i];
|
|
94
|
+
if (a === "--json")
|
|
95
|
+
opts.json = true;
|
|
96
|
+
else if (a === "--hook")
|
|
97
|
+
opts.hook = true;
|
|
98
|
+
else if (a === "--list-rules")
|
|
99
|
+
opts.list = true;
|
|
100
|
+
else if (a === "--widths") {
|
|
101
|
+
widths = (argv[++i] ?? "").split(",").map(Number);
|
|
102
|
+
if (!widths.length || widths.some((w) => !Number.isInteger(w) || w < 200 || w > 3840)) {
|
|
103
|
+
console.error("--widths takes CSS pixel widths from 200 to 3840, e.g. --widths 320,390,1280");
|
|
104
|
+
process.exitCode = 2;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (a === "--ignore")
|
|
109
|
+
(argv[++i] ?? "").split(",").filter(Boolean).forEach((id) => skip.add(id.trim()));
|
|
110
|
+
else if (a.startsWith("--")) {
|
|
111
|
+
console.error(`Unknown option for scan: ${a}`);
|
|
112
|
+
process.exitCode = 2;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
else
|
|
116
|
+
paths.push(a);
|
|
117
|
+
}
|
|
118
|
+
if (opts.list)
|
|
119
|
+
return listRules(opts.json);
|
|
120
|
+
if (opts.hook) {
|
|
121
|
+
// A hook must never block an edit by crashing: anything unexpected ends quietly with exit 0.
|
|
122
|
+
const file = await hookFile();
|
|
123
|
+
if (!file || !existsSync(file) || !scannable(file))
|
|
124
|
+
return;
|
|
125
|
+
const { findings } = await scanPaths([file], skip);
|
|
126
|
+
if (!findings.length)
|
|
127
|
+
return;
|
|
128
|
+
console.error(`RYUX scan found ${findings.length} issue${findings.length === 1 ? "" : "s"} in the file you just edited. Fix each fail; for each warn, fix it or add a ryux-ignore comment with the reason.\n${report(findings, 1)}`);
|
|
129
|
+
process.exitCode = 2;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const urls = paths.filter(isUrl);
|
|
133
|
+
const local = paths.filter((t) => !isUrl(t));
|
|
134
|
+
const targets = local.length || urls.length ? local : ["."];
|
|
135
|
+
const missing = targets.filter((t) => !existsSync(t));
|
|
136
|
+
if (missing.length) {
|
|
137
|
+
console.error(`Not found: ${missing.join(", ")}`);
|
|
138
|
+
process.exitCode = 2;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const { files, findings } = targets.length ? await scanPaths(targets, skip) : { files: 0, findings: [] };
|
|
142
|
+
if (urls.length) {
|
|
143
|
+
try {
|
|
144
|
+
findings.push(...(await scanPages(urls, widths)).filter((f) => !skip.has(f.rule)));
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
console.error(err instanceof Error ? err.message : err);
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const ok = !findings.some((f) => f.level === "fail");
|
|
153
|
+
if (!ok)
|
|
154
|
+
process.exitCode = 1;
|
|
155
|
+
if (opts.json) {
|
|
156
|
+
console.log(JSON.stringify({ ok, cli: VERSION, files, pages: urls.length, widths: urls.length ? widths : [], findings }, null, 2));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
console.log(report(findings, files, urls.length));
|
|
160
|
+
}
|
|
@@ -81,7 +81,7 @@ export const RULES = [
|
|
|
81
81
|
level: "required",
|
|
82
82
|
gate: "hard",
|
|
83
83
|
do: "Give each screen one clear, dominant goal. When the task has a clear next step (a form, checkout, a confirmation), give it one primary action and make secondary actions look secondary.",
|
|
84
|
-
dont: "Put two equal-weight calls to action side by side
|
|
84
|
+
dont: "Put two equal-weight calls to action side by side.",
|
|
85
85
|
notWhen: "Monitoring and configuration screens (dashboards, settings) need a clear goal but not a single dominant action; do not invent one to satisfy this rule.",
|
|
86
86
|
why: "A single clear path shortens the decision and the task.",
|
|
87
87
|
basis: "Hick's law (Hick 1952; Hyman 1953): more options, longer decisions; NNGroup visual hierarchy",
|
|
@@ -814,8 +814,8 @@ export const RULES = [
|
|
|
814
814
|
title: "Detail craft",
|
|
815
815
|
level: "preferred",
|
|
816
816
|
gate: "lock",
|
|
817
|
-
do: "Center icons on the optical middle of the text beside them and size them to its line-height;
|
|
818
|
-
dont: "Let icons sit high or low against their labels, mix stroke weights, center amounts in a table, or invent a one-off shadow for one card.",
|
|
817
|
+
do: "Center icons on the optical middle of the text beside them and size them to its line-height; take every icon from one set and match its stroke to the type weight; right-align (or decimal-align) number columns; take borders, dividers, radii, and shadows from a small token set; truncate long text on purpose, with the full value still reachable (a tooltip, a detail view, or wrapping).",
|
|
818
|
+
dont: "Let icons sit high or low against their labels, mix icon sets or stroke weights, center amounts in a table, or invent a one-off shadow for one card.",
|
|
819
819
|
why: "Each detail is small; together they are what makes a screen look made rather than generated.",
|
|
820
820
|
basis: "Bringhurst (figures and alignment); Apple HIG and Material iconography (optical size and alignment); owner review 2026-10-17: wrong tab icons in withryux.pen",
|
|
821
821
|
check: "render inspection at 390 and 1280: icon center within 2 px of the text center; column alignment",
|
|
@@ -865,6 +865,7 @@ export const RULES = [
|
|
|
865
865
|
num: 11,
|
|
866
866
|
title: "Motion earns its level",
|
|
867
867
|
level: "contextual",
|
|
868
|
+
gate: "lock",
|
|
868
869
|
when: "the design adds motion or transitions",
|
|
869
870
|
do: "Classify each motion: L1 state feedback, L2 component transition, L3 page transition, L4 storytelling, L5 decorative; the higher the level, the stronger the reason it needs.\n - Weigh how often people see it: a surface opened many times a day (command palette, shortcut, core navigation, list selection) appears and closes without movement, at most a fade too short to notice; standard transitions belong to occasional surfaces (modal, drawer, toast), and expression to rare or first-time moments.\n - Take timing and easing from one motion personality, define them once as motion tokens, animate transform and opacity rather than layout properties, and honor reduced motion (RX-A11Y-08).\n - Anything that opens and closes (menu, popover, dialog, drawer) uses transitions that can reverse halfway, so a close or reopen mid-animation turns back from where it is, and it closes no slower than it opened.",
|
|
870
871
|
dont: "Animate everything, give a surface people open many times a day an entrance they must sit through, play an open-only keyframe animation on something people can close or reopen at any moment, use one generic duration and easing (transition: all 0.3s) everywhere, let decorative motion delay content or input, or move data the user is reading (a balance, an amount, a table value) for effect.",
|
|
@@ -1294,6 +1295,18 @@ export const RULES = [
|
|
|
1294
1295
|
basis: `pattern also in ${AS} R-33; written for RYUX`,
|
|
1295
1296
|
check: "review the diff",
|
|
1296
1297
|
},
|
|
1298
|
+
{
|
|
1299
|
+
num: 16,
|
|
1300
|
+
title: "The build passes",
|
|
1301
|
+
level: "required",
|
|
1302
|
+
gate: "hard",
|
|
1303
|
+
do: "Before handoff, run the checks the repo already has (build, typecheck, lint, tests) and fix what fails. Report each one you ran and its result; a check you could not run is named, and the work is not called done.",
|
|
1304
|
+
dont: "Hand off code that does not build or typecheck, silence an error to make it pass (a type cast, a disabled rule, a skipped test), or call it done without running the checks.",
|
|
1305
|
+
notWhen: "design-only work with no code; then CODE QUALITY is N/A",
|
|
1306
|
+
why: "A build that fails cannot be reviewed, rendered, or shipped; every other check in the Delivery Gate depends on it.",
|
|
1307
|
+
basis: "Continuous integration practice; owner review 2026-10-25: build failure belongs with the checks that cannot be argued",
|
|
1308
|
+
check: "run the repo's build, typecheck, lint, and tests; fails on any error",
|
|
1309
|
+
},
|
|
1297
1310
|
]),
|
|
1298
1311
|
...skill("visual-qa", [
|
|
1299
1312
|
{
|
|
@@ -1337,6 +1350,18 @@ export const RULES = [
|
|
|
1337
1350
|
basis: "ryux visual QA loop; ryux evidence principle",
|
|
1338
1351
|
check: "screenshot comparison, search_screens",
|
|
1339
1352
|
},
|
|
1353
|
+
{
|
|
1354
|
+
num: 8,
|
|
1355
|
+
title: "The primary task completes",
|
|
1356
|
+
level: "required",
|
|
1357
|
+
gate: "hard",
|
|
1358
|
+
do: "In the running build or a clickable prototype, do the screen's primary task from start to result as a user would (tap, type, submit), then one failure path (invalid input or a failed request). Name the path you ran.",
|
|
1359
|
+
dont: "Hand off a primary flow with a dead end: a button that does nothing, a submit that never resolves, a step that cannot be reached, or an error with no way back.",
|
|
1360
|
+
notWhen: "static mockups with no interaction; then say the flow was not run and VISUAL QA is NOT VERIFIED",
|
|
1361
|
+
why: "A broken primary action fails the one thing the screen exists for, whatever else is right.",
|
|
1362
|
+
basis: "Usability testing practice (task completion); owner review 2026-10-25: a broken main interaction belongs with the checks that cannot be argued",
|
|
1363
|
+
check: "run the primary task and one failure path in the render; fails on any dead end",
|
|
1364
|
+
},
|
|
1340
1365
|
]),
|
|
1341
1366
|
...skill("anti-slop", [
|
|
1342
1367
|
{
|
|
@@ -1402,12 +1427,11 @@ export const RULES = [
|
|
|
1402
1427
|
num: 6,
|
|
1403
1428
|
title: "Complexity with a reason",
|
|
1404
1429
|
level: "required",
|
|
1405
|
-
gate: "hard",
|
|
1406
1430
|
do: "Remove elements, options, states, code paths, abstractions, and dependencies that serve no stated need. Complexity that cannot be removed is carried by the product (defaults, formatting, calculation), not handed to the user.",
|
|
1407
1431
|
dont: "Add settings, sections, abstractions, or packages \"for later\", or features because similar products have them.",
|
|
1408
1432
|
why: "Unneeded complexity costs every user and every future change.",
|
|
1409
1433
|
basis: "Nielsen heuristic 8; Tesler's law of conserved complexity (Tesler, 1980s): some complexity cannot be removed, only moved; clean-code practice",
|
|
1410
|
-
check: "list each element, option, state, and dependency the work added and the need it serves; fails on any without one",
|
|
1434
|
+
check: "list each element, option, state, and dependency the work added and the need it serves; fails on any without one or a written reason",
|
|
1411
1435
|
formerly: ["RX-K-05"],
|
|
1412
1436
|
},
|
|
1413
1437
|
{
|
|
@@ -1538,10 +1562,11 @@ export const HARD_GATES = [
|
|
|
1538
1562
|
{ item: "Missing critical states", rules: ["RX-EC-01"] },
|
|
1539
1563
|
{ item: "Broken responsive behavior", rules: ["RX-RD-01"] },
|
|
1540
1564
|
{ item: "Accessibility failures", rules: ["RX-A11Y-01", "RX-A11Y-02", "RX-A11Y-03", "RX-A11Y-04"] },
|
|
1541
|
-
{ item: "
|
|
1542
|
-
{ item: "Unexplained interaction behavior", rules: ["RX-IX-01"
|
|
1565
|
+
{ item: "Competing primary actions", rules: ["RX-PR-03"] },
|
|
1566
|
+
{ item: "Unexplained interaction behavior", rules: ["RX-IX-01"] },
|
|
1543
1567
|
{ item: "Duplicate components", rules: ["RX-DS-01"] },
|
|
1544
|
-
{ item: "
|
|
1568
|
+
{ item: "Build or typecheck fails", rules: ["RX-FE-16"] },
|
|
1569
|
+
{ item: "Primary task cannot be completed", rules: ["RX-QA-08"] },
|
|
1545
1570
|
];
|
|
1546
1571
|
// Brief item: consistency that must hold across the product.
|
|
1547
1572
|
export const QUALITY_LOCKS = [
|
|
@@ -1554,6 +1579,8 @@ export const QUALITY_LOCKS = [
|
|
|
1554
1579
|
{ item: "Responsive behavior", rules: ["RX-RD-06"] },
|
|
1555
1580
|
{ item: "Visual hierarchy", rules: ["RX-UI-01"] },
|
|
1556
1581
|
{ item: "Radius", rules: ["RX-UI-03", "RX-DS-03"] },
|
|
1582
|
+
{ item: "Icons", rules: ["RX-UI-15", "RX-UI-20"] },
|
|
1583
|
+
{ item: "Motion", rules: ["RX-UI-11"] },
|
|
1557
1584
|
];
|
|
1558
1585
|
// Contextual activation: load only what the task needs (core is always loaded).
|
|
1559
1586
|
export const ACTIVATION = [
|
|
@@ -44,9 +44,9 @@ export const CORE_WORKFLOW = `One workflow, six stages; each entry point runs th
|
|
|
44
44
|
structure, not paint; reference screens through the ryux MCP when connected. A small change skips it.
|
|
45
45
|
4. **Evaluate and select**: rate the alternatives against the Frame criteria with evidence
|
|
46
46
|
(RX-PR-09, RX-PR-10); the user picks from options (capabilities/design.md); write the Decision Receipt.
|
|
47
|
-
5. **Build and critique**: design or implement the choice, render, inspect, and fix
|
|
48
|
-
unchosen alternative when a finding contradicts the choice, and say why.
|
|
47
|
+
5. **Build and critique**: design or implement the choice, render, inspect, and fix.
|
|
49
48
|
6. **Verify and deliver**: Hard Gates, the Delivery Gate, honest claims, and a user test plan (RX-UX-12).
|
|
49
|
+
In 5 or 6, a finding rooted in the frame or the choice goes back to stage 2 or 3 (Reconsider an unchosen alternative), with the reason; it is not patched in the build.
|
|
50
50
|
|
|
51
51
|
Analyze runs 1. Design runs 1-6. Build runs 1, 5, and 6 (2-4 through Design when no design exists).
|
|
52
52
|
Critique runs 1 and the critique half of 5. QA runs 6. Never go from "generate" straight to "done".`;
|
|
@@ -305,7 +305,8 @@ workflow (SKILL.md); Frame, Explore, and Evaluate happen in Design.
|
|
|
305
305
|
When anything animates, run the motion passes in capabilities/qa.md as well. Run the counts of
|
|
306
306
|
RX-AS-10 on the render and report them in the gate.
|
|
307
307
|
7. **Close with the Delivery Gate.** CODE QUALITY and VISUAL QA need real checks (typecheck,
|
|
308
|
-
tests, render), or say what was not run.
|
|
308
|
+
tests, render), or say what was not run. Run \`npx @ryuxdsgn/design-intelligence scan <changed
|
|
309
|
+
paths>\` as well: fix every fail, and fix each warn or give the reason it stays. For a new flow or a consequential change, add the
|
|
309
310
|
usability test plan (RX-UX-12) unless the design already has one.`;
|
|
310
311
|
export const CORE_EVIDENCE = `Say where every claim comes from:
|
|
311
312
|
|
|
@@ -332,8 +333,7 @@ compliance, product or competitor facts, screenshots, or references (RX-PR-02, R
|
|
|
332
333
|
|
|
333
334
|
Record a source as \`{ type: observed | inferred | knowledge, origin, reference }\`, for example
|
|
334
335
|
\`{ knowledge, ryux-knowledge, scr_123 }\` from the ryux MCP, or \`{ observed, screenshot, hero.png }\`.
|
|
335
|
-
Optional sources (the ryux MCP, Figma, a browser, screenshots, other reference libraries) add
|
|
336
|
-
evidence when connected; none is required.`;
|
|
336
|
+
Optional sources (the ryux MCP, Figma, a browser, screenshots, other reference libraries) add evidence when connected; none is required.`;
|
|
337
337
|
export const CORE_HONESTY = `Report what was checked, how, and what was not available. Do not claim "pixel perfect",
|
|
338
338
|
"fully accessible", "production ready", "senior-level", or "UX optimized" without evidence. Say
|
|
339
339
|
"Keyboard and focus checked by hand; no automated accessibility test was available" instead.
|
|
@@ -761,6 +761,9 @@ render, report these as NOT VERIFIED.
|
|
|
761
761
|
**Render with what is available**, in this order: the project's own preview or test setup;
|
|
762
762
|
\`npx playwright screenshot --viewport-size=1440,900 <url>\` (and 390 wide for mobile); a browser
|
|
763
763
|
tool; a design-tool export such as pen.dev \`Export\`. If none is available, say so in the gate.
|
|
764
|
+
For a build that runs, also measure it: \`npx @ryuxdsgn/design-intelligence scan <url>\` checks
|
|
765
|
+
sideways scroll, contrast, target size, and colliding text at 320, 390, and 1280 px (it needs
|
|
766
|
+
Playwright in the project). Report its fails in the gate; it does not replace looking at the render.
|
|
764
767
|
|
|
765
768
|
${UNTRUSTED_CONTENT}
|
|
766
769
|
|
package/dist/render.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ACTIVATION, GATE_AREAS, GROUPS, HARD_GATES, LEVEL_LABEL, PURPOSE_GATES, QUALITY_LOCKS, RETIRED, RULES, SKILLS, } from "./intelligence/content.js";
|
|
2
2
|
import { VERSION } from "./product.js";
|
|
3
|
+
import { DETECTORS, RENDER_CHECKS } from "./scan.js";
|
|
3
4
|
import { CORE_CAPABILITIES, CORE_DECISION_RECORD, CORE_EVIDENCE, CORE_HONESTY, CORE_POSITIONING, CORE_PRINCIPLE, CORE_WORKFLOW, BUILD_BODY, DESIGN_BODY, GATE_RULES, GUIDES, } from "./intelligence/guides.js";
|
|
4
5
|
import { CRITIQUE_BODY } from "./intelligence/critique.js";
|
|
5
6
|
import { ANALYZE_BODY } from "./intelligence/analyze.js";
|
|
@@ -21,12 +22,18 @@ function ruleBlock(r) {
|
|
|
21
22
|
lines.push(`- Trade-off: ${r.tradeoff}`);
|
|
22
23
|
if (r.check)
|
|
23
24
|
lines.push(`- Check: ${r.check}`);
|
|
25
|
+
const machine = DETECTORS.filter((d) => d.rule === r.id);
|
|
26
|
+
if (machine.length)
|
|
27
|
+
lines.push(`- Machine check: \`ryux scan\` flags ${machine.map((d) => `${d.looksFor} (${d.level})`).join("; ")}`);
|
|
28
|
+
const measured = RENDER_CHECKS.filter((d) => d.rule === r.id);
|
|
29
|
+
if (measured.length)
|
|
30
|
+
lines.push(`- Measured: \`ryux scan <url>\` flags ${measured.map((d) => `${d.looksFor} (${d.level})`).join("; ")}`);
|
|
24
31
|
return lines.join("\n");
|
|
25
32
|
}
|
|
26
33
|
const LEVELS = `- **[Required]**: applies within its stated scope; an exception needs a written reason.
|
|
27
34
|
- **[Preferred]**: the default; break it only with a short written reason.
|
|
28
35
|
- **[Contextual]**: applies only when its "When" situation is present.
|
|
29
|
-
- **[Hard Gate]**: a Required rule with no exceptions. Fix it before declaring the work complete.
|
|
36
|
+
- **[Hard Gate]**: a Required rule with no exceptions. Fix it before declaring the work complete. Kept for failures a check can show (a run, a count, a measurement, a source); a judgment call is a Required rule and goes to Critique.
|
|
30
37
|
- **[Quality Lock]**: consistency that must hold across the product.`;
|
|
31
38
|
export function activationTable() {
|
|
32
39
|
const rows = ACTIVATION.map((a) => `| ${a.task} | ${a.skills.map((s) => `\`${modulePath(s)}\``).join(", ")} |`);
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// Deterministic detectors for RYUX rules a machine can check in source code. Pure: takes file
|
|
2
|
+
// contents, returns findings, no LLM and no network. Each detector names the RX rule it checks; the
|
|
3
|
+
// rule text stays in intelligence/content.ts. "fail" is for a pattern that is wrong wherever it
|
|
4
|
+
// appears; "warn" is for a signal a person or agent should look at.
|
|
5
|
+
/** What `ryux scan <url>` measures on the rendered page (cli/page-probe.ts), for --list-rules and the
|
|
6
|
+
* rule text. */
|
|
7
|
+
export const RENDER_CHECKS = [
|
|
8
|
+
{ rule: "RX-RD-01", level: "fail", looksFor: "a page that scrolls sideways at a scanned width, naming the widest elements" },
|
|
9
|
+
{ rule: "RX-A11Y-01", level: "fail", looksFor: "text under 4.5:1 contrast (3:1 when large) on a solid background; text over images or gradients is skipped" },
|
|
10
|
+
{ rule: "RX-A11Y-02", level: "fail", looksFor: "controls under 24x24px that are crowded by neighbours (warn when spaced as WCAG 2.5.8 allows); rendered text under 12px (warn)" },
|
|
11
|
+
{ rule: "RX-QA-03", level: "warn", looksFor: "text boxes of unrelated elements that overlap" },
|
|
12
|
+
{ rule: "RX-QA-08", level: "warn", looksFor: "script errors thrown while the page loads" },
|
|
13
|
+
];
|
|
14
|
+
const MARKUP = ["html", "htm", "jsx", "tsx", "vue", "svelte", "astro"];
|
|
15
|
+
const STYLE = ["css", "scss", "sass", "less"];
|
|
16
|
+
const SCRIPT = ["js", "mjs", "cjs", "ts"];
|
|
17
|
+
const CODE = [...MARKUP, ...SCRIPT];
|
|
18
|
+
/** Every extension `ryux scan` reads. Markdown is left out: docs quote wrong examples on purpose. */
|
|
19
|
+
export const SCAN_EXTS = [...MARKUP, ...STYLE, ...SCRIPT];
|
|
20
|
+
const short = (s) => {
|
|
21
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
22
|
+
return t.length > 48 ? `${t.slice(0, 45)}...` : t;
|
|
23
|
+
};
|
|
24
|
+
/** Every match of each pattern, as hits with the message built from the matched text. */
|
|
25
|
+
function matches(text, patterns) {
|
|
26
|
+
const hits = [];
|
|
27
|
+
for (const [re, message] of patterns) {
|
|
28
|
+
for (const m of text.matchAll(new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`))) {
|
|
29
|
+
const msg = message(m);
|
|
30
|
+
if (msg)
|
|
31
|
+
hits.push({ index: m.index, length: m[0].length, message: msg });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return hits;
|
|
35
|
+
}
|
|
36
|
+
/** The end of the tag that opens at `start` (index of "<"), skipping ">" inside quotes and {...}. */
|
|
37
|
+
function tagEnd(text, start) {
|
|
38
|
+
let quote = "";
|
|
39
|
+
let depth = 0;
|
|
40
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
41
|
+
const c = text[i];
|
|
42
|
+
if (quote) {
|
|
43
|
+
if (c === quote)
|
|
44
|
+
quote = "";
|
|
45
|
+
}
|
|
46
|
+
else if (c === '"' || c === "'" || c === "`")
|
|
47
|
+
quote = c;
|
|
48
|
+
else if (c === "{")
|
|
49
|
+
depth++;
|
|
50
|
+
else if (c === "}")
|
|
51
|
+
depth = Math.max(0, depth - 1);
|
|
52
|
+
else if (c === ">" && depth === 0)
|
|
53
|
+
return i;
|
|
54
|
+
}
|
|
55
|
+
return -1;
|
|
56
|
+
}
|
|
57
|
+
/** Each opening tag of the given names, with its attribute text. */
|
|
58
|
+
function tags(text, names) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const m of text.matchAll(new RegExp(`<(${names})(?=[\\s/>])`, "g"))) {
|
|
61
|
+
const end = tagEnd(text, m.index);
|
|
62
|
+
if (end < 0)
|
|
63
|
+
continue;
|
|
64
|
+
out.push({ index: m.index, length: end - m.index + 1, name: m[1], attrs: text.slice(m.index + m[0].length, end) });
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
const RUPIAH_FIX = "write Rp1.250.000 (Rp right before the number, dots for thousands, no decimals)";
|
|
69
|
+
const px = (v) => Number.parseFloat(v);
|
|
70
|
+
export const DETECTORS = [
|
|
71
|
+
{
|
|
72
|
+
rule: "RX-CD-02",
|
|
73
|
+
level: "fail",
|
|
74
|
+
looksFor: "Rupiah written as Rp 1.000, Rp. 1.000, Rp1,000, Rp1.000,00, Rp1000, IDR 1000, or Rp followed by a space before a value",
|
|
75
|
+
exts: SCAN_EXTS,
|
|
76
|
+
find: ({ text }) => matches(text, [
|
|
77
|
+
[/\bRp\.?[ \u00a0]*\d[\d.,]*/, (m) => (/^Rp\d{1,3}(?:(?:\.\d{3})*|,\d{1,2})$/.test(m[0].replace(/[.,]$/, "")) && !/,00$/.test(m[0]) ? null : `"${short(m[0])}": ${RUPIAH_FIX}`)],
|
|
78
|
+
[/\bRp[ \u00a0]+(?:\{|\$\{)/, (m) => `"${short(m[0])}...": Rupiah with a space before the value; ${RUPIAH_FIX}`],
|
|
79
|
+
[/["'`]Rp\.?[ \u00a0]["'`]/, (m) => `${short(m[0])}: a Rupiah prefix with a space; ${RUPIAH_FIX}`],
|
|
80
|
+
[/\bIDR[ \u00a0]?\d[\d.,]*/, (m) => `"${short(m[0])}": ${RUPIAH_FIX}`],
|
|
81
|
+
]),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
rule: "RX-FE-12",
|
|
85
|
+
level: "warn",
|
|
86
|
+
looksFor: "Intl currency style with IDR, which prints Rp 1.250.000,00 (a space, and decimals unless set to 0)",
|
|
87
|
+
exts: CODE,
|
|
88
|
+
find: ({ text }) => matches(text, [
|
|
89
|
+
[/currency\s*:\s*["'`]IDR["'`]/, () => "Intl currency style writes a space after Rp (and ,00 unless maximumFractionDigits is 0): format with id-ID grouping and prepend Rp"],
|
|
90
|
+
]),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
rule: "RX-CD-09",
|
|
94
|
+
level: "warn",
|
|
95
|
+
looksFor: "dates as 10/02/2026 and times as 2:30 PM in copy",
|
|
96
|
+
exts: CODE,
|
|
97
|
+
find: ({ text }) => matches(text, [
|
|
98
|
+
[/(?<![\d/])\d{1,2}\/\d{1,2}\/\d{4}(?![\d/])/, (m) => `"${m[0]}": in Indonesian copy write 2 Okt 2026 (other markets follow their locale)`],
|
|
99
|
+
[/\b\d{1,2}[:.]\d{2}\s?(?:AM|PM|am|pm)\b/, (m) => `"${m[0]}": in Indonesian copy write 14.30 in 24-hour form (other markets follow their locale)`],
|
|
100
|
+
]),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
rule: "RX-AS-03",
|
|
104
|
+
level: "fail",
|
|
105
|
+
looksFor: "lorem ipsum filler",
|
|
106
|
+
exts: CODE,
|
|
107
|
+
find: ({ text }) => matches(text, [[/\blorem ipsum\b/i, () => "lorem ipsum: use realistic sample content from the brief, labeled as sample data"]]),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
rule: "RX-AS-03",
|
|
111
|
+
level: "warn",
|
|
112
|
+
looksFor: "placeholder content: John Doe, Jane Doe, [DATA], [TBD], XXXX",
|
|
113
|
+
exts: CODE,
|
|
114
|
+
find: ({ text }) => matches(text, [
|
|
115
|
+
[/\b(?:John|Jane) Doe\b/, (m) => `"${m[0]}": a placeholder person; use realistic sample data and label it, or the real name`],
|
|
116
|
+
[/\[(?:REAL DATA|DATA|TBD|TODO|PLACEHOLDER)\]/i, (m) => `"${m[0]}": a placeholder where sample data would do; keep a visible placeholder only for an asset that does not exist yet`],
|
|
117
|
+
[/(?<![A-Za-z])X{4,}(?![A-Za-z])/, (m) => `"${m[0]}": placeholder characters; use realistic sample data, labeled`],
|
|
118
|
+
]),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
rule: "RX-AS-04",
|
|
122
|
+
level: "warn",
|
|
123
|
+
looksFor: "urgency copy (only 2 left, hurry, offer ends tonight, hanya tersisa 2, buruan, stok terbatas)",
|
|
124
|
+
exts: CODE,
|
|
125
|
+
find: ({ text }) => matches(text, [
|
|
126
|
+
[
|
|
127
|
+
/\bonly \d+ (?:left|remaining)\b|\b(?:offer|sale|deal|promo) ends (?:tonight|today|soon)\b|\bhurry\b|\blimited time only\b|\bdon'?t miss out\b|\bhanya tersisa \d+|\btinggal \d+ lagi\b|\bburuan\b|\b(?:promo|diskon|penawaran) berakhir (?:malam ini|hari ini|segera)\b|\bjangan sampai kehabisan\b|\bstok terbatas\b/i,
|
|
128
|
+
(m) => `"${m[0]}": urgency copy; keep it only when the deadline or quota is real, stated as a plain fact`,
|
|
129
|
+
],
|
|
130
|
+
]),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
rule: "RX-A11Y-02",
|
|
134
|
+
level: "fail",
|
|
135
|
+
looksFor: "zoom turned off: user-scalable=no, maximum-scale=1, userScalable: false, maximumScale: 1",
|
|
136
|
+
exts: CODE,
|
|
137
|
+
find: ({ text }) => matches(text, [
|
|
138
|
+
[/user-scalable\s*=\s*["']?(?:no|0)\b|maximum-scale\s*=\s*["']?1(?:\.0+)?(?![\d.])|userScalable\s*:\s*false|maximumScale\s*:\s*1(?![\d.])/i, (m) => `"${m[0]}": people must be able to zoom; remove it`],
|
|
139
|
+
]),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
rule: "RX-A11Y-02",
|
|
143
|
+
level: "warn",
|
|
144
|
+
looksFor: "text set below 12px (CSS font-size, Tailwind text-[Npx], fontSize in style objects)",
|
|
145
|
+
exts: [...MARKUP, ...STYLE, ...SCRIPT],
|
|
146
|
+
find: ({ text }) => matches(text, [
|
|
147
|
+
[/font-size\s*:\s*(\d+(?:\.\d+)?)px|\btext-\[(\d+(?:\.\d+)?)px\]|fontSize\s*:\s*["'`]?(\d+(?:\.\d+)?)(?:px)?["'`]?(?=\s*[,}])/, (m) => {
|
|
148
|
+
const v = px(m[1] ?? m[2] ?? m[3]);
|
|
149
|
+
return v < 12 ? `${v}px text: body text is 14 to 16px on mobile; below 12px is hard to read` : null;
|
|
150
|
+
}],
|
|
151
|
+
]),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
rule: "RX-A11Y-03",
|
|
155
|
+
level: "fail",
|
|
156
|
+
looksFor: "a positive tabindex, which takes the element out of the visual focus order",
|
|
157
|
+
exts: MARKUP,
|
|
158
|
+
find: ({ text }) => matches(text, [[/\btab[iI]ndex\s*=\s*(?:["']|\{\s*)?([1-9]\d*)/, (m) => `tabindex ${m[1]}: keep focus order the same as the visual order; use 0 or the DOM order`]]),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
rule: "RX-A11Y-03",
|
|
162
|
+
level: "warn",
|
|
163
|
+
looksFor: "focus outlines removed on a control (a button, link, input, or :focus rule) with no focus-visible replacement",
|
|
164
|
+
exts: [...MARKUP, ...STYLE],
|
|
165
|
+
find: ({ ext, text }) => {
|
|
166
|
+
const removed = /outline\s*:\s*["']?(?:none|0)\b|\boutline-none\b/;
|
|
167
|
+
const replaced = /focus-visible|focus:(?:ring|border|shadow|bg|underline|outline-(?!none))/;
|
|
168
|
+
const message = "focus outline removed on a control with no focus-visible style: show a visible focus indicator";
|
|
169
|
+
if (STYLE.includes(ext)) {
|
|
170
|
+
if (replaced.test(text))
|
|
171
|
+
return [];
|
|
172
|
+
const hits = [];
|
|
173
|
+
for (const m of text.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
|
|
174
|
+
const target = /(?:^|[\s,>+~])(?:button|a|input|select|textarea|summary)\b|:focus(?!-visible|-within)|\[role|\.btn|\*/.test(m[1]);
|
|
175
|
+
const r = removed.exec(m[2]);
|
|
176
|
+
if (target && r)
|
|
177
|
+
hits.push({ index: m.index + m[1].length + 1 + r.index, length: r[0].length, message });
|
|
178
|
+
}
|
|
179
|
+
return hits;
|
|
180
|
+
}
|
|
181
|
+
return tags(text, "button|a|input|select|textarea|summary|div|span|li")
|
|
182
|
+
.filter((t) => {
|
|
183
|
+
if (!removed.test(t.attrs) || replaced.test(t.attrs) || /\btab[iI]ndex\s*=\s*(?:["']|\{\s*)?-1/.test(t.attrs))
|
|
184
|
+
return false;
|
|
185
|
+
return !/^(?:div|span|li)$/.test(t.name) || /\brole\s*=\s*["']?(?:button|link|tab|menuitem|option|checkbox|switch)|(?:^|\s)(?:onClick|onclick|@click|on:click)\s*=/.test(t.attrs);
|
|
186
|
+
})
|
|
187
|
+
.map((t) => ({ index: t.index, length: t.length, message }));
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
rule: "RX-A11Y-04",
|
|
192
|
+
level: "fail",
|
|
193
|
+
looksFor: "<img> with no alt attribute",
|
|
194
|
+
exts: MARKUP,
|
|
195
|
+
find: ({ text }) => tags(text, "img")
|
|
196
|
+
.filter((t) => !/\balt\s*=/.test(t.attrs) && !/\{\s*\.\.\./.test(t.attrs))
|
|
197
|
+
.map((t) => ({ index: t.index, length: t.length, message: 'an <img> with no alt: describe meaningful images, use alt="" for decorative ones' })),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
rule: "RX-A11Y-04",
|
|
201
|
+
level: "warn",
|
|
202
|
+
looksFor: "a clickable <div> or <span> with no role",
|
|
203
|
+
exts: MARKUP,
|
|
204
|
+
find: ({ text }) => tags(text, "div|span")
|
|
205
|
+
.filter((t) => /(?:^|\s)(?:onClick|onclick|@click|v-on:click|on:click)\s*=/.test(t.attrs) && !/\brole\s*=/.test(t.attrs))
|
|
206
|
+
.map((t) => ({ index: t.index, length: t.length, message: `a clickable <${t.name}>: use a <button> or <a> so it works from the keyboard and has a role` })),
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
rule: "RX-A11Y-04",
|
|
210
|
+
level: "warn",
|
|
211
|
+
looksFor: "an icon-only <button> with no accessible name",
|
|
212
|
+
exts: MARKUP,
|
|
213
|
+
find: ({ text }) => tags(text, "button").flatMap((t) => {
|
|
214
|
+
if (/\baria-label(?:ledby)?\s*=|\btitle\s*=|\{\s*\.\.\./.test(t.attrs))
|
|
215
|
+
return [];
|
|
216
|
+
const close = text.indexOf("</button>", t.index + t.length);
|
|
217
|
+
if (close < 0)
|
|
218
|
+
return [];
|
|
219
|
+
const inner = text.slice(t.index + t.length, close);
|
|
220
|
+
const icon = /<svg\b|<i\b|<[A-Z]\w*/.test(inner);
|
|
221
|
+
const named = /\balt\s*=\s*["'][^"']+["']|\baria-label\s*=/.test(inner);
|
|
222
|
+
const words = inner.replace(/<[^>]*>/g, "").trim();
|
|
223
|
+
return icon && !named && !words ? [{ index: t.index, length: t.length, message: "an icon-only <button> with no name: add aria-label or visually hidden text" }] : [];
|
|
224
|
+
}),
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
rule: "RX-A11Y-04",
|
|
228
|
+
level: "warn",
|
|
229
|
+
looksFor: "a heading that skips a level (h2 straight to h4)",
|
|
230
|
+
exts: MARKUP,
|
|
231
|
+
find: ({ text }) => {
|
|
232
|
+
const hits = [];
|
|
233
|
+
let prev = 0;
|
|
234
|
+
for (const m of text.matchAll(/<h([1-6])(?=[\s>])/g)) {
|
|
235
|
+
const level = Number(m[1]);
|
|
236
|
+
if (prev && level > prev + 1)
|
|
237
|
+
hits.push({ index: m.index, length: m[0].length, message: `h${prev} then h${level}: headings skip a level; use h${prev + 1}` });
|
|
238
|
+
prev = level;
|
|
239
|
+
}
|
|
240
|
+
return hits;
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
rule: "RX-A11Y-08",
|
|
245
|
+
level: "warn",
|
|
246
|
+
looksFor: "animation in a folder where nothing handles prefers-reduced-motion",
|
|
247
|
+
exts: [...MARKUP, ...STYLE, ...SCRIPT],
|
|
248
|
+
folder: true,
|
|
249
|
+
find: ({ text }, ctx) => {
|
|
250
|
+
if (ctx.reducedMotion !== false)
|
|
251
|
+
return [];
|
|
252
|
+
const m = /@keyframes\b|\banimation\s*:|\banimate-[a-z]|<motion\.\w+/.exec(text);
|
|
253
|
+
return m ? [{ index: m.index, length: m[0].length, message: `"${m[0]}" and nothing in the scanned folder handles prefers-reduced-motion: replace large movement with a fade or a cut when it is set` }] : [];
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
rule: "RX-DS-03",
|
|
258
|
+
level: "warn",
|
|
259
|
+
looksFor: "three or more one-off hex colors in a file that is not a token or theme file",
|
|
260
|
+
exts: [...MARKUP, ...STYLE, ...SCRIPT],
|
|
261
|
+
find: ({ path, text }) => {
|
|
262
|
+
if (/token|theme|colou?r|palette|tailwind\.config/i.test(path))
|
|
263
|
+
return [];
|
|
264
|
+
const lines = text.split("\n");
|
|
265
|
+
let offset = 0;
|
|
266
|
+
const found = [];
|
|
267
|
+
for (const line of lines) {
|
|
268
|
+
if (!/--[\w-]+\s*:/.test(line)) {
|
|
269
|
+
for (const m of line.matchAll(/(?<=[:(,[]\s*|["'`])(?<!(?:fill|stroke|stop-color)=["'])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g)) {
|
|
270
|
+
found.push({ index: offset + m.index, value: m[0].toLowerCase() });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
offset += line.length + 1;
|
|
274
|
+
}
|
|
275
|
+
const distinct = [...new Set(found.map((f) => f.value))];
|
|
276
|
+
if (distinct.length < 3)
|
|
277
|
+
return [];
|
|
278
|
+
return [{ index: found[0].index, length: found[0].value.length, message: `${distinct.length} one-off colors (${distinct.slice(0, 3).join(", ")}${distinct.length > 3 ? ", ..." : ""}): use the color tokens, or define them by role` }];
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
/** Blank out comments (keeping length and line breaks) so commented-out code is not scanned. */
|
|
283
|
+
export function stripComments(raw, ext) {
|
|
284
|
+
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
285
|
+
let text = raw.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/<!--[\s\S]*?-->/g, blank);
|
|
286
|
+
if (ext !== "css") {
|
|
287
|
+
text = text.replace(/(^|[^:\\"'`])(\/\/[^\n]*)/g, (_, pre, c) => pre + blank(c));
|
|
288
|
+
}
|
|
289
|
+
return text;
|
|
290
|
+
}
|
|
291
|
+
export function toSource(path, raw) {
|
|
292
|
+
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
293
|
+
return { path, ext, raw, text: stripComments(raw, ext) };
|
|
294
|
+
}
|
|
295
|
+
/** Rules switched off by `ryux-ignore RX-..` (this line and the next) or `ryux-ignore-file RX-..`.
|
|
296
|
+
* With no IDs, every rule is switched off there. */
|
|
297
|
+
function ignores(raw) {
|
|
298
|
+
const file = new Set();
|
|
299
|
+
const lines = new Map();
|
|
300
|
+
raw.split("\n").forEach((line, i) => {
|
|
301
|
+
const m = /ryux-ignore(-file)?((?:[\s,]+RX-[A-Z0-9]+-\d+)*)/.exec(line);
|
|
302
|
+
if (!m)
|
|
303
|
+
return;
|
|
304
|
+
const ids = m[2].match(/RX-[A-Z0-9]+-\d+/g) ?? ["*"];
|
|
305
|
+
if (m[1])
|
|
306
|
+
ids.forEach((id) => file.add(id));
|
|
307
|
+
else
|
|
308
|
+
for (const n of [i + 1, i + 2])
|
|
309
|
+
lines.set(n, new Set([...(lines.get(n) ?? []), ...ids]));
|
|
310
|
+
});
|
|
311
|
+
return { file, lines };
|
|
312
|
+
}
|
|
313
|
+
function lineCol(raw, index) {
|
|
314
|
+
const before = raw.slice(0, index);
|
|
315
|
+
const line = before.split("\n").length;
|
|
316
|
+
return { line, column: index - before.lastIndexOf("\n") };
|
|
317
|
+
}
|
|
318
|
+
/** Run every detector over the sources. Findings are ordered by file, then line. */
|
|
319
|
+
export function scanSources(sources, opts) {
|
|
320
|
+
const ctx = opts.folder
|
|
321
|
+
? { reducedMotion: sources.some((s) => /prefers-reduced-motion|motion-(?:reduce|safe):|useReducedMotion|reducedMotion/.test(s.text)) }
|
|
322
|
+
: {};
|
|
323
|
+
const findings = [];
|
|
324
|
+
for (const src of sources) {
|
|
325
|
+
const ign = ignores(src.raw);
|
|
326
|
+
for (const d of DETECTORS) {
|
|
327
|
+
if (!d.exts.includes(src.ext) || opts.skip?.has(d.rule) || ign.file.has(d.rule) || ign.file.has("*"))
|
|
328
|
+
continue;
|
|
329
|
+
if (d.folder && !opts.folder)
|
|
330
|
+
continue;
|
|
331
|
+
const seen = new Set();
|
|
332
|
+
for (const hit of d.find(src, ctx)) {
|
|
333
|
+
const { line, column } = lineCol(src.raw, hit.index);
|
|
334
|
+
const off = ign.lines.get(line);
|
|
335
|
+
if (seen.has(hit.index) || off?.has(d.rule) || off?.has("*"))
|
|
336
|
+
continue;
|
|
337
|
+
seen.add(hit.index);
|
|
338
|
+
findings.push({ level: d.level, rule: d.rule, file: src.path, line, column, message: hit.message });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
|
|
343
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryuxdsgn/design-intelligence",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "RYUX: design intelligence for AI agents and designers. One skill, five entry points: Analyze, Design, Build, Critique, and QA, for Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Cline, Copilot, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|