harnery 0.17.0 → 0.19.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 (61) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/browse.d.ts.map +1 -1
  3. package/dist/commands/browse.js +187 -4
  4. package/dist/commands/supervisor.d.ts.map +1 -1
  5. package/dist/commands/supervisor.js +76 -4
  6. package/dist/core/supervisor/index.d.ts +3 -3
  7. package/dist/core/supervisor/index.d.ts.map +1 -1
  8. package/dist/core/supervisor/index.js +2 -2
  9. package/dist/core/supervisor/plan-read.d.ts +2 -1
  10. package/dist/core/supervisor/plan-read.d.ts.map +1 -1
  11. package/dist/core/supervisor/plan-read.js +222 -3
  12. package/dist/core/supervisor/plan-types.d.ts +60 -3
  13. package/dist/core/supervisor/plan-types.d.ts.map +1 -1
  14. package/dist/core/supervisor/plan-types.js +2 -0
  15. package/dist/core/supervisor/planning.d.ts.map +1 -1
  16. package/dist/core/supervisor/planning.js +804 -56
  17. package/dist/core/supervisor/read.d.ts +3 -3
  18. package/dist/core/supervisor/read.d.ts.map +1 -1
  19. package/dist/core/supervisor/read.js +2 -2
  20. package/dist/core/supervisor/runner.d.ts.map +1 -1
  21. package/dist/core/supervisor/runner.js +7 -2
  22. package/dist/core/supervisor/service.js +3 -0
  23. package/dist/core/supervisor/state.d.ts +17 -2
  24. package/dist/core/supervisor/state.d.ts.map +1 -1
  25. package/dist/core/supervisor/state.js +211 -33
  26. package/dist/core/workflow/engine.d.ts.map +1 -1
  27. package/dist/core/workflow/engine.js +24 -6
  28. package/dist/core/workflow/types.d.ts +6 -0
  29. package/dist/core/workflow/types.d.ts.map +1 -1
  30. package/dist/core/workflow/validate.d.ts +3 -2
  31. package/dist/core/workflow/validate.d.ts.map +1 -1
  32. package/dist/core/workflow/validate.js +31 -3
  33. package/dist/lib/browser/client.d.ts +18 -0
  34. package/dist/lib/browser/client.d.ts.map +1 -1
  35. package/dist/lib/browser/client.js +39 -0
  36. package/dist/lib/browser/geometry.d.ts +146 -0
  37. package/dist/lib/browser/geometry.d.ts.map +1 -0
  38. package/dist/lib/browser/geometry.js +525 -0
  39. package/dist/lib/browser/index.d.ts +2 -0
  40. package/dist/lib/browser/index.d.ts.map +1 -1
  41. package/dist/lib/browser/target-size.d.ts +65 -0
  42. package/dist/lib/browser/target-size.d.ts.map +1 -0
  43. package/dist/lib/browser/target-size.js +108 -0
  44. package/package.json +7 -1
  45. package/src/commands/browse.ts +319 -0
  46. package/src/commands/supervisor.ts +92 -5
  47. package/src/core/supervisor/index.ts +17 -1
  48. package/src/core/supervisor/plan-read.ts +292 -2
  49. package/src/core/supervisor/plan-types.ts +69 -1
  50. package/src/core/supervisor/planning.ts +1012 -51
  51. package/src/core/supervisor/read.ts +16 -1
  52. package/src/core/supervisor/runner.ts +9 -2
  53. package/src/core/supervisor/service.ts +3 -0
  54. package/src/core/supervisor/state.ts +272 -31
  55. package/src/core/workflow/engine.ts +29 -6
  56. package/src/core/workflow/types.ts +6 -0
  57. package/src/core/workflow/validate.ts +34 -3
  58. package/src/lib/browser/client.ts +62 -0
  59. package/src/lib/browser/geometry.ts +695 -0
  60. package/src/lib/browser/index.ts +25 -0
  61. package/src/lib/browser/target-size.ts +180 -0
@@ -0,0 +1,108 @@
1
+ export function buildTargetSizeCheck() {
2
+ return async ({ selector, profile }) => {
3
+ const minSizePx = profile === "comfortable" ? 44 : 24;
4
+ const axe = window.axe;
5
+ if (!axe)
6
+ throw new Error("Target-size engine was not injected into the page");
7
+ const context = selector ? document.querySelector(selector) : document;
8
+ if (!context) {
9
+ return {
10
+ rule: "hit",
11
+ selector,
12
+ found: false,
13
+ outcome: "fail",
14
+ profile,
15
+ minSizePx,
16
+ nodes: [],
17
+ };
18
+ }
19
+ axe.configure({
20
+ checks: [
21
+ { id: "target-size", options: { minSize: minSizePx } },
22
+ { id: "target-offset", options: { minOffset: minSizePx } },
23
+ ],
24
+ });
25
+ const results = await axe.run(context, {
26
+ runOnly: { type: "rule", values: ["target-size"] },
27
+ rules: { "target-size": { enabled: true } },
28
+ resultTypes: ["violations", "passes", "incomplete"],
29
+ });
30
+ const normalizeTarget = (target) => {
31
+ if (!Array.isArray(target))
32
+ return target == null ? [] : [String(target)];
33
+ return target.map((part) => Array.isArray(part) ? part.map(String).join(" >>> ") : String(part));
34
+ };
35
+ const mapRuleNodes = (rules, outcome) => (rules ?? []).flatMap((rule) => (rule.nodes ?? []).map((node) => {
36
+ const checks = [...(node.any ?? []), ...(node.all ?? []), ...(node.none ?? [])];
37
+ const check = checks[0];
38
+ const target = normalizeTarget(node.target);
39
+ let rect = null;
40
+ const first = target[0];
41
+ if (first && !first.includes(" >>> ")) {
42
+ try {
43
+ const element = document.querySelector(first);
44
+ if (element) {
45
+ const r = element.getBoundingClientRect();
46
+ rect = { x: r.x, y: r.y, width: r.width, height: r.height };
47
+ }
48
+ }
49
+ catch {
50
+ rect = null;
51
+ }
52
+ }
53
+ return {
54
+ outcome,
55
+ target,
56
+ html: (node.html ?? "").slice(0, 300),
57
+ message: (check?.message ?? "").slice(0, 500),
58
+ failureSummary: node.failureSummary?.slice(0, 500) ?? null,
59
+ relatedTargets: (check?.relatedNodes ?? []).map((related) => normalizeTarget(related.target)),
60
+ data: check?.data ?? null,
61
+ rect,
62
+ };
63
+ }));
64
+ const nodes = [
65
+ ...mapRuleNodes(results.violations, "fail"),
66
+ ...mapRuleNodes(results.incomplete, "unknown"),
67
+ ...mapRuleNodes(results.passes, "pass"),
68
+ ];
69
+ const outcome = nodes.some((node) => node.outcome === "fail")
70
+ ? "fail"
71
+ : nodes.some((node) => node.outcome === "unknown")
72
+ ? "unknown"
73
+ : "pass";
74
+ return { rule: "hit", selector, found: true, outcome, profile, minSizePx, nodes };
75
+ };
76
+ }
77
+ export function buildTargetSizeAnnotateScript() {
78
+ return (results) => {
79
+ const ROOT_ID = "__harnery-target-size-annotations__";
80
+ document.getElementById(ROOT_ID)?.remove();
81
+ const root = document.createElement("div");
82
+ root.id = ROOT_ID;
83
+ root.style.cssText =
84
+ "position:absolute;top:0;left:0;width:100%;height:0;overflow:visible;pointer-events:none;z-index:2147483647";
85
+ document.body.appendChild(root);
86
+ const sx = window.scrollX;
87
+ const sy = window.scrollY;
88
+ for (const result of results) {
89
+ for (const node of result.nodes) {
90
+ if (!node.rect || node.outcome === "pass")
91
+ continue;
92
+ const color = node.outcome === "fail" ? "#dc2626" : "#f59e0b";
93
+ const box = document.createElement("div");
94
+ box.style.cssText = `position:absolute;left:${node.rect.x + sx}px;top:${node.rect.y + sy}px;width:${node.rect.width}px;height:${node.rect.height}px;border:2px solid ${color};box-sizing:border-box;background:${color}1a;pointer-events:none`;
95
+ const tag = document.createElement("div");
96
+ tag.textContent = `hit ${node.outcome} · ${result.minSizePx}px`;
97
+ tag.style.cssText = `position:absolute;left:0;top:-18px;background:${color};color:white;font:12px/1.4 system-ui,sans-serif;padding:1px 5px;border-radius:3px;white-space:nowrap`;
98
+ box.appendChild(tag);
99
+ root.appendChild(box);
100
+ }
101
+ }
102
+ };
103
+ }
104
+ export function buildClearTargetSizeAnnotationsScript() {
105
+ return () => {
106
+ document.getElementById("__harnery-target-size-annotations__")?.remove();
107
+ };
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -87,6 +87,11 @@
87
87
  "types": "./dist/core/supervisor/read.d.ts",
88
88
  "import": "./dist/core/supervisor/read.js"
89
89
  },
90
+ "./core/supervisor/plans": {
91
+ "bun": "./src/core/supervisor/plan-read.ts",
92
+ "types": "./dist/core/supervisor/plan-read.d.ts",
93
+ "import": "./dist/core/supervisor/plan-read.js"
94
+ },
90
95
  "./core/policy": {
91
96
  "bun": "./src/core/policy/index.ts",
92
97
  "types": "./dist/core/policy/index.d.ts",
@@ -183,6 +188,7 @@
183
188
  },
184
189
  "dependencies": {
185
190
  "@mozilla/readability": "^0.6.0",
191
+ "axe-core": "^4.12.1",
186
192
  "commander": "^14.0.3",
187
193
  "gpt-tokenizer": "^3.4.0",
188
194
  "js-yaml": "^4.3.0",
@@ -9,8 +9,12 @@ import {
9
9
  captureDevOverlay,
10
10
  type DevOverlayResult,
11
11
  type Diagnostics,
12
+ type LayoutAxis,
13
+ type LayoutLintResult,
12
14
  type OverflowResult,
13
15
  type RuntsResult,
16
+ type TargetSizeProfile,
17
+ type TargetSizeResult,
14
18
  type VisibilityResult,
15
19
  type WidthResult,
16
20
  wslHeadedLaunchArgs,
@@ -96,6 +100,31 @@ interface BrowseOpts {
96
100
  checkOverflow?: boolean;
97
101
  checkOverflowFail?: boolean;
98
102
  checkOverflowAnnotate?: boolean;
103
+ // Rendered-layout relation checks
104
+ checkAlign?: string[];
105
+ checkAlignAxis?: string;
106
+ checkAlignThreshold?: string;
107
+ checkAlignFail?: boolean;
108
+ checkAlignAnnotate?: boolean;
109
+ checkGap?: string[];
110
+ checkGapAxis?: string;
111
+ checkGapExpected?: string;
112
+ checkGapThreshold?: string;
113
+ checkGapFail?: boolean;
114
+ checkGapAnnotate?: boolean;
115
+ checkClip?: string[];
116
+ checkClipThreshold?: string;
117
+ checkClipFail?: boolean;
118
+ checkClipAnnotate?: boolean;
119
+ checkOverlap?: string[];
120
+ checkOverlapThreshold?: string;
121
+ checkOverlapFail?: boolean;
122
+ checkOverlapAnnotate?: boolean;
123
+ // Optional selector; null means the full document. Repeatable.
124
+ checkHit?: Array<string | null>;
125
+ checkHitProfile?: string;
126
+ checkHitFail?: boolean;
127
+ checkHitAnnotate?: boolean;
99
128
  // Visual regression
100
129
  baseline?: string;
101
130
  diff?: string;
@@ -269,6 +298,79 @@ export function registerBrowseCommand(
269
298
  "--no-check-runts-annotate",
270
299
  "Skip drawing runt boxes on the screenshot (JSON still emitted).",
271
300
  )
301
+ .option(
302
+ "--check-align <selector>",
303
+ "Check rendered child-content alignment inside this container (repeatable).",
304
+ (value: string, previous: string[] = []) => [...previous, value],
305
+ [] as string[],
306
+ )
307
+ .option("--check-align-axis <axis>", "Alignment axis: auto | x | y (default auto).", "auto")
308
+ .option(
309
+ "--check-align-threshold <px>",
310
+ "Maximum rendered-center drift in CSS pixels (default 2).",
311
+ "2",
312
+ )
313
+ .option("--check-align-fail", "Exit 2 when an alignment target fails or is missing.")
314
+ .option("--no-check-align-annotate", "Skip alignment screenshot annotations.")
315
+ .option(
316
+ "--check-gap <selector>",
317
+ "Check adjacent rendered gaps inside this container (repeatable).",
318
+ (value: string, previous: string[] = []) => [...previous, value],
319
+ [] as string[],
320
+ )
321
+ .option("--check-gap-axis <axis>", "Gap axis: auto | x | y (default auto).", "auto")
322
+ .option(
323
+ "--check-gap-expected <px>",
324
+ "Expected gap in CSS pixels. Without it, groups of 3+ infer the median gap.",
325
+ )
326
+ .option("--check-gap-threshold <px>", "Maximum gap deviation in CSS pixels (default 2).", "2")
327
+ .option("--check-gap-fail", "Exit 2 when a gap target fails or is missing.")
328
+ .option("--no-check-gap-annotate", "Skip gap screenshot annotations.")
329
+ .option(
330
+ "--check-clip <selector>",
331
+ "Check descendants against this container's rectangular clipping chain (repeatable).",
332
+ (value: string, previous: string[] = []) => [...previous, value],
333
+ [] as string[],
334
+ )
335
+ .option(
336
+ "--check-clip-threshold <px>",
337
+ "Allowed rectangular overrun in CSS pixels (default 0).",
338
+ "0",
339
+ )
340
+ .option("--check-clip-fail", "Exit 2 when a clip target fails or is missing.")
341
+ .option("--no-check-clip-annotate", "Skip clip screenshot annotations.")
342
+ .option(
343
+ "--check-overlap <selector>",
344
+ "Check measurable in-flow siblings for unintended overlap (repeatable).",
345
+ (value: string, previous: string[] = []) => [...previous, value],
346
+ [] as string[],
347
+ )
348
+ .option(
349
+ "--check-overlap-threshold <px>",
350
+ "Allowed overlap depth on either axis in CSS pixels (default 0).",
351
+ "0",
352
+ )
353
+ .option("--check-overlap-fail", "Exit 2 when an overlap target fails or is missing.")
354
+ .option("--no-check-overlap-annotate", "Skip overlap screenshot annotations.")
355
+ .option(
356
+ "--check-hit [selector]",
357
+ "Check pointer-target size and spacing in the document or optional scope (repeatable).",
358
+ (value: string | boolean, previous: Array<string | null> = []) => [
359
+ ...previous,
360
+ typeof value === "string" ? value : null,
361
+ ],
362
+ [] as Array<string | null>,
363
+ )
364
+ .option(
365
+ "--check-hit-profile <profile>",
366
+ "Target profile: wcag-aa (24px) | comfortable (44px).",
367
+ "wcag-aa",
368
+ )
369
+ .option(
370
+ "--check-hit-fail",
371
+ "Exit 2 when target sizing fails, is incomplete, or a scope is missing.",
372
+ )
373
+ .option("--no-check-hit-annotate", "Skip target-size screenshot annotations.")
272
374
  .option(
273
375
  "--baseline <name>",
274
376
  "Save the captured screenshot as a named baseline at " +
@@ -308,6 +410,30 @@ async function runBrowse(
308
410
  opts: BrowseOpts,
309
411
  context: HarneryProgramContext | undefined,
310
412
  ): Promise<void> {
413
+ const alignAxis = parseLayoutAxis(opts.checkAlignAxis ?? "auto", "--check-align-axis");
414
+ const gapAxis = parseLayoutAxis(opts.checkGapAxis ?? "auto", "--check-gap-axis");
415
+ const alignThreshold = parseNonNegativeNumber(
416
+ opts.checkAlignThreshold ?? "2",
417
+ "--check-align-threshold",
418
+ );
419
+ const gapThreshold = parseNonNegativeNumber(
420
+ opts.checkGapThreshold ?? "2",
421
+ "--check-gap-threshold",
422
+ );
423
+ const gapExpected =
424
+ opts.checkGapExpected === undefined
425
+ ? null
426
+ : parseNonNegativeNumber(opts.checkGapExpected, "--check-gap-expected");
427
+ const clipThreshold = parseNonNegativeNumber(
428
+ opts.checkClipThreshold ?? "0",
429
+ "--check-clip-threshold",
430
+ );
431
+ const overlapThreshold = parseNonNegativeNumber(
432
+ opts.checkOverlapThreshold ?? "0",
433
+ "--check-overlap-threshold",
434
+ );
435
+ const hitProfile = parseTargetSizeProfile(opts.checkHitProfile ?? "wcag-aa");
436
+
311
437
  // Commander: `--no-cookies` turns `opts.cookies` into `false`. Default is `true`.
312
438
  const jar =
313
439
  opts.cookies === false
@@ -388,6 +514,39 @@ async function runBrowse(
388
514
  minChars: Number.parseInt(opts.checkRuntsMinChars ?? "40", 10),
389
515
  });
390
516
  }
517
+ const hasLayoutLint =
518
+ (opts.checkAlign?.length ?? 0) > 0 ||
519
+ (opts.checkGap?.length ?? 0) > 0 ||
520
+ (opts.checkClip?.length ?? 0) > 0 ||
521
+ (opts.checkOverlap?.length ?? 0) > 0;
522
+ let layoutLint: LayoutLintResult | undefined;
523
+ if (hasLayoutLint) {
524
+ layoutLint = await browser.checkLayoutLint({
525
+ align: (opts.checkAlign ?? []).map((selector) => ({
526
+ selector,
527
+ axis: alignAxis,
528
+ tolerancePx: alignThreshold,
529
+ })),
530
+ gap: (opts.checkGap ?? []).map((selector) => ({
531
+ selector,
532
+ axis: gapAxis,
533
+ tolerancePx: gapThreshold,
534
+ expectedGapPx: gapExpected,
535
+ })),
536
+ clip: (opts.checkClip ?? []).map((selector) => ({
537
+ selector,
538
+ tolerancePx: clipThreshold,
539
+ })),
540
+ overlap: (opts.checkOverlap ?? []).map((selector) => ({
541
+ selector,
542
+ tolerancePx: overlapThreshold,
543
+ })),
544
+ });
545
+ }
546
+ let hit: TargetSizeResult[] | undefined;
547
+ if (opts.checkHit && opts.checkHit.length > 0) {
548
+ hit = await browser.checkTargetSize(opts.checkHit, hitProfile);
549
+ }
391
550
  const widthThreshold = Number.parseFloat(opts.checkWidthThreshold ?? "0.9");
392
551
  const annotateWidth = widths && opts.checkWidthAnnotate !== false;
393
552
  const annotateOverflow = overflow && opts.checkOverflowAnnotate !== false;
@@ -402,6 +561,27 @@ async function runBrowse(
402
561
  if (annotateRunts) {
403
562
  await browser.annotateRunts(runts!);
404
563
  }
564
+ const annotateLayoutLint = layoutLint
565
+ ? {
566
+ align: opts.checkAlignAnnotate === false ? [] : layoutLint.align,
567
+ gap: opts.checkGapAnnotate === false ? [] : layoutLint.gap,
568
+ clip: opts.checkClipAnnotate === false ? [] : layoutLint.clip,
569
+ overlap: opts.checkOverlapAnnotate === false ? [] : layoutLint.overlap,
570
+ }
571
+ : undefined;
572
+ if (
573
+ annotateLayoutLint &&
574
+ (annotateLayoutLint.align.length > 0 ||
575
+ annotateLayoutLint.gap.length > 0 ||
576
+ annotateLayoutLint.clip.length > 0 ||
577
+ annotateLayoutLint.overlap.length > 0)
578
+ ) {
579
+ await browser.annotateLayoutLint(annotateLayoutLint);
580
+ }
581
+ const annotateHit = hit && opts.checkHitAnnotate !== false;
582
+ if (annotateHit) {
583
+ await browser.annotateTargetSize(hit!);
584
+ }
405
585
 
406
586
  if (opts.login) {
407
587
  await new Promise<void>((res) => {
@@ -435,6 +615,8 @@ async function runBrowse(
435
615
  widths,
436
616
  overflow,
437
617
  runts,
618
+ layoutLint,
619
+ hit,
438
620
  devOverlay,
439
621
  batchResult,
440
622
  );
@@ -448,6 +630,8 @@ async function runBrowse(
448
630
  widths,
449
631
  overflow,
450
632
  runts,
633
+ layoutLint,
634
+ hit,
451
635
  devOverlay,
452
636
  batchResult,
453
637
  );
@@ -462,6 +646,12 @@ async function runBrowse(
462
646
  if (annotateRunts) {
463
647
  await browser.clearRuntsAnnotations();
464
648
  }
649
+ if (annotateLayoutLint) {
650
+ await browser.clearLayoutLintAnnotations();
651
+ }
652
+ if (annotateHit) {
653
+ await browser.clearTargetSizeAnnotations();
654
+ }
465
655
 
466
656
  if (opts.checkVisibleFail && visibility) {
467
657
  const threshold = Number.parseFloat(opts.checkVisibleThreshold ?? "0.9");
@@ -521,6 +711,8 @@ async function runBrowse(
521
711
  );
522
712
  process.exitCode = 2;
523
713
  }
714
+
715
+ applyLayoutLintFailGates(opts, layoutLint, hit);
524
716
  } finally {
525
717
  await browser.close();
526
718
  }
@@ -643,6 +835,8 @@ async function runPrintMode(
643
835
  widths: WidthResult[] | undefined,
644
836
  overflow: OverflowResult | undefined,
645
837
  runts: RuntsResult | undefined,
838
+ layoutLint: LayoutLintResult | undefined,
839
+ hit: TargetSizeResult[] | undefined,
646
840
  devOverlay: DevOverlayResult | undefined,
647
841
  batchResult: BatchResult | undefined,
648
842
  ): Promise<void> {
@@ -667,6 +861,13 @@ async function runPrintMode(
667
861
  if (widths) result.width = widths;
668
862
  if (overflow) result.overflow = overflow;
669
863
  if (runts) result.runts = runts;
864
+ if (layoutLint) {
865
+ result.align = layoutLint.align;
866
+ result.gap = layoutLint.gap;
867
+ result.clip = layoutLint.clip;
868
+ result.overlap = layoutLint.overlap;
869
+ }
870
+ if (hit) result.hit = hit;
670
871
  if (devOverlay) result.devOverlay = devOverlay;
671
872
  if (batchResult && batchResult.clipboardReads.length > 0) {
672
873
  result.batchClipboardReads = batchResult.clipboardReads;
@@ -697,6 +898,8 @@ async function runTrioMode(
697
898
  widths: WidthResult[] | undefined,
698
899
  overflow: OverflowResult | undefined,
699
900
  runts: RuntsResult | undefined,
901
+ layoutLint: LayoutLintResult | undefined,
902
+ hit: TargetSizeResult[] | undefined,
700
903
  devOverlay: DevOverlayResult | undefined,
701
904
  batchResult: BatchResult | undefined,
702
905
  ): Promise<void> {
@@ -739,6 +942,13 @@ async function runTrioMode(
739
942
  if (widths) envelope.width = widths;
740
943
  if (overflow) envelope.overflow = overflow;
741
944
  if (runts) envelope.runts = runts;
945
+ if (layoutLint) {
946
+ envelope.align = layoutLint.align;
947
+ envelope.gap = layoutLint.gap;
948
+ envelope.clip = layoutLint.clip;
949
+ envelope.overlap = layoutLint.overlap;
950
+ }
951
+ if (hit) envelope.hit = hit;
742
952
  if (devOverlay) envelope.devOverlay = devOverlay;
743
953
  if (batchResult && batchResult.clipboardReads.length > 0) {
744
954
  envelope.batchClipboardReads = batchResult.clipboardReads;
@@ -845,6 +1055,18 @@ async function runTrioMode(
845
1055
  emit.log(`check-overflow: [${ok ? "OK" : "FAIL"}] ${detail}`, ok ? "info" : "warn");
846
1056
  }
847
1057
 
1058
+ if (layoutLint) {
1059
+ logLayoutLintSummary(layoutLint);
1060
+ }
1061
+ if (hit) {
1062
+ const lines = hit.map((result) => {
1063
+ const failed = result.nodes.filter((node) => node.outcome === "fail").length;
1064
+ const unknown = result.nodes.filter((node) => node.outcome === "unknown").length;
1065
+ return ` [${result.outcome.toUpperCase()}] ${result.selector ?? "document"}: ${failed} failed, ${unknown} unknown, ${result.nodes.length} evaluated (${result.minSizePx}px)`;
1066
+ });
1067
+ emit.log(`check-hit:\n${lines.join("\n")}`, "info");
1068
+ }
1069
+
848
1070
  if (savedBaseline) {
849
1071
  emit.log(
850
1072
  `baseline saved: ${savedBaseline.name} → ${savedBaseline.path} (${savedBaseline.bytes} bytes)`,
@@ -877,6 +1099,103 @@ async function runTrioMode(
877
1099
  // Helpers
878
1100
  // ---------------------------------------------------------------------------
879
1101
 
1102
+ function parseNonNegativeNumber(value: string, flag: string): number {
1103
+ const parsed = Number(value);
1104
+ if (!Number.isFinite(parsed) || parsed < 0) {
1105
+ throw new Error(`${flag} must be a finite non-negative number (got: ${value})`);
1106
+ }
1107
+ return parsed;
1108
+ }
1109
+
1110
+ function parseLayoutAxis(value: string, flag: string): LayoutAxis {
1111
+ if (value === "auto" || value === "x" || value === "y") return value;
1112
+ throw new Error(`${flag} must be auto, x, or y (got: ${value})`);
1113
+ }
1114
+
1115
+ function parseTargetSizeProfile(value: string): TargetSizeProfile {
1116
+ if (value === "wcag-aa" || value === "comfortable") return value;
1117
+ throw new Error(`--check-hit-profile must be wcag-aa or comfortable (got: ${value})`);
1118
+ }
1119
+
1120
+ function logLayoutLintSummary(result: LayoutLintResult): void {
1121
+ const lines: string[] = [];
1122
+ for (const check of result.align) {
1123
+ const worst = Math.max(
1124
+ 0,
1125
+ ...check.clusters.flatMap((cluster) =>
1126
+ cluster.children.map((child) => Math.abs(child.deltaPx)),
1127
+ ),
1128
+ );
1129
+ lines.push(
1130
+ ` [${check.outcome.toUpperCase()}] align ${check.selector}: worst ${worst.toFixed(1)}px (${check.axis})`,
1131
+ );
1132
+ }
1133
+ for (const check of result.gap) {
1134
+ const worst = Math.max(
1135
+ 0,
1136
+ ...check.clusters.flatMap((cluster) =>
1137
+ cluster.pairs.map((pair) => Math.abs(pair.deltaPx ?? 0)),
1138
+ ),
1139
+ );
1140
+ lines.push(
1141
+ ` [${check.outcome.toUpperCase()}] gap ${check.selector}: worst ${worst.toFixed(1)}px (${check.axis})`,
1142
+ );
1143
+ }
1144
+ for (const check of result.clip) {
1145
+ const worst = Math.max(0, ...check.issues.map((issue) => issue.maxOverrunPx));
1146
+ lines.push(
1147
+ ` [${check.outcome.toUpperCase()}] clip ${check.selector}: ${check.issues.length} issues, worst ${worst.toFixed(1)}px, ${check.unsupported.length} unknown shapes`,
1148
+ );
1149
+ }
1150
+ for (const check of result.overlap) {
1151
+ const worst = Math.max(0, ...check.issues.map((issue) => issue.areaPx));
1152
+ lines.push(
1153
+ ` [${check.outcome.toUpperCase()}] overlap ${check.selector}: ${check.issues.length} collisions, worst ${worst.toFixed(0)}px²`,
1154
+ );
1155
+ }
1156
+ if (lines.length > 0) emit.log(`layout-lint:\n${lines.join("\n")}`, "info");
1157
+ }
1158
+
1159
+ function applyLayoutLintFailGates(
1160
+ opts: BrowseOpts,
1161
+ layoutLint: LayoutLintResult | undefined,
1162
+ hit: TargetSizeResult[] | undefined,
1163
+ ): void {
1164
+ const gate = <T extends { selector: string; found: boolean; outcome: string }>(
1165
+ enabled: boolean | undefined,
1166
+ rule: string,
1167
+ results: T[] | undefined,
1168
+ ): void => {
1169
+ if (!enabled || !results) return;
1170
+ const failed = results.filter((result) => !result.found || result.outcome === "fail");
1171
+ for (const result of failed) {
1172
+ emit.log(
1173
+ `check-${rule} FAIL ${result.selector}: ${result.found ? "rendered relation failed" : "element not found"}`,
1174
+ "warn",
1175
+ );
1176
+ }
1177
+ if (failed.length > 0) process.exitCode = 2;
1178
+ };
1179
+
1180
+ gate(opts.checkAlignFail, "align", layoutLint?.align);
1181
+ gate(opts.checkGapFail, "gap", layoutLint?.gap);
1182
+ gate(opts.checkClipFail, "clip", layoutLint?.clip);
1183
+ gate(opts.checkOverlapFail, "overlap", layoutLint?.overlap);
1184
+
1185
+ if (opts.checkHitFail && hit) {
1186
+ const failed = hit.filter(
1187
+ (result) => !result.found || result.outcome === "fail" || result.outcome === "unknown",
1188
+ );
1189
+ for (const result of failed) {
1190
+ emit.log(
1191
+ `check-hit FAIL ${result.selector ?? "document"}: ${result.found ? result.outcome : "element not found"}`,
1192
+ "warn",
1193
+ );
1194
+ }
1195
+ if (failed.length > 0) process.exitCode = 2;
1196
+ }
1197
+ }
1198
+
880
1199
  function summarizeDiagnostics(diag: Diagnostics): Record<string, unknown> {
881
1200
  return {
882
1201
  viewport: diag.viewport,