@godxjp/ui 32.7.2 → 32.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +10 -3
- package/README.md +5 -5
- package/agent/DESIGN.md +10 -3
- package/agent/START-HERE.md +9 -7
- package/agent/components/AppShell.json +1 -0
- package/agent/components/BlockEditor.json +1 -0
- package/agent/components/PageContainer.json +1 -0
- package/agent/components/Progress.json +2 -2
- package/agent/components/SearchInput.json +1 -0
- package/agent/components/Topbar.json +2 -0
- package/agent/components/TopbarItem.json +1 -0
- package/agent/components/TopbarSearch.json +49 -0
- package/agent/components-index.json +5 -0
- package/agent/components.json +58 -2
- package/agent/index.json +7 -7
- package/agent/llms.txt +7 -7
- package/agent/tokens.json +38 -2
- package/dist/components/data-display/index.d.ts +1 -1
- package/dist/components/data-display/progress.d.ts +8 -1
- package/dist/components/layout/index.d.ts +2 -0
- package/dist/components/layout/index.js +2 -0
- package/dist/components/layout/topbar-search.d.ts +12 -0
- package/dist/components/layout/topbar-search.js +87 -0
- package/dist/contracts/measurement.json +1 -1
- package/dist/i18n/messages/en.json +1 -0
- package/dist/i18n/messages/ja.json +1 -0
- package/dist/i18n/messages/vi.json +1 -0
- package/dist/props/components/layout.prop.d.ts +22 -0
- package/dist/props/registry.d.ts +9 -0
- package/dist/props/registry.js +16 -0
- package/dist/styles/card-layout.css +1 -1
- package/dist/styles/data-display-layout.css +27 -0
- package/dist/styles/layers.json +7 -1
- package/dist/styles/shell-layout.css +123 -2
- package/dist/styles.css +1 -1
- package/dist/tokens/components/card.css +3 -1
- package/dist/tokens/components/data-display.css +2 -0
- package/dist/tokens/components/shell.css +7 -0
- package/package.json +2 -2
- package/scripts/_agent-setup.mjs +10 -5
- package/scripts/cli.mjs +12 -6
- package/scripts/visual-audit.mjs +1367 -90
package/scripts/visual-audit.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export const TESTED_VERSIONS = {
|
|
|
29
29
|
|
|
30
30
|
/** Shape one product finding from a rule id (pure — unit-tested without a browser). */
|
|
31
31
|
export function buildFinding(ruleId, url, message) {
|
|
32
|
-
const rule =
|
|
32
|
+
const rule = allRules().find((r) => r.id === ruleId);
|
|
33
33
|
return {
|
|
34
34
|
url,
|
|
35
35
|
rule: ruleId,
|
|
@@ -39,16 +39,43 @@ export function buildFinding(ruleId, url, message) {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Every rule `--rules` prints: the legacy checks plus the C01–C12 probes and checklist rules. */
|
|
43
|
+
export function allRules() {
|
|
44
|
+
return [...VISUAL_RULES, ...PROBE_RULES];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The only completion line a passing run may print (ADR process step 5) — never "done". */
|
|
48
|
+
export const ACCEPTANCE_PENDING = "probes passed; independent acceptance pending";
|
|
49
|
+
|
|
42
50
|
/**
|
|
43
51
|
* Assemble the machine-readable result (pure). `findings` are PRODUCT results; `errors` are
|
|
44
|
-
* INFRASTRUCTURE failures (a page that would not load, axe that would not inject).
|
|
52
|
+
* INFRASTRUCTURE failures (a page that would not load, axe that would not inject). `matrix` holds
|
|
53
|
+
* one cell per route × viewport (per-rule status + the reviewer checklist), `unmapped` what the
|
|
54
|
+
* probes found but could not map — reviewer required, never a pass.
|
|
45
55
|
*/
|
|
46
|
-
export function buildResult({
|
|
56
|
+
export function buildResult({
|
|
57
|
+
findings = [],
|
|
58
|
+
errors = [],
|
|
59
|
+
pages = 0,
|
|
60
|
+
viewports = [],
|
|
61
|
+
matrix = [],
|
|
62
|
+
unmapped = [],
|
|
63
|
+
} = {}) {
|
|
47
64
|
const productErrors = findings.filter((f) => f.severity === "error");
|
|
48
65
|
const warnings = findings.filter((f) => f.severity === "warn");
|
|
49
66
|
const failedToLoad = errors.filter((e) => e.stage === "navigate").length;
|
|
50
|
-
|
|
67
|
+
// With several viewports one route can fail to load more than once, so count audited ROUTES
|
|
68
|
+
// from the matrix when there is one.
|
|
69
|
+
const audited = matrix.length ? new Set(matrix.map((c) => c.url)).size : pages - failedToLoad;
|
|
51
70
|
const status = errors.length === 0 ? "ok" : audited > 0 ? "partial" : "error";
|
|
71
|
+
// Zero machine failures earns the pending line, nothing more: the semantic checklist, the
|
|
72
|
+
// unmapped items and the independent review are still open.
|
|
73
|
+
const acceptance =
|
|
74
|
+
status === "ok" && findings.length === 0
|
|
75
|
+
? ACCEPTANCE_PENDING
|
|
76
|
+
: status === "ok"
|
|
77
|
+
? "probes failed; fix the findings and rerun"
|
|
78
|
+
: "audit incomplete; infrastructure errors";
|
|
52
79
|
return {
|
|
53
80
|
status,
|
|
54
81
|
tool: TESTED_VERSIONS,
|
|
@@ -57,8 +84,14 @@ export function buildResult({ findings = [], errors = [], pages = 0 } = {}) {
|
|
|
57
84
|
warnings: warnings.length,
|
|
58
85
|
pages,
|
|
59
86
|
infrastructureErrors: errors.length,
|
|
87
|
+
viewports: viewports.map(viewportLabel),
|
|
88
|
+
unmapped: unmapped.length,
|
|
89
|
+
checklistPending: matrix.reduce((n, c) => n + c.checklist.length, 0),
|
|
60
90
|
},
|
|
91
|
+
acceptance,
|
|
61
92
|
findings,
|
|
93
|
+
unmapped,
|
|
94
|
+
matrix,
|
|
62
95
|
errors,
|
|
63
96
|
};
|
|
64
97
|
}
|
|
@@ -334,6 +367,1165 @@ export function collectInPage() {
|
|
|
334
367
|
}
|
|
335
368
|
/* c8 ignore stop */
|
|
336
369
|
|
|
370
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
371
|
+
// Responsive probes C01–C12 (ADR "Adopt calibrated probes with independent semantic acceptance",
|
|
372
|
+
// 2026-10-14). The audit used to open ONE 1280×900 viewport, so every phone-width defect — a
|
|
373
|
+
// squeezed top-bar search, the user's name filling the bar, a 56px editor gutter, four header
|
|
374
|
+
// buttons in one row — passed unseen. These probes run per width.
|
|
375
|
+
//
|
|
376
|
+
// Every numeric threshold below is REPO-CHOSEN unless the rule cites WCAG; each finding carries
|
|
377
|
+
// its threshold and origin. Probes are ADVISORY (severity warn) until the ADR's pilot promotes
|
|
378
|
+
// them, and they measure only what a MAPPING names: the library's own hooks (data-slot / ui-*
|
|
379
|
+
// classes) or a consumer's `data-audit-*` override. Anything that looks relevant but is not
|
|
380
|
+
// mapped is reported as "unmapped — reviewer required", never as a pass.
|
|
381
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
/** Default width matrix (ADR "Widths"). Heights are repo-chosen samples. */
|
|
384
|
+
export const DEFAULT_VIEWPORTS = [
|
|
385
|
+
{ width: 320, height: 900 },
|
|
386
|
+
{ width: 390, height: 844 },
|
|
387
|
+
{ width: 768, height: 1024 },
|
|
388
|
+
{ width: 1024, height: 900 },
|
|
389
|
+
{ width: 1280, height: 900 },
|
|
390
|
+
{ width: 844, height: 390 },
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
/** Repo-chosen breakpoints that decide which probes apply at a width. */
|
|
394
|
+
export const COMPACT_BELOW = 600;
|
|
395
|
+
export const DESKTOP_FROM = 1024;
|
|
396
|
+
|
|
397
|
+
/** Thresholds, each tagged with where the number comes from. */
|
|
398
|
+
export const PROBE_THRESHOLDS = {
|
|
399
|
+
clipTolerancePx: { value: 1.5, unit: "px", origin: "repo-chosen" },
|
|
400
|
+
searchMinCh: { value: 8, unit: "ch", origin: "repo-chosen" },
|
|
401
|
+
editorMaxInsetPx: { value: 24, unit: "px", origin: "repo-chosen" },
|
|
402
|
+
compactDirectActions: { value: 2, unit: "direct actions (+1 overflow)", origin: "repo-chosen" },
|
|
403
|
+
targetMinPx: { value: 24, unit: "px", origin: "WCAG 2.2 SC 2.5.8" },
|
|
404
|
+
railToggleMaxPx: { value: 8, unit: "px", origin: "repo-chosen" },
|
|
405
|
+
keylineMaxPx: { value: 2, unit: "px", origin: "repo-chosen" },
|
|
406
|
+
labelMaxFontPx: { value: 12, unit: "px", origin: "repo-chosen" },
|
|
407
|
+
labelMinTrackingEm: { value: 0.04, unit: "em", origin: "repo-chosen" },
|
|
408
|
+
compactBelowPx: { value: COMPACT_BELOW, unit: "px", origin: "repo-chosen" },
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const RC = "repo-chosen threshold";
|
|
412
|
+
|
|
413
|
+
/** Probe + checklist catalog (mirrored in mcp/src/data/visual-rules.ts — check:audit-sync). */
|
|
414
|
+
export const PROBE_RULES = [
|
|
415
|
+
{
|
|
416
|
+
id: "c01-content-reachable",
|
|
417
|
+
severity: "warn",
|
|
418
|
+
category: "layout",
|
|
419
|
+
standard: `WCAG 2.2 SC 1.4.10 / 1.4.4 / 1.4.12; 1.5px tolerance ${RC}`,
|
|
420
|
+
kind: "probe",
|
|
421
|
+
fix: "Nothing may be cut off: no horizontal scroll on the document or the app's main region, and no text/icon partly hidden by an overflow:hidden/clip ancestor. Let the container shrink its content (min-width:0 + wrap), or truncate with a recoverable full value (title / aria-label).",
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
id: "c02-search-usable",
|
|
425
|
+
severity: "warn",
|
|
426
|
+
category: "layout",
|
|
427
|
+
standard: `search text area ≥ 8ch after padding/icon, hint fits within 1.5px (${RC}); WCAG 1.4.10 principle`,
|
|
428
|
+
kind: "probe",
|
|
429
|
+
fix: "A search field narrower than 8ch is not a search field. Below the width where it fits, show a named search trigger (data-audit-role=search-trigger) that opens a full-width field; keep the scope hint visible somewhere when the placeholder no longer fits.",
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
id: "c03-single-brand",
|
|
433
|
+
severity: "warn",
|
|
434
|
+
category: "layout",
|
|
435
|
+
standard: "repo-chosen policy — human checklist only",
|
|
436
|
+
kind: "checklist",
|
|
437
|
+
fix: "Reviewer counts brand presentations in the closed and open shell: one logo+wordmark, distinct from the service name and breadcrumb.",
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
id: "c04-compact-account-name",
|
|
441
|
+
severity: "warn",
|
|
442
|
+
category: "a11y",
|
|
443
|
+
standard: `compact (<600px) bar shows no account name (${RC}); trigger name per WCAG 2.2 SC 4.1.2`,
|
|
444
|
+
kind: "probe",
|
|
445
|
+
fix: "Below 600px the bar shows the avatar only: hide the account name (it belongs in the opened account menu) and make the avatar the trigger — accessible name + aria-haspopup, opening identity and account controls.",
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
id: "c05-compact-editor-inset",
|
|
449
|
+
severity: "warn",
|
|
450
|
+
category: "layout",
|
|
451
|
+
standard: `first unindented line ≤ 24px from the editor inner edge below 600px (${RC}); WCAG 1.4.10 principle`,
|
|
452
|
+
kind: "probe",
|
|
453
|
+
fix: "Reclaim the desktop drag-handle gutter on phones: the editor's base inset (padding included) is at most 24px below 600px; keep list indentation, and give tap/keyboard alternatives to the hover handle.",
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
id: "c06-compact-actions",
|
|
457
|
+
severity: "warn",
|
|
458
|
+
category: "layout",
|
|
459
|
+
standard: `≤2 direct actions + ≤1 overflow per task-action group below 600px (${RC}); targets ≥24×24 per WCAG 2.2 SC 2.5.8`,
|
|
460
|
+
kind: "probe",
|
|
461
|
+
fix: "On phones a task-action group shows at most two direct actions plus one overflow menu that really holds the rest; every target at least 24×24 CSS px (or a documented SC 2.5.8 exception).",
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
id: "c07-width-control",
|
|
465
|
+
severity: "warn",
|
|
466
|
+
category: "layout",
|
|
467
|
+
standard: "repo-chosen policy — human checklist (no library hook maps a width control)",
|
|
468
|
+
kind: "checklist",
|
|
469
|
+
fix: "A wide-page control must move content to an approved destination width (±1px), or be hidden on phones / labelled as a desktop-only preference that persists.",
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
id: "c08-sidebar-field-edges",
|
|
473
|
+
severity: "warn",
|
|
474
|
+
category: "layout",
|
|
475
|
+
standard: "repo-chosen policy — human checklist (equivalent groups are a semantic call)",
|
|
476
|
+
kind: "checklist",
|
|
477
|
+
fix: "Equivalent sidebar fields share left and right edges within 2px; the reviewer inventories the fields before reading any mapping.",
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
id: "c09-progress-role",
|
|
481
|
+
severity: "warn",
|
|
482
|
+
category: "color",
|
|
483
|
+
standard: "repo-chosen policy — human checklist (tone ↔ meaning is a semantic call)",
|
|
484
|
+
kind: "checklist",
|
|
485
|
+
fix: "Ordinary branded progress uses the brand role; a status tone (success/warning/destructive) only where the bar reports that status. Thickness matches the approved variant ±1px.",
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
id: "c10-rail-toggle-attached",
|
|
489
|
+
severity: "warn",
|
|
490
|
+
category: "layout",
|
|
491
|
+
standard: `toggle centre within 8px of the declared rail edge at ≥1024px (${RC})`,
|
|
492
|
+
kind: "probe",
|
|
493
|
+
fix: "An edge-mounted rail toggle sits on the rail edge it controls in both desktop states. Declare the pattern with data-audit-role=rail-toggle (+ data-audit-edge=start|end).",
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
id: "c11-search-keyline",
|
|
497
|
+
severity: "warn",
|
|
498
|
+
category: "layout",
|
|
499
|
+
standard: `desktop search start within 2px of the content column start (${RC})`,
|
|
500
|
+
kind: "probe",
|
|
501
|
+
fix: "A centred top-bar search starts on the content column's keyline, not the window's centre. Applies when the search sits in Topbar `center` (or carries data-audit-keyline).",
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
id: "c12-tracked-uppercase-label",
|
|
505
|
+
severity: "warn",
|
|
506
|
+
category: "layout",
|
|
507
|
+
standard: `section label uppercase AND ≤12px AND tracking ≥0.04em fails (${RC}); no WCAG font floor implied`,
|
|
508
|
+
kind: "probe",
|
|
509
|
+
fix: "Section labels read as sentence-case text at the body scale, not tiny tracked capitals. Literal domain codes are exempt when marked as such (<code>, <kbd>, data-audit-literal).",
|
|
510
|
+
},
|
|
511
|
+
];
|
|
512
|
+
|
|
513
|
+
/** Checklist lines a reviewer resolves per width (C03 + the semantic qualifiers of C01–C12). */
|
|
514
|
+
export const REVIEW_CHECKLIST = [
|
|
515
|
+
{
|
|
516
|
+
rule: "c01-content-reachable",
|
|
517
|
+
scope: "all",
|
|
518
|
+
check:
|
|
519
|
+
"Confirm each value exempted as recoverable is reachable by keyboard and touch, and that exempt scroll regions are legitimate 2-D content.",
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
rule: "c02-search-usable",
|
|
523
|
+
scope: "all",
|
|
524
|
+
check:
|
|
525
|
+
"Type a realistic localized query (Japanese / Vietnamese); the icon is whole and the search submits; the mapped field is the product's search.",
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
rule: "c03-single-brand",
|
|
529
|
+
scope: "all",
|
|
530
|
+
check:
|
|
531
|
+
"Count brand presentations in the closed and open shell: one logo+wordmark, distinct from the service name and breadcrumb.",
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
rule: "c04-compact-account-name",
|
|
535
|
+
scope: "compact",
|
|
536
|
+
check:
|
|
537
|
+
"Open the account menu from the avatar: identity (name, email) and account controls are inside it; the mapping names the real account slot.",
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
rule: "c05-compact-editor-inset",
|
|
541
|
+
scope: "compact",
|
|
542
|
+
check:
|
|
543
|
+
"Separate base inset from list indentation; block move/insert has a tap and keyboard alternative to the hover handle.",
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
rule: "c06-compact-actions",
|
|
547
|
+
scope: "compact",
|
|
548
|
+
check:
|
|
549
|
+
"Execute every action moved into the overflow menu; the mapped groups are the task-action groups (navigation and avatar excluded).",
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
rule: "c07-width-control",
|
|
553
|
+
scope: "all",
|
|
554
|
+
check:
|
|
555
|
+
"If a page-width control exists: each state reaches an approved destination width, or it is hidden on phones / a labelled desktop preference that persists across reload.",
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
rule: "c08-sidebar-field-edges",
|
|
559
|
+
scope: "all",
|
|
560
|
+
check:
|
|
561
|
+
"Inventory equivalent sidebar fields first, then confirm their left/right edges agree within 2px (explicit fixed-length exceptions only).",
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
rule: "c09-progress-role",
|
|
565
|
+
scope: "all",
|
|
566
|
+
check:
|
|
567
|
+
"Each progress bar's tone matches its meaning (brand for ordinary progress, a status tone only for status) and its thickness matches the approved variant.",
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
rule: "c10-rail-toggle-attached",
|
|
571
|
+
scope: "desktop",
|
|
572
|
+
check:
|
|
573
|
+
"Collapse and expand the rail: the toggle works and stays on the intended rail edge in both states; the mobile drawer uses its own control.",
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
rule: "c11-search-keyline",
|
|
577
|
+
scope: "desktop",
|
|
578
|
+
check:
|
|
579
|
+
"Identify the intended content column yourself and remeasure the search start against it; an end-aligned search is a deliberate layout, not a keyline miss.",
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
rule: "c12-tracked-uppercase-label",
|
|
583
|
+
scope: "all",
|
|
584
|
+
check:
|
|
585
|
+
"Classify flagged and unmapped small capitals: section labels fail, literal codes and metadata are exempt.",
|
|
586
|
+
},
|
|
587
|
+
];
|
|
588
|
+
|
|
589
|
+
const PROBE_SCOPE = {
|
|
590
|
+
"c01-content-reachable": "all",
|
|
591
|
+
"c02-search-usable": "all",
|
|
592
|
+
"c04-compact-account-name": "compact",
|
|
593
|
+
"c05-compact-editor-inset": "compact",
|
|
594
|
+
"c06-compact-actions": "compact",
|
|
595
|
+
"c10-rail-toggle-attached": "desktop",
|
|
596
|
+
"c11-search-keyline": "desktop",
|
|
597
|
+
"c12-tracked-uppercase-label": "all",
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
/** Does a rule scope (all | compact | desktop) apply at this viewport width? */
|
|
601
|
+
export function scopeApplies(scope, width) {
|
|
602
|
+
if (scope === "compact") return width < COMPACT_BELOW;
|
|
603
|
+
if (scope === "desktop") return width >= DESKTOP_FROM;
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Parse `--widths 390x844,1280x900` (a bare width takes height 900). Throws on garbage. */
|
|
608
|
+
export function parseViewports(spec) {
|
|
609
|
+
if (spec == null) return DEFAULT_VIEWPORTS;
|
|
610
|
+
const out = [];
|
|
611
|
+
for (const raw of String(spec).split(",")) {
|
|
612
|
+
const part = raw.trim();
|
|
613
|
+
if (!part) continue;
|
|
614
|
+
const m = part.match(/^(\d{2,5})(?:x(\d{2,5}))?$/i);
|
|
615
|
+
if (!m) throw new Error(`--widths: "${part}" is not WIDTH or WIDTHxHEIGHT`);
|
|
616
|
+
out.push({ width: Number(m[1]), height: m[2] ? Number(m[2]) : 900 });
|
|
617
|
+
}
|
|
618
|
+
if (out.length === 0) throw new Error("--widths: no viewport given");
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export const viewportLabel = (v) => `${v.width}x${v.height}`;
|
|
623
|
+
|
|
624
|
+
/** C12 predicate on one measured label (pure). */
|
|
625
|
+
export function isTrackedUppercaseLabel(l) {
|
|
626
|
+
const T = PROBE_THRESHOLDS;
|
|
627
|
+
return (
|
|
628
|
+
!l.literal &&
|
|
629
|
+
l.uppercase &&
|
|
630
|
+
l.fontPx <= T.labelMaxFontPx.value &&
|
|
631
|
+
l.trackingEm >= T.labelMinTrackingEm.value - 1e-6
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Turn raw in-page measurements (collectProbes + interactions) into findings, unmapped items and
|
|
637
|
+
* per-rule status for ONE route × viewport cell. PURE — unit-tested with synthetic measurements.
|
|
638
|
+
*
|
|
639
|
+
* Status per rule: fail | pass | partial (mapped elements pass, but unmapped candidates remain) |
|
|
640
|
+
* unmapped (only candidates) | not-found (nothing to measure — the reviewer confirms N/A) |
|
|
641
|
+
* not-applicable-at-width | checklist. Only "pass" is a machine pass, and only for what was mapped.
|
|
642
|
+
*/
|
|
643
|
+
export function evaluateProbes(raw, { url, viewport }) {
|
|
644
|
+
const T = PROBE_THRESHOLDS;
|
|
645
|
+
const vp = viewportLabel(viewport);
|
|
646
|
+
const findings = [];
|
|
647
|
+
const unmapped = [];
|
|
648
|
+
const rules = {};
|
|
649
|
+
const applies = (rule) => scopeApplies(PROBE_SCOPE[rule], viewport.width);
|
|
650
|
+
const add = (rule, element, message, measured, threshold) =>
|
|
651
|
+
findings.push({
|
|
652
|
+
...buildFinding(rule, url, message),
|
|
653
|
+
viewport: vp,
|
|
654
|
+
element,
|
|
655
|
+
measured,
|
|
656
|
+
threshold: threshold ?? null,
|
|
657
|
+
});
|
|
658
|
+
const noteUnmapped = (rule, list) => {
|
|
659
|
+
if (!applies(rule)) return;
|
|
660
|
+
for (const c of list || [])
|
|
661
|
+
unmapped.push({ url, viewport: vp, rule, element: c.element, reason: c.reason });
|
|
662
|
+
};
|
|
663
|
+
const settle = (rule, mappedCount) => {
|
|
664
|
+
if (!applies(rule)) return (rules[rule] = "not-applicable-at-width");
|
|
665
|
+
const fails = findings.filter((f) => f.rule === rule).length;
|
|
666
|
+
const open = unmapped.filter((u) => u.rule === rule).length;
|
|
667
|
+
if (fails > 0) return (rules[rule] = "fail");
|
|
668
|
+
if (mappedCount > 0) return (rules[rule] = open > 0 ? "partial" : "pass");
|
|
669
|
+
return (rules[rule] = open > 0 ? "unmapped" : "not-found");
|
|
670
|
+
};
|
|
671
|
+
const tol = T.clipTolerancePx.value;
|
|
672
|
+
|
|
673
|
+
// C01 — reachable content (the whole document is always mapped).
|
|
674
|
+
for (const s of raw.c01?.scrollers || [])
|
|
675
|
+
if (s.excess > tol)
|
|
676
|
+
add(
|
|
677
|
+
"c01-content-reachable",
|
|
678
|
+
s.element,
|
|
679
|
+
`${s.element} scrolls horizontally by ${s.excess.toFixed(1)}px (> ${tol}px, repo-chosen tolerance)`,
|
|
680
|
+
{ excessPx: s.excess },
|
|
681
|
+
T.clipTolerancePx,
|
|
682
|
+
);
|
|
683
|
+
for (const c of raw.c01?.clips || []) {
|
|
684
|
+
if (c.recoverable || c.px <= tol) continue;
|
|
685
|
+
add(
|
|
686
|
+
"c01-content-reachable",
|
|
687
|
+
c.element,
|
|
688
|
+
`${c.kind}${c.text ? ` "${c.text}"` : ""} in ${c.element} is cut ${c.px.toFixed(1)}px on the ${c.axis} axis by ${c.clipper} (> ${tol}px, repo-chosen tolerance)`,
|
|
689
|
+
{ clippedPx: +c.px.toFixed(2), axis: c.axis, clipper: c.clipper },
|
|
690
|
+
T.clipTolerancePx,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
settle("c01-content-reachable", 1);
|
|
694
|
+
|
|
695
|
+
// C02 — search can be identified, edited and submitted.
|
|
696
|
+
const searches = raw.c02?.mapped || [];
|
|
697
|
+
for (const s of searches) {
|
|
698
|
+
const ch = s.chPx > 0 ? s.contentPx / s.chPx : 0;
|
|
699
|
+
if (ch < T.searchMinCh.value)
|
|
700
|
+
add(
|
|
701
|
+
"c02-search-usable",
|
|
702
|
+
s.element,
|
|
703
|
+
`search text area is ${s.contentPx.toFixed(0)}px ≈ ${ch.toFixed(1)}ch after padding/icon (< ${T.searchMinCh.value}ch, repo-chosen)`,
|
|
704
|
+
{ contentPx: +s.contentPx.toFixed(1), ch: +ch.toFixed(2) },
|
|
705
|
+
T.searchMinCh,
|
|
706
|
+
);
|
|
707
|
+
if (s.placeholder && s.placeholderPx > s.contentPx + tol && !s.hintElsewhere)
|
|
708
|
+
add(
|
|
709
|
+
"c02-search-usable",
|
|
710
|
+
s.element,
|
|
711
|
+
`search hint "${s.placeholder}" needs ${s.placeholderPx.toFixed(0)}px of ${s.contentPx.toFixed(0)}px and is not visible elsewhere (${tol}px tolerance, repo-chosen)`,
|
|
712
|
+
{ placeholderPx: +s.placeholderPx.toFixed(1), contentPx: +s.contentPx.toFixed(1) },
|
|
713
|
+
T.clipTolerancePx,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
noteUnmapped("c02-search-usable", raw.c02?.candidates);
|
|
717
|
+
settle("c02-search-usable", searches.length);
|
|
718
|
+
|
|
719
|
+
// C04 — compact account access without the name in the bar.
|
|
720
|
+
const accounts = applies("c04-compact-account-name") ? raw.c04?.mapped || [] : [];
|
|
721
|
+
for (const a of accounts) {
|
|
722
|
+
if (a.nameVisible)
|
|
723
|
+
add(
|
|
724
|
+
"c04-compact-account-name",
|
|
725
|
+
a.element,
|
|
726
|
+
`account name "${a.nameText}" is visible in the bar below ${COMPACT_BELOW}px (repo-chosen compact policy)`,
|
|
727
|
+
{ nameVisible: true },
|
|
728
|
+
T.compactBelowPx,
|
|
729
|
+
);
|
|
730
|
+
if (!a.trigger) {
|
|
731
|
+
add(
|
|
732
|
+
"c04-compact-account-name",
|
|
733
|
+
a.element,
|
|
734
|
+
"the avatar is not inside an account trigger — nothing in the bar opens the account controls",
|
|
735
|
+
{ trigger: null },
|
|
736
|
+
);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (!a.trigger.name)
|
|
740
|
+
add(
|
|
741
|
+
"c04-compact-account-name",
|
|
742
|
+
a.trigger.element,
|
|
743
|
+
"account trigger has no accessible name (WCAG 2.2 SC 4.1.2)",
|
|
744
|
+
{ accessibleName: "" },
|
|
745
|
+
);
|
|
746
|
+
if (!a.trigger.hasPopup)
|
|
747
|
+
add(
|
|
748
|
+
"c04-compact-account-name",
|
|
749
|
+
a.trigger.element,
|
|
750
|
+
"account trigger declares no popup (aria-haspopup / aria-expanded) — inert avatar",
|
|
751
|
+
{ hasPopup: false },
|
|
752
|
+
);
|
|
753
|
+
else if (a.trigger.opens === false)
|
|
754
|
+
add(
|
|
755
|
+
"c04-compact-account-name",
|
|
756
|
+
a.trigger.element,
|
|
757
|
+
"activating the account trigger opened no menu or dialog — inert avatar",
|
|
758
|
+
{ opens: false },
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
noteUnmapped("c04-compact-account-name", raw.c04?.candidates);
|
|
762
|
+
settle("c04-compact-account-name", accounts.length);
|
|
763
|
+
|
|
764
|
+
// C05 — compact editor inset.
|
|
765
|
+
const editors = applies("c05-compact-editor-inset") ? raw.c05?.mapped || [] : [];
|
|
766
|
+
for (const e of editors)
|
|
767
|
+
if (e.insetPx > T.editorMaxInsetPx.value + 0.5)
|
|
768
|
+
add(
|
|
769
|
+
"c05-compact-editor-inset",
|
|
770
|
+
e.element,
|
|
771
|
+
`first unindented line starts ${e.insetPx.toFixed(0)}px from the editor's inner edge (> ${T.editorMaxInsetPx.value}px below ${COMPACT_BELOW}px, repo-chosen)`,
|
|
772
|
+
{ insetPx: +e.insetPx.toFixed(1) },
|
|
773
|
+
T.editorMaxInsetPx,
|
|
774
|
+
);
|
|
775
|
+
settle("c05-compact-editor-inset", editors.length);
|
|
776
|
+
|
|
777
|
+
// C06 — compact task actions and their targets.
|
|
778
|
+
const groups = applies("c06-compact-actions") ? raw.c06?.mapped || [] : [];
|
|
779
|
+
for (const g of groups) {
|
|
780
|
+
if (g.direct > T.compactDirectActions.value || g.overflow > 1)
|
|
781
|
+
add(
|
|
782
|
+
"c06-compact-actions",
|
|
783
|
+
g.element,
|
|
784
|
+
`task-action group shows ${g.direct} direct action(s) + ${g.overflow} overflow trigger(s) (> ${T.compactDirectActions.value} + 1 below ${COMPACT_BELOW}px, repo-chosen)`,
|
|
785
|
+
{ direct: g.direct, overflow: g.overflow },
|
|
786
|
+
T.compactDirectActions,
|
|
787
|
+
);
|
|
788
|
+
if (g.singleRow && g.rows > 1)
|
|
789
|
+
add(
|
|
790
|
+
"c06-compact-actions",
|
|
791
|
+
g.element,
|
|
792
|
+
`declared single-row toolbar wraps onto ${g.rows} rows`,
|
|
793
|
+
{ rows: g.rows },
|
|
794
|
+
);
|
|
795
|
+
for (const t of g.smallTargets || [])
|
|
796
|
+
add(
|
|
797
|
+
"c06-compact-actions",
|
|
798
|
+
t.element,
|
|
799
|
+
`action "${t.name}" is ${t.width}×${t.height}px (< 24×24, WCAG 2.2 SC 2.5.8) with no Spacing/Inline exception`,
|
|
800
|
+
{ width: t.width, height: t.height },
|
|
801
|
+
T.targetMinPx,
|
|
802
|
+
);
|
|
803
|
+
for (const o of g.overflowMenus || [])
|
|
804
|
+
if (o.items === 0)
|
|
805
|
+
add(
|
|
806
|
+
"c06-compact-actions",
|
|
807
|
+
o.element,
|
|
808
|
+
"overflow trigger opened no menu items — the relocated actions are lost",
|
|
809
|
+
{ menuItems: 0 },
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
noteUnmapped("c06-compact-actions", raw.c06?.candidates);
|
|
813
|
+
settle("c06-compact-actions", groups.length);
|
|
814
|
+
|
|
815
|
+
// C10 — edge-mounted rail toggle.
|
|
816
|
+
const toggles = applies("c10-rail-toggle-attached") ? raw.c10?.mapped || [] : [];
|
|
817
|
+
for (const t of toggles)
|
|
818
|
+
if (t.distancePx > T.railToggleMaxPx.value)
|
|
819
|
+
add(
|
|
820
|
+
"c10-rail-toggle-attached",
|
|
821
|
+
t.element,
|
|
822
|
+
`rail toggle centre is ${t.distancePx.toFixed(1)}px from the rail's ${t.edge} edge (> ${T.railToggleMaxPx.value}px, repo-chosen)`,
|
|
823
|
+
{ distancePx: +t.distancePx.toFixed(1), edge: t.edge },
|
|
824
|
+
T.railToggleMaxPx,
|
|
825
|
+
);
|
|
826
|
+
noteUnmapped("c10-rail-toggle-attached", raw.c10?.candidates);
|
|
827
|
+
settle("c10-rail-toggle-attached", toggles.length);
|
|
828
|
+
|
|
829
|
+
// C11 — desktop search keyline.
|
|
830
|
+
const keylines = applies("c11-search-keyline") ? raw.c11?.mapped || [] : [];
|
|
831
|
+
for (const k of keylines)
|
|
832
|
+
if (k.deltaPx > T.keylineMaxPx.value)
|
|
833
|
+
add(
|
|
834
|
+
"c11-search-keyline",
|
|
835
|
+
k.element,
|
|
836
|
+
`search starts ${k.deltaPx.toFixed(1)}px off the content column start (${k.column}) (> ${T.keylineMaxPx.value}px, repo-chosen)`,
|
|
837
|
+
{ deltaPx: +k.deltaPx.toFixed(1), column: k.column },
|
|
838
|
+
T.keylineMaxPx,
|
|
839
|
+
);
|
|
840
|
+
noteUnmapped("c11-search-keyline", raw.c11?.candidates);
|
|
841
|
+
settle("c11-search-keyline", keylines.length);
|
|
842
|
+
if (rules["c11-search-keyline"] === "not-found" && raw.c11?.endAligned)
|
|
843
|
+
rules["c11-search-keyline"] = "not-applicable-end-aligned";
|
|
844
|
+
|
|
845
|
+
// C12 — tiny tracked uppercase section labels.
|
|
846
|
+
const labels = raw.c12?.mapped || [];
|
|
847
|
+
for (const l of labels)
|
|
848
|
+
if (isTrackedUppercaseLabel(l))
|
|
849
|
+
add(
|
|
850
|
+
"c12-tracked-uppercase-label",
|
|
851
|
+
l.element,
|
|
852
|
+
`section label "${l.text}" is uppercase at ${l.fontPx}px with ${l.trackingEm.toFixed(3)}em tracking (≤ ${T.labelMaxFontPx.value}px and ≥ ${T.labelMinTrackingEm.value}em, repo-chosen)`,
|
|
853
|
+
{ fontPx: l.fontPx, trackingEm: +l.trackingEm.toFixed(3) },
|
|
854
|
+
T.labelMaxFontPx,
|
|
855
|
+
);
|
|
856
|
+
noteUnmapped("c12-tracked-uppercase-label", raw.c12?.candidates);
|
|
857
|
+
settle("c12-tracked-uppercase-label", labels.length);
|
|
858
|
+
|
|
859
|
+
for (const r of PROBE_RULES) if (r.kind === "checklist") rules[r.id] = "checklist";
|
|
860
|
+
|
|
861
|
+
const checklist = REVIEW_CHECKLIST.filter((c) => scopeApplies(c.scope, viewport.width)).map(
|
|
862
|
+
(c) => ({ rule: c.rule, check: c.check, status: "pending" }),
|
|
863
|
+
);
|
|
864
|
+
return { findings, unmapped, cell: { url, viewport: vp, rules, checklist } };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Collect raw probe geometry in the rendered page. Self-contained (serialised into Chromium) — no
|
|
869
|
+
* imports, no closure over module scope.
|
|
870
|
+
*/
|
|
871
|
+
/* c8 ignore start — runs inside the browser; exercised by the visual-audit-probes spawn test. */
|
|
872
|
+
export function collectProbes(opts) {
|
|
873
|
+
const { width, compactBelow: COMPACT, desktopFrom: DESKTOP } = opts;
|
|
874
|
+
const csCache = new Map();
|
|
875
|
+
const cs = (el) => {
|
|
876
|
+
let v = csCache.get(el);
|
|
877
|
+
if (!v) {
|
|
878
|
+
v = getComputedStyle(el);
|
|
879
|
+
csCache.set(el, v);
|
|
880
|
+
}
|
|
881
|
+
return v;
|
|
882
|
+
};
|
|
883
|
+
const rtl = (el) => cs(el).direction === "rtl";
|
|
884
|
+
const visuallyHidden = (el) => {
|
|
885
|
+
for (let a = el; a && a !== document.documentElement; a = a.parentElement) {
|
|
886
|
+
if (a.classList.contains("sr-only") || a.getAttribute("data-slot") === "visually-hidden")
|
|
887
|
+
return true;
|
|
888
|
+
const s = cs(a);
|
|
889
|
+
if (s.overflow === "visible" && s.clip === "auto" && s.clipPath === "none") continue;
|
|
890
|
+
const r = a.getBoundingClientRect();
|
|
891
|
+
if (r.width <= 1 || r.height <= 1) return true;
|
|
892
|
+
}
|
|
893
|
+
return false;
|
|
894
|
+
};
|
|
895
|
+
const shown = (el) => {
|
|
896
|
+
if (!el || !el.isConnected) return false;
|
|
897
|
+
const r = el.getBoundingClientRect();
|
|
898
|
+
if (r.width <= 1 || r.height <= 1) return false;
|
|
899
|
+
for (let a = el; a && a !== document.documentElement; a = a.parentElement) {
|
|
900
|
+
const s = cs(a);
|
|
901
|
+
if (s.display === "none" || s.visibility === "hidden" || s.opacity === "0") return false;
|
|
902
|
+
}
|
|
903
|
+
return !visuallyHidden(el);
|
|
904
|
+
};
|
|
905
|
+
const self = (n) => {
|
|
906
|
+
let s = n.tagName.toLowerCase();
|
|
907
|
+
if (n.id) s += `#${n.id}`;
|
|
908
|
+
const slot = n.getAttribute("data-slot");
|
|
909
|
+
const role = n.getAttribute("data-audit-role");
|
|
910
|
+
if (slot) s += `[data-slot=${slot}]`;
|
|
911
|
+
if (role) s += `[data-audit-role=${role}]`;
|
|
912
|
+
const cls = [...n.classList].find((c) => /^(ui-|app-|sb-)/.test(c)) || n.classList[0];
|
|
913
|
+
if (cls && !slot && !role) s += `.${cls}`;
|
|
914
|
+
return s;
|
|
915
|
+
};
|
|
916
|
+
const desc = (el) => {
|
|
917
|
+
if (!el || el.nodeType !== 1) return "(none)";
|
|
918
|
+
if (el.id) return self(el);
|
|
919
|
+
const anc = el.parentElement && el.parentElement.closest("[id]");
|
|
920
|
+
return anc ? `${self(anc)} ${self(el)}` : self(el);
|
|
921
|
+
};
|
|
922
|
+
const snippet = (s) => (s || "").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
923
|
+
const OVERLAY = "[role=menu], [role=dialog], [role=listbox], [data-slot=dropdown-menu-content]";
|
|
924
|
+
const accName = (el) => {
|
|
925
|
+
const label = el.getAttribute("aria-label");
|
|
926
|
+
if (label && label.trim()) return label.trim();
|
|
927
|
+
const by = el.getAttribute("aria-labelledby");
|
|
928
|
+
if (by) {
|
|
929
|
+
const t = by
|
|
930
|
+
.split(/\s+/)
|
|
931
|
+
.map((id) => (document.getElementById(id) || {}).textContent || "")
|
|
932
|
+
.join(" ")
|
|
933
|
+
.trim();
|
|
934
|
+
if (t) return t;
|
|
935
|
+
}
|
|
936
|
+
return snippet(el.textContent || el.getAttribute("title") || "");
|
|
937
|
+
};
|
|
938
|
+
let probeSeq = document.querySelectorAll("[data-visual-audit-probe]").length;
|
|
939
|
+
const tag = (el) => {
|
|
940
|
+
if (!el.hasAttribute("data-visual-audit-probe"))
|
|
941
|
+
el.setAttribute("data-visual-audit-probe", String(probeSeq++));
|
|
942
|
+
return el.getAttribute("data-visual-audit-probe");
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
// ── C01: document / main horizontal excess + clipped ink ────────────────────────────────
|
|
946
|
+
// `document.scrollWidth` alone is blind under AppShell: `.app-main` is overflow:auto, so a page
|
|
947
|
+
// that overflows scrolls inside main and never reaches the document.
|
|
948
|
+
const scrollers = [];
|
|
949
|
+
const se = document.scrollingElement || document.documentElement;
|
|
950
|
+
scrollers.push({ element: "document", excess: se.scrollWidth - se.clientWidth });
|
|
951
|
+
for (const m of new Set(document.querySelectorAll("main, [role=main], .app-main"))) {
|
|
952
|
+
const ox = cs(m).overflowX;
|
|
953
|
+
if (!shown(m) || (ox !== "auto" && ox !== "scroll")) continue;
|
|
954
|
+
scrollers.push({ element: desc(m), excess: m.scrollWidth - m.clientWidth });
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const clips = [];
|
|
958
|
+
const SKIP = "script, style, noscript, template, input, textarea, select, option";
|
|
959
|
+
const items = [];
|
|
960
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
961
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
962
|
+
const p = n.parentElement;
|
|
963
|
+
if (!n.data.trim() || !p || p.closest(SKIP) || p.closest("svg")) continue;
|
|
964
|
+
items.push({ node: n, owner: p, kind: "text" });
|
|
965
|
+
}
|
|
966
|
+
for (const el of document.querySelectorAll("svg, img, [role=img], canvas, video")) {
|
|
967
|
+
if ((el.parentElement && el.parentElement.closest("svg")) || !el.parentElement) continue;
|
|
968
|
+
items.push({ node: el, owner: el.parentElement, kind: "icon" });
|
|
969
|
+
}
|
|
970
|
+
for (const it of items) {
|
|
971
|
+
const host = it.kind === "text" ? it.owner : it.node;
|
|
972
|
+
if (!shown(host)) continue;
|
|
973
|
+
let rects;
|
|
974
|
+
if (it.kind === "text") {
|
|
975
|
+
const range = document.createRange();
|
|
976
|
+
range.selectNodeContents(it.node);
|
|
977
|
+
rects = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
|
|
978
|
+
// A text line's client rect is its font's CONTENT AREA, taller than the line box when the
|
|
979
|
+
// line-height is tight. Measure against the line box (half-leading trimmed) so a 1.2
|
|
980
|
+
// line-height inside a truncating box is not reported as cut ink. Approximation: line box,
|
|
981
|
+
// not glyph ink — check:text-ink-clip owns glyph ink.
|
|
982
|
+
const lh = parseFloat(cs(it.owner).lineHeight);
|
|
983
|
+
if (Number.isFinite(lh))
|
|
984
|
+
rects = rects.map((r) => {
|
|
985
|
+
if (r.height <= lh) return r;
|
|
986
|
+
const cut = (r.height - lh) / 2;
|
|
987
|
+
return { left: r.left, right: r.right, top: r.top + cut, bottom: r.bottom - cut };
|
|
988
|
+
});
|
|
989
|
+
} else {
|
|
990
|
+
const r = it.node.getBoundingClientRect();
|
|
991
|
+
rects = r.width > 0 && r.height > 0 ? [r] : [];
|
|
992
|
+
}
|
|
993
|
+
if (!rects.length) continue;
|
|
994
|
+
const box = {
|
|
995
|
+
left: Math.min(...rects.map((r) => r.left)),
|
|
996
|
+
right: Math.max(...rects.map((r) => r.right)),
|
|
997
|
+
top: Math.min(...rects.map((r) => r.top)),
|
|
998
|
+
bottom: Math.max(...rects.map((r) => r.bottom)),
|
|
999
|
+
};
|
|
1000
|
+
let worst = null;
|
|
1001
|
+
let hidden = false;
|
|
1002
|
+
// Walk outward from the nearest box. Text is clipped by its OWN element too (a truncating
|
|
1003
|
+
// span); an icon starts at its parent. A scroll container (auto/scroll) keeps content
|
|
1004
|
+
// reachable on that axis, but it never excuses a hidden/clip ancestor INSIDE it.
|
|
1005
|
+
for (
|
|
1006
|
+
let a = it.owner;
|
|
1007
|
+
a && a !== document.body && a !== document.documentElement;
|
|
1008
|
+
a = a.parentElement
|
|
1009
|
+
) {
|
|
1010
|
+
const s = cs(a);
|
|
1011
|
+
const clipX = s.overflowX === "hidden" || s.overflowX === "clip";
|
|
1012
|
+
const clipY = s.overflowY === "hidden" || s.overflowY === "clip";
|
|
1013
|
+
if (!clipX && !clipY) continue;
|
|
1014
|
+
if (a.clientWidth <= 1 || a.clientHeight <= 1) {
|
|
1015
|
+
hidden = true; // visually-hidden idiom — painted nowhere
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
const ar = a.getBoundingClientRect();
|
|
1019
|
+
const cl = ar.left + a.clientLeft;
|
|
1020
|
+
const ct = ar.top + a.clientTop;
|
|
1021
|
+
const clip = { left: cl, right: cl + a.clientWidth, top: ct, bottom: ct + a.clientHeight };
|
|
1022
|
+
if (
|
|
1023
|
+
box.right <= clip.left ||
|
|
1024
|
+
box.left >= clip.right ||
|
|
1025
|
+
box.bottom <= clip.top ||
|
|
1026
|
+
box.top >= clip.bottom
|
|
1027
|
+
) {
|
|
1028
|
+
hidden = true; // wholly clipped = hidden content (closed drawer, slide), not cut ink
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
const dx = clipX ? Math.max(clip.left - box.left, box.right - clip.right, 0) : 0;
|
|
1032
|
+
const dy = clipY ? Math.max(clip.top - box.top, box.bottom - clip.bottom, 0) : 0;
|
|
1033
|
+
const px = Math.max(dx, dy);
|
|
1034
|
+
if (px <= 0 || (worst && px <= worst.px)) continue;
|
|
1035
|
+
const ownerStyle = cs(it.owner);
|
|
1036
|
+
const truncating =
|
|
1037
|
+
(dx >= dy && (s.textOverflow === "ellipsis" || ownerStyle.textOverflow === "ellipsis")) ||
|
|
1038
|
+
(s.webkitLineClamp && s.webkitLineClamp !== "none");
|
|
1039
|
+
let recoverable = false;
|
|
1040
|
+
if (it.kind === "text" && truncating) {
|
|
1041
|
+
const full = snippet(it.owner.textContent).slice(0, 20);
|
|
1042
|
+
const carrier = it.owner.closest("[title], [aria-label], [data-audit-recoverable]");
|
|
1043
|
+
recoverable =
|
|
1044
|
+
!!carrier &&
|
|
1045
|
+
(carrier.hasAttribute("data-audit-recoverable") ||
|
|
1046
|
+
snippet(carrier.getAttribute("title")).includes(full) ||
|
|
1047
|
+
snippet(carrier.getAttribute("aria-label")).includes(full));
|
|
1048
|
+
}
|
|
1049
|
+
worst = { px, axis: dx >= dy ? "x" : "y", clipper: desc(a), recoverable };
|
|
1050
|
+
}
|
|
1051
|
+
if (hidden || !worst) continue;
|
|
1052
|
+
clips.push({
|
|
1053
|
+
kind: it.kind,
|
|
1054
|
+
element: desc(host),
|
|
1055
|
+
text: it.kind === "text" ? snippet(it.node.data) : "",
|
|
1056
|
+
...worst,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// ── C02: search fields ───────────────────────────────────────────────────────────────────
|
|
1061
|
+
const ctx = document.createElement("canvas").getContext("2d");
|
|
1062
|
+
const textPx = (el, text) => {
|
|
1063
|
+
const s = cs(el);
|
|
1064
|
+
ctx.font = `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`;
|
|
1065
|
+
return ctx.measureText(text).width;
|
|
1066
|
+
};
|
|
1067
|
+
const SEARCH =
|
|
1068
|
+
".ui-search-input-control, [role=searchbox], input[type=search], [data-audit-role=search]";
|
|
1069
|
+
const searchFields = [...document.querySelectorAll(SEARCH)].filter(
|
|
1070
|
+
(el) => el.matches("input, textarea, [contenteditable=true]") && shown(el),
|
|
1071
|
+
);
|
|
1072
|
+
const slotOf = (el) =>
|
|
1073
|
+
el.closest("[data-slot=topbar-center]")
|
|
1074
|
+
? "center"
|
|
1075
|
+
: el.closest("[data-slot=topbar-end], .app-topbar-right")
|
|
1076
|
+
? "end"
|
|
1077
|
+
: el.closest("[data-slot=topbar-start], .app-topbar-left")
|
|
1078
|
+
? "start"
|
|
1079
|
+
: el.closest(".app-topbar, [data-slot=topbar]")
|
|
1080
|
+
? "bar"
|
|
1081
|
+
: null;
|
|
1082
|
+
const measureSearch = (el) => {
|
|
1083
|
+
const s = cs(el);
|
|
1084
|
+
const contentPx = el.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight);
|
|
1085
|
+
const placeholder = el.getAttribute("placeholder") || "";
|
|
1086
|
+
const others = [
|
|
1087
|
+
...(el.labels ? [...el.labels] : []),
|
|
1088
|
+
...(el.getAttribute("aria-describedby") || "")
|
|
1089
|
+
.split(/\s+/)
|
|
1090
|
+
.filter(Boolean)
|
|
1091
|
+
.map((id) => document.getElementById(id))
|
|
1092
|
+
.filter(Boolean),
|
|
1093
|
+
];
|
|
1094
|
+
return {
|
|
1095
|
+
element: desc(el),
|
|
1096
|
+
contentPx,
|
|
1097
|
+
chPx: textPx(el, "0"),
|
|
1098
|
+
placeholder: snippet(placeholder),
|
|
1099
|
+
placeholderPx: placeholder ? textPx(el, placeholder) : 0,
|
|
1100
|
+
hintElsewhere:
|
|
1101
|
+
!!placeholder &&
|
|
1102
|
+
others.some((o) => shown(o) && snippet(o.textContent).includes(snippet(placeholder))),
|
|
1103
|
+
};
|
|
1104
|
+
};
|
|
1105
|
+
const SEARCH_WORDS = /search|検索|tìm|搜索|recherche|suche|buscar|검색/i;
|
|
1106
|
+
const searchCandidates = [...document.querySelectorAll("input:not([type]), input[type=text]")]
|
|
1107
|
+
.filter(
|
|
1108
|
+
(el) =>
|
|
1109
|
+
shown(el) &&
|
|
1110
|
+
!el.matches(SEARCH) &&
|
|
1111
|
+
SEARCH_WORDS.test(
|
|
1112
|
+
`${el.getAttribute("aria-label") || ""} ${el.getAttribute("placeholder") || ""} ${el.name || ""}`,
|
|
1113
|
+
),
|
|
1114
|
+
)
|
|
1115
|
+
.map((el) => ({
|
|
1116
|
+
element: desc(el),
|
|
1117
|
+
reason:
|
|
1118
|
+
"looks like a search field but carries no search mapping (SearchInput, role=searchbox, type=search or data-audit-role=search)",
|
|
1119
|
+
}));
|
|
1120
|
+
const searchTriggers = [...document.querySelectorAll("[data-audit-role=search-trigger]")]
|
|
1121
|
+
.filter(shown)
|
|
1122
|
+
.map((el) => ({ element: desc(el), probe: tag(el) }));
|
|
1123
|
+
|
|
1124
|
+
// ── C04: account name in the compact bar ────────────────────────────────────────────────
|
|
1125
|
+
const BAR = [".app-topbar", "[data-slot=topbar]", "header"];
|
|
1126
|
+
const accounts = [];
|
|
1127
|
+
const accountAvatars = new Set();
|
|
1128
|
+
const visibleTextOutside = (region, exclude) => {
|
|
1129
|
+
const parts = [];
|
|
1130
|
+
const w = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
|
|
1131
|
+
for (let n = w.nextNode(); n; n = w.nextNode())
|
|
1132
|
+
if (n.data.trim() && !(exclude && exclude.contains(n)) && shown(n.parentElement))
|
|
1133
|
+
parts.push(n.data);
|
|
1134
|
+
return snippet(parts.join(" "));
|
|
1135
|
+
};
|
|
1136
|
+
const pushAccount = (region, nameEl, avatar, t, nameText) => {
|
|
1137
|
+
if (avatar) accountAvatars.add(avatar);
|
|
1138
|
+
const text = nameText ?? (nameEl && shown(nameEl) ? snippet(nameEl.textContent) : "");
|
|
1139
|
+
accounts.push({
|
|
1140
|
+
element: desc(region),
|
|
1141
|
+
nameVisible: !!text,
|
|
1142
|
+
nameText: text,
|
|
1143
|
+
trigger: t
|
|
1144
|
+
? {
|
|
1145
|
+
element: desc(t),
|
|
1146
|
+
name: accName(t),
|
|
1147
|
+
hasPopup:
|
|
1148
|
+
(t.hasAttribute("aria-haspopup") && t.getAttribute("aria-haspopup") !== "false") ||
|
|
1149
|
+
t.hasAttribute("aria-expanded"),
|
|
1150
|
+
probe: tag(t),
|
|
1151
|
+
}
|
|
1152
|
+
: null,
|
|
1153
|
+
});
|
|
1154
|
+
};
|
|
1155
|
+
const accountCandidates = [];
|
|
1156
|
+
if (width < COMPACT) {
|
|
1157
|
+
// `.ui-topbar-item` as well as the slot: under `<DropdownMenuTrigger asChild>` (the usual
|
|
1158
|
+
// account menu) the trigger's own data-slot wins the prop merge, and only the class survives.
|
|
1159
|
+
for (const item of document.querySelectorAll("[data-slot=topbar-item], .ui-topbar-item")) {
|
|
1160
|
+
const avatar = item.querySelector("[data-slot=avatar]");
|
|
1161
|
+
if (!avatar || !shown(item) || item.closest(OVERLAY)) continue;
|
|
1162
|
+
// The label box exists only when `labelHideBelow` is set; otherwise the name is the cell's
|
|
1163
|
+
// plain child. So the name is any VISIBLE text in the cell outside the avatar — looking for
|
|
1164
|
+
// the label slot alone reported a visible name as absent, a false pass.
|
|
1165
|
+
pushAccount(item, null, avatar, item, visibleTextOutside(item, avatar));
|
|
1166
|
+
}
|
|
1167
|
+
for (const chip of document.querySelectorAll("[data-slot=account-chip]")) {
|
|
1168
|
+
if (!chip.closest(BAR.join(", ")) || !shown(chip) || chip.closest(OVERLAY)) continue;
|
|
1169
|
+
const avatar = chip.querySelector("[data-slot=avatar]");
|
|
1170
|
+
const trig = avatar && avatar.closest("button, [role=button], a[href]");
|
|
1171
|
+
pushAccount(
|
|
1172
|
+
chip,
|
|
1173
|
+
chip.querySelector("[data-slot=text]"),
|
|
1174
|
+
avatar,
|
|
1175
|
+
trig && chip.contains(trig) ? trig : null,
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
for (const nameEl of document.querySelectorAll("[data-audit-role=account-name]")) {
|
|
1179
|
+
if (nameEl.closest(OVERLAY)) continue;
|
|
1180
|
+
const region = nameEl.closest("[data-audit-role=account]") || nameEl.parentElement;
|
|
1181
|
+
const trig =
|
|
1182
|
+
region.querySelector("[data-audit-role=account-trigger]") ||
|
|
1183
|
+
nameEl.closest("button, [role=button]") ||
|
|
1184
|
+
region.querySelector("button, [role=button]");
|
|
1185
|
+
pushAccount(region, nameEl, region.querySelector("[data-slot=avatar]"), trig);
|
|
1186
|
+
}
|
|
1187
|
+
for (const a of document.querySelectorAll(BAR.map((b) => `${b} [data-slot=avatar]`).join(", ")))
|
|
1188
|
+
if (shown(a) && !accountAvatars.has(a) && !a.closest(OVERLAY))
|
|
1189
|
+
accountCandidates.push({
|
|
1190
|
+
element: desc(a),
|
|
1191
|
+
reason:
|
|
1192
|
+
"avatar in the bar outside any mapped account slot (TopbarItem / AccountChip / data-audit-role=account-name)",
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// ── C05: compact editor inset ────────────────────────────────────────────────────────────
|
|
1197
|
+
const editors = [];
|
|
1198
|
+
if (width < COMPACT) {
|
|
1199
|
+
const all = [
|
|
1200
|
+
...document.querySelectorAll(
|
|
1201
|
+
".ui-block-editor-content, [data-audit-role=editor], [contenteditable=true]",
|
|
1202
|
+
),
|
|
1203
|
+
].filter(shown);
|
|
1204
|
+
const roots = all.filter((el) => !all.some((o) => o !== el && o.contains(el)));
|
|
1205
|
+
const INDENTED = "li, ul, ol, blockquote, pre, table, figure, [data-audit-indented]";
|
|
1206
|
+
for (const ed of roots) {
|
|
1207
|
+
const r = ed.getBoundingClientRect();
|
|
1208
|
+
const isRtl = rtl(ed);
|
|
1209
|
+
const inner = isRtl ? r.right - ed.clientLeft : r.left + ed.clientLeft;
|
|
1210
|
+
let start = null;
|
|
1211
|
+
const w = document.createTreeWalker(ed, NodeFilter.SHOW_TEXT);
|
|
1212
|
+
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
|
1213
|
+
if (!n.data.trim() || n.parentElement.closest(INDENTED)) continue;
|
|
1214
|
+
const range = document.createRange();
|
|
1215
|
+
range.selectNodeContents(n);
|
|
1216
|
+
const rr = [...range.getClientRects()].find((x) => x.width > 0);
|
|
1217
|
+
if (!rr) continue;
|
|
1218
|
+
start = isRtl ? rr.right : rr.left;
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
1221
|
+
if (start === null) {
|
|
1222
|
+
const s = cs(ed);
|
|
1223
|
+
start = isRtl ? inner - parseFloat(s.paddingRight) : inner + parseFloat(s.paddingLeft);
|
|
1224
|
+
}
|
|
1225
|
+
editors.push({ element: desc(ed), insetPx: Math.abs(start - inner) });
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// ── C06: compact task-action groups ─────────────────────────────────────────────────────
|
|
1230
|
+
const GROUPS =
|
|
1231
|
+
".ui-page-header-extra, [data-slot=card-bar-extra], .ui-filter-bar-actions, [data-audit-role=task-actions]";
|
|
1232
|
+
const ACTION = "button, a[href], [role=button]";
|
|
1233
|
+
const isOverflow = (b) =>
|
|
1234
|
+
b.matches("[data-slot=dropdown-menu-trigger], [data-audit-role=overflow]") ||
|
|
1235
|
+
["menu", "true"].includes(b.getAttribute("aria-haspopup"));
|
|
1236
|
+
const groups = [];
|
|
1237
|
+
const actionCandidates = [];
|
|
1238
|
+
if (width < COMPACT) {
|
|
1239
|
+
const mapped = [...document.querySelectorAll(GROUPS)].filter(shown);
|
|
1240
|
+
for (const g of mapped) {
|
|
1241
|
+
if (mapped.some((o) => o !== g && o.contains(g))) continue;
|
|
1242
|
+
const acts = [...g.querySelectorAll(ACTION)].filter(
|
|
1243
|
+
(b) =>
|
|
1244
|
+
shown(b) &&
|
|
1245
|
+
!b.querySelector("[data-slot=avatar]") &&
|
|
1246
|
+
!b.closest("nav") &&
|
|
1247
|
+
!(b.parentElement && b.parentElement.closest(ACTION)),
|
|
1248
|
+
);
|
|
1249
|
+
const overflow = acts.filter(isOverflow);
|
|
1250
|
+
const boxes = acts.map((b) => {
|
|
1251
|
+
const r = b.getBoundingClientRect();
|
|
1252
|
+
return { b, r, cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
|
|
1253
|
+
});
|
|
1254
|
+
const smallTargets = boxes
|
|
1255
|
+
.filter(
|
|
1256
|
+
({ b, r }) =>
|
|
1257
|
+
(r.width < 24 || r.height < 24) &&
|
|
1258
|
+
cs(b).display !== "inline" &&
|
|
1259
|
+
!b.hasAttribute("data-audit-target-exception") &&
|
|
1260
|
+
boxes.some(
|
|
1261
|
+
(o) =>
|
|
1262
|
+
o.b !== b &&
|
|
1263
|
+
Math.hypot(o.cx - (r.left + r.width / 2), o.cy - (r.top + r.height / 2)) < 24,
|
|
1264
|
+
),
|
|
1265
|
+
)
|
|
1266
|
+
.map(({ b, r }) => ({
|
|
1267
|
+
element: desc(b),
|
|
1268
|
+
name: accName(b),
|
|
1269
|
+
width: Math.round(r.width),
|
|
1270
|
+
height: Math.round(r.height),
|
|
1271
|
+
}));
|
|
1272
|
+
groups.push({
|
|
1273
|
+
element: desc(g),
|
|
1274
|
+
direct: acts.length - overflow.length,
|
|
1275
|
+
overflow: overflow.length,
|
|
1276
|
+
rows: new Set(boxes.map(({ r }) => Math.round(r.top / 4))).size,
|
|
1277
|
+
singleRow: g.getAttribute("data-audit-single-row") === "true",
|
|
1278
|
+
smallTargets,
|
|
1279
|
+
overflowTriggers: overflow.map((b) => ({ element: desc(b), probe: tag(b) })),
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
const EXCLUDE = `${BAR.join(", ")}, nav, [role=tablist], [role=radiogroup], [role=toolbar], [role=menu], [role=dialog], [role=grid], table, ${GROUPS}`;
|
|
1283
|
+
for (const el of document.querySelectorAll("body *")) {
|
|
1284
|
+
if (!cs(el).display.includes("flex") || el.closest(EXCLUDE) || !shown(el)) continue;
|
|
1285
|
+
const kids = [...el.children].filter(
|
|
1286
|
+
(c) =>
|
|
1287
|
+
shown(c) &&
|
|
1288
|
+
(c.matches(ACTION) || (c.children.length === 1 && c.firstElementChild.matches(ACTION))),
|
|
1289
|
+
);
|
|
1290
|
+
if (kids.length >= 3)
|
|
1291
|
+
actionCandidates.push({
|
|
1292
|
+
element: desc(el),
|
|
1293
|
+
reason: `${kids.length} buttons in one row outside any mapped task-action group (PageContainer extra / CardBar extra / FilterBar actions / data-audit-role=task-actions)`,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// ── C10: edge-mounted rail toggle ───────────────────────────────────────────────────────
|
|
1299
|
+
const toggles = [];
|
|
1300
|
+
const railCandidates = [];
|
|
1301
|
+
if (width >= DESKTOP) {
|
|
1302
|
+
const rail = [
|
|
1303
|
+
...document.querySelectorAll("[data-audit-role=rail], .app-sidebar, .sb-root"),
|
|
1304
|
+
].find(shown);
|
|
1305
|
+
for (const t of document.querySelectorAll("[data-audit-role=rail-toggle]")) {
|
|
1306
|
+
if (!shown(t)) continue;
|
|
1307
|
+
if (!rail) {
|
|
1308
|
+
railCandidates.push({
|
|
1309
|
+
element: desc(t),
|
|
1310
|
+
reason:
|
|
1311
|
+
"declared rail toggle but no visible rail to anchor it (this state is not measurable)",
|
|
1312
|
+
});
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
const rr = rail.getBoundingClientRect();
|
|
1316
|
+
const tr = t.getBoundingClientRect();
|
|
1317
|
+
const edge = t.getAttribute("data-audit-edge") === "start" ? "start" : "end";
|
|
1318
|
+
const right = (edge === "end") !== rtl(rail);
|
|
1319
|
+
toggles.push({
|
|
1320
|
+
element: desc(t),
|
|
1321
|
+
edge,
|
|
1322
|
+
distancePx: Math.abs(tr.left + tr.width / 2 - (right ? rr.right : rr.left)),
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
const sidebar = document.querySelector("[data-audit-role=rail], .app-sidebar");
|
|
1326
|
+
for (const t of document.querySelectorAll("[aria-controls]")) {
|
|
1327
|
+
if (t.matches("[data-audit-role=rail-toggle]") || !shown(t) || !sidebar) continue;
|
|
1328
|
+
const target = document.getElementById(t.getAttribute("aria-controls"));
|
|
1329
|
+
if (target && sidebar.contains(target))
|
|
1330
|
+
railCandidates.push({
|
|
1331
|
+
element: desc(t),
|
|
1332
|
+
reason:
|
|
1333
|
+
"controls the rail but the edge-mounted pattern is not declared (data-audit-role=rail-toggle) — reviewer decides whether C10 applies",
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ── C11: desktop search keyline ─────────────────────────────────────────────────────────
|
|
1339
|
+
const keylines = [];
|
|
1340
|
+
const keylineCandidates = [];
|
|
1341
|
+
let endAligned = false;
|
|
1342
|
+
if (width >= DESKTOP) {
|
|
1343
|
+
const column =
|
|
1344
|
+
[...document.querySelectorAll("[data-audit-role=content-column]")].find(shown) ||
|
|
1345
|
+
[...document.querySelectorAll(".app-main .ui-page-body, .app-main .ui-page-header")].find(
|
|
1346
|
+
shown,
|
|
1347
|
+
);
|
|
1348
|
+
for (const field of searchFields) {
|
|
1349
|
+
const root = field.closest(".ui-search-input, [data-audit-role=search]") || field;
|
|
1350
|
+
const keyed = !!field.closest("[data-audit-keyline]");
|
|
1351
|
+
const slot = slotOf(field);
|
|
1352
|
+
if (!keyed && slot !== "center") {
|
|
1353
|
+
if (slot === "end") endAligned = true;
|
|
1354
|
+
else if (slot)
|
|
1355
|
+
keylineCandidates.push({
|
|
1356
|
+
element: desc(root),
|
|
1357
|
+
reason:
|
|
1358
|
+
"top-bar search outside the centre slot — reviewer decides whether it follows the content keyline",
|
|
1359
|
+
});
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
if (!column) {
|
|
1363
|
+
keylineCandidates.push({
|
|
1364
|
+
element: desc(root),
|
|
1365
|
+
reason:
|
|
1366
|
+
"no content column found (PageContainer body/header inside AppShell main, or data-audit-role=content-column)",
|
|
1367
|
+
});
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
const cr = column.getBoundingClientRect();
|
|
1371
|
+
const ccs = cs(column);
|
|
1372
|
+
const isRtl = rtl(column);
|
|
1373
|
+
const colStart = isRtl
|
|
1374
|
+
? cr.right - column.clientLeft - parseFloat(ccs.paddingRight)
|
|
1375
|
+
: cr.left + column.clientLeft + parseFloat(ccs.paddingLeft);
|
|
1376
|
+
const sr = root.getBoundingClientRect();
|
|
1377
|
+
keylines.push({
|
|
1378
|
+
element: desc(root),
|
|
1379
|
+
column: desc(column),
|
|
1380
|
+
deltaPx: Math.abs((isRtl ? sr.right : sr.left) - colStart),
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// ── C12: tiny tracked uppercase section labels ──────────────────────────────────────────
|
|
1386
|
+
const LABELS =
|
|
1387
|
+
"h1, h2, h3, h4, h5, h6, [role=heading], legend, .sb-section-label, [class*=group-label], .ui-separator-label, .ui-toolbar-label, [data-slot=stat-card-label], [data-audit-role=section-label]";
|
|
1388
|
+
const LITERAL = "code, kbd, samp, [data-audit-literal]";
|
|
1389
|
+
const METADATA =
|
|
1390
|
+
"[data-slot=badge], [data-audit-role=metadata], button, a[href], input, select, textarea, [role=button]";
|
|
1391
|
+
const measureLabel = (el) => {
|
|
1392
|
+
const s = cs(el);
|
|
1393
|
+
const text = snippet(el.textContent);
|
|
1394
|
+
const fontPx = parseFloat(s.fontSize);
|
|
1395
|
+
const ls = s.letterSpacing === "normal" ? 0 : parseFloat(s.letterSpacing);
|
|
1396
|
+
const cased = (text.match(/\p{L}/gu) || []).filter((c) => c.toLowerCase() !== c.toUpperCase());
|
|
1397
|
+
// Literal evidence: the label sits in, or consists wholly of, code/kbd/samp/data-audit-literal.
|
|
1398
|
+
let literal = !!el.closest(LITERAL);
|
|
1399
|
+
if (!literal) {
|
|
1400
|
+
const lit = [...el.querySelectorAll(LITERAL)].map((x) => x.textContent).join(" ");
|
|
1401
|
+
literal = !!lit.trim() && snippet(lit) === text;
|
|
1402
|
+
}
|
|
1403
|
+
return {
|
|
1404
|
+
element: desc(el),
|
|
1405
|
+
text,
|
|
1406
|
+
fontPx,
|
|
1407
|
+
trackingEm: fontPx ? ls / fontPx : 0,
|
|
1408
|
+
// Uppercase needs a script with case: `text-transform: uppercase` on ページ changes nothing.
|
|
1409
|
+
uppercase:
|
|
1410
|
+
cased.length >= 2 && (s.textTransform === "uppercase" || text === text.toUpperCase()),
|
|
1411
|
+
literal,
|
|
1412
|
+
};
|
|
1413
|
+
};
|
|
1414
|
+
const labelEls = [...document.querySelectorAll(LABELS)];
|
|
1415
|
+
const labels = labelEls.filter((el) => shown(el) && snippet(el.textContent)).map(measureLabel);
|
|
1416
|
+
const labelCandidates = [];
|
|
1417
|
+
for (const el of document.querySelectorAll("body *")) {
|
|
1418
|
+
if (el.closest(LABELS) || el.closest(METADATA) || el.closest(LITERAL)) continue;
|
|
1419
|
+
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.data.trim()) || !shown(el)) continue;
|
|
1420
|
+
const m = measureLabel(el);
|
|
1421
|
+
if (m.text.length <= 60 && !m.literal && isTracked(m))
|
|
1422
|
+
labelCandidates.push({
|
|
1423
|
+
element: m.element,
|
|
1424
|
+
reason: `"${m.text}" is tiny tracked uppercase but not mapped as a section label or metadata — reviewer classifies`,
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
function isTracked(m) {
|
|
1428
|
+
return m.uppercase && m.fontPx <= 12 && m.trackingEm >= 0.04 - 1e-6;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
return {
|
|
1432
|
+
c01: { scrollers, clips },
|
|
1433
|
+
c02: {
|
|
1434
|
+
mapped: searchFields.map(measureSearch),
|
|
1435
|
+
slots: searchFields.map(slotOf),
|
|
1436
|
+
candidates: searchCandidates,
|
|
1437
|
+
triggers: searchTriggers,
|
|
1438
|
+
},
|
|
1439
|
+
c04: { mapped: accounts, candidates: accountCandidates },
|
|
1440
|
+
c05: { mapped: editors },
|
|
1441
|
+
c06: { mapped: groups, candidates: actionCandidates },
|
|
1442
|
+
c10: { mapped: toggles, candidates: railCandidates },
|
|
1443
|
+
c11: { mapped: keylines, candidates: keylineCandidates, endAligned },
|
|
1444
|
+
c12: { mapped: labels, candidates: labelCandidates },
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
/* c8 ignore stop */
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Run the probes for one route × viewport, including the interactions a static read cannot
|
|
1451
|
+
* answer: does the avatar open a menu, does the overflow menu hold actions, does a declared
|
|
1452
|
+
* search trigger open a usable field. Each interaction is undone (Escape) before the next.
|
|
1453
|
+
*/
|
|
1454
|
+
/* c8 ignore start — browser glue; exercised by the visual-audit-probes spawn test. */
|
|
1455
|
+
async function runProbes(page, url, viewport) {
|
|
1456
|
+
const opts = { width: viewport.width, compactBelow: COMPACT_BELOW, desktopFrom: DESKTOP_FROM };
|
|
1457
|
+
const raw = await page.evaluate(collectProbes, opts);
|
|
1458
|
+
const menuState = () =>
|
|
1459
|
+
page.evaluate(() => {
|
|
1460
|
+
const vis = (el) => {
|
|
1461
|
+
const r = el.getBoundingClientRect();
|
|
1462
|
+
const s = getComputedStyle(el);
|
|
1463
|
+
return r.width > 1 && r.height > 1 && s.visibility !== "hidden" && s.display !== "none";
|
|
1464
|
+
};
|
|
1465
|
+
const menus = [
|
|
1466
|
+
...document.querySelectorAll(
|
|
1467
|
+
"[role=menu], [role=dialog], [data-slot=dropdown-menu-content]",
|
|
1468
|
+
),
|
|
1469
|
+
].filter(vis);
|
|
1470
|
+
const items = menus.flatMap((m) =>
|
|
1471
|
+
[
|
|
1472
|
+
...m.querySelectorAll(
|
|
1473
|
+
"[role=menuitem], [role=menuitemcheckbox], [role=menuitemradio], a[href], button",
|
|
1474
|
+
),
|
|
1475
|
+
].filter(vis),
|
|
1476
|
+
);
|
|
1477
|
+
return { open: menus.length > 0, items: items.length };
|
|
1478
|
+
});
|
|
1479
|
+
const activate = async (probe) => {
|
|
1480
|
+
await page.locator(`[data-visual-audit-probe="${probe}"]`).first().click({ timeout: 3_000 });
|
|
1481
|
+
await page.waitForTimeout(250);
|
|
1482
|
+
const state = await menuState();
|
|
1483
|
+
await page.keyboard.press("Escape");
|
|
1484
|
+
await page.waitForTimeout(150);
|
|
1485
|
+
return state;
|
|
1486
|
+
};
|
|
1487
|
+
for (const a of raw.c04.mapped) {
|
|
1488
|
+
if (!a.trigger?.hasPopup) continue;
|
|
1489
|
+
a.trigger.opens = await activate(a.trigger.probe).then(
|
|
1490
|
+
(s) => s.open,
|
|
1491
|
+
() => false,
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
for (const g of raw.c06.mapped) {
|
|
1495
|
+
g.overflowMenus = [];
|
|
1496
|
+
for (const o of g.overflowTriggers)
|
|
1497
|
+
g.overflowMenus.push({
|
|
1498
|
+
element: o.element,
|
|
1499
|
+
items: await activate(o.probe).then(
|
|
1500
|
+
(s) => s.items,
|
|
1501
|
+
() => 0,
|
|
1502
|
+
),
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
if (raw.c02.mapped.length === 0) {
|
|
1506
|
+
for (const trig of raw.c02.triggers) {
|
|
1507
|
+
try {
|
|
1508
|
+
await page
|
|
1509
|
+
.locator(`[data-visual-audit-probe="${trig.probe}"]`)
|
|
1510
|
+
.first()
|
|
1511
|
+
.click({ timeout: 3_000 });
|
|
1512
|
+
await page.waitForTimeout(250);
|
|
1513
|
+
const opened = await page.evaluate(collectProbes, opts);
|
|
1514
|
+
for (const m of opened.c02.mapped)
|
|
1515
|
+
raw.c02.mapped.push({ ...m, element: `${m.element} (opened by ${trig.element})` });
|
|
1516
|
+
await page.keyboard.press("Escape");
|
|
1517
|
+
} catch {
|
|
1518
|
+
raw.c02.candidates.push({
|
|
1519
|
+
element: trig.element,
|
|
1520
|
+
reason: "declared search trigger could not be activated",
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
return evaluateProbes(raw, { url, viewport });
|
|
1526
|
+
}
|
|
1527
|
+
/* c8 ignore stop */
|
|
1528
|
+
|
|
337
1529
|
/** Optional-peer loader — throws a tagged bootstrap error instead of a raw stack. */
|
|
338
1530
|
async function loadDeps() {
|
|
339
1531
|
try {
|
|
@@ -355,9 +1547,12 @@ async function loadDeps() {
|
|
|
355
1547
|
* (page/context/browser) is guaranteed via finally on BOTH success and failure.
|
|
356
1548
|
*/
|
|
357
1549
|
/* c8 ignore start — the browser-driving glue; exercised by scripts/visual-audit-smoke.mjs in CI. */
|
|
358
|
-
async function audit(targets, { chromium }) {
|
|
1550
|
+
async function audit(targets, { chromium }, viewports = DEFAULT_VIEWPORTS) {
|
|
359
1551
|
const findings = [];
|
|
360
1552
|
const errors = [];
|
|
1553
|
+
const matrix = [];
|
|
1554
|
+
const unmapped = [];
|
|
1555
|
+
const timings = [];
|
|
361
1556
|
let browser;
|
|
362
1557
|
let context;
|
|
363
1558
|
try {
|
|
@@ -373,104 +1568,147 @@ async function audit(targets, { chromium }) {
|
|
|
373
1568
|
"(or set PLAYWRIGHT_CHROMIUM_EXECUTABLE to a system Chromium).";
|
|
374
1569
|
throw err;
|
|
375
1570
|
}
|
|
376
|
-
context = await browser.newContext({ viewport:
|
|
1571
|
+
context = await browser.newContext({ viewport: viewports[0] });
|
|
377
1572
|
const page = await context.newPage();
|
|
378
1573
|
|
|
379
1574
|
for (const url of targets) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
1575
|
+
const started = Date.now();
|
|
1576
|
+
// A fresh navigation per width, never a resize of the last one: an open menu, a drawer or a
|
|
1577
|
+
// scroll position carried over from the previous cell would contaminate this one.
|
|
1578
|
+
for (const viewport of viewports) {
|
|
1579
|
+
const vp = viewportLabel(viewport);
|
|
1580
|
+
await page.setViewportSize(viewport);
|
|
1581
|
+
try {
|
|
1582
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
|
|
1583
|
+
await page.evaluate(() => document.fonts?.ready);
|
|
1584
|
+
} catch (e) {
|
|
1585
|
+
errors.push({
|
|
1586
|
+
url,
|
|
1587
|
+
viewport: vp,
|
|
1588
|
+
stage: "navigate",
|
|
1589
|
+
message: `could not load page: ${e.message}`,
|
|
1590
|
+
});
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
const before = findings.length;
|
|
386
1594
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
1595
|
+
try {
|
|
1596
|
+
// 2) computed-style / layout heuristics via the PURE rules.
|
|
1597
|
+
const m = await page.evaluate(collectInPage);
|
|
1598
|
+
for (const a of m.accents) {
|
|
1599
|
+
if (isOversaturated(a.rgb))
|
|
1600
|
+
findings.push(
|
|
1601
|
+
buildFinding(
|
|
1602
|
+
"oversaturated-accent",
|
|
1603
|
+
url,
|
|
1604
|
+
`<${a.tag}> background OKLCH chroma ${oklchChroma(a.rgb).toFixed(3)} > 0.18 (rgb ${a.rgb.r},${a.rgb.g},${a.rgb.b})`,
|
|
1605
|
+
),
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
for (const t of m.targets) {
|
|
1609
|
+
// `exempt` is set by the SC 2.5.8 Inline / Spacing carve-outs measured in the page.
|
|
1610
|
+
if (!t.exempt && isUndersizedTarget(t))
|
|
1611
|
+
findings.push(
|
|
1612
|
+
buildFinding(
|
|
1613
|
+
"target-size-min",
|
|
1614
|
+
url,
|
|
1615
|
+
`target "${t.name || "(unnamed)"}" is ${t.width}×${t.height}px (< 24×24)`,
|
|
1616
|
+
),
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
if (hasEmoji(m.text)) {
|
|
1620
|
+
const found = (m.text.match(/\p{Extended_Pictographic}/gu) || []).slice(0, 8).join(" ");
|
|
1621
|
+
findings.push(
|
|
1622
|
+
buildFinding("emoji-rendered", url, `emoji rendered in product text: ${found}`),
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
for (const layer of missingCssLayers(m.layers || []))
|
|
392
1626
|
findings.push(
|
|
393
1627
|
buildFinding(
|
|
394
|
-
"
|
|
1628
|
+
"css-layers-missing",
|
|
395
1629
|
url,
|
|
396
|
-
|
|
1630
|
+
`component layer "${layer}" is not loaded — its rules do not resolve`,
|
|
397
1631
|
),
|
|
398
1632
|
);
|
|
399
|
-
|
|
400
|
-
for (const t of m.targets) {
|
|
401
|
-
// `exempt` is set by the SC 2.5.8 Inline / Spacing carve-outs measured in the page.
|
|
402
|
-
if (!t.exempt && isUndersizedTarget(t))
|
|
1633
|
+
for (const r of controlHeightMismatches(m.rows || []))
|
|
403
1634
|
findings.push(
|
|
404
1635
|
buildFinding(
|
|
405
|
-
"
|
|
1636
|
+
"control-height-mismatch",
|
|
406
1637
|
url,
|
|
407
|
-
`
|
|
1638
|
+
`row "${r.name}" mixes control heights ${[...new Set(r.heights)].join("/")}px`,
|
|
408
1639
|
),
|
|
409
1640
|
);
|
|
410
|
-
|
|
411
|
-
if (hasEmoji(m.text)) {
|
|
412
|
-
const found = (m.text.match(/\p{Extended_Pictographic}/gu) || []).slice(0, 8).join(" ");
|
|
413
|
-
findings.push(
|
|
414
|
-
buildFinding("emoji-rendered", url, `emoji rendered in product text: ${found}`),
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
|
-
for (const layer of missingCssLayers(m.layers || []))
|
|
418
|
-
findings.push(
|
|
419
|
-
buildFinding(
|
|
420
|
-
"css-layers-missing",
|
|
421
|
-
url,
|
|
422
|
-
`component layer "${layer}" is not loaded — its rules do not resolve`,
|
|
423
|
-
),
|
|
424
|
-
);
|
|
425
|
-
for (const r of controlHeightMismatches(m.rows || []))
|
|
426
|
-
findings.push(
|
|
427
|
-
buildFinding(
|
|
428
|
-
"control-height-mismatch",
|
|
429
|
-
url,
|
|
430
|
-
`row "${r.name}" mixes control heights ${[...new Set(r.heights)].join("/")}px`,
|
|
431
|
-
),
|
|
432
|
-
);
|
|
433
|
-
for (const r of mixedButtonHeights(m.buttonRows || []))
|
|
434
|
-
findings.push(
|
|
435
|
-
buildFinding(
|
|
436
|
-
"mixed-button-height",
|
|
437
|
-
url,
|
|
438
|
-
`row "${r.name}" mixes Button heights ${r.heights.map((h) => +h.toFixed(1)).join("/")}px (sizes ${r.sizes.join("/")})`,
|
|
439
|
-
),
|
|
440
|
-
);
|
|
441
|
-
const tight = tightCardPairs(m.cardPairs || []);
|
|
442
|
-
if (tight.length)
|
|
443
|
-
findings.push(
|
|
444
|
-
buildFinding(
|
|
445
|
-
"sibling-card-gap",
|
|
446
|
-
url,
|
|
447
|
-
`${tight.length} adjacent Card pair(s) closer than 8px (min gap ${Math.min(...tight.map((p) => p.gap))}px)`,
|
|
448
|
-
),
|
|
449
|
-
);
|
|
450
|
-
for (const t of starvedRowTexts(m.rowTexts || []))
|
|
451
|
-
findings.push(
|
|
452
|
-
buildFinding(
|
|
453
|
-
"row-content-starved",
|
|
454
|
-
url,
|
|
455
|
-
`text "${t.text}" squeezed to ${t.visible}px of ${t.needed}px by a sibling in its row`,
|
|
456
|
-
),
|
|
457
|
-
);
|
|
458
|
-
m.alerts.forEach((al, i) => {
|
|
459
|
-
for (const issue of alertControlIssues(al))
|
|
1641
|
+
for (const r of mixedButtonHeights(m.buttonRows || []))
|
|
460
1642
|
findings.push(
|
|
461
|
-
buildFinding(
|
|
1643
|
+
buildFinding(
|
|
1644
|
+
"mixed-button-height",
|
|
1645
|
+
url,
|
|
1646
|
+
`row "${r.name}" mixes Button heights ${r.heights.map((h) => +h.toFixed(1)).join("/")}px (sizes ${r.sizes.join("/")})`,
|
|
1647
|
+
),
|
|
462
1648
|
);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
1649
|
+
const tight = tightCardPairs(m.cardPairs || []);
|
|
1650
|
+
if (tight.length)
|
|
1651
|
+
findings.push(
|
|
1652
|
+
buildFinding(
|
|
1653
|
+
"sibling-card-gap",
|
|
1654
|
+
url,
|
|
1655
|
+
`${tight.length} adjacent Card pair(s) closer than 8px (min gap ${Math.min(...tight.map((p) => p.gap))}px)`,
|
|
1656
|
+
),
|
|
1657
|
+
);
|
|
1658
|
+
for (const t of starvedRowTexts(m.rowTexts || []))
|
|
1659
|
+
findings.push(
|
|
1660
|
+
buildFinding(
|
|
1661
|
+
"row-content-starved",
|
|
1662
|
+
url,
|
|
1663
|
+
`text "${t.text}" squeezed to ${t.visible}px of ${t.needed}px by a sibling in its row`,
|
|
1664
|
+
),
|
|
1665
|
+
);
|
|
1666
|
+
m.alerts.forEach((al, i) => {
|
|
1667
|
+
for (const issue of alertControlIssues(al))
|
|
1668
|
+
findings.push(
|
|
1669
|
+
buildFinding("alert-controls-misplaced", url, `banner #${i + 1}: ${issue}`),
|
|
1670
|
+
);
|
|
1671
|
+
});
|
|
1672
|
+
// The checks above run at every width, but most of what they find (a missing CSS layer,
|
|
1673
|
+
// an emoji) is the same at all six: report it once, listing the widths it appeared at.
|
|
1674
|
+
for (const f of findings.splice(before)) {
|
|
1675
|
+
const seen = findings.find(
|
|
1676
|
+
(o) => o.url === f.url && o.rule === f.rule && o.message === f.message,
|
|
1677
|
+
);
|
|
1678
|
+
if (seen) seen.viewports.push(vp);
|
|
1679
|
+
else findings.push({ ...f, viewport: vp, viewports: [vp] });
|
|
1680
|
+
}
|
|
1681
|
+
// 3) responsive probes C01–C12 for this cell.
|
|
1682
|
+
const probed = await runProbes(page, url, viewport);
|
|
1683
|
+
findings.push(...probed.findings);
|
|
1684
|
+
unmapped.push(...probed.unmapped);
|
|
1685
|
+
matrix.push(probed.cell);
|
|
1686
|
+
} catch (e) {
|
|
1687
|
+
errors.push({
|
|
1688
|
+
url,
|
|
1689
|
+
viewport: vp,
|
|
1690
|
+
stage: "analyze",
|
|
1691
|
+
message: `audit engine failed: ${e.message}`,
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
466
1694
|
}
|
|
1695
|
+
timings.push({ url, viewports: viewports.length, ms: Date.now() - started });
|
|
467
1696
|
}
|
|
468
1697
|
} finally {
|
|
469
1698
|
await context?.close().catch(() => {});
|
|
470
1699
|
await browser?.close().catch(() => {});
|
|
471
1700
|
}
|
|
472
1701
|
|
|
473
|
-
|
|
1702
|
+
const result = buildResult({
|
|
1703
|
+
findings,
|
|
1704
|
+
errors,
|
|
1705
|
+
pages: targets.length,
|
|
1706
|
+
viewports,
|
|
1707
|
+
matrix,
|
|
1708
|
+
unmapped,
|
|
1709
|
+
});
|
|
1710
|
+
result.timings = timings;
|
|
1711
|
+
return result;
|
|
474
1712
|
}
|
|
475
1713
|
/* c8 ignore stop */
|
|
476
1714
|
|
|
@@ -487,8 +1725,10 @@ const C = {
|
|
|
487
1725
|
function finish(result, { asJson, strict, pages }) {
|
|
488
1726
|
const code = exitCodeFor(result, { strict });
|
|
489
1727
|
if (asJson) {
|
|
490
|
-
|
|
491
|
-
process.exit(
|
|
1728
|
+
// Exit only once the write has flushed: a six-width report is far larger than a pipe buffer,
|
|
1729
|
+
// and process.exit() right after write() cut it mid-document — invalid JSON on stdout.
|
|
1730
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n", () => process.exit(code));
|
|
1731
|
+
return;
|
|
492
1732
|
}
|
|
493
1733
|
|
|
494
1734
|
// Bootstrap failure — no page was audited.
|
|
@@ -502,10 +1742,31 @@ function finish(result, { asJson, strict, pages }) {
|
|
|
502
1742
|
|
|
503
1743
|
for (const f of result.findings) {
|
|
504
1744
|
const tag = f.severity === "error" ? `${C.red}error${C.reset}` : `${C.yellow}warn ${C.reset}`;
|
|
505
|
-
|
|
1745
|
+
const at =
|
|
1746
|
+
f.viewports?.length > 1 ? ` @${f.viewports.join(",")}` : f.viewport ? ` @${f.viewport}` : "";
|
|
1747
|
+
console.log(`${tag} ${C.bold}${f.url}${at}${C.reset} ${C.dim}[${f.rule}]${C.reset}`);
|
|
506
1748
|
console.log(` ${f.message}`);
|
|
1749
|
+
if (f.threshold)
|
|
1750
|
+
console.log(
|
|
1751
|
+
` ${C.dim}threshold: ${f.threshold.value}${f.threshold.unit === "px" || f.threshold.unit === "ch" || f.threshold.unit === "em" ? f.threshold.unit : ` ${f.threshold.unit}`} (${f.threshold.origin})${C.reset}`,
|
|
1752
|
+
);
|
|
507
1753
|
if (f.standard) console.log(` ${C.dim}standard: ${f.standard}${C.reset}`);
|
|
508
1754
|
}
|
|
1755
|
+
for (const u of result.unmapped || []) {
|
|
1756
|
+
console.log(
|
|
1757
|
+
`${C.yellow}unmapped — reviewer required${C.reset} ${C.bold}${u.url} @${u.viewport}${C.reset} ${C.dim}[${u.rule}]${C.reset}`,
|
|
1758
|
+
);
|
|
1759
|
+
console.log(` ${u.element}: ${u.reason}`);
|
|
1760
|
+
}
|
|
1761
|
+
for (const cell of result.matrix || []) {
|
|
1762
|
+
const by = {};
|
|
1763
|
+
for (const [rule, st] of Object.entries(cell.rules)) (by[st] ??= []).push(rule.slice(0, 3));
|
|
1764
|
+
console.log(
|
|
1765
|
+
`${C.dim}cell ${cell.url} @${cell.viewport}: ${Object.entries(by)
|
|
1766
|
+
.map(([st, ids]) => `${st} ${ids.join(",")}`)
|
|
1767
|
+
.join(" · ")} · checklist ${cell.checklist.length} pending${C.reset}`,
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
509
1770
|
for (const e of result.errors) {
|
|
510
1771
|
console.log(
|
|
511
1772
|
`${C.red}infra${C.reset} ${C.bold}${e.url ?? "(bootstrap)"}${C.reset} ${C.dim}[${e.stage}]${C.reset}`,
|
|
@@ -517,10 +1778,15 @@ function finish(result, { asJson, strict, pages }) {
|
|
|
517
1778
|
`\ngodxjp-ui visual-audit [${result.status}]: ${C.red}${errors} error(s)${C.reset}, ` +
|
|
518
1779
|
`${C.yellow}${warnings} warning(s)${C.reset}` +
|
|
519
1780
|
(result.errors.length ? `, ${C.red}${result.errors.length} infra error(s)${C.reset}` : "") +
|
|
520
|
-
` across ${pages} page(s).`,
|
|
1781
|
+
` across ${pages} page(s) × ${result.summary.viewports.length} viewport(s) [${result.summary.viewports.join(", ")}].`,
|
|
521
1782
|
);
|
|
522
|
-
|
|
523
|
-
|
|
1783
|
+
// Never "done", never a bare ✓: zero machine failures still leaves the checklist, the unmapped
|
|
1784
|
+
// items and the independent review open (ADR process step 5).
|
|
1785
|
+
console.log(`${result.acceptance}.`);
|
|
1786
|
+
if (result.summary.unmapped || result.summary.checklistPending)
|
|
1787
|
+
console.log(
|
|
1788
|
+
`${C.dim}${result.summary.unmapped} unmapped item(s) and ${result.summary.checklistPending} checklist line(s) await an independent reviewer (--format json lists them per width).${C.reset}`,
|
|
1789
|
+
);
|
|
524
1790
|
process.exit(code);
|
|
525
1791
|
}
|
|
526
1792
|
/* c8 ignore stop */
|
|
@@ -534,14 +1800,25 @@ async function main() {
|
|
|
534
1800
|
const strict = args.includes("--strict");
|
|
535
1801
|
|
|
536
1802
|
if (args.includes("--rules")) {
|
|
537
|
-
process.stdout.write(JSON.stringify(
|
|
1803
|
+
process.stdout.write(JSON.stringify(allRules(), null, 2) + "\n");
|
|
538
1804
|
process.exit(0);
|
|
539
1805
|
}
|
|
540
1806
|
|
|
541
|
-
const
|
|
1807
|
+
const wIdx = args.indexOf("--widths");
|
|
1808
|
+
let viewports;
|
|
1809
|
+
try {
|
|
1810
|
+
viewports = parseViewports(wIdx === -1 ? null : (args[wIdx + 1] ?? ""));
|
|
1811
|
+
} catch (e) {
|
|
1812
|
+
finish(buildBootstrapError(e.message), { asJson, strict, pages: 0 });
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const urls = args.filter(
|
|
1817
|
+
(a, i) => !a.startsWith("--") && args[i - 1] !== "--format" && args[i - 1] !== "--widths",
|
|
1818
|
+
);
|
|
542
1819
|
if (urls.length === 0) {
|
|
543
1820
|
const msg =
|
|
544
|
-
"no baseUrl given — usage: visual-audit.mjs [--format json] [--strict] <baseUrl> [route …] " +
|
|
1821
|
+
"no baseUrl given — usage: visual-audit.mjs [--format json] [--strict] [--widths 390x844,1280x900] <baseUrl> [route …] " +
|
|
545
1822
|
"(baseUrl is the running app; extra args are routes appended to it)";
|
|
546
1823
|
finish(buildBootstrapError(msg), { asJson, strict, pages: 0 });
|
|
547
1824
|
return;
|
|
@@ -562,7 +1839,7 @@ async function main() {
|
|
|
562
1839
|
}
|
|
563
1840
|
|
|
564
1841
|
try {
|
|
565
|
-
const result = await audit(targets, deps);
|
|
1842
|
+
const result = await audit(targets, deps, viewports);
|
|
566
1843
|
finish(result, { asJson, strict, pages: targets.length });
|
|
567
1844
|
} catch (e) {
|
|
568
1845
|
// A launch/setup failure = infrastructure error (never "zero violations").
|