agent-device-lab 0.0.0-stage → 0.3.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 (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +150 -2
  4. package/assets/dashboard/app.css +238 -0
  5. package/assets/dashboard/app.js +976 -0
  6. package/assets/dashboard/index.html +120 -0
  7. package/bin/agentlab.js +8 -0
  8. package/dist/cli/ci.js +443 -0
  9. package/dist/cli/flow.js +127 -0
  10. package/dist/cli/main.js +855 -0
  11. package/dist/cli/signals.js +38 -0
  12. package/dist/core/action-log.js +125 -0
  13. package/dist/core/auth.js +175 -0
  14. package/dist/core/bundle.js +432 -0
  15. package/dist/core/checks.js +137 -0
  16. package/dist/core/ci.js +389 -0
  17. package/dist/core/commands.js +366 -0
  18. package/dist/core/control.js +217 -0
  19. package/dist/core/detectors.js +392 -0
  20. package/dist/core/devices.js +83 -0
  21. package/dist/core/doctor.js +203 -0
  22. package/dist/core/explore-safety.js +110 -0
  23. package/dist/core/extract.js +1445 -0
  24. package/dist/core/feed.js +493 -0
  25. package/dist/core/findings.js +179 -0
  26. package/dist/core/format.js +311 -0
  27. package/dist/core/init.js +607 -0
  28. package/dist/core/lab.js +1979 -0
  29. package/dist/core/migrate.js +35 -0
  30. package/dist/core/observation.js +161 -0
  31. package/dist/core/ownership.js +142 -0
  32. package/dist/core/process-group.js +206 -0
  33. package/dist/core/process-identity.js +56 -0
  34. package/dist/core/profile.js +816 -0
  35. package/dist/core/project-runner.js +359 -0
  36. package/dist/core/replay.js +303 -0
  37. package/dist/core/reproduction.js +36 -0
  38. package/dist/core/scan-policy.js +156 -0
  39. package/dist/core/scan-report.js +145 -0
  40. package/dist/core/scan.js +455 -0
  41. package/dist/core/schema.js +39 -0
  42. package/dist/core/services.js +134 -0
  43. package/dist/core/steps.js +80 -0
  44. package/dist/core/sweep.js +138 -0
  45. package/dist/core/trace-sanitize.js +641 -0
  46. package/dist/core/url-redact.js +25 -0
  47. package/dist/core/versions.js +29 -0
  48. package/dist/core/zip.js +264 -0
  49. package/dist/daemon/main.js +160 -0
  50. package/dist/daemon/state.js +93 -0
  51. package/dist/dashboard/open.js +25 -0
  52. package/dist/dashboard/server.js +366 -0
  53. package/dist/mcp/server.js +89 -0
  54. package/package.json +63 -4
@@ -0,0 +1,392 @@
1
+ export const DETECTORS = {
2
+ 'horizontal-overflow': { version: 1, family: 'overflow', description: 'The document is wider than the viewport, so the page pans sideways.' },
3
+ 'control-clipped': { version: 1, family: 'offscreen', description: 'A control extends past the left or right edge of the viewport.' },
4
+ 'horizontal-pan-required': { version: 1, family: 'offscreen', description: 'Reaching a control needed a sideways pan of the view.' },
5
+ 'control-obstructed': { version: 2, family: 'covered', description: 'After scrolling a control into view (and on past a fixed bar), a pointer at its centre lands on another element.' },
6
+ 'container-clipped': { version: 3, family: 'clipped', description: 'A control or text is cut off by an ancestor with overflow hidden or clip.' },
7
+ 'text-clipped': { version: 3, family: 'clipped', description: 'Text overflows its own box, which hides the overflow without an ellipsis.' },
8
+ 'text-truncated': { version: 1, family: 'clipped', description: 'Text is shortened with an ellipsis or line clamp.' },
9
+ 'fixed-collision': { version: 1, family: 'covered', description: 'Two fixed or sticky layers overlap; no scrolling moves them apart.' },
10
+ 'content-under-fixed': { version: 1, family: 'covered', description: 'At the top or bottom of the page, content stays under a fixed or sticky bar.' },
11
+ 'modal-overflow': { version: 1, family: 'modal', description: 'An open dialog or drawer extends past the viewport and cannot be scrolled.' },
12
+ 'unreachable-content': { version: 2, family: 'clipped', description: 'Content lies before a scroll origin (left of or above it), where no scrolling can reveal it.' },
13
+ 'outside-container': { version: 1, family: 'overflow', description: 'A control sticks out of the visible box (border, background or shadow) that contains it.' },
14
+ 'content-scroll-x': { version: 2, family: 'scroll', description: 'An ordinary content region scrolls sideways, with nothing marking it as an intentional scroller.' },
15
+ 'tap-target': { version: 1, family: 'target-size', description: 'A target is smaller than the chosen WCAG target-size rule allows, after its exceptions.' },
16
+ 'layout-shift': { version: 1, family: 'shift', description: 'Content moved while a state settled, without recent input (Layout Instability API).' },
17
+ 'text-wrap-change': { version: 1, family: 'wrap', description: 'A control label is on one line at a width and on more lines at a nearby narrower width.' },
18
+ };
19
+ const r1 = (n) => Math.round(n * 10) / 10;
20
+ const pct = (n) => `${Math.round(n * 100)}%`;
21
+ const who = (el) => `${el.role} "${el.name || el.selector}"`;
22
+ export function targetOf(el) {
23
+ return { role: el.role, name: el.name || el.selector, ...(el.ref ? { ref: el.ref } : {}), selector: el.selector };
24
+ }
25
+ /** Every layout detector over one measured state. */
26
+ export function detectLayout(raw, opts) {
27
+ const on = opts.enabled;
28
+ const hits = [];
29
+ if (on('tap-target'))
30
+ hits.push(...tapTargetHits(raw.targets, opts.tapTargets));
31
+ if (on('container-clipped'))
32
+ for (const c of raw.clips) {
33
+ const h = clipHit(c);
34
+ if (h)
35
+ hits.push(h);
36
+ }
37
+ for (const t of raw.texts) {
38
+ const h = textHit(t);
39
+ if (h && on(h.kind))
40
+ hits.push(h);
41
+ }
42
+ if (on('fixed-collision'))
43
+ hits.push(...fixedCollisionHits(raw.fixed));
44
+ if (on('unreachable-content'))
45
+ for (const b of raw.beforeOrigin) {
46
+ const h = beforeOriginHit(b);
47
+ if (h)
48
+ hits.push(h);
49
+ }
50
+ if (on('outside-container'))
51
+ for (const o of raw.outside)
52
+ hits.push(outsideHit(o));
53
+ if (on('content-scroll-x'))
54
+ for (const s of raw.scrollers) {
55
+ const h = scrollerHit(s, raw.viewport.width);
56
+ if (h)
57
+ hits.push(h);
58
+ }
59
+ return hits;
60
+ }
61
+ // ---------- tap targets (WCAG 2.2) ----------
62
+ /**
63
+ * SC 2.5.8 Target Size (Minimum), level AA: a target is at least 24×24 CSS px, unless
64
+ * - spacing: a 24 px circle centred on its box intersects no other target and no other undersized
65
+ * target's circle;
66
+ * - inline: it sits in a sentence (its size is set by the line of text);
67
+ * - user agent: its size is set by the browser and not changed by the author.
68
+ * (The "equivalent" and "essential" exceptions cannot be measured and are not applied.)
69
+ * SC 2.5.5 Target Size (Enhanced), level AAA: at least 44×44, with the inline, user-agent (and
70
+ * unmeasurable) exceptions but no spacing exception.
71
+ */
72
+ export function tapTargetHits(targets, policy) {
73
+ const aaa = policy.standard === 'wcag22-aaa';
74
+ const min = aaa ? 44 : 24;
75
+ const radius = 12;
76
+ const centre = (b) => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 });
77
+ const small = (t) => t.box.w < min - 0.05 || t.box.h < min - 0.05;
78
+ const contains = (a, b) => a.x <= b.x + 0.5 && a.y <= b.y + 0.5 && a.x + a.w >= b.x + b.w - 0.5 && a.y + a.h >= b.y + b.h - 0.5;
79
+ const distToBox = (p, b) => Math.hypot(Math.max(b.x - p.x, 0, p.x - (b.x + b.w)), Math.max(b.y - p.y, 0, p.y - (b.y + b.h)));
80
+ const hits = [];
81
+ for (const t of targets) {
82
+ if (!small(t) || t.inline || t.userAgent)
83
+ continue;
84
+ const c = centre(t.box);
85
+ let conflict;
86
+ if (!aaa) {
87
+ for (const o of targets) {
88
+ if (o === t || contains(o.box, t.box) || contains(t.box, o.box))
89
+ continue;
90
+ const d = distToBox(c, o.box);
91
+ if (d < radius - 0.05) {
92
+ conflict = { other: o, distance: d, how: `its 24 px circle reaches ${who(o.el)} (${r1(d)} px from its centre)` };
93
+ break;
94
+ }
95
+ if (small(o) && !o.inline && !o.userAgent) {
96
+ const dc = Math.hypot(c.x - centre(o.box).x, c.y - centre(o.box).y);
97
+ if (dc < 2 * radius - 0.05) {
98
+ conflict = { other: o, distance: dc, how: `its 24 px circle overlaps the circle of ${who(o.el)} (centres ${r1(dc)} px apart; 24 px needed)` };
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ if (!conflict)
104
+ continue; // the spacing exception applies
105
+ }
106
+ const rule = aaa ? 'WCAG 2.2 SC 2.5.5 (AAA, 44 px)' : 'WCAG 2.2 SC 2.5.8 (AA, 24 px)';
107
+ hits.push({
108
+ kind: 'tap-target', severity: 'medium', confidence: 'confirmed', confidenceScore: 0.9, basis: ['standard'], target: targetOf(t.el),
109
+ message: `${who(t.el)} is ${r1(t.box.w)}×${r1(t.box.h)} px, under the ${min} px minimum of ${rule}` +
110
+ (conflict ? `, and the spacing exception does not apply: ${conflict.how}` : ''),
111
+ evidence: {
112
+ width: r1(t.box.w), height: r1(t.box.h), minimum: min, standard: policy.standard,
113
+ ...(conflict ? { nearest: who(conflict.other.el).slice(0, 80), distancePx: r1(conflict.distance) } : {}),
114
+ },
115
+ });
116
+ }
117
+ return hits;
118
+ }
119
+ // ---------- clipping ----------
120
+ function clipHit(c) {
121
+ if (c.visibleShare <= 0.001 || c.peek || c.moving || c.hiddenPx < 4)
122
+ return undefined; // hidden on purpose, a peeking carousel, content in motion, or a sub-pixel nick
123
+ const hidden = 1 - c.visibleShare;
124
+ const base = { kind: 'container-clipped', target: targetOf(c.el), basis: ['clipping'] };
125
+ const evidence = { hiddenPx: c.hiddenPx, visibleShare: c.visibleShare, clippedBy: c.clipper, width: r1(c.box.w), height: r1(c.box.h) };
126
+ if (c.el.control) {
127
+ if (!c.centreHit) {
128
+ return { ...base, severity: 'high', confidence: 'confirmed', confidenceScore: 0.95, basis: ['clipping', 'hit-test'], evidence,
129
+ message: `${who(c.el)} is cut off by ${c.clipper} (${pct(hidden)} hidden, ${c.hiddenPx} px); a pointer at its centre does not reach it` };
130
+ }
131
+ return { ...base, severity: hidden >= 0.1 ? 'medium' : 'low', confidence: 'confirmed', confidenceScore: 0.85, evidence,
132
+ message: `${who(c.el)} is partly cut off by ${c.clipper} (${pct(hidden)} hidden, ${c.hiddenPx} px)` };
133
+ }
134
+ if (hidden >= 0.1) {
135
+ return { ...base, severity: hidden >= 0.25 ? 'medium' : 'low', confidence: 'confirmed', confidenceScore: 0.85, evidence,
136
+ message: `text "${c.el.name}" is cut off by ${c.clipper} (${pct(hidden)} hidden, ${c.hiddenPx} px)` };
137
+ }
138
+ return { ...base, severity: 'low', confidence: 'heuristic', confidenceScore: 0.4, basis: ['geometry'], evidence,
139
+ message: `text "${c.el.name}" loses ${c.hiddenPx} px at the edge of ${c.clipper}` };
140
+ }
141
+ function textHit(t) {
142
+ if (t.moving && !t.ellipsis && !t.clamp)
143
+ return undefined; // a marquee or carousel in motion: what is cut changes every frame
144
+ const shown = t.clientSize / Math.max(1, t.scrollSize);
145
+ const evidence = { axis: t.axis, fullPx: t.scrollSize, shownPx: t.clientSize, shownShare: r1(shown * 100) / 100, alternative: t.alternative ? 'yes' : 'no' };
146
+ const label = t.control ? `the label of ${who(t.control)}` : t.heading ? `heading or label "${t.el.name}"` : `text "${t.el.name}"`;
147
+ const target = targetOf(t.control ?? t.el);
148
+ if (t.ellipsis || t.clamp) {
149
+ const important = !!t.control || t.heading;
150
+ if (important && !t.alternative) {
151
+ return { kind: 'text-truncated', severity: 'medium', confidence: 'confirmed', confidenceScore: 0.85, basis: ['clipping'], target, evidence,
152
+ message: `${label} is truncated with ${t.ellipsis ? 'an ellipsis' : 'a line clamp'} (shows ${t.clientSize} of ${t.scrollSize} px) and no title or accessible name gives the full text` };
153
+ }
154
+ return { kind: 'text-truncated', severity: 'low', confidence: 'heuristic', confidenceScore: 0.4, basis: ['geometry'], target, evidence,
155
+ message: `${label} is truncated (shows ${t.clientSize} of ${t.scrollSize} px)${t.alternative ? '; the full text is available as a title or accessible name' : ''}` };
156
+ }
157
+ if (t.axis === 'y' && !t.control && !t.heading) {
158
+ return { kind: 'text-clipped', severity: 'medium', confidence: 'heuristic', confidenceScore: 0.6, basis: ['geometry'], target, evidence,
159
+ message: `${label} is taller than its box (${t.scrollSize} px in ${t.clientSize} px) and the rest is hidden without a clamp or ellipsis` };
160
+ }
161
+ const hidden = 1 - shown;
162
+ return {
163
+ kind: 'text-clipped', severity: t.control && hidden >= 0.25 ? 'high' : 'medium', confidence: 'confirmed', confidenceScore: 0.9, basis: ['clipping'], target, evidence,
164
+ message: `${label} is cut off: ${t.scrollSize} px of text in a ${t.clientSize} px box that hides the rest without an ellipsis (${pct(hidden)} hidden)`,
165
+ };
166
+ }
167
+ // ---------- fixed layers ----------
168
+ function fixedCollisionHits(fixed) {
169
+ const layers = fixed.filter((f) => f.onScreen >= 0.5 && f.viewportShare < 0.6 && f.box.w * f.box.h >= 64);
170
+ const hits = [];
171
+ const seen = new Set();
172
+ for (const a of layers) {
173
+ for (const c of a.controls) {
174
+ if (!c.coveredBy)
175
+ continue;
176
+ const b = layers.find((l) => l !== a && l.el.selector === c.coveredBy) ?? fixed.find((l) => l.el.selector === c.coveredBy);
177
+ if (!b || b.viewportShare >= 0.6)
178
+ continue; // a backdrop under a dialog covers the page by design
179
+ const key = `${c.el.selector}|${c.el.name}`;
180
+ if (seen.has(key))
181
+ continue;
182
+ seen.add(key);
183
+ hits.push({
184
+ kind: 'fixed-collision', severity: 'high', confidence: 'confirmed', confidenceScore: 0.95, basis: ['hit-test'], target: targetOf(c.el),
185
+ message: `${who(c.el)} in the ${a.position} layer ${a.el.selector} is covered at its centre by the ${b.position} layer ${who(b.el)}; both stay put when the page scrolls, so a person cannot uncover it`,
186
+ evidence: { coveredBy: b.el.selector, layer: a.el.selector, targetLeft: r1(c.box.x), targetTop: r1(c.box.y), targetWidth: r1(c.box.w), targetHeight: r1(c.box.h) },
187
+ });
188
+ }
189
+ }
190
+ for (let i = 0; i < layers.length; i++) {
191
+ for (let j = i + 1; j < layers.length; j++) {
192
+ const a = layers[i];
193
+ const b = layers[j];
194
+ const ix = Math.max(0, Math.min(a.box.x + a.box.w, b.box.x + b.box.w) - Math.max(a.box.x, b.box.x));
195
+ const iy = Math.max(0, Math.min(a.box.y + a.box.h, b.box.y + b.box.h) - Math.max(a.box.y, b.box.y));
196
+ if (ix * iy < 64)
197
+ continue;
198
+ const smaller = a.box.w * a.box.h <= b.box.w * b.box.h ? a : b;
199
+ const other = smaller === a ? b : a;
200
+ if (hits.some((h) => h.evidence.layer === smaller.el.selector || h.evidence.layer === other.el.selector))
201
+ continue; // already confirmed by a hit test
202
+ const share = (ix * iy) / Math.max(1, smaller.box.w * smaller.box.h);
203
+ hits.push({
204
+ kind: 'fixed-collision', severity: share >= 0.25 ? 'medium' : 'low', confidence: 'heuristic', confidenceScore: 0.5, basis: ['geometry'], target: targetOf(smaller.el),
205
+ message: `the ${smaller.position} layer ${who(smaller.el)} overlaps the ${other.position} layer ${who(other.el)} by ${Math.round(ix)}×${Math.round(iy)} px (${pct(share)} of it); no control is covered at its centre`,
206
+ evidence: { overlapWith: other.el.selector, overlapPx: Math.round(ix * iy), overlapShare: r1(share * 100) / 100 },
207
+ });
208
+ }
209
+ }
210
+ return hits;
211
+ }
212
+ /** Content that stays under a fixed or sticky bar at the page's top or bottom (see coveredAtExtremes). */
213
+ export function coveredHits(covered) {
214
+ const hits = [];
215
+ for (const c of covered) {
216
+ const where = c.edge === 'bottom' ? 'at the bottom of the page' : 'at the top of the page';
217
+ const evidence = { bar: c.bar, edge: c.edge, coveredShare: c.share, targetTop: r1(c.box.y), targetHeight: r1(c.box.h) };
218
+ const base = { kind: 'content-under-fixed', target: targetOf(c.el), evidence };
219
+ if (c.el.control && c.centreCovered) {
220
+ hits.push({ ...base, severity: 'high', confidence: 'confirmed', confidenceScore: 0.95, basis: ['hit-test'],
221
+ message: `${who(c.el)} stays under the fixed bar ${c.bar} ${where}: the page cannot scroll further, so a pointer at its centre always lands on the bar` });
222
+ }
223
+ else if (!c.el.control && c.share >= 0.5) {
224
+ hits.push({ ...base, severity: 'medium', confidence: 'confirmed', confidenceScore: 0.85, basis: ['clipping'],
225
+ message: `text "${c.el.name}" stays ${pct(c.share)} hidden under the fixed bar ${c.bar} ${where}; no scroll position reveals it` });
226
+ }
227
+ else {
228
+ hits.push({ ...base, severity: 'low', confidence: 'heuristic', confidenceScore: 0.5, basis: ['geometry'],
229
+ message: `${c.el.control ? who(c.el) : `text "${c.el.name}"`} is ${pct(c.share)} under the fixed bar ${c.bar} ${where}` });
230
+ }
231
+ }
232
+ return hits;
233
+ }
234
+ /**
235
+ * An open dialog or drawer taller or wider than the viewport. Confirmed when a control inside it
236
+ * cannot be brought into view even after scrolling it (and its scroll containers) into view.
237
+ */
238
+ export function modalHits(modal, unreachable, viewport) {
239
+ if (!modal)
240
+ return [];
241
+ const evidence = { dialogBottom: r1(modal.box.y + modal.box.h), dialogRight: r1(modal.box.x + modal.box.w), viewportWidth: viewport.width, viewportHeight: viewport.height, overflowPx: modal.overflowPx, scrollable: modal.scrollable ? 'yes' : 'no' };
242
+ if (unreachable.length) {
243
+ const first = unreachable[0];
244
+ // The dialog is the problem (which controls fall off depends on the height), so it is the target.
245
+ return [{
246
+ kind: 'modal-overflow', severity: 'high', confidence: 'confirmed', confidenceScore: 0.95, basis: ['interaction'], target: targetOf(modal.el),
247
+ message: `${who(modal.el)} extends ${modal.overflowPx} px past the ${viewport.width}×${viewport.height} viewport; ${unreachable.length} control(s) inside it cannot be scrolled into view: ${unreachable.slice(0, 4).map((u) => who(u.el)).join(', ')}${unreachable.length > 4 ? ', …' : ''}`,
248
+ evidence: { ...evidence, unreachable: unreachable.length, firstUnreachable: who(first.el).slice(0, 80) },
249
+ }];
250
+ }
251
+ if (modal.overflowPx >= 4 && !modal.scrollable) {
252
+ return [{
253
+ kind: 'modal-overflow', severity: 'medium', confidence: 'heuristic', confidenceScore: 0.6, basis: ['geometry'], target: targetOf(modal.el),
254
+ message: `${who(modal.el)} extends ${modal.overflowPx} px past the ${viewport.width}×${viewport.height} viewport and has no scrollable area; every control inside was still reachable`,
255
+ evidence,
256
+ }];
257
+ }
258
+ return [];
259
+ }
260
+ function beforeOriginHit(b) {
261
+ if (b.hiddenPx < 4 || b.moving)
262
+ return undefined;
263
+ const evidence = { hiddenPx: b.hiddenPx, hiddenShare: b.share, container: b.container, left: r1(b.box.x), top: r1(b.box.y) };
264
+ const base = { kind: 'unreachable-content', target: targetOf(b.el), evidence };
265
+ const what = b.el.control ? who(b.el) : `text "${b.el.name}"`;
266
+ const msg = `${what} starts ${b.hiddenPx} px before the scroll origin of ${b.container} (${pct(b.share)} of it); no scrolling can reveal that part`;
267
+ if (b.el.control)
268
+ return { ...base, severity: b.centreHidden ? 'high' : 'medium', confidence: 'confirmed', confidenceScore: 0.9, basis: ['clipping'], message: msg };
269
+ if (b.share >= 0.25)
270
+ return { ...base, severity: 'medium', confidence: 'confirmed', confidenceScore: 0.85, basis: ['clipping'], message: msg };
271
+ return { ...base, severity: 'low', confidence: 'heuristic', confidenceScore: 0.5, basis: ['geometry'], message: msg };
272
+ }
273
+ function outsideHit(o) {
274
+ const worst = Math.max(o.overhang.top, o.overhang.right, o.overhang.bottom, o.overhang.left);
275
+ const side = Object.entries(o.overhang).sort((a, b) => b[1] - a[1])[0][0];
276
+ return {
277
+ kind: 'outside-container', severity: worst >= 16 ? 'medium' : 'low', confidence: 'heuristic', confidenceScore: 0.5, basis: ['geometry'], target: targetOf(o.el),
278
+ message: `${who(o.el)} sticks ${worst} px out of the ${side} of its container ${o.container}`,
279
+ evidence: { overhangPx: worst, side, container: o.container, containerWidth: r1(o.containerBox.w), width: r1(o.box.w) },
280
+ };
281
+ }
282
+ function scrollerHit(s, viewportWidth) {
283
+ const excess = s.scrollWidth - s.clientWidth;
284
+ if (s.intentional || excess < 4)
285
+ return undefined;
286
+ return {
287
+ kind: 'content-scroll-x', severity: s.paragraphs > 0 && excess >= s.clientWidth * 0.25 ? 'medium' : 'low', confidence: 'heuristic', confidenceScore: 0.4, basis: ['geometry'],
288
+ target: targetOf(s.el),
289
+ message: `${s.el.selector} scrolls sideways by ${excess} px (content ${s.scrollWidth} px in ${s.clientWidth} px) at a ${viewportWidth} px viewport, and nothing marks it as an intentional scroller (no carousel, list, table, tab or snap semantics)`,
290
+ evidence: { scrollWidth: s.scrollWidth, clientWidth: s.clientWidth, excessPx: excess, paragraphs: s.paragraphs },
291
+ };
292
+ }
293
+ /**
294
+ * Unexpected layout shifts while a state settled: the browser's layout-shift entries that had no
295
+ * recent input, summed. 0.1 is the Core Web Vitals boundary above which CLS is not "good".
296
+ */
297
+ export function shiftHits(entries, min, when) {
298
+ const unexpected = entries.filter((e) => !e.recentInput);
299
+ const score = unexpected.reduce((s, e) => s + e.value, 0);
300
+ if (score < min || !unexpected.length)
301
+ return [];
302
+ const sources = unexpected.flatMap((e) => e.sources);
303
+ const biggest = [...sources].sort((a, b) => Math.abs(b.to[1] - b.from[1]) + Math.abs(b.to[0] - b.from[0]) - (Math.abs(a.to[1] - a.from[1]) + Math.abs(a.to[0] - a.from[0])))[0];
304
+ const moved = sources.slice(0, 3).map((s) => `${s.selector}${s.name ? ` "${s.name}"` : ''} (${s.from[0]},${s.from[1]})→(${s.to[0]},${s.to[1]})`).join('; ');
305
+ const confirmed = score >= 0.1;
306
+ return [{
307
+ kind: 'layout-shift', severity: confirmed ? 'medium' : 'low', confidence: confirmed ? 'confirmed' : 'heuristic', confidenceScore: confirmed ? 0.85 : 0.5,
308
+ basis: ['browser-metric'],
309
+ // One problem per state and route, whichever element moved most at a given width.
310
+ target: { role: 'page', name: 'layout', ...(biggest ? { selector: biggest.selector } : {}) },
311
+ message: `content moved ${when} without recent input: layout-shift score ${score.toFixed(3)} over ${unexpected.length} shift(s)${confirmed ? ' (above the 0.1 Core Web Vitals threshold)' : ''}; moved: ${moved}`,
312
+ evidence: { score: Math.round(score * 1000) / 1000, shifts: unexpected.length, firstAtMs: unexpected[0].at },
313
+ }];
314
+ }
315
+ export const labelKey = (el) => `${el.role}\u0000${el.name}`;
316
+ export function matchesControl(m, el, route) {
317
+ const glob = (p, s) => new RegExp(`^${p.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`, 'i').test(s);
318
+ return (!m.role || m.role === el.role) && (!m.name || glob(m.name, el.name)) && (!m.route || !route || glob(m.route, route));
319
+ }
320
+ /**
321
+ * Compare label line counts between nearby widths (the wider at most `ratio` times the narrower). A
322
+ * label that wraps only at the narrower width is a heuristic warning unless it causes harm there:
323
+ * clipping or a hidden label, overlap with another control (functional), a control much taller than
324
+ * at the wider width or than the control beside it (cosmetic), or a declared no-wrap expectation.
325
+ */
326
+ export function wrapHits(samples, opts) {
327
+ const sorted = [...samples].sort((a, b) => a.width - b.width);
328
+ const out = [];
329
+ const done = new Set();
330
+ for (let i = 0; i < sorted.length - 1; i++) {
331
+ const n = sorted[i];
332
+ const w = sorted[i + 1];
333
+ if (w.width / n.width > opts.ratio)
334
+ continue;
335
+ const wide = new Map(w.labels.map((l) => [labelKey(l.el), l]));
336
+ for (const ln of n.labels) {
337
+ const lw = wide.get(labelKey(ln.el));
338
+ const key = `${n.device}|${labelKey(ln.el)}`;
339
+ if (!lw || ln.lines <= lw.lines || done.has(key))
340
+ continue;
341
+ done.add(key);
342
+ const heightRatio = ln.box.h / Math.max(1, lw.box.h);
343
+ const rowRatio = ln.rowMinH ? ln.box.h / Math.max(1, ln.rowMinH) : 1;
344
+ const harm = [];
345
+ let kind = 'none';
346
+ if (n.hiddenLabels.has(labelKey(ln.el)))
347
+ harm.push('its label is cut off or truncated');
348
+ if (ln.overlaps)
349
+ harm.push(`it overlaps ${ln.overlaps}`);
350
+ if (harm.length)
351
+ kind = 'functional';
352
+ const expected = opts.noWrap.find((m) => matchesControl(m, ln.el, opts.route));
353
+ if (kind === 'none' && expected) {
354
+ kind = 'expectation';
355
+ harm.push('the project expects this label never to wrap (scan.noWrap)');
356
+ }
357
+ const tall = heightRatio >= 1.35 && ln.box.h - lw.box.h >= 12;
358
+ const uneven = rowRatio >= 1.3;
359
+ if (kind === 'none' && (tall || uneven)) {
360
+ kind = 'cosmetic';
361
+ if (tall)
362
+ harm.push(`it grows from ${r1(lw.box.h)} to ${r1(ln.box.h)} px tall (${heightRatio.toFixed(2)}×)`);
363
+ if (uneven)
364
+ harm.push(`it is ${rowRatio.toFixed(2)}× the height of the control beside it (${r1(ln.rowMinH)} px)`);
365
+ }
366
+ const severity = kind === 'functional' || kind === 'expectation' ? 'medium' : 'low';
367
+ const confirmed = kind !== 'none';
368
+ const basis = kind === 'functional' ? ['comparison', 'clipping'] : ['comparison'];
369
+ const verdict = kind === 'functional' ? 'Functional harm' : kind === 'expectation' ? 'Violates a declared expectation' : kind === 'cosmetic' ? 'Cosmetic: nothing is hidden or covered' : 'No harm measured: wrapping by itself is not a defect';
370
+ const frames = [
371
+ ...(n.frame ? [{ label: `${n.width} px: ${ln.lines} lines`, path: n.frame, device: n.device }] : []),
372
+ ...(w.frame ? [{ label: `${w.width} px: ${lw.lines} line${lw.lines > 1 ? 's' : ''}`, path: w.frame, device: w.device }] : []),
373
+ ];
374
+ out.push({
375
+ device: n.device,
376
+ hit: {
377
+ kind: 'text-wrap-change', severity, confidence: confirmed ? 'confirmed' : 'heuristic', confidenceScore: kind === 'functional' ? 0.85 : kind === 'expectation' ? 0.9 : kind === 'cosmetic' ? 0.7 : 0.4,
378
+ basis, target: targetOf(ln.el), frames,
379
+ message: `${who(ln.el)} wraps to ${ln.lines} lines at ${n.width} px (${r1(ln.box.h)} px tall) but fits on ${lw.lines} line${lw.lines > 1 ? 's' : ''} at ${w.width} px (${r1(lw.box.h)} px). ` +
380
+ `${verdict}${harm.length ? `: ${harm.join('; ')}` : ''}`,
381
+ evidence: {
382
+ harm: kind, linesNarrow: ln.lines, linesWide: lw.lines, widthNarrow: n.width, widthWide: w.width, comparedWith: w.device,
383
+ heightNarrow: r1(ln.box.h), heightWide: r1(lw.box.h), controlWidthNarrow: r1(ln.box.w), controlWidthWide: r1(lw.box.w),
384
+ ...(ln.rowMinH ? { rowNeighbourHeight: r1(ln.rowMinH) } : {}),
385
+ },
386
+ },
387
+ });
388
+ }
389
+ }
390
+ return out;
391
+ }
392
+ //# sourceMappingURL=detectors.js.map
@@ -0,0 +1,83 @@
1
+ import { LabError } from './schema.js';
2
+ // Generic CSS-pixel profiles, deliberately not named after real handsets: this is Chromium
3
+ // device emulation, not a phone.
4
+ const ANDROID_CHROME_UA = 'Mozilla/5.0 (Linux; Android 14; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36';
5
+ const ANDROID_TABLET_UA = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36';
6
+ const DESKTOP_CHROME_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36';
7
+ export const DEVICES = {
8
+ 'mobile-320': {
9
+ id: 'mobile-320',
10
+ label: 'Small mobile 320x568 @2x, touch',
11
+ viewport: { width: 320, height: 568 },
12
+ deviceScaleFactor: 2,
13
+ isMobile: true,
14
+ hasTouch: true,
15
+ userAgent: ANDROID_CHROME_UA,
16
+ },
17
+ 'mobile-390': {
18
+ id: 'mobile-390',
19
+ label: 'Generic mobile 390x844 @3x, touch',
20
+ viewport: { width: 390, height: 844 },
21
+ deviceScaleFactor: 3,
22
+ isMobile: true,
23
+ hasTouch: true,
24
+ userAgent: ANDROID_CHROME_UA,
25
+ },
26
+ 'tablet-768': {
27
+ id: 'tablet-768',
28
+ label: 'Tablet 768x1024 @2x, touch',
29
+ viewport: { width: 768, height: 1024 },
30
+ deviceScaleFactor: 2,
31
+ isMobile: true,
32
+ hasTouch: true,
33
+ userAgent: ANDROID_TABLET_UA,
34
+ },
35
+ 'desktop-1440': {
36
+ id: 'desktop-1440',
37
+ label: 'Desktop 1440x900, mouse',
38
+ viewport: { width: 1440, height: 900 },
39
+ deviceScaleFactor: 1,
40
+ isMobile: false,
41
+ hasTouch: false,
42
+ userAgent: DESKTOP_CHROME_UA,
43
+ },
44
+ };
45
+ /** The responsive sweep's default widths, narrowest first. */
46
+ export const SWEEP_DEVICES = ['mobile-320', 'mobile-390', 'tablet-768', 'desktop-1440'];
47
+ /**
48
+ * A built-in profile, or one the project defines in agentlab.json `devices` (optionally extending a
49
+ * built-in). Project devices take precedence.
50
+ */
51
+ export function getDevice(id, custom = {}) {
52
+ const own = custom[id];
53
+ if (own) {
54
+ const base = own.extends ? DEVICES[own.extends] : undefined;
55
+ if (own.extends && !base) {
56
+ throw new LabError('unknown_device', `Device "${id}" extends unknown device "${own.extends}"`, { hint: `Built-in: ${Object.keys(DEVICES).join(', ')}` });
57
+ }
58
+ const viewport = own.viewport ?? base.viewport;
59
+ const isMobile = own.isMobile ?? base?.isMobile ?? false;
60
+ return {
61
+ id,
62
+ label: own.label ?? `${id} ${viewport.width}x${viewport.height}${base ? ` (from ${base.id})` : ''}`,
63
+ viewport,
64
+ deviceScaleFactor: own.deviceScaleFactor ?? base?.deviceScaleFactor ?? 1,
65
+ isMobile,
66
+ hasTouch: own.hasTouch ?? base?.hasTouch ?? isMobile,
67
+ userAgent: own.userAgent ?? base?.userAgent ?? (isMobile ? ANDROID_CHROME_UA : DESKTOP_CHROME_UA),
68
+ };
69
+ }
70
+ const device = DEVICES[id];
71
+ if (!device) {
72
+ throw new LabError('unknown_device', `Unknown device profile "${id}"`, {
73
+ hint: `Available: ${[...Object.keys(DEVICES), ...Object.keys(custom)].join(', ')}`,
74
+ });
75
+ }
76
+ return device;
77
+ }
78
+ export const EMULATION_LIMITATIONS = [
79
+ 'Chromium device emulation on the host OS: not a real phone, not iOS Safari or Android WebView',
80
+ 'No on-screen keyboard, native permission prompts or device hardware',
81
+ 'Touch events are synthesised by the browser, not produced by a touchscreen',
82
+ ];
83
+ //# sourceMappingURL=devices.js.map