@broberg/lens-daemon 0.0.0-stage → 0.1.5
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 +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +239 -0
- package/src/artifact-uploads.ts +397 -0
- package/src/auth/index.ts +301 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2953 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/secret-fill.ts +80 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1493 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
|
@@ -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 */
|