@broberg/lens-daemon 0.0.0-stage → 0.1.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.
@@ -0,0 +1,951 @@
1
+ // F126.1 — DOM/geometry composition critic (the deterministic half).
2
+ //
3
+ // Lens proves an element is in the DOM (expectVisible/expectText) and that a
4
+ // surface matches a pixel baseline. Neither proves the layout HOLDS TOGETHER:
5
+ // nothing overlapping, occluding, clipped, off-screen, or unreadable. A new
6
+ // surface has no baseline at all, so first-render composition bugs are invisible.
7
+ //
8
+ // This engine answers "does this screen hold together?" with ZERO model calls —
9
+ // one in-page measurement pass via document.elementFromPoint + getBoundingClientRect
10
+ // + getComputedStyle. It is deterministic, millisecond-fast, names the exact
11
+ // element (data-testid + selector + box), and is always on. The driver bug — a ✕
12
+ // close-button painted ON TOP of a "shipped v0.2.0" badge, hiding its text, while
13
+ // Lens reported green — is caught here as an `occlusion` finding.
14
+ //
15
+ // Vision (F126.2) is the complement for what geometry can't express ("looks
16
+ // unbalanced / off-brand"). This file makes NO model calls.
17
+
18
+ import type { Page } from 'playwright';
19
+
20
+ export type CriticSeverity = 'high' | 'medium' | 'low';
21
+ export type CriticKind =
22
+ | 'occlusion'
23
+ | 'overlap'
24
+ | 'clipped'
25
+ | 'off-viewport'
26
+ | 'zero-size'
27
+ | 'contrast'
28
+ | 'wiggle'
29
+ | 'unseen-before-action'
30
+ | 'nothing-to-judge';
31
+
32
+ export interface CriticBox {
33
+ x: number;
34
+ y: number;
35
+ w: number;
36
+ h: number;
37
+ }
38
+
39
+ /** A reference to the element that is covering another (occlusion findings). */
40
+ export interface CriticRef {
41
+ testid: string | null;
42
+ selector: string;
43
+ tag: string;
44
+ label: string;
45
+ }
46
+
47
+ export interface DomFinding {
48
+ kind: CriticKind;
49
+ severity: CriticSeverity;
50
+ testid: string | null;
51
+ selector: string;
52
+ label: string;
53
+ box: CriticBox;
54
+ message: string;
55
+ /** Present on `occlusion` findings: what is painted on top. */
56
+ occludedBy?: CriticRef;
57
+ }
58
+
59
+ export interface DomCriticOptions {
60
+ /** Cap the scanned element set (bounds the O(n²) overlap pass). Default 1500. */
61
+ maxElements?: number;
62
+ /** Cap returned findings (highest-severity first). Default 120. */
63
+ maxFindings?: number;
64
+ }
65
+
66
+ /** Run the geometry critic against an already-navigated, settled page.
67
+ * Returns findings sorted high→low severity. Makes no model calls. */
68
+ export async function runDomCritic(page: Page, opts: DomCriticOptions = {}): Promise<DomFinding[]> {
69
+ const cfg = { maxElements: opts.maxElements ?? 1500 };
70
+ const raw = (await page.evaluate(inPageCritic, cfg).catch(() => [] as DomFinding[])) as DomFinding[];
71
+ const rank: Record<CriticSeverity, number> = { high: 0, medium: 1, low: 2 };
72
+ raw.sort((a, b) => rank[a.severity] - rank[b.severity]);
73
+ return raw.slice(0, opts.maxFindings ?? 120);
74
+ }
75
+
76
+ /** Rollup counts by severity — handy for the Review-AC gate (F126.4). */
77
+ export function summarizeFindings(findings: DomFinding[]): {
78
+ total: number;
79
+ high: number;
80
+ medium: number;
81
+ low: number;
82
+ } {
83
+ return {
84
+ total: findings.length,
85
+ high: findings.filter((f) => f.severity === 'high').length,
86
+ medium: findings.filter((f) => f.severity === 'medium').length,
87
+ low: findings.filter((f) => f.severity === 'low').length,
88
+ };
89
+ }
90
+
91
+ // ── The in-page measurement. Runs in the BROWSER (serialized by Playwright), so
92
+ // every helper is defined inside; no outer closure is available. ────────────
93
+
94
+ /* eslint-disable */
95
+ function inPageCritic(cfg: { maxElements: number }): DomFinding[] {
96
+ const W = window.innerWidth;
97
+ const H = window.innerHeight;
98
+ // The full scrollable page extent. An element below/beside the current fold
99
+ // but within this extent is reachable by scrolling (and shown whole in a
100
+ // fullPage capture), so it is NOT off-viewport — only elements off the
101
+ // reachable canvas are genuinely lost. F126.7.
102
+ // F126.8 — ask BOTH scrolling roots, because which one scrolls is a CSS choice.
103
+ //
104
+ // The original read only documentElement. Measured on xrt81.com 2026-08-17:
105
+ //
106
+ // window.innerHeight 800
107
+ // documentElement.scrollHeight 800 ← what we asked
108
+ // body.scrollHeight 3180 ← where the page scrolls
109
+ //
110
+ // With `html { overflow-y: auto }` the document element does not grow; the body
111
+ // does. So docH came out 800, every element below the fold had zero overlap with
112
+ // the "reachable canvas", and the guard concluded a perfectly ordinary long page
113
+ // was off-viewport. xrt81 got 4 high + 57 medium findings on a healthy landing
114
+ // page — 3 of the 4 high ones false, all 57 medium false — and a high finding
115
+ // folds the gate to fail. `mode:'fullPage'` did not help, and could not: the rule
116
+ // never consulted the capture mode, it consulted the wrong element.
117
+ //
118
+ // That is worse than a noisy check. A gate that goes red on a sound page teaches
119
+ // people the red means nothing, and the one REAL finding then arrives as item 1
120
+ // of 61 with 60 false. The opposite end of the same failure as under-reporting,
121
+ // and it destroys the same signal.
122
+ const docW = Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0, W);
123
+ const docH = Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0, H);
124
+ const findings: DomFinding[] = [];
125
+
126
+ const INTERACTIVE_SEL =
127
+ 'button,a[href],input:not([type=hidden]),select,textarea,[role=button],[role=link],' +
128
+ '[role=checkbox],[role=switch],[role=tab],[role=menuitem],[contenteditable=true],' +
129
+ '[tabindex]:not([tabindex="-1"])';
130
+ const OVERLAY_ROLES = new Set(['dialog', 'alertdialog', 'tooltip', 'menu', 'listbox']);
131
+
132
+ const isHidden = (el: Element, cs: CSSStyleDeclaration): boolean =>
133
+ cs.display === 'none' ||
134
+ cs.visibility === 'hidden' ||
135
+ cs.visibility === 'collapse' ||
136
+ parseFloat(cs.opacity || '1') < 0.02 ||
137
+ el.getAttribute('aria-hidden') === 'true' ||
138
+ (el as HTMLElement).hidden === true ||
139
+ // Not rendered at all — e.g. a descendant of a display:none ancestor, whose
140
+ // OWN computed display is still block but which is laid out nowhere. Without
141
+ // this it slips past the own-display check below and is mis-flagged as a
142
+ // high-severity zero-size defect (false RED). F126.6.
143
+ el.getClientRects().length === 0;
144
+
145
+ /** F074.35 — effectively invisible because an ANCESTOR is transparent.
146
+ *
147
+ * isHidden already tests opacity, but on the element's OWN computed style —
148
+ * and CSS opacity does NOT inherit, so a child of an opacity:0 parent
149
+ * computes to 1 and sails past it. fd-sundhed's mobile compact bar is exactly
150
+ * that: `fixed top-0` with `opacity-0 pointer-events-none` until you scroll,
151
+ * and two of its children were reported as covered by the visible burger menu
152
+ * on top of them (#18609). Nothing is taken from a user who cannot see it. */
153
+ const isTransparentThroughChain = (el: Element): boolean => {
154
+ let n: Element | null = el;
155
+ for (let d = 0; n && d < 12; d++) {
156
+ if (parseFloat(getComputedStyle(n).opacity || '1') < 0.02) return true;
157
+ n = n.parentElement;
158
+ }
159
+ return false;
160
+ };
161
+
162
+ /** F074.35 — nothing to lose behind this element.
163
+ *
164
+ * Two rules with DIFFERENT scopes, deliberately, rather than one blanket skip:
165
+ * invisible means nothing to read; unreachable means nothing to tap. A fully
166
+ * visible paragraph with pointer-events:none that is covered still robs the
167
+ * reader, so unreachability alone must not exempt text.
168
+ *
169
+ * fd-sundhed's judgement, worth keeping: pointer-events is the strongest of
170
+ * the signals, because it is the only one that directly says "a tap here does
171
+ * not reach me" — opacity 0 can be one frame of a fade that is tappable a
172
+ * moment later. Their bar carries both, so their data cannot separate them. */
173
+ const nothingToLose = (el: Element, cs: CSSStyleDeclaration, interactive: boolean): boolean =>
174
+ isTransparentThroughChain(el) || (interactive && cs.pointerEvents === 'none');
175
+
176
+ // Walk up: is this element (or an ancestor) an intentionally-stacked layer —
177
+ // a fixed/sticky bar, a modal, a tooltip, a menu? Those legitimately cover
178
+ // content; never flag them or treat them as bogus occluders.
179
+ const isIntentionalOverlay = (el: Element | null): boolean => {
180
+ let n: Element | null = el;
181
+ while (n && n !== document.body && n !== document.documentElement) {
182
+ const cs = getComputedStyle(n);
183
+ if (cs.position === 'fixed' || cs.position === 'sticky') return true;
184
+ const role = n.getAttribute('role');
185
+ if (role && OVERLAY_ROLES.has(role)) return true;
186
+ if (n.getAttribute('aria-modal') === 'true') return true;
187
+ if (n.hasAttribute('data-overlay')) return true;
188
+ n = n.parentElement;
189
+ }
190
+ return false;
191
+ };
192
+
193
+ const isInteractive = (el: Element): boolean => el.matches(INTERACTIVE_SEL);
194
+
195
+ // Element owns visible text if it has a direct non-whitespace text node.
196
+ const ownsText = (el: Element): boolean => {
197
+ for (const node of Array.from(el.childNodes)) {
198
+ if (node.nodeType === 3 && (node.textContent || '').trim().length > 0) return true;
199
+ }
200
+ return false;
201
+ };
202
+
203
+ const labelOf = (el: Element): string => {
204
+ const tid = el.getAttribute('data-testid');
205
+ if (tid) return tid;
206
+ const txt = (el.textContent || '').trim().replace(/\s+/g, ' ');
207
+ if (txt) return txt.slice(0, 40);
208
+ const id = (el as HTMLElement).id;
209
+ if (id) return '#' + id;
210
+ return el.tagName.toLowerCase();
211
+ };
212
+
213
+ /** One step of a positional path: tag, first class, and an nth-of-type index
214
+ * when the parent holds more than one of that tag. */
215
+ const step = (el: Element): string => {
216
+ const tag = el.tagName.toLowerCase();
217
+ const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean)[0];
218
+ const base = cls ? `${tag}.${cls}` : tag;
219
+ const parent = el.parentElement;
220
+ if (!parent) return base;
221
+ const sameTag = Array.from(parent.children).filter(
222
+ (c) => c.tagName === el.tagName,
223
+ );
224
+ if (sameTag.length < 2) return base;
225
+ return `${base}:nth-of-type(${sameTag.indexOf(el) + 1})`;
226
+ };
227
+
228
+ /** How a finding names its element.
229
+ *
230
+ * F074.33: this used to fall back to the BARE TAG. A finding could read
231
+ * '"a" is covered by "svg"' with no way to tell WHICH a and WHICH svg — two
232
+ * unrelated links produced identical selectors, so a reader reasonably
233
+ * assumed the nesting visible in their own markup and went looking at the
234
+ * wrong element (fd-sundhed, #18602, twice). An unidentifiable finding is
235
+ * worse than no finding: it costs someone an investigation and then costs
236
+ * the tool its credibility.
237
+ *
238
+ * A testid or id still wins outright — they are stable and human-meaningful.
239
+ * Otherwise build a short positional path, which is ugly but unambiguous. */
240
+ const selectorOf = (el: Element): string => {
241
+ const tid = el.getAttribute('data-testid');
242
+ if (tid) return `[data-testid="${tid}"]`;
243
+ const id = (el as HTMLElement).id;
244
+ if (id) return `#${id}`;
245
+ const parts: string[] = [step(el)];
246
+ let n = el.parentElement;
247
+ // Up to three ancestors, stopping early at a testid/id — that anchor is far
248
+ // more useful to a human than another two tag names.
249
+ for (let d = 0; n && d < 3 && n.tagName !== 'BODY'; d++) {
250
+ const anchorTid = n.getAttribute('data-testid');
251
+ const anchorId = (n as HTMLElement).id;
252
+ if (anchorTid) {
253
+ parts.unshift(`[data-testid="${anchorTid}"]`);
254
+ break;
255
+ }
256
+ if (anchorId) {
257
+ parts.unshift(`#${anchorId}`);
258
+ break;
259
+ }
260
+ parts.unshift(step(n));
261
+ n = n.parentElement;
262
+ }
263
+ return parts.join(' > ');
264
+ };
265
+
266
+ const refOf = (el: Element): CriticRef => ({
267
+ testid: el.getAttribute('data-testid'),
268
+ selector: selectorOf(el),
269
+ tag: el.tagName.toLowerCase(),
270
+ label: labelOf(el),
271
+ });
272
+
273
+ const boxOf = (r: DOMRect): CriticBox => ({
274
+ x: Math.round(r.left),
275
+ y: Math.round(r.top),
276
+ w: Math.round(r.width),
277
+ h: Math.round(r.height),
278
+ });
279
+
280
+ // Off-screen positioning is often an intentional scroll/overflow container
281
+ // (carousel, virtualized list, off-canvas drawer). If an ancestor clips on the
282
+ // axis the element sits outside of, the element is deliberately out-of-view.
283
+ const isScrollClipped = (el: Element, r: DOMRect): boolean => {
284
+ let n = el.parentElement;
285
+ while (n && n !== document.body) {
286
+ const cs = getComputedStyle(n);
287
+ const clipsX = cs.overflowX === 'auto' || cs.overflowX === 'scroll' || cs.overflowX === 'hidden';
288
+ const clipsY = cs.overflowY === 'auto' || cs.overflowY === 'scroll' || cs.overflowY === 'hidden';
289
+ if (clipsX || clipsY) {
290
+ const a = n.getBoundingClientRect();
291
+ const cx = r.left + r.width / 2;
292
+ const cy = r.top + r.height / 2;
293
+ if (clipsX && (cx < a.left - 1 || cx > a.right + 1)) return true;
294
+ if (clipsY && (cy < a.top - 1 || cy > a.bottom + 1)) return true;
295
+ }
296
+ n = n.parentElement;
297
+ }
298
+ return false;
299
+ };
300
+
301
+ // The main document itself scrolls (a long page, or a fullPage capture) — an
302
+ // element within the page's scrollable extent is reachable, not off-viewport.
303
+ // isScrollClipped only covers nested scroll containers; this covers the page. F126.7.
304
+ const isReachableByPageScroll = (r: DOMRect): boolean => {
305
+ const dl = r.left + window.scrollX;
306
+ const dt = r.top + window.scrollY;
307
+ const dr = r.right + window.scrollX;
308
+ const db = r.bottom + window.scrollY;
309
+ const ix = Math.max(0, Math.min(dr, docW) - Math.max(dl, 0));
310
+ const iy = Math.max(0, Math.min(db, docH) - Math.max(dt, 0));
311
+ const area = r.width * r.height;
312
+ return area > 0 && (ix * iy) / area >= 0.25;
313
+ };
314
+
315
+ /** F126.22 — the part of the page an element can actually be SEEN through:
316
+ * the viewport intersected with every ancestor that clips on that axis
317
+ * (overflow auto / scroll / hidden / clip). A point outside it is scrolled
318
+ * out of view, not covered — elementFromPoint there hits whatever is behind. */
319
+ const clipCache = new Map<Element, { l: number; t: number; r: number; b: number }>();
320
+ const clipOf = (el: Element) => {
321
+ const hit = clipCache.get(el);
322
+ if (hit) return hit;
323
+ let l = 0, t = 0, r = W, b = H;
324
+ const clips = (v: string) => v === 'auto' || v === 'scroll' || v === 'hidden' || v === 'clip';
325
+ for (let p = el.parentElement; p && p !== document.documentElement; p = p.parentElement) {
326
+ const pcs = getComputedStyle(p);
327
+ const cx = clips(pcs.overflowX), cy = clips(pcs.overflowY);
328
+ if (!cx && !cy) continue;
329
+ const pr = p.getBoundingClientRect();
330
+ if (cx) { l = Math.max(l, pr.left); r = Math.min(r, pr.right); }
331
+ if (cy) { t = Math.max(t, pr.top); b = Math.min(b, pr.bottom); }
332
+ }
333
+ const out = { l, t, r, b };
334
+ clipCache.set(el, out);
335
+ return out;
336
+ };
337
+
338
+ // ── Collect candidates ──────────────────────────────────────────────────
339
+ interface Cand {
340
+ el: Element;
341
+ r: DOMRect;
342
+ cs: CSSStyleDeclaration;
343
+ interactive: boolean;
344
+ text: boolean;
345
+ }
346
+ const cands: Cand[] = [];
347
+ const all = Array.from(document.querySelectorAll('body *')).slice(0, cfg.maxElements);
348
+ for (const el of all) {
349
+ const interactive = isInteractive(el);
350
+ const text = ownsText(el);
351
+ if (!interactive && !text) continue;
352
+ const cs = getComputedStyle(el);
353
+ const r = el.getBoundingClientRect();
354
+ cands.push({ el, r, cs, interactive, text });
355
+ }
356
+
357
+ // ── F074.31 — text placed just before an ALWAYS-VISIBLE control, rendered
358
+ // below the fold. DOM order is not visual order when the control is fixed
359
+ // or sticky: an author who writes the guidance immediately before the
360
+ // approve button has stated an intent the layout then breaks, and the user
361
+ // can act without ever reading it (fd-sundhed, 2026-08-04 — a manager could
362
+ // press Approve having never seen the guidance; their own
363
+ // indexOf(box) < indexOf(button) test was green throughout).
364
+ //
365
+ // Deliberately NOT the broader rule that was proposed ("if any fixed/sticky
366
+ // control is on screen, stop waiving below-fold findings on this page") —
367
+ // that flags every below-fold element on any long page with a sticky nav,
368
+ // and noise that trains people to ignore the critic costs more than the bug.
369
+ // The DOM adjacency is what makes this specific and what carries the intent.
370
+ const inViewport = (r: DOMRect): boolean => {
371
+ const ix = Math.max(0, Math.min(r.right, W) - Math.max(r.left, 0));
372
+ const iy = Math.max(0, Math.min(r.bottom, H) - Math.max(r.top, 0));
373
+ return r.width > 0 && r.height > 0 && (ix * iy) / (r.width * r.height) >= 0.5;
374
+ };
375
+
376
+ /** A control is "always visible" when IT or an ancestor is fixed/sticky — in
377
+ * real markup the buttons sit INSIDE a fixed action bar, so checking only the
378
+ * button itself finds nothing (it is position:static). The bar is also the
379
+ * right anchor for adjacency: the guidance is written before the BAR. */
380
+ const stickyHost = (el: Element): { host: Element; pos: string } | null => {
381
+ let n: Element | null = el;
382
+ for (let d = 0; n && d < 6; d++) {
383
+ const pos = getComputedStyle(n).position;
384
+ if (pos === 'fixed' || pos === 'sticky') return { host: n, pos };
385
+ n = n.parentElement;
386
+ }
387
+ return null;
388
+ };
389
+
390
+ /** What the author wrote IMMEDIATELY before this: its previous sibling, and its
391
+ * parent's. Only the immediate one — "just above the button" is the intent we
392
+ * can read from markup; anything looser starts matching unrelated content that
393
+ * merely happens to appear earlier in the document. */
394
+ const domPredecessors = (start: Element): Element[] => {
395
+ const out: Element[] = [];
396
+ let node: Element | null = start;
397
+ for (let depth = 0; node && depth < 2; depth++) {
398
+ if (node.previousElementSibling) out.push(node.previousElementSibling);
399
+ node = node.parentElement;
400
+ }
401
+ return out;
402
+ };
403
+
404
+ const pushed = new Set<string>();
405
+ const push = (f: DomFinding) => {
406
+ const key = `${f.kind}|${f.selector}|${f.occludedBy?.selector ?? ''}`;
407
+ if (pushed.has(key)) return;
408
+ pushed.add(key);
409
+ findings.push(f);
410
+ };
411
+
412
+ // F074.31 — run before the per-element checks so the finding is reported
413
+ // against the CONTROL that stays visible, which is what a reader looks for.
414
+ const visibleTextCands = cands.filter((c) => c.text && !isHidden(c.el, c.cs));
415
+ for (const c of cands) {
416
+ if (!c.interactive || isHidden(c.el, c.cs)) continue;
417
+ // "Always visible" = it, or the bar it sits in, does not scroll away.
418
+ const anchored = stickyHost(c.el);
419
+ if (!anchored) continue;
420
+ const pos = anchored.pos;
421
+ if (!inViewport(c.r)) continue;
422
+
423
+ for (const p of domPredecessors(anchored.host)) {
424
+ const hit = visibleTextCands.find((t2) => t2.el === p || p.contains(t2.el));
425
+ if (!hit) continue;
426
+ // A whole page section is not "the guidance right above the button".
427
+ if (hit.r.height > H * 1.5) continue;
428
+ // The whole signal is "DOM adjacency implied visual adjacency, and the
429
+ // layout broke that". The implication only exists while the text is in
430
+ // NORMAL FLOW — an absolutely/fixed-positioned block was explicitly placed
431
+ // somewhere, so its position in the markup promised nothing and there is no
432
+ // contradicted intent. Without this, any fixed widget whose preceding
433
+ // sibling happens to be a positioned block far down the page fires.
434
+ const hp = hit.cs.position;
435
+ if (hp === 'absolute' || hp === 'fixed') continue;
436
+ // Below the fold specifically — scrolled-past content is a different case.
437
+ if (inViewport(hit.r) || hit.r.top < H * 0.9) continue;
438
+ push({
439
+ kind: 'unseen-before-action',
440
+ severity: 'high',
441
+ testid: hit.el.getAttribute('data-testid'),
442
+ selector: selectorOf(hit.el),
443
+ label: labelOf(hit.el),
444
+ box: boxOf(hit.r),
445
+ message:
446
+ `"${labelOf(hit.el)}" is written immediately before "${labelOf(c.el)}" in the DOM, but that ` +
447
+ `${pos} control is on screen while this text is ${Math.round(hit.r.top - H)}px below the fold — ` +
448
+ 'the user can act without ever reading it.',
449
+ });
450
+ break; // one finding per always-visible control
451
+ }
452
+ }
453
+
454
+ // ── Per-element checks ──────────────────────────────────────────────────
455
+ for (const c of cands) {
456
+ const { el, r, cs } = c;
457
+
458
+ // Intentionally-hidden or not-rendered elements (incl. descendants of a
459
+ // display:none ancestor) are excluded BEFORE any severity is assigned —
460
+ // otherwise a laid-out-nowhere control is mis-flagged as a zero-size defect
461
+ // (the false RED this story fixes). F126.6.
462
+ if (isHidden(el, cs)) continue;
463
+
464
+ // zero-size interactive — rendered (in layout) but unclickable.
465
+ if (c.interactive && (r.width < 2 || r.height < 2)) {
466
+ push({
467
+ kind: 'zero-size',
468
+ severity: 'high',
469
+ testid: el.getAttribute('data-testid'),
470
+ selector: selectorOf(el),
471
+ label: labelOf(el),
472
+ box: boxOf(r),
473
+ message: `Interactive element "${labelOf(el)}" has ~zero size (${Math.round(r.width)}×${Math.round(r.height)}px) — present but unusable.`,
474
+ });
475
+ continue; // a 0-size element can't meaningfully overlap/occlude
476
+ }
477
+
478
+ if (r.width <= 0 || r.height <= 0) continue;
479
+
480
+ // off-viewport — substantially outside the viewport, not in a scroll container.
481
+ const ix = Math.max(0, Math.min(r.right, W) - Math.max(r.left, 0));
482
+ const iy = Math.max(0, Math.min(r.bottom, H) - Math.max(r.top, 0));
483
+ const onScreen = (ix * iy) / (r.width * r.height);
484
+ if (
485
+ onScreen < 0.25 &&
486
+ !isReachableByPageScroll(r) &&
487
+ !isScrollClipped(el, r) &&
488
+ !isIntentionalOverlay(el)
489
+ ) {
490
+ // F126.18 — SAY WHAT DECIDED IT. The gate above is `!isReachableByPageScroll`,
491
+ // which compares the element against the scrollable canvas (docW/docH, the
492
+ // documentElement ∪ body union F126.9 had to introduce). "Outside the viewport"
493
+ // is equally true of anything below the fold, so a reader who trusts the words
494
+ // reads a real finding as a fold — helpdesk did exactly that on 20 Sep 2026,
495
+ // waived it with evidence, then had to retract and correct two review rows on
496
+ // closed cards. The measurement was in hand and thrown away.
497
+ //
498
+ // Page coordinates, same basis as isReachableByPageScroll.
499
+ const pastRight = Math.round(r.right + window.scrollX - docW);
500
+ const pastBottom = Math.round(r.bottom + window.scrollY - docH);
501
+ const pastLeft = Math.round(0 - (r.left + window.scrollX));
502
+ const pastTop = Math.round(0 - (r.top + window.scrollY));
503
+ // Report the axis actually violated — a horizontally unreachable element must
504
+ // not be described with a vertical number.
505
+ const axes: Array<[number, string]> = [
506
+ [pastBottom, `its bottom is ${pastBottom}px past the scrollable canvas (${Math.round(r.bottom + window.scrollY)} vs ${Math.round(docH)})`],
507
+ [pastTop, `its top is ${pastTop}px above the scrollable canvas (${Math.round(r.top + window.scrollY)} vs 0)`],
508
+ [pastRight, `its right edge is ${pastRight}px past the scrollable canvas (${Math.round(r.right + window.scrollX)} vs ${Math.round(docW)})`],
509
+ [pastLeft, `its left edge is ${pastLeft}px before the scrollable canvas (${Math.round(r.left + window.scrollX)} vs 0)`],
510
+ ];
511
+ const worst = axes.reduce((a, b) => (b[0] > a[0] ? b : a));
512
+ // The deciding fact first; the percentage stays because it is true and useful,
513
+ // just not what gated the finding.
514
+ const why = worst[0] > 0
515
+ ? `cannot be reached by scrolling — ${worst[1]}`
516
+ : `cannot be reached by scrolling — less than 25% of it lies within the scrollable canvas (${Math.round(docW)}×${Math.round(docH)})`;
517
+ push({
518
+ kind: 'off-viewport',
519
+ severity: c.interactive ? 'high' : 'medium',
520
+ testid: el.getAttribute('data-testid'),
521
+ selector: selectorOf(el),
522
+ label: labelOf(el),
523
+ box: boxOf(r),
524
+ message: `${c.interactive ? 'Interactive element' : 'Content'} "${labelOf(el)}" ${why}. ${Math.round((1 - onScreen) * 100)}% outside the viewport.`,
525
+ });
526
+ }
527
+
528
+ // clipped text — hard clip with no ellipsis affordance (ellipsis = intentional).
529
+ if (c.text) {
530
+ const he = el as HTMLElement;
531
+ const overflowsX = he.scrollWidth > he.clientWidth + 1;
532
+ const overflowsY = he.scrollHeight > he.clientHeight + 1;
533
+ const hardClip =
534
+ (cs.overflowX === 'hidden' || cs.overflowX === 'clip' || cs.overflowY === 'hidden' || cs.overflowY === 'clip') &&
535
+ cs.textOverflow !== 'ellipsis';
536
+ if ((overflowsX || overflowsY) && hardClip) {
537
+ push({
538
+ kind: 'clipped',
539
+ severity: c.interactive ? 'high' : 'medium',
540
+ testid: el.getAttribute('data-testid'),
541
+ selector: selectorOf(el),
542
+ label: labelOf(el),
543
+ box: boxOf(r),
544
+ message: `Text in "${labelOf(el)}" is clipped (content ${overflowsX ? he.scrollWidth + '>' + he.clientWidth + 'px wide' : he.scrollHeight + '>' + he.clientHeight + 'px tall'}) with no ellipsis.`,
545
+ });
546
+ }
547
+ }
548
+
549
+ // contrast — text colour vs a resolvable SOLID background (skip gradients/images).
550
+ //
551
+ // ONE EXEMPTION, and it is WCAG's own, not ours (F280.16). SC 1.4.3 says in
552
+ // as many words: "Text that is part of a logo or brand name has no minimum
553
+ // contrast requirement." A wordmark is a picture that happens to be made of
554
+ // letters — its colours ARE the brand, and darkening them to pass a ratio
555
+ // changes the logo rather than fixing a readability problem.
556
+ //
557
+ // Deliberately NARROW so it cannot become a mute button:
558
+ // · opt-in per element, never inferred from a class name or a testid;
559
+ // · the value must be exactly "logotype" — any other value is ignored, so
560
+ // `data-contrast-exempt="true"` does nothing;
561
+ // · it exempts CONTRAST ONLY. Clipping, occlusion and the wiggle rule
562
+ // still apply to a logo, because those are not about the standard's
563
+ // logotype carve-out.
564
+ // It uses closest() because a wordmark is usually a link wrapping spans, and
565
+ // the finding lands on the innermost text node.
566
+ if (c.text && !el.closest('[data-contrast-exempt="logotype"]')) {
567
+ const fg = parseRGB(cs.color);
568
+ if (fg && fg.a >= 0.999 && !(el as HTMLElement).matches(':disabled') && el.getAttribute('aria-disabled') !== 'true') {
569
+ const bg = resolveSolidBg(el);
570
+ if (bg) {
571
+ const ratio = contrast(fg, bg);
572
+ const size = parseFloat(cs.fontSize || '16');
573
+ const bold = parseInt(cs.fontWeight || '400', 10) >= 700;
574
+ const large = size >= 24 || (size >= 18.66 && bold);
575
+ const min = large ? 3.0 : 4.5;
576
+ if (ratio < min) {
577
+ push({
578
+ kind: 'contrast',
579
+ severity: ratio < min - 1 ? 'high' : 'medium',
580
+ testid: el.getAttribute('data-testid'),
581
+ selector: selectorOf(el),
582
+ label: labelOf(el),
583
+ box: boxOf(r),
584
+ message: `Low contrast on "${labelOf(el)}": ${ratio.toFixed(2)}:1 (WCAG AA needs ${min}:1).`,
585
+ });
586
+ }
587
+ }
588
+ }
589
+ }
590
+
591
+ // occlusion — sample points; is the element's own area covered by a
592
+ // non-overlay, non-relative element? (elementFromPoint ignores
593
+ // pointer-events:none, so decorative overlays don't false-positive.)
594
+ const samples: Array<[number, number, boolean]> = [
595
+ [r.left + r.width / 2, r.top + r.height / 2, true], // centre (weighted)
596
+ [r.left + r.width * 0.2, r.top + r.height * 0.3, false],
597
+ [r.left + r.width * 0.8, r.top + r.height * 0.3, false],
598
+ [r.left + r.width * 0.2, r.top + r.height * 0.7, false],
599
+ [r.left + r.width * 0.8, r.top + r.height * 0.7, false],
600
+ ];
601
+ // F126.22 — a point SCROLLED OUT of a clipping ancestor is not covered, it is
602
+ // out of view: elementFromPoint there hits whatever is painted behind the
603
+ // scroller and reads as occlusion. Measured on a deliberate springlist
604
+ // (overflow-y:auto, max-height:60vh): items 26–31 below the fold gave 15 high
605
+ // findings on a list that looked right. So the sampled area is the element
606
+ // INTERSECTED with every clipping ancestor, the same way the viewport already is.
607
+ const { l: clipL, t: clipT, r: clipR, b: clipB } = clipOf(el);
608
+ let centreCovered = false;
609
+ let edgeCovered = 0;
610
+ let occluder: Element | null = null;
611
+ for (const [sx, sy, isCentre] of samples) {
612
+ if (sx < 0 || sy < 0 || sx > W || sy > H) continue;
613
+ if (sx < clipL || sx > clipR || sy < clipT || sy > clipB) continue;
614
+ const top = document.elementFromPoint(sx, sy);
615
+ if (!top || top === el || el.contains(top) || top.contains(el)) continue;
616
+ if (isIntentionalOverlay(top)) continue;
617
+ // F126.27 — elementFromPoint DOES return an opacity:0 / visibility:hidden
618
+ // layer, but a layer nobody can see covers nothing. trailmem's hero stacks
619
+ // two crossfading title spans on purpose; the transparent one was named as
620
+ // covering the visible one (run 758697dd, 7 high). Same test the covered
621
+ // side already uses (nothingToLose), now on the occluder side too.
622
+ if (getComputedStyle(top).visibility === 'hidden' || isTransparentThroughChain(top)) continue;
623
+ occluder = top;
624
+ if (isCentre) centreCovered = true;
625
+ else edgeCovered++;
626
+ }
627
+ if (occluder && (centreCovered || edgeCovered >= 1) && !nothingToLose(el, cs, c.interactive)) {
628
+ const coversMeaning = centreCovered || c.text; // text whose edge is covered loses content
629
+ push({
630
+ kind: 'occlusion',
631
+ severity: c.interactive || (c.text && centreCovered) ? 'high' : coversMeaning ? 'medium' : 'low',
632
+ testid: el.getAttribute('data-testid'),
633
+ selector: selectorOf(el),
634
+ label: labelOf(el),
635
+ box: boxOf(r),
636
+ message: `"${labelOf(el)}" is covered by "${labelOf(occluder)}"${centreCovered ? ' (its centre is hidden)' : ' (partly hidden)'}.`,
637
+ occludedBy: refOf(occluder),
638
+ });
639
+ }
640
+ }
641
+
642
+ // ── F074.33 — say so when there was nothing to judge. ─────────────────────
643
+ // fd-sundhed's app has a 1.5s splash as `fixed inset-0 z-[100]`; the critic
644
+ // returned pass with ZERO findings three times, because there was literally
645
+ // nothing on screen. They caught it only by fetching the screenshot and
646
+ // looking. A critic finding nothing behind a full-screen overlay is
647
+ // indistinguishable from one finding nothing on a clean screen — and it is
648
+ // the most convincing green run you can get.
649
+ //
650
+ // ADVISORY on purpose ('low'), never a failure: covering the viewport is
651
+ // exactly what a legitimate modal, splash or cookie wall does. The point is
652
+ // that the report should not read as "we looked and it was fine".
653
+ for (const c of cands) {
654
+ if (isHidden(c.el, c.cs)) continue;
655
+ const pos = c.cs.position;
656
+ if (pos !== 'fixed' && pos !== 'absolute') continue;
657
+ const covers = (Math.min(c.r.right, W) - Math.max(c.r.left, 0)) *
658
+ (Math.min(c.r.bottom, H) - Math.max(c.r.top, 0));
659
+ if (covers < 0.9 * W * H) continue;
660
+ // Only when it is actually ON TOP — a full-bleed background container is not
661
+ // hiding anything.
662
+ const mid = document.elementFromPoint(W / 2, H / 2);
663
+ if (!mid || !(c.el === mid || c.el.contains(mid))) continue;
664
+ push({
665
+ kind: 'nothing-to-judge',
666
+ severity: 'low',
667
+ testid: c.el.getAttribute('data-testid'),
668
+ selector: selectorOf(c.el),
669
+ label: labelOf(c.el),
670
+ box: boxOf(c.r),
671
+ message:
672
+ `"${labelOf(c.el)}" covers ${Math.round((covers / (W * H)) * 100)}% of the viewport — ` +
673
+ 'the critic judged this overlay, not the screen behind it. A run with no findings here ' +
674
+ 'means "nothing was visible to judge", not "the screen is fine".',
675
+ });
676
+ break; // one is enough; it is a statement about the run, not a list
677
+ }
678
+
679
+ // ── Pairwise overlap — sibling/cousin collisions occlusion-sampling missed.
680
+ // Conservative: real intersection of two non-nested, non-overlay elements
681
+ // where at least one is interactive/text. Skipped if the pair already has an
682
+ // occlusion finding. ───────────────────────────────────────────────────
683
+ const overlapSet = cands
684
+ .filter((c) => !isHidden(c.el, c.cs) && c.r.width > 0 && c.r.height > 0 && !isIntentionalOverlay(c.el))
685
+ .slice(0, 400);
686
+ for (let i = 0; i < overlapSet.length; i++) {
687
+ for (let j = i + 1; j < overlapSet.length; j++) {
688
+ const a = overlapSet[i]!;
689
+ const b = overlapSet[j]!;
690
+ if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
691
+ // F074.35 — same rule as the sampler: an element the user can neither see
692
+ // nor reach has nothing to lose. Applied to BOTH so they cannot disagree.
693
+ if (nothingToLose(a.el, a.cs, a.interactive) || nothingToLose(b.el, b.cs, b.interactive)) continue;
694
+ // F126.22 — only the VISIBLE parts can collide: an item scrolled out of a
695
+ // list sits under whatever follows the list without anything being hidden.
696
+ const ca = clipOf(a.el), cb = clipOf(b.el);
697
+ const ox = Math.max(0, Math.min(a.r.right, b.r.right, ca.r, cb.r) - Math.max(a.r.left, b.r.left, ca.l, cb.l));
698
+ const oy = Math.max(0, Math.min(a.r.bottom, b.r.bottom, ca.b, cb.b) - Math.max(a.r.top, b.r.top, ca.t, cb.t));
699
+ const area = ox * oy;
700
+ if (area <= 0) continue;
701
+ const minArea = Math.min(a.r.width * a.r.height, b.r.width * b.r.height);
702
+ if (area < Math.max(64, 0.25 * minArea)) continue;
703
+ const key = `occlusion|${selectorOf(a.el)}|${selectorOf(b.el)}`;
704
+ const keyR = `occlusion|${selectorOf(b.el)}|${selectorOf(a.el)}`;
705
+ if (pushed.has(key) || pushed.has(keyR)) continue;
706
+ push({
707
+ kind: 'overlap',
708
+ severity: 'medium',
709
+ testid: a.el.getAttribute('data-testid'),
710
+ selector: selectorOf(a.el),
711
+ label: labelOf(a.el),
712
+ box: boxOf(a.r),
713
+ message: `"${labelOf(a.el)}" and "${labelOf(b.el)}" overlap by ${Math.round(area)}px² (${Math.round((area / minArea) * 100)}% of the smaller).`,
714
+ occludedBy: refOf(b.el),
715
+ });
716
+ }
717
+ }
718
+
719
+ // ── F074.70 — THE WIGGLE. At a phone width the page must not scroll sideways.
720
+ //
721
+ // Christian, 2026-08-28, on Settings → Secrets: "Vildeste wiggle jeg til dato
722
+ // har set dig lave". Measured on production at 393px:
723
+ //
724
+ // secrets-table overflow-x visible width 547
725
+ // secrets-root overflow-x visible clientWidth 359 scrollWidth 571
726
+ // settings-root overflow-x visible clientWidth 393 scrollWidth 588
727
+ //
728
+ // 195px of page hanging past the viewport, and the critic said nothing.
729
+ //
730
+ // WHY IT SAID NOTHING, because the near-miss is the whole design of this rule:
731
+ // `isReachableByPageScroll` asks whether an element sits inside `docW` — and
732
+ // docW is grown BY the overflow. So a page you can drag sideways is, by that
733
+ // test, perfectly reachable. Reachability was never the question; whether the
734
+ // page should scroll sideways AT ALL is.
735
+ //
736
+ // ONE finding per run, never one per element. This file has already paid for
737
+ // the opposite (57 false mediums on a healthy xrt81 page, F126.8): a gate that
738
+ // goes red in bulk teaches people the red means nothing. So the page-level fact
739
+ // is the finding, and the worst offender is NAMED INSIDE it as the place to look.
740
+ //
741
+ // Phone widths only. A 1280px desktop with a wide table is a different design
742
+ // conversation; a phone that drags is simply broken.
743
+ if (W <= 820) {
744
+ // TWO WAYS A PHONE GOES WRONG, and the first version of this rule only had one.
745
+ //
746
+ // Measured on the very page that prompted it, AFTER the fix: docW read 393 on a
747
+ // 393px viewport BOTH before and after. The document never grew, because an
748
+ // ancestor was clipping with overflow-x:hidden — so a check keyed on docW alone
749
+ // would have reported this page clean while a column sat cut off at the edge.
750
+ // That is the same trap the fleet already has written down: scrollWidth cannot
751
+ // see clipped overflow. Building the check against only the half I had just
752
+ // measured would have shipped a guard blind to the defect that motivated it.
753
+ //
754
+ // So: the page GROWING is one signal, and content spilling past the viewport
755
+ // with nothing SCROLLABLE holding it is the other. `hidden` contains the drag
756
+ // and loses the content; `auto`/`scroll` is a scroller doing its job.
757
+ const firstOverflowAncestor = (el: Element): string | null => {
758
+ let p: Element | null = el.parentElement;
759
+ while (p && p !== document.body) {
760
+ const ox = getComputedStyle(p).overflowX;
761
+ if (ox === 'auto' || ox === 'scroll' || ox === 'hidden') return ox;
762
+ p = p.parentElement;
763
+ }
764
+ return null;
765
+ };
766
+ let clipped: { el: Element; r: DOMRect } | null = null;
767
+ for (const c of cands) {
768
+ if (c.r.width <= 0 || c.r.right <= W + 1) continue;
769
+ const ox = firstOverflowAncestor(c.el);
770
+ if (ox === 'auto' || ox === 'scroll') continue; // reachable by design
771
+ if (!clipped || c.r.right > clipped.r.right) clipped = { el: c.el, r: c.r };
772
+ }
773
+
774
+ // ── F126.16 — A THIRD SIGNAL: CONTENT SPILLING OUT OF ITS OWN BOX ─────
775
+ //
776
+ // Both signals above read `getBoundingClientRect()`, which is the element's
777
+ // BOX. Content can overflow its own box while the box stays comfortably
778
+ // inside the viewport, and a rect is structurally blind to that.
779
+ //
780
+ // Measured on /dashboard/resume at 393px, 11 Sep 2026 — the wiggle Christian
781
+ // reported for the FIFTH time, which this rule watched go past:
782
+ //
783
+ // every element's right edge inside 393px, in FOUR runs on TWO engines
784
+ // resume-timeline-root clientWidth 361 scrollWidth 507
785
+ // its overflow-x visible
786
+ //
787
+ // The comment at the top of this rule already quoted the correct measurement
788
+ // (`clientWidth 359 scrollWidth 571`) and the code below it asked about
789
+ // rects. It knew the right question and asked a different one.
790
+ //
791
+ // THE CONDITION IS "REACHES PAST THE VIEWPORT", NOT "IS WIDER THAN ITS BOX".
792
+ // That distinction is what keeps this from drowning the rule in furniture:
793
+ // a tooltip label inside a 14px icon is legitimately wider than its trigger.
794
+ // Measured on the same page after the fix — the three widest raw ratios were
795
+ // all `cm-tip` elements (71-in-14, 77-in-46, 56-in-32), none of which is
796
+ // anywhere near the edge. Laying the content out from the element's OWN left
797
+ // edge answers the question the owner is actually asking: does anything end
798
+ // up off the side of his phone.
799
+ //
800
+ // The usual cause, named in the message so the reader gets a mechanism and
801
+ // not just a number: a long unbroken token (a mail subject, an id, a URL) in
802
+ // a flex child. `min-width: 0` lets the BOX shrink and does nothing to the
803
+ // TEXT — `overflow-wrap: anywhere` is what wraps it, and `break-word` does
804
+ // not, because it leaves min-content width untouched.
805
+ let spilled: { el: Element; r: DOMRect; right: number; over: number } | null = null;
806
+ for (const c of cands) {
807
+ const el = c.el as HTMLElement;
808
+ const cw = el.clientWidth;
809
+ const sw = el.scrollWidth;
810
+ if (cw <= 0 || sw <= cw + 1) continue;
811
+
812
+ // ── F126.19 — A FORM CONTROL SCROLLS ITS OWN VALUE, AND `overflow-x`
813
+ // DOES NOT SAY SO ────────────────────────────────────────────────
814
+ //
815
+ // The guard on the next line exists for exactly this case: "it holds its
816
+ // own overflow, so the page is not the one spilling." It cannot catch an
817
+ // input, because a text control's computed overflow-x is NOT `auto` — the
818
+ // scrolling is in the widget, not in the box model. So the one family of
819
+ // elements whose content is ALWAYS allowed to be wider than its box walked
820
+ // straight through the exemption written for it.
821
+ //
822
+ // Reported by sanneandersen on /da/admin/qigong/rejse at 393px:
823
+ //
824
+ // widest offender "qigong-lesson-title" holds content 513px wide
825
+ // inside a 235px box → wiggle, high, "reaches 120px past the viewport"
826
+ //
827
+ // The box is 235px. The 513px is the TITLE SOMEBODY TYPED. Nothing is off
828
+ // the side of the phone.
829
+ //
830
+ // WHY THIS ONE COULD NOT BE LEFT TO THE CONSUMER TO SILENCE: the message
831
+ // below prints its own remedy — `overflow-wrap: anywhere` — and an input
832
+ // value never wraps at any CSS setting. The only way to drive the number
833
+ // to zero is a field wider than its longest possible value, which is not a
834
+ // thing you can write for an arbitrary title. An unachievable finding is
835
+ // worse than a wrong one: a wrong one gets corrected, an unachievable one
836
+ // gets waived, and the waiver gets faster every time (F126.18, helpdesk).
837
+ //
838
+ // NARROW ON PURPOSE. This exempts the CONTROL, not the layout around it:
839
+ // a div with a long unbroken token is still a real spill, an input whose
840
+ // BOX overflows the viewport is still caught by the rect signal above, and
841
+ // the `clipped` rule is untouched.
842
+ const tag = el.tagName;
843
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') continue;
844
+
845
+ const ox = getComputedStyle(el).overflowX;
846
+ if (ox === 'auto' || ox === 'scroll') continue; // it scrolls its own content
847
+ // F126.27 — it CLIPS its own content (a truncated label: nowrap + hidden +
848
+ // ellipsis). The content ends at the element's own box, so it cannot move
849
+ // the page; scrollWidth > clientWidth here is the truncation working.
850
+ // Reported by three sessions on 11 Oct 2026 (mockup-frame source line, a
851
+ // recent-project row). Text cut with NO ellipsis is still the `clipped`
852
+ // rule's finding; content clipped by an ANCESTOR (SPILL_ABSORBED) still
853
+ // passes through here, because its own overflow-x is visible.
854
+ if (ox === 'hidden' || ox === 'clip') continue;
855
+ const anc = firstOverflowAncestor(el);
856
+ if (anc === 'auto' || anc === 'scroll') continue; // reachable by design
857
+ const right = c.r.left + sw;
858
+ const over = right - W;
859
+ // NO `if (over <= 0) continue` HERE, and that absence is deliberate.
860
+ //
861
+ // I wrote one, and a mutation proved it could never fire: picking the
862
+ // LARGEST `over` already excludes anything that does not reach the edge,
863
+ // and `overBy > 1` below suppresses the finding when nothing does. Removing
864
+ // the guard reddened nothing — twice, including against a fixture built
865
+ // specifically to catch it (a tooltip beside a real spill).
866
+ //
867
+ // So it was dead code wearing a safeguard's clothes, which is worse than no
868
+ // guard at all: the next reader trusts it and stops looking for the real
869
+ // mechanism. The real mechanism is this max, and the tooltip control below
870
+ // is what proves the outcome.
871
+ if (!spilled || over > spilled.over) spilled = { el, r: c.r, right, over };
872
+ }
873
+
874
+ const overBy = Math.round(
875
+ Math.max(docW - W, clipped ? clipped.r.right - W : 0, spilled ? spilled.over : 0),
876
+ );
877
+ if (overBy > 1) {
878
+ // The actionable half: the widest thing sticking out that is NOT inside a
879
+ // container designed to scroll horizontally. A tab strip with
880
+ // `overflow-x:auto` is a scroller doing its job — reporting its children
881
+ // would bury the real offender under furniture. (Measured: on this page the
882
+ // naive count was 77 elements, of which 77 were the tab strip's.)
883
+ // Whichever reaches FURTHER is the place to look. A spill can beat a
884
+ // box overflow and vice versa; naming the smaller one sends the reader to
885
+ // the wrong element with a correct-looking number beside it.
886
+ const spillFurther = spilled && (!clipped || spilled.right > clipped.r.right);
887
+ const worst: { el: Element; r: DOMRect } | null = spillFurther ? spilled : clipped;
888
+ const tid = worst ? worst.el.getAttribute('data-testid') : null;
889
+ const where = worst
890
+ ? spillFurther
891
+ ? `widest offender "${labelOf(worst.el)}" holds content ${Math.round(spilled!.right)}px wide inside a ` +
892
+ `${Math.round((worst.el as HTMLElement).clientWidth)}px box — its CONTENT spills, its box does not. ` +
893
+ `Usually a long unbroken token in a flex child: min-width:0 shrinks the box, overflow-wrap:anywhere wraps the text ` +
894
+ `(break-word does not — it leaves min-content width untouched)`
895
+ : `widest offender "${labelOf(worst.el)}" reaches ${Math.round(worst.r.right)}px`
896
+ : 'no single element identified — the overflow comes from a container, not a leaf';
897
+ findings.push({
898
+ kind: 'wiggle',
899
+ severity: 'high',
900
+ testid: tid,
901
+ selector: worst ? selectorOf(worst.el) : 'body',
902
+ label: worst ? labelOf(worst.el) : 'page',
903
+ box: worst
904
+ ? { x: Math.round(worst.r.x), y: Math.round(worst.r.y), w: Math.round(worst.r.width), h: Math.round(worst.r.height) }
905
+ : { x: 0, y: 0, w: W, h: H },
906
+ message:
907
+ (docW > W + 1
908
+ ? `The page scrolls SIDEWAYS at ${W}px: content is ${Math.round(docW)}px wide, ${overBy}px past the viewport. `
909
+ : `Content is CUT OFF at ${W}px: it reaches ${overBy}px past the viewport and the only thing holding it is an ` +
910
+ `overflow-x:hidden ancestor, so it cannot be scrolled to — the page may still drag on a real phone. `) +
911
+ `${where}. Wide content must scroll inside its own overflow-x:auto container; the page body must never scroll horizontally.`,
912
+ });
913
+ }
914
+ }
915
+
916
+ return findings;
917
+
918
+ // ── colour helpers (WCAG) ──────────────────────────────────────────────
919
+ function parseRGB(s: string): { r: number; g: number; b: number; a: number } | null {
920
+ const m = s.match(/rgba?\(([^)]+)\)/);
921
+ if (!m || !m[1]) return null;
922
+ const p = m[1].split(',').map((x) => parseFloat(x.trim()));
923
+ if (p.length < 3) return null;
924
+ return { r: p[0]!, g: p[1]!, b: p[2]!, a: p.length >= 4 ? p[3]! : 1 };
925
+ }
926
+ function resolveSolidBg(el: Element): { r: number; g: number; b: number } | null {
927
+ let n: Element | null = el;
928
+ while (n) {
929
+ const cs = getComputedStyle(n);
930
+ if (cs.backgroundImage && cs.backgroundImage !== 'none') return null; // gradient/image → vision's job
931
+ const bg = parseRGB(cs.backgroundColor);
932
+ if (bg && bg.a >= 0.999) return { r: bg.r, g: bg.g, b: bg.b };
933
+ if (bg && bg.a > 0.001) return null; // semi-transparent layer → undeterminable
934
+ n = n.parentElement;
935
+ }
936
+ return null;
937
+ }
938
+ function lum(c: { r: number; g: number; b: number }): number {
939
+ const f = (v: number) => {
940
+ const s = v / 255;
941
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
942
+ };
943
+ return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
944
+ }
945
+ function contrast(a: { r: number; g: number; b: number }, b: { r: number; g: number; b: number }): number {
946
+ const l1 = lum(a);
947
+ const l2 = lum(b);
948
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
949
+ }
950
+ }
951
+ /* eslint-enable */