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.
- package/README.md +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +187 -4
- package/dist/commands/supervisor.d.ts.map +1 -1
- package/dist/commands/supervisor.js +76 -4
- package/dist/core/supervisor/index.d.ts +3 -3
- package/dist/core/supervisor/index.d.ts.map +1 -1
- package/dist/core/supervisor/index.js +2 -2
- package/dist/core/supervisor/plan-read.d.ts +2 -1
- package/dist/core/supervisor/plan-read.d.ts.map +1 -1
- package/dist/core/supervisor/plan-read.js +222 -3
- package/dist/core/supervisor/plan-types.d.ts +60 -3
- package/dist/core/supervisor/plan-types.d.ts.map +1 -1
- package/dist/core/supervisor/plan-types.js +2 -0
- package/dist/core/supervisor/planning.d.ts.map +1 -1
- package/dist/core/supervisor/planning.js +804 -56
- package/dist/core/supervisor/read.d.ts +3 -3
- package/dist/core/supervisor/read.d.ts.map +1 -1
- package/dist/core/supervisor/read.js +2 -2
- package/dist/core/supervisor/runner.d.ts.map +1 -1
- package/dist/core/supervisor/runner.js +7 -2
- package/dist/core/supervisor/service.js +3 -0
- package/dist/core/supervisor/state.d.ts +17 -2
- package/dist/core/supervisor/state.d.ts.map +1 -1
- package/dist/core/supervisor/state.js +211 -33
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +24 -6
- package/dist/core/workflow/types.d.ts +6 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/validate.d.ts +3 -2
- package/dist/core/workflow/validate.d.ts.map +1 -1
- package/dist/core/workflow/validate.js +31 -3
- package/dist/lib/browser/client.d.ts +18 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +39 -0
- package/dist/lib/browser/geometry.d.ts +146 -0
- package/dist/lib/browser/geometry.d.ts.map +1 -0
- package/dist/lib/browser/geometry.js +525 -0
- package/dist/lib/browser/index.d.ts +2 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/target-size.d.ts +65 -0
- package/dist/lib/browser/target-size.d.ts.map +1 -0
- package/dist/lib/browser/target-size.js +108 -0
- package/package.json +7 -1
- package/src/commands/browse.ts +319 -0
- package/src/commands/supervisor.ts +92 -5
- package/src/core/supervisor/index.ts +17 -1
- package/src/core/supervisor/plan-read.ts +292 -2
- package/src/core/supervisor/plan-types.ts +69 -1
- package/src/core/supervisor/planning.ts +1012 -51
- package/src/core/supervisor/read.ts +16 -1
- package/src/core/supervisor/runner.ts +9 -2
- package/src/core/supervisor/service.ts +3 -0
- package/src/core/supervisor/state.ts +272 -31
- package/src/core/workflow/engine.ts +29 -6
- package/src/core/workflow/types.ts +6 -0
- package/src/core/workflow/validate.ts +34 -3
- package/src/lib/browser/client.ts +62 -0
- package/src/lib/browser/geometry.ts +695 -0
- package/src/lib/browser/index.ts +25 -0
- 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.
|
|
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",
|
package/src/commands/browse.ts
CHANGED
|
@@ -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,
|