@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.
@@ -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
+ }
@@ -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, or leave the screen's goal unclear.",
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; match icon 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 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: "Unclear goal or competing actions", rules: ["RX-PR-03"] },
1542
- { item: "Unexplained interaction behavior", rules: ["RX-IX-01", "RX-IX-12"] },
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: "Unnecessary complexity", rules: ["RX-AS-06"] },
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; Reconsider an
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. For a new flow or a consequential change, add the
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.15.0",
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": {