@ryuxdsgn/design-intelligence 2.15.0 → 2.19.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 +55 -4
- 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/analyze.js +4 -2
- package/dist/intelligence/content.js +37 -10
- package/dist/intelligence/guides.js +16 -8
- 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;
|
|
@@ -440,6 +463,21 @@ const CONTEXT_FIELDS = [
|
|
|
440
463
|
{ value: "Premium", label: "Premium" },
|
|
441
464
|
] },
|
|
442
465
|
},
|
|
466
|
+
{
|
|
467
|
+
label: "Voice and tone", hint: "(voice words, how it addresses people, what it must never sound like; tone shifts by moment)",
|
|
468
|
+
ask: { flag: "voice", group: "Design", question: "How should it talk?", example: 'Hangat, lugas, pakai "kamu"', options: [
|
|
469
|
+
{ value: "Warm and casual", label: "Warm and casual" },
|
|
470
|
+
{ value: "Calm and professional", label: "Calm and professional" },
|
|
471
|
+
{ value: "Direct and brief", label: "Direct and brief" },
|
|
472
|
+
{ value: "Playful", label: "Playful", hint: "only where it costs no clarity" },
|
|
473
|
+
{ value: "Reassuring", label: "Reassuring", hint: "for money, health, or waiting" },
|
|
474
|
+
], then: { question: "What must it never sound like? Pick any.", example: "not preachy", multi: true, options: [
|
|
475
|
+
{ value: "not stiff or overly formal", label: "Stiff or overly formal" },
|
|
476
|
+
{ value: "not salesy or hyped", label: "Salesy or hyped" },
|
|
477
|
+
{ value: "not too familiar", label: "Too familiar" },
|
|
478
|
+
{ value: "no jokes when things go wrong", label: "Jokey when things go wrong" },
|
|
479
|
+
] } },
|
|
480
|
+
},
|
|
443
481
|
{
|
|
444
482
|
label: "Motion direction", hint: "(feel, what motion communicates, what to avoid)",
|
|
445
483
|
ask: { flag: "motion", group: "Design", question: "How much motion?", example: "Only to confirm a payment went through", options: [
|
|
@@ -723,6 +761,9 @@ Commands:
|
|
|
723
761
|
setup Teach RYUX about this project: a few skippable questions, saved to DESIGN.md.
|
|
724
762
|
init Install RYUX and add the DESIGN.md project context block.
|
|
725
763
|
check Check the install: files, version, references, pointers, project context.
|
|
764
|
+
scan Check source files for rules a machine can see (Rupiah format, alt text, zoom, focus, ...).
|
|
765
|
+
No LLM, no network. Default path: this folder. Give a URL to measure the rendered page
|
|
766
|
+
(sideways scroll, contrast, target size, colliding text); that needs Playwright.
|
|
726
767
|
update Update an install to this version (also migrates RYUX 1.x folders).
|
|
727
768
|
remove Remove RYUX and the marked blocks.
|
|
728
769
|
help Show this help.
|
|
@@ -737,8 +778,12 @@ Options:
|
|
|
737
778
|
--mcp print the ryux MCP connect command
|
|
738
779
|
--yes skip the confirmation (remove)
|
|
739
780
|
--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)
|
|
741
|
-
--
|
|
781
|
+
--json print check's or scan's findings as JSON (for agents and CI)
|
|
782
|
+
--hook install: run ryux scan after each Claude Code edit (opt-in)
|
|
783
|
+
--list-rules scan: list what each check looks for
|
|
784
|
+
--ignore <ids> scan: skip rules, e.g. RX-DS-03 (or a ryux-ignore RX-.. comment in the file)
|
|
785
|
+
--widths <list> scan <url>: viewport widths in CSS px (default 320,390,1280)
|
|
786
|
+
--product, --work, --audience, --market, --constraints, --intent, --ux, --ui, --voice, --motion <text>
|
|
742
787
|
set project context fields without questions (setup)
|
|
743
788
|
|
|
744
789
|
Examples:
|
|
@@ -754,8 +799,12 @@ Examples:
|
|
|
754
799
|
${CLI_CMD} remove --yes
|
|
755
800
|
${CLI_CMD} init --agent claude --dry-run
|
|
756
801
|
${CLI_CMD} check --json
|
|
802
|
+
${CLI_CMD} scan src
|
|
803
|
+
${CLI_CMD} scan http://localhost:3000/checkout
|
|
804
|
+
${CLI_CMD} scan --list-rules
|
|
805
|
+
${CLI_CMD} install --agent claude --hook
|
|
757
806
|
|
|
758
|
-
Exit codes: 0 done or healthy, 1 a problem was found or the command failed, 2 the command was used wrongly.`);
|
|
807
|
+
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
808
|
}
|
|
760
809
|
async function main() {
|
|
761
810
|
const argv = process.argv.slice(2);
|
|
@@ -774,6 +823,8 @@ async function main() {
|
|
|
774
823
|
case "check":
|
|
775
824
|
case "doctor":
|
|
776
825
|
return runCheck(flags);
|
|
826
|
+
case "scan":
|
|
827
|
+
return runScan(argv.slice(1));
|
|
777
828
|
case "init":
|
|
778
829
|
return runInit(flags);
|
|
779
830
|
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
|
+
}
|
|
@@ -55,8 +55,10 @@ Cover what the captures show; skip what they do not, and say it was not visible.
|
|
|
55
55
|
| **Imagery and motion** | art direction (product UI, photography, illustration, 3D) and its style; each visual's job; composition (where the focal point sits, the space kept for copy); motion character and level (L1 to L5) when motion was captured |
|
|
56
56
|
| **Design language** | the visual language in 3 to 5 traits, each tied to evidence; component patterns; the spacing and typography systems; interaction patterns |
|
|
57
57
|
|
|
58
|
-
Also note content and tone (natural Indonesian or translated, how money and dates are written
|
|
59
|
-
local patterns (QRIS, virtual accounts, OTP, addresses, paylater, e-KYC).
|
|
58
|
+
Also note content and tone (natural Indonesian or translated, how money and dates are written,
|
|
59
|
+
"kamu" or "Anda") and local patterns (QRIS, virtual accounts, OTP, addresses, paylater, e-KYC). When
|
|
60
|
+
there is enough copy, place the voice on the four RX-CD-11 scales with the screens it comes from;
|
|
61
|
+
it is observed, not the brand's rule until the user confirms it.
|
|
60
62
|
|
|
61
63
|
### 4. Patterns and evidence
|
|
62
64
|
|
|
@@ -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",
|
|
@@ -661,13 +661,13 @@ export const RULES = [
|
|
|
661
661
|
num: 11,
|
|
662
662
|
title: "Tone fits the moment, voice stays",
|
|
663
663
|
level: "preferred",
|
|
664
|
-
do: "Set a tone profile once, from the brand and the users, on four scales: formal to casual, serious to funny, respectful to irreverent, matter-of-fact to enthusiastic; add a few tone words and the anti-tone words to avoid (\"hangat, tidak sok akrab\").\n - Keep the voice the same everywhere and shift the tone with the user's state: matter-of-fact and serious for errors, money, and waiting; brief for success, where a check mark already says it worked (\"Alamat disimpan\", without \"berhasil\" unless the voice wants the warmth); warmer only in onboarding and empty moments.\n - Add personality only where it costs no clarity or length: clarity first, concision second, character third.",
|
|
664
|
+
do: "Set a tone profile once, from the brand and the users, on four scales: formal to casual, serious to funny, respectful to irreverent, matter-of-fact to enthusiastic; add a few tone words and the anti-tone words to avoid (\"hangat, tidak sok akrab\").\n - Take it from the Voice and tone field in DESIGN.md when it is set. When it is blank, infer it from the product's existing copy or brand docs, label it inferred in Assumptions, and keep it out of DESIGN.md until the user confirms it: copy that is live is observed, not a brand rule.\n - Keep the voice the same everywhere and shift the tone with the user's state: matter-of-fact and serious for errors, money, and waiting; brief for success, where a check mark already says it worked (\"Alamat disimpan\", without \"berhasil\" unless the voice wants the warmth); warmer only in onboarding and empty moments.\n - Add personality only where it costs no clarity or length: clarity first, concision second, character third.",
|
|
665
665
|
dont: "Joke in an error or a payment failure, cheer on a routine save, or switch between \"kamu\" and \"Anda\" across screens.",
|
|
666
666
|
why: "The same words feel caring in one moment and careless in another; tone measurably changes how users judge a product.",
|
|
667
667
|
basis: "NNGroup tone-of-voice dimensions (four dimensions; tested tone profiles); NNGroup 3 C's of informational microcopy",
|
|
668
668
|
notWhen: "the brand guide already defines the tone; follow it",
|
|
669
669
|
tradeoff: "a sober tone in serious moments gives up some brand flavour",
|
|
670
|
-
check: "review",
|
|
670
|
+
check: "review against the Voice and tone field in DESIGN.md; a profile taken from existing copy is labeled inferred",
|
|
671
671
|
},
|
|
672
672
|
{
|
|
673
673
|
num: 12,
|
|
@@ -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".`;
|
|
@@ -73,17 +73,22 @@ The six stages of the core workflow (SKILL.md), as Design runs them.
|
|
|
73
73
|
counts toward the three) instead of choosing it yourself. **No DESIGN.md and no product docs is the
|
|
74
74
|
case where these questions matter most, never a reason to skip them:** ask, then wait for the
|
|
75
75
|
answer before designing. Complete context means no questions.
|
|
76
|
+
**Voice and tone** is not asked by default: when the field is blank, infer it from the product's
|
|
77
|
+
existing copy or brand docs, label it inferred, and continue (RX-CD-11). Ask only when there is
|
|
78
|
+
no source at all and the work is mostly copy (onboarding, marketing, error and success messages);
|
|
79
|
+
it counts toward the three.
|
|
76
80
|
**Ask as choices.** When you ask, or the user picks (stage 4, Reconsider), use the agent's
|
|
77
81
|
question or choice tool if it has one: two to four options per question, the recommended one
|
|
78
82
|
first and labeled, and room for the user's own answer. Without such a tool, write numbered
|
|
79
83
|
options with the recommendation first, so the user can answer with a number. Never ask what the prompt, DESIGN.md, or the
|
|
80
84
|
product docs already answer. For the rest, make the smallest reasonable assumption, write it
|
|
81
85
|
down, and continue (RX-PR-01, RX-PR-02).
|
|
82
|
-
**Record direction answers.** When the user answers a UI
|
|
83
|
-
|
|
86
|
+
**Record direction answers.** When the user answers a UI direction, motion direction, or voice
|
|
87
|
+
question, or confirms an inferred voice, write the answer into the UI direction, Motion direction,
|
|
88
|
+
or Voice and tone field of the RYUX project context in DESIGN.md:
|
|
84
89
|
between \`<!-- ryux-context:start -->\` and \`<!-- ryux-context:end -->\`, one line per field
|
|
85
90
|
(\`- **UI direction**: Calm, trustworthy\`). If DESIGN.md or the block does not exist, create it
|
|
86
|
-
with
|
|
91
|
+
with the fields answered. Change only those field lines; leave every other line as it is. Your own
|
|
87
92
|
assumptions do not go into DESIGN.md.
|
|
88
93
|
**What exists.** When there is a screen, product, or design system already, read its
|
|
89
94
|
analysis or run Analyze first (capabilities/analyze.md). Existing screens, components, and any
|
|
@@ -305,7 +310,8 @@ workflow (SKILL.md); Frame, Explore, and Evaluate happen in Design.
|
|
|
305
310
|
When anything animates, run the motion passes in capabilities/qa.md as well. Run the counts of
|
|
306
311
|
RX-AS-10 on the render and report them in the gate.
|
|
307
312
|
7. **Close with the Delivery Gate.** CODE QUALITY and VISUAL QA need real checks (typecheck,
|
|
308
|
-
tests, render), or say what was not run.
|
|
313
|
+
tests, render), or say what was not run. Run \`npx @ryuxdsgn/design-intelligence scan <changed
|
|
314
|
+
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
315
|
usability test plan (RX-UX-12) unless the design already has one.`;
|
|
310
316
|
export const CORE_EVIDENCE = `Say where every claim comes from:
|
|
311
317
|
|
|
@@ -332,8 +338,7 @@ compliance, product or competitor facts, screenshots, or references (RX-PR-02, R
|
|
|
332
338
|
|
|
333
339
|
Record a source as \`{ type: observed | inferred | knowledge, origin, reference }\`, for example
|
|
334
340
|
\`{ 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.`;
|
|
341
|
+
Optional sources (the ryux MCP, Figma, a browser, screenshots, other reference libraries) add evidence when connected; none is required.`;
|
|
337
342
|
export const CORE_HONESTY = `Report what was checked, how, and what was not available. Do not claim "pixel perfect",
|
|
338
343
|
"fully accessible", "production ready", "senior-level", or "UX optimized" without evidence. Say
|
|
339
344
|
"Keyboard and focus checked by hand; no automated accessibility test was available" instead.
|
|
@@ -761,6 +766,9 @@ render, report these as NOT VERIFIED.
|
|
|
761
766
|
**Render with what is available**, in this order: the project's own preview or test setup;
|
|
762
767
|
\`npx playwright screenshot --viewport-size=1440,900 <url>\` (and 390 wide for mobile); a browser
|
|
763
768
|
tool; a design-tool export such as pen.dev \`Export\`. If none is available, say so in the gate.
|
|
769
|
+
For a build that runs, also measure it: \`npx @ryuxdsgn/design-intelligence scan <url>\` checks
|
|
770
|
+
sideways scroll, contrast, target size, and colliding text at 320, 390, and 1280 px (it needs
|
|
771
|
+
Playwright in the project). Report its fails in the gate; it does not replace looking at the render.
|
|
764
772
|
|
|
765
773
|
${UNTRUSTED_CONTENT}
|
|
766
774
|
|
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.19.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": {
|