@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.
- package/dist/components/charts/area-chart.d.ts +8 -0
- package/dist/components/charts/area-chart.js +8 -0
- package/dist/components/charts/bar-chart.d.ts +8 -0
- package/dist/components/charts/bar-chart.js +8 -0
- package/dist/components/charts/chart-cartesian.d.ts +31 -0
- package/dist/components/charts/chart-cartesian.js +178 -0
- package/dist/components/charts/chart-frame.d.ts +44 -0
- package/dist/components/charts/chart-frame.js +72 -0
- package/dist/components/charts/chart-summary.d.ts +21 -0
- package/dist/components/charts/chart-summary.js +23 -0
- package/dist/components/charts/index.d.ts +16 -0
- package/dist/components/charts/index.js +12 -0
- package/dist/components/charts/line-chart.d.ts +8 -0
- package/dist/components/charts/line-chart.js +8 -0
- package/dist/components/charts/pie-chart.d.ts +8 -0
- package/dist/components/charts/pie-chart.js +73 -0
- package/dist/components/data-display/index.d.ts +2 -0
- package/dist/components/data-display/index.js +2 -0
- package/dist/components/data-display/list-row.d.ts +24 -0
- package/dist/components/data-display/list-row.js +31 -0
- package/dist/components/data-entry/command.d.ts +10 -10
- package/dist/components/data-entry/transfer.js +1 -1
- package/dist/components/general/button.d.ts +1 -0
- package/dist/components/general/button.js +3 -0
- package/dist/components/general/index.d.ts +2 -0
- package/dist/components/general/index.js +4 -1
- package/dist/components/general/logo.d.ts +28 -0
- package/dist/components/general/logo.js +45 -0
- package/dist/components/layout/index.d.ts +1 -1
- package/dist/components/layout/sidebar.js +32 -24
- package/dist/components/layout/topbar.d.ts +19 -2
- package/dist/components/layout/topbar.js +7 -128
- package/dist/i18n/messages/en.json +5 -0
- package/dist/i18n/messages/ja.json +5 -0
- package/dist/i18n/messages/vi.json +5 -0
- package/dist/props/components/charts.prop.d.ts +89 -0
- package/dist/props/components/charts.prop.js +0 -0
- package/dist/props/components/general.prop.d.ts +5 -0
- package/dist/props/components/index.d.ts +2 -1
- package/dist/props/components/layout.prop.d.ts +16 -33
- package/dist/props/registry.d.ts +170 -11
- package/dist/props/registry.js +112 -11
- package/dist/styles/chart-layout.css +37 -0
- package/dist/styles/data-display-layout.css +38 -0
- package/dist/styles/density.css +62 -0
- package/dist/styles/index.css +1 -0
- package/dist/styles/shell-layout.css +39 -0
- package/dist/styles/text-layout.css +3 -0
- package/dist/tokens/base.css +2 -0
- package/dist/tokens/components/list-row.css +9 -0
- package/dist/tokens/components/logo.css +11 -0
- package/dist/tokens/foundation.css +13 -0
- package/package.json +43 -7
- package/scripts/_agent-setup.mjs +145 -0
- package/scripts/audit-hook.mjs +66 -0
- package/scripts/cli.mjs +26 -0
- package/scripts/init-agent-kit.mjs +52 -0
- package/scripts/postinstall.mjs +31 -0
- package/scripts/ui-audit.mjs +165 -3
- package/scripts/visual-audit-rules.mjs +119 -0
- package/scripts/visual-audit.mjs +261 -0
package/scripts/ui-audit.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
});
|