@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.
Files changed (42) hide show
  1. package/DESIGN.md +10 -3
  2. package/README.md +5 -5
  3. package/agent/DESIGN.md +10 -3
  4. package/agent/START-HERE.md +9 -7
  5. package/agent/components/AppShell.json +1 -0
  6. package/agent/components/BlockEditor.json +1 -0
  7. package/agent/components/PageContainer.json +1 -0
  8. package/agent/components/Progress.json +2 -2
  9. package/agent/components/SearchInput.json +1 -0
  10. package/agent/components/Topbar.json +2 -0
  11. package/agent/components/TopbarItem.json +1 -0
  12. package/agent/components/TopbarSearch.json +49 -0
  13. package/agent/components-index.json +5 -0
  14. package/agent/components.json +58 -2
  15. package/agent/index.json +7 -7
  16. package/agent/llms.txt +7 -7
  17. package/agent/tokens.json +38 -2
  18. package/dist/components/data-display/index.d.ts +1 -1
  19. package/dist/components/data-display/progress.d.ts +8 -1
  20. package/dist/components/layout/index.d.ts +2 -0
  21. package/dist/components/layout/index.js +2 -0
  22. package/dist/components/layout/topbar-search.d.ts +12 -0
  23. package/dist/components/layout/topbar-search.js +87 -0
  24. package/dist/contracts/measurement.json +1 -1
  25. package/dist/i18n/messages/en.json +1 -0
  26. package/dist/i18n/messages/ja.json +1 -0
  27. package/dist/i18n/messages/vi.json +1 -0
  28. package/dist/props/components/layout.prop.d.ts +22 -0
  29. package/dist/props/registry.d.ts +9 -0
  30. package/dist/props/registry.js +16 -0
  31. package/dist/styles/card-layout.css +1 -1
  32. package/dist/styles/data-display-layout.css +27 -0
  33. package/dist/styles/layers.json +7 -1
  34. package/dist/styles/shell-layout.css +123 -2
  35. package/dist/styles.css +1 -1
  36. package/dist/tokens/components/card.css +3 -1
  37. package/dist/tokens/components/data-display.css +2 -0
  38. package/dist/tokens/components/shell.css +7 -0
  39. package/package.json +2 -2
  40. package/scripts/_agent-setup.mjs +10 -5
  41. package/scripts/cli.mjs +12 -6
  42. package/scripts/visual-audit.mjs +1367 -90
@@ -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 = VISUAL_RULES.find((r) => r.id === ruleId);
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({ findings = [], errors = [], pages = 0 } = {}) {
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
- const audited = pages - failedToLoad;
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: { width: 1280, height: 900 } });
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
- try {
381
- await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
382
- } catch (e) {
383
- errors.push({ url, stage: "navigate", message: `could not load page: ${e.message}` });
384
- continue;
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
- try {
388
- // 2) computed-style / layout heuristics via the PURE rules.
389
- const m = await page.evaluate(collectInPage);
390
- for (const a of m.accents) {
391
- if (isOversaturated(a.rgb))
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
- "oversaturated-accent",
1628
+ "css-layers-missing",
395
1629
  url,
396
- `<${a.tag}> background OKLCH chroma ${oklchChroma(a.rgb).toFixed(3)} > 0.18 (rgb ${a.rgb.r},${a.rgb.g},${a.rgb.b})`,
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
- "target-size-min",
1636
+ "control-height-mismatch",
406
1637
  url,
407
- `target "${t.name || "(unnamed)"}" is ${t.width}×${t.height}px (< 24×24)`,
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("alert-controls-misplaced", url, `banner #${i + 1}: ${issue}`),
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
- } catch (e) {
465
- errors.push({ url, stage: "analyze", message: `audit engine failed: ${e.message}` });
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
- return buildResult({ findings, errors, pages: targets.length });
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
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
491
- process.exit(code);
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
- console.log(`${tag} ${C.bold}${f.url}${C.reset} ${C.dim}[${f.rule}]${C.reset}`);
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
- if (result.status === "ok" && result.findings.length === 0)
523
- console.log("✓ No visual/runtime a11y violations found.");
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(VISUAL_RULES, null, 2) + "\n");
1803
+ process.stdout.write(JSON.stringify(allRules(), null, 2) + "\n");
538
1804
  process.exit(0);
539
1805
  }
540
1806
 
541
- const urls = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--format");
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").