@godxjp/ui 13.17.4 → 14.0.1

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.
Files changed (61) hide show
  1. package/dist/components/charts/area-chart.d.ts +8 -0
  2. package/dist/components/charts/area-chart.js +8 -0
  3. package/dist/components/charts/bar-chart.d.ts +8 -0
  4. package/dist/components/charts/bar-chart.js +8 -0
  5. package/dist/components/charts/chart-cartesian.d.ts +31 -0
  6. package/dist/components/charts/chart-cartesian.js +178 -0
  7. package/dist/components/charts/chart-frame.d.ts +44 -0
  8. package/dist/components/charts/chart-frame.js +72 -0
  9. package/dist/components/charts/chart-summary.d.ts +21 -0
  10. package/dist/components/charts/chart-summary.js +23 -0
  11. package/dist/components/charts/index.d.ts +16 -0
  12. package/dist/components/charts/index.js +12 -0
  13. package/dist/components/charts/line-chart.d.ts +8 -0
  14. package/dist/components/charts/line-chart.js +8 -0
  15. package/dist/components/charts/pie-chart.d.ts +8 -0
  16. package/dist/components/charts/pie-chart.js +73 -0
  17. package/dist/components/data-display/index.d.ts +2 -0
  18. package/dist/components/data-display/index.js +2 -0
  19. package/dist/components/data-display/list-row.d.ts +24 -0
  20. package/dist/components/data-display/list-row.js +31 -0
  21. package/dist/components/data-entry/command.d.ts +10 -10
  22. package/dist/components/data-entry/transfer.js +1 -1
  23. package/dist/components/general/button.d.ts +1 -0
  24. package/dist/components/general/button.js +3 -0
  25. package/dist/components/general/index.d.ts +2 -0
  26. package/dist/components/general/index.js +4 -1
  27. package/dist/components/general/logo.d.ts +28 -0
  28. package/dist/components/general/logo.js +45 -0
  29. package/dist/components/layout/index.d.ts +1 -1
  30. package/dist/components/layout/sidebar.js +32 -24
  31. package/dist/components/layout/topbar.d.ts +19 -2
  32. package/dist/components/layout/topbar.js +7 -128
  33. package/dist/i18n/messages/en.json +5 -0
  34. package/dist/i18n/messages/ja.json +5 -0
  35. package/dist/i18n/messages/vi.json +5 -0
  36. package/dist/props/components/charts.prop.d.ts +89 -0
  37. package/dist/props/components/charts.prop.js +0 -0
  38. package/dist/props/components/general.prop.d.ts +5 -0
  39. package/dist/props/components/index.d.ts +2 -1
  40. package/dist/props/components/layout.prop.d.ts +16 -33
  41. package/dist/props/registry.d.ts +170 -11
  42. package/dist/props/registry.js +112 -11
  43. package/dist/styles/chart-layout.css +37 -0
  44. package/dist/styles/data-display-layout.css +38 -0
  45. package/dist/styles/density.css +62 -0
  46. package/dist/styles/index.css +1 -0
  47. package/dist/styles/shell-layout.css +39 -0
  48. package/dist/styles/text-layout.css +3 -0
  49. package/dist/tokens/base.css +2 -0
  50. package/dist/tokens/components/list-row.css +9 -0
  51. package/dist/tokens/components/logo.css +11 -0
  52. package/dist/tokens/foundation.css +13 -0
  53. package/package.json +43 -7
  54. package/scripts/_agent-setup.mjs +145 -0
  55. package/scripts/audit-hook.mjs +66 -0
  56. package/scripts/cli.mjs +26 -0
  57. package/scripts/init-agent-kit.mjs +52 -0
  58. package/scripts/postinstall.mjs +31 -0
  59. package/scripts/ui-audit.mjs +165 -3
  60. package/scripts/visual-audit-rules.mjs +119 -0
  61. package/scripts/visual-audit.mjs +261 -0
@@ -17,7 +17,7 @@
17
17
  * node packages/godx-ui/scripts/ui-audit.mjs src/ui lib/ # custom scan dirs
18
18
  */
19
19
  import { readdirSync, readFileSync, statSync } from "node:fs";
20
- import { join, relative } from "node:path";
20
+ import { isAbsolute, join, relative } from "node:path";
21
21
 
22
22
  const CWD = process.cwd();
23
23
  const args = process.argv.slice(2);
@@ -31,7 +31,23 @@ const SCAN_DIRS = dirArgs.length
31
31
  const PALETTE =
32
32
  "red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|gray|grey|slate|zinc|neutral|stone";
33
33
 
34
- /** @type {{id:string, severity:'error'|'warn', test:RegExp, message:string}[]} */
34
+ /**
35
+ * Emoji in product text — Unicode "Extended_Pictographic" set (UTS #51). Catches
36
+ * ✅🎉🔥🚀 etc. but NOT typographic punctuation (· — × ✓), which have their own rules.
37
+ */
38
+ const EMOJI = /\p{Extended_Pictographic}/u;
39
+ /** Regional-indicator pairs = emoji flags (🇯🇵) — broken on Win/Linux; use Intl.DisplayNames. */
40
+ const EMOJI_FLAG = /\p{Regional_Indicator}/u;
41
+
42
+ /**
43
+ * @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string}[]}
44
+ *
45
+ * `standard` cites the international spec a rule enforces, so a finding is auditable
46
+ * against a real norm rather than house taste alone:
47
+ * WCAG 2.2 (W3C Rec) · WAI-ARIA 1.2 + APG (W3C) · ECMA-402 Intl (TC39) ·
48
+ * Unicode UTS #51 emoji · BCP-47 · ISO 4217 / 3166 / 8601 · IANA tz ·
49
+ * CSS Logical Properties L1 (W3C) · HTML Living Standard (WHATWG).
50
+ */
35
51
  const RULES = [
36
52
  {
37
53
  id: "no-space-xy",
@@ -177,8 +193,118 @@ const RULES = [
177
193
  message:
178
194
  "Prefer semantic tokens (text-primary-foreground, bg-background…) over raw white/black (rules §4).",
179
195
  },
196
+
197
+ // ─── International-standard a11y / i18n / RTL rules (WARN — guide the agent, never block) ───
198
+ {
199
+ id: "no-emoji-in-ui",
200
+ severity: "warn",
201
+ test: EMOJI,
202
+ standard: "Unicode UTS #51 (emoji) · WCAG 2.2 SC 1.1.1",
203
+ message:
204
+ "No emoji in product UI (✅🎉🔥…). State the fact quietly in i18n-keyed copy; use a Lucide icon for affordance and a Badge `tone` for status. Emoji break on Win/Linux and pollute the accessible name.",
205
+ },
206
+ {
207
+ id: "no-emoji-flag",
208
+ severity: "warn",
209
+ test: EMOJI_FLAG,
210
+ standard: "ISO 3166-1 · ECMA-402 Intl.DisplayNames · Unicode UTS #51",
211
+ message:
212
+ "No emoji flags (🇯🇵) — they render inconsistently across OSes. Derive country names from Intl.DisplayNames(locale, { type: 'region' }) keyed by ISO 3166-1 alpha-2.",
213
+ },
214
+ {
215
+ id: "no-physical-direction",
216
+ severity: "warn",
217
+ // Physical-edge utilities break RTL. Logical equivalents: ms-/me-/ps-/pe-, start-/end-,
218
+ // text-start/end, border-s/e, rounded-s/e. `-mx-`/`-px-` (both edges) are RTL-safe → not matched.
219
+ test: /\b-?(ml|mr|pl|pr|left|right)-(?:\d|\[|auto|px|full)|\b(?:rounded-[tb]?[lr]|border-[lr]|text-(?:left|right))\b/,
220
+ standard: "W3C CSS Logical Properties & Values L1 · WCAG 2.2 (1.3.2 / reflow)",
221
+ message:
222
+ "Use LOGICAL direction utilities for RTL safety: ms-/me-/ps-/pe-, start-/end-, text-start/end, border-s/e, rounded-s/e — not physical ml-/mr-/pl-/pr-/left-/right-/text-left|right.",
223
+ },
224
+ {
225
+ id: "icon-button-needs-name",
226
+ severity: "warn",
227
+ // An icon-only Button (size="icon") with no author-supplied accessible name. A combobox/icon
228
+ // button's name is computed from author (aria-label / aria-labelledby / title), not glyph content.
229
+ test: /<Button\b(?=[^>]*\bsize=["']icon["'])(?![^>]*\b(?:aria-label|aria-labelledby|title)=)[^>]*>/,
230
+ standard: "WCAG 2.2 SC 4.1.2 · 1.1.1 · WAI-ARIA 1.2",
231
+ message:
232
+ "Icon-only <Button size=\"icon\"> needs an accessible name — add aria-label={t('…')}. The icon is decorative (aria-hidden); the name comes from the author, not the glyph.",
233
+ },
234
+ {
235
+ id: "img-needs-alt",
236
+ severity: "warn",
237
+ test: /<img\b(?![^>]*\balt=)[^>]*>/,
238
+ standard: "WCAG 2.2 SC 1.1.1 · HTML Living Standard (WHATWG)",
239
+ message:
240
+ 'Every <img> needs an alt attribute (alt="" for purely decorative images). Prefer the <Avatar>/<AspectRatio> primitives for product imagery.',
241
+ },
242
+ {
243
+ id: "no-positive-tabindex",
244
+ severity: "warn",
245
+ test: /tab[Ii]ndex=(?:["']?[1-9]|\{[1-9])/,
246
+ standard: "WCAG 2.2 SC 2.4.3 · WAI-ARIA APG",
247
+ message:
248
+ "No positive tabIndex — it breaks the natural focus order. Use tabIndex={0} (focusable) or {-1} (programmatic) only; manage roving focus via the primitive.",
249
+ },
250
+ {
251
+ id: "hardcoded-currency",
252
+ severity: "warn",
253
+ // A currency glyph glued to an interpolation in JSX text: `>¥{amount}` / `>{x}円`.
254
+ test: /(?:>[\s]*[¥$€£₫]\s*\{)|(?:\}\s*円)/,
255
+ standard: "ISO 4217 · ECMA-402 Intl.NumberFormat",
256
+ message:
257
+ "Don't hand-format currency (¥{amount}). Use Intl.NumberFormat(locale, { style: 'currency', currency }) — ISO 4217 code drives the symbol and minor units per locale.",
258
+ },
259
+ {
260
+ id: "raw-intl-date",
261
+ severity: "warn",
262
+ test: /\.toLocale(?:Date|Time)?String\(\s*\)|new Date\([^)]*\)\.(?:getMonth|getDate|getFullYear)\(\)\s*\+/,
263
+ standard: "ISO 8601 · IANA tz database · ECMA-402 Intl.DateTimeFormat",
264
+ message:
265
+ "Don't hand-build or locale-default dates. Use formatDate from @godxjp/ui/datetime (Intl.DateTimeFormat + IANA timezone + ISO-8601), which respects the AppProvider locale/timezone.",
266
+ },
267
+ {
268
+ id: "hand-rolled-close-glyph",
269
+ severity: "warn",
270
+ // A literal close glyph as JSX text — almost always a hand-rolled dismiss that should be a slot.
271
+ test: />\s*[✕✖×╳]\s*</,
272
+ standard: "WAI-ARIA 1.2 (dialog) · WCAG 2.2 SC 4.1.2 · @godxjp/ui Alert/Dialog anatomy",
273
+ message:
274
+ "Don't hand-roll a ✕ close. Pass onDismiss to <Alert> (renders the × top-right with an aria-label), or use <Dialog>/<Sheet> which ship their own labelled close. A bare glyph has no accessible name.",
275
+ },
276
+ {
277
+ id: "no-em-dash-in-copy",
278
+ severity: "warn",
279
+ // Em-dash (U+2014) in JSX text — dxs-kintai uses the middot `·` for JP/EN pairs and calm copy.
280
+ test: /[A-Za-z0-9぀-ヿ一-鿿]\s*—\s*[A-Za-z0-9぀-ヿ一-鿿]/,
281
+ standard: "@godxjp/ui dxs-kintai typography (best-ux) · Unicode punctuation",
282
+ message:
283
+ "No em-dash (—) in product copy. Use a middot `·` for JP/EN label pairs, or restructure into two calm sentences. Keep copy factual and quiet.",
284
+ },
180
285
  ];
181
286
 
287
+ /**
288
+ * `--rules` — print the full rule catalog (id · severity · standard · message) as JSON and exit.
289
+ * The single source of truth other tooling (the MCP `list_audit_rules`, the sync guard) reads,
290
+ * so the executable rules and the agent-facing docs never drift.
291
+ */
292
+ if (args.includes("--rules")) {
293
+ process.stdout.write(
294
+ JSON.stringify(
295
+ RULES.map((r) => ({
296
+ id: r.id,
297
+ severity: r.severity,
298
+ standard: r.standard ?? null,
299
+ message: r.message,
300
+ })),
301
+ null,
302
+ 2,
303
+ ) + "\n",
304
+ );
305
+ process.exit(0);
306
+ }
307
+
182
308
  /**
183
309
  * Blank out `//` line comments and block comments (incl. JSDoc) — replacing them with spaces and
184
310
  * preserving newlines so line numbers stay accurate — while KEEPING string/template literals (the
@@ -242,6 +368,15 @@ function isSuppressed(ruleId, sameLine, prevLine) {
242
368
  }
243
369
 
244
370
  function walk(dir, acc = []) {
371
+ // Accept a FILE path directly (the per-file editor hook passes one), not just a directory.
372
+ try {
373
+ if (statSync(dir).isFile()) {
374
+ if (dir.endsWith(".tsx") || dir.endsWith(".ts")) acc.push(dir);
375
+ return acc;
376
+ }
377
+ } catch {
378
+ return acc;
379
+ }
245
380
  let entries;
246
381
  try {
247
382
  entries = readdirSync(dir);
@@ -266,9 +401,17 @@ function walk(dir, acc = []) {
266
401
  const CARD_FLUSH =
267
402
  /<Card(?![^>]*\bp-0\b)(?:\s[^>]*)?>\s*<(?!CardContent|CardHeader|CardCover|CardFooter|\/Card)/g;
268
403
 
404
+ // Structural: a bare <label>/<Label> paired with a TEXT control (its sibling) instead of a
405
+ // <FormField>. FormField OWNS the label↔control association (htmlFor/id), aria-describedby/
406
+ // error wiring, AND the field rhythm (label gap + field spacing) — a hand-rolled Label+Input
407
+ // loses all of it (the cramped login-form failure mode). Checkbox/Radio/Switch use Field/Label
408
+ // legitimately, so they are NOT matched. Whole-file pass (the pair spans lines).
409
+ const BARE_FIELD =
410
+ /<(?:label|Label)\b[^>]*>[\s\S]{0,240}?<\/(?:label|Label)>\s*<(?:Input|Select|Textarea|NumberInput|SearchInput|SearchSelect|DatePicker|DateRangePicker|TimePicker|MonthPicker|MonthRangePicker|Cascader|TreeSelect|input)\b/g;
411
+
269
412
  const findings = [];
270
413
  for (const dir of SCAN_DIRS) {
271
- for (const file of walk(join(CWD, dir))) {
414
+ for (const file of walk(isAbsolute(dir) ? dir : join(CWD, dir))) {
272
415
  const rel = relative(CWD, file);
273
416
  const content = readFileSync(file, "utf8");
274
417
  const origLines = content.split("\n");
@@ -282,6 +425,7 @@ for (const dir of SCAN_DIRS) {
282
425
  line: i + 1,
283
426
  rule: rule.id,
284
427
  severity: rule.severity,
428
+ standard: rule.standard,
285
429
  message: rule.message,
286
430
  snippet: (origLines[i] ?? line).trim().slice(0, 120),
287
431
  });
@@ -302,6 +446,23 @@ for (const dir of SCAN_DIRS) {
302
446
  snippet: match[0].replace(/\s+/g, " ").slice(0, 120),
303
447
  });
304
448
  }
449
+ for (const match of scanContent.matchAll(BARE_FIELD)) {
450
+ const lineNo = scanContent.slice(0, match.index).split("\n").length;
451
+ if (
452
+ isSuppressed("bare-control-needs-formfield", origLines[lineNo - 1], origLines[lineNo - 2])
453
+ )
454
+ continue;
455
+ findings.push({
456
+ file: rel,
457
+ line: lineNo,
458
+ rule: "bare-control-needs-formfield",
459
+ severity: "warn",
460
+ standard: "WCAG 2.2 SC 1.3.1 · 3.3.2 · @godxjp/ui FormField (cardinal rule 227)",
461
+ message:
462
+ "A bare <Label>/<label> paired with a control — wrap the field in <FormField label=…>. FormField owns the label↔control id wiring, aria-describedby/error, AND the field rhythm (label gap + field spacing); a hand-rolled Label+Input loses all of it (the cramped/mis-spaced form).",
463
+ snippet: match[0].replace(/\s+/g, " ").slice(0, 120),
464
+ });
465
+ }
305
466
  }
306
467
  }
307
468
 
@@ -329,6 +490,7 @@ if (asJson) {
329
490
  const tag = f.severity === "error" ? `${C.red}error${C.reset}` : `${C.yellow}warn ${C.reset}`;
330
491
  console.log(`${tag} ${C.bold}${f.file}:${f.line}${C.reset} ${C.dim}[${f.rule}]${C.reset}`);
331
492
  console.log(` ${f.message}`);
493
+ if (f.standard) console.log(` ${C.dim}standard: ${f.standard}${C.reset}`);
332
494
  console.log(` ${C.dim}${f.snippet}${C.reset}`);
333
495
  }
334
496
  console.log(
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Visual-audit decision logic — PURE functions, zero browser/DOM dependency.
3
+ *
4
+ * `scripts/visual-audit.mjs` collects measurements from a REAL rendered page
5
+ * (Playwright) and feeds them here; the browser glue stays thin and these rules
6
+ * are fully unit-testable without a browser. The same functions are injected into
7
+ * the page for in-browser heuristics.
8
+ *
9
+ * Each rule cites the international standard it enforces — see VISUAL_RULES.
10
+ */
11
+
12
+ /** Catalog (agent-facing mirror lives in mcp/src/data/visual-rules.ts; kept in sync by a guard). */
13
+ export const VISUAL_RULES = [
14
+ {
15
+ id: "axe-violations",
16
+ severity: "warn",
17
+ category: "a11y",
18
+ standard: "WCAG 2.2 A/AA · WAI-ARIA 1.2 (axe-core engine)",
19
+ fix: "Fix each axe violation at its node — contrast (1.4.3), name/role/value (4.1.2), ARIA, landmarks. axe runs on the REAL DOM, catching what static analysis cannot.",
20
+ },
21
+ {
22
+ id: "target-size-min",
23
+ severity: "warn",
24
+ category: "a11y",
25
+ standard: "WCAG 2.2 SC 2.5.8 (24×24 AA) · 2.5.5 (44×44 AAA)",
26
+ fix: "Interactive targets must be ≥24×24 CSS px. Size controls from the --control-height tier; don't shrink icon buttons below it.",
27
+ },
28
+ {
29
+ id: "oversaturated-accent",
30
+ severity: "warn",
31
+ category: "color",
32
+ standard: "@godxjp/ui dxs-kintai 渋み (OKLCH chroma ≤ 0.18)",
33
+ fix: "Desaturate brand/primary surfaces — keep OKLCH chroma ≤ 0.18. Read --primary tokens; never paint a full-width bar in raw vivid blue.",
34
+ },
35
+ {
36
+ id: "emoji-rendered",
37
+ severity: "warn",
38
+ category: "i18n",
39
+ standard: "Unicode UTS #51 · WCAG 2.2 SC 1.1.1",
40
+ fix: "Remove emoji from rendered product text; use quiet i18n copy + a Lucide icon + a Badge tone for status.",
41
+ },
42
+ {
43
+ id: "alert-controls-misplaced",
44
+ severity: "warn",
45
+ category: "layout",
46
+ standard: "@godxjp/ui Alert anatomy · WAI-ARIA 1.2 · WCAG 2.2 SC 4.1.2",
47
+ fix: "Use <Alert>: ONE leading tone icon, <Alert.Actions> normal-width trailing-right, onDismiss × top-right, ONE horizontal row — never a vertical stack with a full-width action bar or a centered ✕.",
48
+ },
49
+ ];
50
+
51
+ /** sRGB channel (0–255) → linear-light component. */
52
+ function srgbToLinear(c) {
53
+ const x = c / 255;
54
+ return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
55
+ }
56
+
57
+ /**
58
+ * OKLCH chroma of an sRGB color — the perceptual saturation the 渋み rule bounds.
59
+ * sRGB → linear → OKLab (Björn Ottosson) → chroma = hypot(a, b).
60
+ * @param {{r:number,g:number,b:number}} rgb 0–255 channels
61
+ * @returns {number} OKLCH chroma (~0 grey … ~0.37 max sRGB)
62
+ */
63
+ export function oklchChroma({ r, g, b }) {
64
+ const lr = srgbToLinear(r);
65
+ const lg = srgbToLinear(g);
66
+ const lb = srgbToLinear(b);
67
+ const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
68
+ const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
69
+ const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
70
+ const l_ = Math.cbrt(l);
71
+ const m_ = Math.cbrt(m);
72
+ const s_ = Math.cbrt(s);
73
+ const oa = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
74
+ const ob = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
75
+ return Math.hypot(oa, ob);
76
+ }
77
+
78
+ /** dxs-kintai restraint bound. A signal/brand surface above this chroma "screams". */
79
+ export const CHROMA_LIMIT = 0.18;
80
+
81
+ /** @returns {boolean} true when an accent surface exceeds the 渋み chroma limit. */
82
+ export function isOversaturated(rgb, limit = CHROMA_LIMIT) {
83
+ return oklchChroma(rgb) > limit;
84
+ }
85
+
86
+ /** WCAG 2.2 SC 2.5.8 — a tappable target smaller than 24×24 CSS px. */
87
+ export function isUndersizedTarget({ width, height }, min = 24) {
88
+ return width > 0 && height > 0 && (width < min || height < min);
89
+ }
90
+
91
+ /** Unicode UTS #51 — any emoji pictograph in rendered text (excludes · — × ✓). */
92
+ const EMOJI_RE = /\p{Extended_Pictographic}/u;
93
+ export function hasEmoji(text) {
94
+ return typeof text === "string" && EMOJI_RE.test(text);
95
+ }
96
+
97
+ /**
98
+ * Alert/banner anatomy — given measurements of a notification region, list the
99
+ * structural mistakes (the "stacked notification banner" tell).
100
+ *
101
+ * @param {{iconCount:number, actionWidthRatio:number, direction:'row'|'column',
102
+ * hasDismiss:boolean, dismissCorner:'top-right'|'other'|null}} m
103
+ * @returns {string[]} human-readable issues (empty = correct anatomy)
104
+ */
105
+ export function alertControlIssues(m) {
106
+ const issues = [];
107
+ if (m.iconCount > 1) issues.push("multiple icons — Alert takes ONE leading tone icon");
108
+ if (m.actionWidthRatio > 0.85)
109
+ issues.push("action is a full-width bar — use a normal-width Button in <Alert.Actions>");
110
+ if (m.direction === "column")
111
+ issues.push("controls stacked vertically — Alert is ONE horizontal row");
112
+ if (m.hasDismiss && m.dismissCorner !== "top-right")
113
+ issues.push("dismiss ✕ not in the top-right corner — pass onDismiss to <Alert>");
114
+ return issues;
115
+ }
116
+
117
+ export function findVisualRule(id) {
118
+ return VISUAL_RULES.find((r) => r.id === id);
119
+ }
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * godxjp-ui VISUAL audit — the runtime counterpart to the static ui-audit.mjs.
4
+ *
5
+ * Static audit (ui-audit.mjs) reads SOURCE with regexes — zero deps, fast, runs on
6
+ * every save. This audit drives a REAL browser (Playwright) over the running app and
7
+ * runs axe-core + computed-style/layout heuristics, catching what source can't see:
8
+ * colour contrast, target size, OKLCH chroma of a rendered accent, emoji that survived
9
+ * to the DOM, and a mis-laid-out notification banner. Warnings by default (agent
10
+ * guidance) — pass --strict to exit non-zero. Run it BEFORE an AI/human visual review.
11
+ *
12
+ * Playwright + @axe-core/playwright are OPTIONAL peer deps — installed only by apps that
13
+ * run this audit, so the static audit and the library stay browser-free.
14
+ *
15
+ * Usage (from the consuming app, against its running dev/preview server):
16
+ * node node_modules/@godxjp/ui/scripts/visual-audit.mjs http://localhost:5173 /invoices /settings
17
+ * node node_modules/@godxjp/ui/scripts/visual-audit.mjs --format json http://localhost:5173
18
+ * node node_modules/@godxjp/ui/scripts/visual-audit.mjs --strict http://localhost:5173 # CI gate
19
+ * PLAYWRIGHT_CHROMIUM_EXECUTABLE=/usr/bin/chromium node …/visual-audit.mjs http://… # system browser
20
+ */
21
+ import {
22
+ VISUAL_RULES,
23
+ isOversaturated,
24
+ oklchChroma,
25
+ isUndersizedTarget,
26
+ hasEmoji,
27
+ alertControlIssues,
28
+ } from "./visual-audit-rules.mjs";
29
+
30
+ const args = process.argv.slice(2);
31
+ const asJson = args.includes("--format") && args[args.indexOf("--format") + 1] === "json";
32
+ const strict = args.includes("--strict");
33
+ const listRules = args.includes("--rules");
34
+
35
+ if (listRules) {
36
+ process.stdout.write(JSON.stringify(VISUAL_RULES, null, 2) + "\n");
37
+ process.exit(0);
38
+ }
39
+
40
+ const C = {
41
+ red: "\x1b[31m",
42
+ yellow: "\x1b[33m",
43
+ dim: "\x1b[2m",
44
+ bold: "\x1b[1m",
45
+ reset: "\x1b[0m",
46
+ };
47
+
48
+ const urls = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--format");
49
+ if (urls.length === 0) {
50
+ console.error(
51
+ "usage: visual-audit.mjs [--format json] [--strict] <baseUrl> [route …]\n" +
52
+ " (baseUrl is the running app; extra args are routes appended to it)",
53
+ );
54
+ process.exit(2);
55
+ }
56
+ const base = urls[0].replace(/\/$/, "");
57
+ const routes = urls.length > 1 ? urls.slice(1) : ["/"];
58
+ const targets = routes.map((r) =>
59
+ r.startsWith("http") ? r : `${base}${r.startsWith("/") ? r : `/${r}`}`,
60
+ );
61
+
62
+ /** Optional-peer loader — print a clear install hint instead of a stack trace. */
63
+ async function loadDeps() {
64
+ try {
65
+ const [{ chromium }, axe] = await Promise.all([
66
+ import("playwright"),
67
+ import("@axe-core/playwright"),
68
+ ]);
69
+ return { chromium, AxeBuilder: axe.default ?? axe.AxeBuilder };
70
+ } catch {
71
+ console.error(
72
+ "visual-audit needs the optional peers `playwright` and `@axe-core/playwright`:\n" +
73
+ " pnpm add -D playwright @axe-core/playwright\n" +
74
+ " pnpm exec playwright install chromium\n" +
75
+ "(or set PLAYWRIGHT_CHROMIUM_EXECUTABLE to a system Chromium).",
76
+ );
77
+ process.exit(2);
78
+ }
79
+ }
80
+
81
+ /** Collect raw measurements from the rendered page; the PURE rules run back in node. */
82
+ /* c8 ignore start — runs inside the browser, exercised in the consumer/CI env. */
83
+ function collectInPage() {
84
+ const rgb = (s) => {
85
+ const m = s && s.match(/rgba?\(([^)]+)\)/);
86
+ if (!m) return null;
87
+ const [r, g, b, a = "1"] = m[1].split(",").map((x) => x.trim());
88
+ return { r: +r, g: +g, b: +b, a: +a };
89
+ };
90
+ const visible = (el) => {
91
+ const r = el.getBoundingClientRect();
92
+ const cs = getComputedStyle(el);
93
+ return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none";
94
+ };
95
+
96
+ // Accent surfaces — buttons / primary CTAs / banners whose background carries colour.
97
+ const accents = [];
98
+ for (const el of document.querySelectorAll(
99
+ "button, [role=button], a[class*=primary], [data-slot=alert], [class*=banner], [class*=notification]",
100
+ )) {
101
+ if (!visible(el)) continue;
102
+ const c = rgb(getComputedStyle(el).backgroundColor);
103
+ if (c && c.a > 0.5)
104
+ accents.push({ rgb: { r: c.r, g: c.g, b: c.b }, tag: el.tagName.toLowerCase() });
105
+ }
106
+
107
+ // Interactive targets — for the 24×24 minimum.
108
+ const targets = [];
109
+ for (const el of document.querySelectorAll(
110
+ "a[href], button, [role=button], input:not([type=hidden]), select, [tabindex]:not([tabindex='-1'])",
111
+ )) {
112
+ if (!visible(el)) continue;
113
+ const r = el.getBoundingClientRect();
114
+ const name = (el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 40);
115
+ targets.push({ width: Math.round(r.width), height: Math.round(r.height), name });
116
+ }
117
+
118
+ // Emoji that survived to the rendered DOM.
119
+ const text = document.body ? document.body.innerText : "";
120
+
121
+ // Notification banners — measure their anatomy.
122
+ const alerts = [];
123
+ for (const el of document.querySelectorAll(
124
+ "[role=alert], [role=status], [data-slot=alert], [class*=banner], [class*=notification]",
125
+ )) {
126
+ if (!visible(el)) continue;
127
+ const box = el.getBoundingClientRect();
128
+ const icons = el.querySelectorAll("svg, img").length;
129
+ const btns = [...el.querySelectorAll("button, [role=button], a[href]")];
130
+ const actionWidthRatio = btns.length
131
+ ? Math.max(...btns.map((b) => b.getBoundingClientRect().width)) / (box.width || 1)
132
+ : 0;
133
+ const cs = getComputedStyle(el);
134
+ const direction =
135
+ cs.display.includes("flex") && cs.flexDirection.startsWith("column")
136
+ ? "column"
137
+ : cs.display.includes("flex")
138
+ ? "row"
139
+ : "column";
140
+ const dismiss = btns.find((b) =>
141
+ /close|dismiss|閉じる|×|✕/i.test(b.getAttribute("aria-label") || b.textContent || ""),
142
+ );
143
+ let dismissCorner = null;
144
+ if (dismiss) {
145
+ const d = dismiss.getBoundingClientRect();
146
+ const nearTop = d.top - box.top < box.height * 0.4;
147
+ const nearRight = box.right - d.right < box.width * 0.25;
148
+ dismissCorner = nearTop && nearRight ? "top-right" : "other";
149
+ }
150
+ alerts.push({
151
+ iconCount: icons,
152
+ actionWidthRatio,
153
+ direction,
154
+ hasDismiss: !!dismiss,
155
+ dismissCorner,
156
+ });
157
+ }
158
+
159
+ return { accents, targets, text, alerts };
160
+ }
161
+ /* c8 ignore stop */
162
+
163
+ async function run() {
164
+ const { chromium, AxeBuilder } = await loadDeps();
165
+ const browser = await chromium.launch({
166
+ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined,
167
+ });
168
+ const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
169
+ const findings = [];
170
+ const add = (ruleId, url, message) => {
171
+ const rule = VISUAL_RULES.find((r) => r.id === ruleId);
172
+ findings.push({
173
+ url,
174
+ rule: ruleId,
175
+ severity: rule?.severity ?? "warn",
176
+ standard: rule?.standard,
177
+ message,
178
+ });
179
+ };
180
+
181
+ for (const url of targets) {
182
+ try {
183
+ await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
184
+ } catch (e) {
185
+ add("axe-violations", url, `could not load page: ${e.message}`);
186
+ continue;
187
+ }
188
+
189
+ // 1) axe-core — real WCAG/ARIA engine.
190
+ const { violations } = await new AxeBuilder({ page }).analyze();
191
+ for (const v of violations) {
192
+ add(
193
+ "axe-violations",
194
+ url,
195
+ `${v.id} (${v.impact}) — ${v.help} [${v.nodes.length} node(s)] ${v.helpUrl}`,
196
+ );
197
+ }
198
+
199
+ // 2) computed-style / layout heuristics via the PURE rules.
200
+ const m = await page.evaluate(collectInPage);
201
+ for (const a of m.accents) {
202
+ if (isOversaturated(a.rgb))
203
+ add(
204
+ "oversaturated-accent",
205
+ url,
206
+ `<${a.tag}> background OKLCH chroma ${oklchChroma(a.rgb).toFixed(3)} > 0.18 (rgb ${a.rgb.r},${a.rgb.g},${a.rgb.b})`,
207
+ );
208
+ }
209
+ for (const t of m.targets) {
210
+ if (isUndersizedTarget(t))
211
+ add(
212
+ "target-size-min",
213
+ url,
214
+ `target "${t.name || "(unnamed)"}" is ${t.width}×${t.height}px (< 24×24)`,
215
+ );
216
+ }
217
+ if (hasEmoji(m.text)) {
218
+ const found = (m.text.match(/\p{Extended_Pictographic}/gu) || []).slice(0, 8).join(" ");
219
+ add("emoji-rendered", url, `emoji rendered in product text: ${found}`);
220
+ }
221
+ m.alerts.forEach((al, i) => {
222
+ for (const issue of alertControlIssues(al))
223
+ add("alert-controls-misplaced", url, `banner #${i + 1}: ${issue}`);
224
+ });
225
+ }
226
+
227
+ await browser.close();
228
+ report(findings);
229
+ }
230
+
231
+ function report(findings) {
232
+ const errors = findings.filter((f) => f.severity === "error");
233
+ const warnings = findings.filter((f) => f.severity === "warn");
234
+ if (asJson) {
235
+ process.stdout.write(
236
+ JSON.stringify(
237
+ { summary: { errors: errors.length, warnings: warnings.length }, findings },
238
+ null,
239
+ 2,
240
+ ) + "\n",
241
+ );
242
+ } else {
243
+ for (const f of findings) {
244
+ const tag = f.severity === "error" ? `${C.red}error${C.reset}` : `${C.yellow}warn ${C.reset}`;
245
+ console.log(`${tag} ${C.bold}${f.url}${C.reset} ${C.dim}[${f.rule}]${C.reset}`);
246
+ console.log(` ${f.message}`);
247
+ if (f.standard) console.log(` ${C.dim}standard: ${f.standard}${C.reset}`);
248
+ }
249
+ console.log(
250
+ `\ngodxjp-ui visual-audit: ${C.red}${errors.length} error(s)${C.reset}, ${C.yellow}${warnings.length} warning(s)${C.reset} across ${targets.length} page(s).`,
251
+ );
252
+ if (findings.length === 0) console.log("✓ No visual/runtime a11y violations found.");
253
+ }
254
+ // Warnings never block; --strict turns any finding into a non-zero exit (CI gate).
255
+ process.exit(strict && findings.length > 0 ? 1 : errors.length > 0 ? 1 : 0);
256
+ }
257
+
258
+ run().catch((e) => {
259
+ console.error(`visual-audit failed: ${e.message}`);
260
+ process.exit(2);
261
+ });