@godxjp/ui 32.8.0 → 32.10.1

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