@seybel/breakhound 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +388 -2
- package/bin/breakhound.cjs +25 -0
- package/dist/cli.js +7318 -0
- package/dist/index.cjs +34 -0
- package/dist/index.d.cts +254 -0
- package/dist/index.d.ts +254 -0
- package/dist/index.js +7 -0
- package/dist/inject.js +2236 -0
- package/dist/next.cjs +87 -0
- package/dist/next.d.cts +18 -0
- package/dist/next.d.ts +18 -0
- package/dist/next.js +56 -0
- package/dist/schema.json +530 -0
- package/dist/source-loader.cjs +1650 -0
- package/dist/vite.cjs +1689 -0
- package/dist/vite.d.cts +29 -0
- package/dist/vite.d.ts +29 -0
- package/dist/vite.js +1662 -0
- package/dist/webpack.cjs +75 -0
- package/dist/webpack.d.cts +23 -0
- package/dist/webpack.d.ts +23 -0
- package/dist/webpack.js +44 -0
- package/package.json +104 -4
package/dist/inject.js
ADDED
|
@@ -0,0 +1,2236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(() => {
|
|
3
|
+
// ../analyzers/src/dom.ts
|
|
4
|
+
var UNCLIPPED = {
|
|
5
|
+
left: -Infinity,
|
|
6
|
+
right: Infinity,
|
|
7
|
+
top: -Infinity,
|
|
8
|
+
bottom: Infinity,
|
|
9
|
+
width: Infinity,
|
|
10
|
+
height: Infinity
|
|
11
|
+
};
|
|
12
|
+
function intersectBox(a, b) {
|
|
13
|
+
const left = Math.max(a.left, b.left);
|
|
14
|
+
const right = Math.min(a.right, b.right);
|
|
15
|
+
const top = Math.max(a.top, b.top);
|
|
16
|
+
const bottom = Math.min(a.bottom, b.bottom);
|
|
17
|
+
if (right <= left || bottom <= top) return null;
|
|
18
|
+
return { left, right, top, bottom, width: right - left, height: bottom - top };
|
|
19
|
+
}
|
|
20
|
+
function clipFor(rect, style, outer) {
|
|
21
|
+
const x = style.overflowX !== "visible";
|
|
22
|
+
const y = style.overflowY !== "visible";
|
|
23
|
+
if (!x && !y) return outer;
|
|
24
|
+
const own = {
|
|
25
|
+
left: x ? rect.left : -Infinity,
|
|
26
|
+
right: x ? rect.right : Infinity,
|
|
27
|
+
top: y ? rect.top : -Infinity,
|
|
28
|
+
bottom: y ? rect.bottom : Infinity,
|
|
29
|
+
width: x ? rect.width : Infinity,
|
|
30
|
+
height: y ? rect.height : Infinity
|
|
31
|
+
};
|
|
32
|
+
return intersectBox(own, outer) ?? { ...rect, right: rect.left, width: 0 };
|
|
33
|
+
}
|
|
34
|
+
var SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
35
|
+
"SCRIPT",
|
|
36
|
+
"STYLE",
|
|
37
|
+
"TEMPLATE",
|
|
38
|
+
"NOSCRIPT",
|
|
39
|
+
"LINK",
|
|
40
|
+
"META",
|
|
41
|
+
"HEAD",
|
|
42
|
+
"TITLE",
|
|
43
|
+
"BR",
|
|
44
|
+
"WBR",
|
|
45
|
+
"SOURCE",
|
|
46
|
+
"TRACK",
|
|
47
|
+
"OPTION",
|
|
48
|
+
"OPTGROUP",
|
|
49
|
+
"DATALIST"
|
|
50
|
+
]);
|
|
51
|
+
var DEV_OVERLAYS = [
|
|
52
|
+
"nextjs-portal",
|
|
53
|
+
"[data-nextjs-toast]",
|
|
54
|
+
"[data-nextjs-dev-tools-button]",
|
|
55
|
+
"#__next-build-watcher",
|
|
56
|
+
"#__next-prerender-indicator",
|
|
57
|
+
"#nuxt-devtools-container",
|
|
58
|
+
"#vue-inspector-container",
|
|
59
|
+
"#__vue-devtools-container__",
|
|
60
|
+
"vite-error-overlay",
|
|
61
|
+
"vite-plugin-checker-error-overlay",
|
|
62
|
+
"astro-dev-toolbar",
|
|
63
|
+
"astro-dev-overlay",
|
|
64
|
+
"#webpack-dev-server-client-overlay",
|
|
65
|
+
"#react-refresh-overlay",
|
|
66
|
+
".tsqd-parent-container",
|
|
67
|
+
".TanStackRouterDevtools",
|
|
68
|
+
"#svelte-inspector-host",
|
|
69
|
+
"vercel-live-feedback"
|
|
70
|
+
].join(",");
|
|
71
|
+
var LEAF_TAGS = /* @__PURE__ */ new Set([
|
|
72
|
+
"svg",
|
|
73
|
+
"SVG",
|
|
74
|
+
"MATH",
|
|
75
|
+
"math",
|
|
76
|
+
"SELECT",
|
|
77
|
+
"TEXTAREA",
|
|
78
|
+
"IFRAME",
|
|
79
|
+
"VIDEO",
|
|
80
|
+
"CANVAS"
|
|
81
|
+
]);
|
|
82
|
+
function renderedChildren(el) {
|
|
83
|
+
if (el.shadowRoot) return Array.from(el.shadowRoot.children);
|
|
84
|
+
if (el instanceof HTMLSlotElement) {
|
|
85
|
+
const assigned = el.assignedElements({ flatten: true });
|
|
86
|
+
if (assigned.length) return assigned;
|
|
87
|
+
}
|
|
88
|
+
return Array.from(el.children);
|
|
89
|
+
}
|
|
90
|
+
function composedParent(el) {
|
|
91
|
+
if (el.assignedSlot) return el.assignedSlot;
|
|
92
|
+
if (el.parentElement) return el.parentElement;
|
|
93
|
+
const root = el.parentNode;
|
|
94
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
95
|
+
}
|
|
96
|
+
function closestComposed(el, selector) {
|
|
97
|
+
for (let p = el; p; p = composedParent(p)) {
|
|
98
|
+
if (p.matches(selector)) return p;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function shadowRoots() {
|
|
103
|
+
const out = [];
|
|
104
|
+
const walk = (root) => {
|
|
105
|
+
for (const el of root.querySelectorAll("*")) {
|
|
106
|
+
if (el.shadowRoot) {
|
|
107
|
+
out.push(el.shadowRoot);
|
|
108
|
+
walk(el.shadowRoot);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
walk(document);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
function toBox(r) {
|
|
116
|
+
return {
|
|
117
|
+
left: r.left + window.scrollX,
|
|
118
|
+
right: r.right + window.scrollX,
|
|
119
|
+
top: r.top + window.scrollY,
|
|
120
|
+
bottom: r.bottom + window.scrollY,
|
|
121
|
+
width: r.width,
|
|
122
|
+
height: r.height
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function clipsX(style) {
|
|
126
|
+
return style.overflowX !== "visible";
|
|
127
|
+
}
|
|
128
|
+
function isRendered(el, style) {
|
|
129
|
+
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (typeof el.checkVisibility === "function") {
|
|
133
|
+
return el.checkVisibility({ opacityProperty: true, visibilityProperty: true });
|
|
134
|
+
}
|
|
135
|
+
return Number.parseFloat(style.opacity) > 0;
|
|
136
|
+
}
|
|
137
|
+
function isVisuallyHidden(rec) {
|
|
138
|
+
const { style, rect } = rec;
|
|
139
|
+
if (rect.width <= 1 || rect.height <= 1) {
|
|
140
|
+
if (style.overflow !== "visible" || style.position === "absolute") return true;
|
|
141
|
+
}
|
|
142
|
+
if (style.clip && style.clip !== "auto" && /rect\(\s*0/.test(style.clip)) return true;
|
|
143
|
+
if (style.clipPath && /inset\(\s*50%/.test(style.clipPath)) return true;
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
var ORNAMENT_OPACITY = 0.3;
|
|
147
|
+
function isOrnament(rec) {
|
|
148
|
+
if (rec.el.closest('[aria-hidden="true"]')) return true;
|
|
149
|
+
let opacity = 1;
|
|
150
|
+
for (let p = rec; p; p = p.parent) {
|
|
151
|
+
opacity *= Number.parseFloat(p.style.opacity) || 0;
|
|
152
|
+
if (opacity < ORNAMENT_OPACITY) return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
var BREAKS_ANYWHERE = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
|
|
157
|
+
function breaksAnywhere(text) {
|
|
158
|
+
const chars = text.replace(/\s/g, "").length;
|
|
159
|
+
return chars > 0 && (text.match(BREAKS_ANYWHERE)?.length ?? 0) * 2 >= chars;
|
|
160
|
+
}
|
|
161
|
+
var MAX_FRAGMENTS = 64;
|
|
162
|
+
function measureOwnText(el) {
|
|
163
|
+
let left = Infinity;
|
|
164
|
+
let right = -Infinity;
|
|
165
|
+
let top = Infinity;
|
|
166
|
+
let bottom = -Infinity;
|
|
167
|
+
const lineTops = [];
|
|
168
|
+
const pieces = [];
|
|
169
|
+
const range = document.createRange();
|
|
170
|
+
let text = "";
|
|
171
|
+
for (const node of el.childNodes) {
|
|
172
|
+
if (node.nodeType !== Node.TEXT_NODE || !node.textContent?.trim()) continue;
|
|
173
|
+
text += ` ${node.textContent}`;
|
|
174
|
+
range.selectNodeContents(node);
|
|
175
|
+
for (const r of range.getClientRects()) {
|
|
176
|
+
if (r.width === 0 || r.height === 0) continue;
|
|
177
|
+
left = Math.min(left, r.left);
|
|
178
|
+
right = Math.max(right, r.right);
|
|
179
|
+
top = Math.min(top, r.top);
|
|
180
|
+
bottom = Math.max(bottom, r.bottom);
|
|
181
|
+
pieces.push(r);
|
|
182
|
+
if (!lineTops.some((t) => Math.abs(t - r.top) < r.height / 2)) lineTops.push(r.top);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
text = text.replace(/\s+/g, " ").trim();
|
|
186
|
+
if (left === Infinity) return { rect: null, fragments: [], lines: 0, text };
|
|
187
|
+
const sx = window.scrollX;
|
|
188
|
+
const sy = window.scrollY;
|
|
189
|
+
const fragments = pieces.length <= MAX_FRAGMENTS ? pieces.map((r) => ({
|
|
190
|
+
left: r.left + sx,
|
|
191
|
+
right: r.right + sx,
|
|
192
|
+
top: r.top + sy,
|
|
193
|
+
bottom: r.bottom + sy,
|
|
194
|
+
width: r.width,
|
|
195
|
+
height: r.height
|
|
196
|
+
})) : [];
|
|
197
|
+
return {
|
|
198
|
+
fragments,
|
|
199
|
+
rect: {
|
|
200
|
+
left: left + sx,
|
|
201
|
+
right: right + sx,
|
|
202
|
+
top: top + sy,
|
|
203
|
+
bottom: bottom + sy,
|
|
204
|
+
width: right - left,
|
|
205
|
+
height: bottom - top
|
|
206
|
+
},
|
|
207
|
+
lines: lineTops.length,
|
|
208
|
+
text
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function snapshot() {
|
|
212
|
+
const html = document.documentElement;
|
|
213
|
+
const body = document.body;
|
|
214
|
+
const htmlStyle = getComputedStyle(html);
|
|
215
|
+
const bodyStyle = body ? getComputedStyle(body) : htmlStyle;
|
|
216
|
+
const viewportWidth = html.clientWidth;
|
|
217
|
+
const scrollWidth = Math.max(html.scrollWidth, body?.scrollWidth ?? 0);
|
|
218
|
+
const rootClipsX = clipsX(htmlStyle) || clipsX(bodyStyle);
|
|
219
|
+
const records = [];
|
|
220
|
+
const byElement = /* @__PURE__ */ new Map();
|
|
221
|
+
const visit = (el, parent, depth) => {
|
|
222
|
+
if (SKIP_TAGS.has(el.tagName) || el.matches(DEV_OVERLAYS)) return;
|
|
223
|
+
const style = getComputedStyle(el);
|
|
224
|
+
if (!isRendered(el, style)) return;
|
|
225
|
+
if (style.display === "contents") {
|
|
226
|
+
for (const child of renderedChildren(el)) visit(child, parent, depth);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const own = LEAF_TAGS.has(el.tagName) ? { rect: null, fragments: [], lines: 0, text: "" } : measureOwnText(el);
|
|
230
|
+
let outOfFlow = parent?.outOfFlow ?? null;
|
|
231
|
+
if (style.position === "fixed") outOfFlow = "fixed";
|
|
232
|
+
else if (style.position === "absolute" && outOfFlow !== "fixed") outOfFlow = "absolute";
|
|
233
|
+
const positioned = style.position === "fixed" || style.position === "absolute";
|
|
234
|
+
const rect = toBox(el.getBoundingClientRect());
|
|
235
|
+
const clip = style.position === "fixed" ? UNCLIPPED : parent?.innerClip ?? UNCLIPPED;
|
|
236
|
+
const innerClip = el === document.body || el === html ? clip : clipFor(rect, style, clip);
|
|
237
|
+
const rec = {
|
|
238
|
+
el,
|
|
239
|
+
parent,
|
|
240
|
+
style,
|
|
241
|
+
rect,
|
|
242
|
+
// Text hidden by the element's own overflow (ellipsis, line-clamp) is not painted.
|
|
243
|
+
textRect: own.rect && style.overflowX === "visible" && style.overflowY === "visible" ? own.rect : own.rect && intersectBox(own.rect, innerClip),
|
|
244
|
+
textFragments: own.fragments,
|
|
245
|
+
rawTextRect: own.rect,
|
|
246
|
+
text: own.text,
|
|
247
|
+
textLines: own.lines,
|
|
248
|
+
clipAncestor: parent && parent.el !== body && parent.el !== html && clipsX(parent.style) ? parent : parent?.clipAncestor ?? null,
|
|
249
|
+
outOfFlow,
|
|
250
|
+
oofRoot: null,
|
|
251
|
+
clip,
|
|
252
|
+
innerClip,
|
|
253
|
+
depth
|
|
254
|
+
};
|
|
255
|
+
rec.oofRoot = positioned ? rec : parent?.oofRoot ?? null;
|
|
256
|
+
records.push(rec);
|
|
257
|
+
byElement.set(el, rec);
|
|
258
|
+
if (LEAF_TAGS.has(el.tagName)) return;
|
|
259
|
+
for (const child of renderedChildren(el)) visit(child, rec, depth + 1);
|
|
260
|
+
};
|
|
261
|
+
if (body) {
|
|
262
|
+
for (const child of renderedChildren(body)) visit(child, null, 1);
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
viewportWidth,
|
|
266
|
+
viewportHeight: html.clientHeight,
|
|
267
|
+
rootClipsX,
|
|
268
|
+
rootScrollsX: !rootClipsX && scrollWidth > viewportWidth,
|
|
269
|
+
rtl: bodyStyle.direction === "rtl",
|
|
270
|
+
scrollWidth,
|
|
271
|
+
records,
|
|
272
|
+
byElement
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function isDeliberateOffset(rec) {
|
|
276
|
+
const s = rec.style;
|
|
277
|
+
if ((Number.parseFloat(s.marginLeft) || 0) < 0) return true;
|
|
278
|
+
if ((Number.parseFloat(s.marginRight) || 0) < 0) return true;
|
|
279
|
+
if ((Number.parseFloat(s.marginTop) || 0) < 0) return true;
|
|
280
|
+
if ((Number.parseFloat(s.marginBottom) || 0) < 0) return true;
|
|
281
|
+
if (s.transform && s.transform !== "none") return true;
|
|
282
|
+
if (s.position === "relative" && (s.top !== "auto" && s.top !== "0px" || s.left !== "auto" && s.left !== "0px")) {
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
function contentBox(rec) {
|
|
288
|
+
const s = rec.style;
|
|
289
|
+
const bl = Number.parseFloat(s.borderLeftWidth) || 0;
|
|
290
|
+
const br = Number.parseFloat(s.borderRightWidth) || 0;
|
|
291
|
+
const pl = Number.parseFloat(s.paddingLeft) || 0;
|
|
292
|
+
const pr = Number.parseFloat(s.paddingRight) || 0;
|
|
293
|
+
const left = rec.rect.left + bl + pl;
|
|
294
|
+
const right = rec.rect.right - br - pr;
|
|
295
|
+
return { ...rec.rect, left, right, width: right - left };
|
|
296
|
+
}
|
|
297
|
+
function paintedRight(rec) {
|
|
298
|
+
return Math.max(rec.rect.right, rec.textRect?.right ?? -Infinity);
|
|
299
|
+
}
|
|
300
|
+
function paintedLeft(rec) {
|
|
301
|
+
return Math.min(rec.rect.left, rec.textRect?.left ?? Infinity);
|
|
302
|
+
}
|
|
303
|
+
function isInline(style) {
|
|
304
|
+
return style.display === "inline";
|
|
305
|
+
}
|
|
306
|
+
function fontSize(rec) {
|
|
307
|
+
return Number.parseFloat(rec.style.fontSize) || 16;
|
|
308
|
+
}
|
|
309
|
+
var TOUCH_MAX_WIDTH = 767;
|
|
310
|
+
function px(n) {
|
|
311
|
+
return Math.round(n);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ../analyzers/src/source.ts
|
|
315
|
+
var SOURCE_ATTR = "data-bh-src";
|
|
316
|
+
function fromAttribute(value, exact) {
|
|
317
|
+
const m = value.match(/^(.+?)(?::(\d+))?(?::(\d+))?$/);
|
|
318
|
+
if (!m?.[1]) return null;
|
|
319
|
+
return {
|
|
320
|
+
file: m[1],
|
|
321
|
+
...m[2] ? { line: Number(m[2]) } : {},
|
|
322
|
+
...m[3] ? { column: Number(m[3]) } : {},
|
|
323
|
+
exact
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
var NOT_APP_CODE = /node_modules|\/\.vite\/deps\/|\/@vite\/|\/@react-refresh|\/_next\/static\/chunks\/|react-dom|jsx-dev-runtime|react-stack/;
|
|
327
|
+
function fileFromStack(stack) {
|
|
328
|
+
for (const line of stack.split("\n")) {
|
|
329
|
+
const m = line.match(/((?:https?|file):\/\/[^\s)]+|webpack-internal:\/\/\/[^\s)]+):\d+:\d+/);
|
|
330
|
+
const url = m?.[1];
|
|
331
|
+
if (!url || NOT_APP_CODE.test(url)) continue;
|
|
332
|
+
if (url.startsWith("webpack-internal:")) {
|
|
333
|
+
const path = url.match(/\/\.\/(.+)$/)?.[1];
|
|
334
|
+
if (path) return path.split("?")[0] ?? null;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
const u = new URL(url);
|
|
339
|
+
return u.protocol === "file:" ? decodeURIComponent(u.pathname) : u.pathname;
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
function componentName(type) {
|
|
346
|
+
if (!type) return void 0;
|
|
347
|
+
if (typeof type === "function") return type.displayName || type.name || void 0;
|
|
348
|
+
return type.displayName || componentName(type.type) || componentName(type.render);
|
|
349
|
+
}
|
|
350
|
+
function reactSource(el) {
|
|
351
|
+
const key = Object.keys(el).find((k) => k.startsWith("__reactFiber$"));
|
|
352
|
+
const fiber = key ? el[key] : null;
|
|
353
|
+
if (!fiber) return null;
|
|
354
|
+
const owner = fiber._debugOwner;
|
|
355
|
+
const component = componentName(owner?.type);
|
|
356
|
+
const debug = fiber._debugSource;
|
|
357
|
+
if (debug?.fileName && !NOT_APP_CODE.test(debug.fileName)) {
|
|
358
|
+
return {
|
|
359
|
+
file: debug.fileName,
|
|
360
|
+
...debug.lineNumber ? { line: debug.lineNumber } : {},
|
|
361
|
+
...debug.columnNumber ? { column: debug.columnNumber } : {},
|
|
362
|
+
...component ? { component } : {},
|
|
363
|
+
exact: true
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const stack = fiber._debugStack;
|
|
367
|
+
const text = typeof stack === "string" ? stack : stack?.stack;
|
|
368
|
+
const file = typeof text === "string" ? fileFromStack(text) : null;
|
|
369
|
+
return file ? { file, ...component ? { component } : {}, exact: false } : null;
|
|
370
|
+
}
|
|
371
|
+
function vueSource(el) {
|
|
372
|
+
for (let instance = el.__vueParentComponent; instance; instance = instance.parent) {
|
|
373
|
+
const file = instance.type?.__file;
|
|
374
|
+
if (typeof file === "string" && !NOT_APP_CODE.test(file)) {
|
|
375
|
+
const component = instance.type.__name || instance.type.name;
|
|
376
|
+
return { file, ...component ? { component } : {}, exact: false };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
function sourceOf(el) {
|
|
382
|
+
try {
|
|
383
|
+
const own = el.getAttribute(SOURCE_ATTR);
|
|
384
|
+
if (own) return fromAttribute(own, true);
|
|
385
|
+
const found = reactSource(el) ?? vueSource(el);
|
|
386
|
+
if (found) return found;
|
|
387
|
+
const above = el.parentElement?.closest(`[${SOURCE_ATTR}]`);
|
|
388
|
+
const value = above?.getAttribute(SOURCE_ATTR);
|
|
389
|
+
return value ? fromAttribute(value, false) : null;
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ../analyzers/src/selector.ts
|
|
396
|
+
var cache = /* @__PURE__ */ new WeakMap();
|
|
397
|
+
var owners = /* @__PURE__ */ new WeakMap();
|
|
398
|
+
function elementOf(info) {
|
|
399
|
+
return owners.get(info);
|
|
400
|
+
}
|
|
401
|
+
function isStableId(id) {
|
|
402
|
+
if (!id || /[:«»]/.test(id)) return false;
|
|
403
|
+
if (/^(radix|headlessui|react-aria|mui|chakra|ember)[-_]/i.test(id)) return false;
|
|
404
|
+
if (/\d{3,}/.test(id)) return false;
|
|
405
|
+
return /^[A-Za-z][\w-]*$/.test(id);
|
|
406
|
+
}
|
|
407
|
+
function usefulClasses(el) {
|
|
408
|
+
const out = [];
|
|
409
|
+
for (const c of el.classList) {
|
|
410
|
+
if (/[:[\]/!@.%#()]/.test(c)) continue;
|
|
411
|
+
if (/^(css|sc|jsx|emotion|svelte|astro|styled)-[\w-]*\d/i.test(c)) continue;
|
|
412
|
+
if (/^_?[a-zA-Z]+_[a-zA-Z0-9]+__[\w-]{4,}$/.test(c)) continue;
|
|
413
|
+
if (/^[a-z]{1,3}[A-Z0-9]\w{4,}$/.test(c) && /\d/.test(c)) continue;
|
|
414
|
+
out.push(c);
|
|
415
|
+
}
|
|
416
|
+
return out.sort((a, b) => score(b) - score(a)).slice(0, 2);
|
|
417
|
+
}
|
|
418
|
+
function score(c) {
|
|
419
|
+
let s = c.length;
|
|
420
|
+
if (/^(flex|grid|block|inline|hidden|p[trblxy]?-|m[trblxy]?-|w-|h-|text-|bg-|gap-)/.test(c))
|
|
421
|
+
s -= 20;
|
|
422
|
+
return s;
|
|
423
|
+
}
|
|
424
|
+
function cssEscape(s) {
|
|
425
|
+
return CSS.escape(s);
|
|
426
|
+
}
|
|
427
|
+
function segment(el, withClasses, withNth) {
|
|
428
|
+
let s = el.tagName.toLowerCase();
|
|
429
|
+
if (withClasses)
|
|
430
|
+
s += usefulClasses(el).map((c) => `.${cssEscape(c)}`).join("");
|
|
431
|
+
if (withNth) {
|
|
432
|
+
const siblings = el.parentElement?.children ?? el.parentNode?.children;
|
|
433
|
+
const same = Array.from(siblings ?? []).filter((c) => c.tagName === el.tagName);
|
|
434
|
+
if (same.length > 1) s += `:nth-of-type(${same.indexOf(el) + 1})`;
|
|
435
|
+
}
|
|
436
|
+
return s;
|
|
437
|
+
}
|
|
438
|
+
function isUnique(selector, el, root) {
|
|
439
|
+
try {
|
|
440
|
+
const found = root.querySelectorAll(selector);
|
|
441
|
+
return found.length === 1 && found[0] === el;
|
|
442
|
+
} catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
var SHADOW_SEPARATOR = " >>> ";
|
|
447
|
+
function selectorWithin(el, root) {
|
|
448
|
+
if (el.id && isStableId(el.id) && isUnique(`#${cssEscape(el.id)}`, el, root))
|
|
449
|
+
return `#${cssEscape(el.id)}`;
|
|
450
|
+
const parts = [];
|
|
451
|
+
let current = el;
|
|
452
|
+
while (current && current !== document.documentElement) {
|
|
453
|
+
if (current !== el && current.id && isStableId(current.id)) {
|
|
454
|
+
parts.unshift(`#${cssEscape(current.id)}`);
|
|
455
|
+
const candidate = parts.join(" > ");
|
|
456
|
+
if (isUnique(candidate, el, root)) return candidate;
|
|
457
|
+
parts.shift();
|
|
458
|
+
}
|
|
459
|
+
for (const [cls, nth] of [
|
|
460
|
+
[true, false],
|
|
461
|
+
[true, true]
|
|
462
|
+
]) {
|
|
463
|
+
const candidate = [segment(current, cls, nth), ...parts].join(" > ");
|
|
464
|
+
if (isUnique(candidate, el, root)) return candidate;
|
|
465
|
+
}
|
|
466
|
+
parts.unshift(segment(current, true, true));
|
|
467
|
+
current = current.parentElement;
|
|
468
|
+
}
|
|
469
|
+
return parts.join(" > ");
|
|
470
|
+
}
|
|
471
|
+
function selectorFor(el) {
|
|
472
|
+
const root = el.getRootNode();
|
|
473
|
+
if (root instanceof ShadowRoot) {
|
|
474
|
+
return `${selectorFor(root.host)}${SHADOW_SEPARATOR}${selectorWithin(el, root)}`;
|
|
475
|
+
}
|
|
476
|
+
return selectorWithin(el, document);
|
|
477
|
+
}
|
|
478
|
+
var selectors = /* @__PURE__ */ new WeakMap();
|
|
479
|
+
function cachedSelector(el) {
|
|
480
|
+
let s = selectors.get(el);
|
|
481
|
+
if (s === void 0) {
|
|
482
|
+
s = selectorFor(el);
|
|
483
|
+
selectors.set(el, s);
|
|
484
|
+
}
|
|
485
|
+
return s;
|
|
486
|
+
}
|
|
487
|
+
function lineage(el, max = 16) {
|
|
488
|
+
const out = [];
|
|
489
|
+
for (let p = composedParent(el); p && p !== document.body && out.length < max; p = composedParent(p)) {
|
|
490
|
+
out.push(cachedSelector(p));
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
function resolveSelector(selector) {
|
|
495
|
+
let root = document;
|
|
496
|
+
let found = null;
|
|
497
|
+
for (const part of selector.split(SHADOW_SEPARATOR)) {
|
|
498
|
+
if (!root) return null;
|
|
499
|
+
try {
|
|
500
|
+
found = root.querySelector(part);
|
|
501
|
+
} catch {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
root = found?.shadowRoot ?? null;
|
|
505
|
+
}
|
|
506
|
+
return found;
|
|
507
|
+
}
|
|
508
|
+
function readablePath(el) {
|
|
509
|
+
const parts = [];
|
|
510
|
+
let current = el;
|
|
511
|
+
while (current && current !== document.body && parts.length < 4) {
|
|
512
|
+
const cls = usefulClasses(current)[0];
|
|
513
|
+
parts.unshift(current.tagName.toLowerCase() + (cls ? `.${cls}` : ""));
|
|
514
|
+
current = composedParent(current);
|
|
515
|
+
}
|
|
516
|
+
return parts.join(" > ");
|
|
517
|
+
}
|
|
518
|
+
function openingTag(el) {
|
|
519
|
+
const tag = el.tagName.toLowerCase();
|
|
520
|
+
let out = `<${tag}`;
|
|
521
|
+
for (const attr of el.attributes) {
|
|
522
|
+
if (attr.name === SOURCE_ATTR) continue;
|
|
523
|
+
const value = attr.value.length > 60 ? `${attr.value.slice(0, 60)}...` : attr.value;
|
|
524
|
+
out += value ? ` ${attr.name}="${value.replace(/"/g, """)}"` : ` ${attr.name}`;
|
|
525
|
+
}
|
|
526
|
+
out += ">";
|
|
527
|
+
return out.length > 200 ? `${out.slice(0, 197)}...>` : out;
|
|
528
|
+
}
|
|
529
|
+
function findByText(wanted) {
|
|
530
|
+
const needle = wanted.replace(/\s+/g, " ").trim().toLowerCase();
|
|
531
|
+
if (needle.length < 3 || !document.body) return null;
|
|
532
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
533
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
534
|
+
const el = node.parentElement;
|
|
535
|
+
if (!el || el.closest("script, style, noscript")) continue;
|
|
536
|
+
const r = el.getBoundingClientRect();
|
|
537
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
538
|
+
for (let p = el; p && p !== document.body; p = p.parentElement) {
|
|
539
|
+
const text = (p.textContent ?? "").replace(/\s+/g, " ").toLowerCase();
|
|
540
|
+
if (text.includes(needle)) return describe(p);
|
|
541
|
+
if (text.length > needle.length * 4) break;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
function describe(el) {
|
|
547
|
+
const cached = cache.get(el);
|
|
548
|
+
if (cached) return cached;
|
|
549
|
+
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
550
|
+
const info = {
|
|
551
|
+
selector: cachedSelector(el),
|
|
552
|
+
path: readablePath(el),
|
|
553
|
+
html: openingTag(el),
|
|
554
|
+
text: text ? text.length > 80 ? `${text.slice(0, 77)}...` : text : null,
|
|
555
|
+
source: sourceOf(el)
|
|
556
|
+
};
|
|
557
|
+
cache.set(el, info);
|
|
558
|
+
owners.set(info, el);
|
|
559
|
+
return info;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ../analyzers/src/detectors/page-overflow.ts
|
|
563
|
+
var TOLERANCE = 0.5;
|
|
564
|
+
var SIDEWAYS_CONTENT = /* @__PURE__ */ new Set(["TABLE", "PRE", "CODE"]);
|
|
565
|
+
var PAGE_CONTENT = "h1,h2,h3,h4,form,input,textarea,select,main,article";
|
|
566
|
+
function wrapperEdge(snap, rec) {
|
|
567
|
+
const container = rec.clipAncestor;
|
|
568
|
+
if (!container) return { kind: "root" };
|
|
569
|
+
const vw = snap.viewportWidth;
|
|
570
|
+
if (container.rect.left > 1 || container.rect.right < vw - 1) return null;
|
|
571
|
+
const cs = container.style;
|
|
572
|
+
if (cs.scrollSnapType && cs.scrollSnapType !== "none") return null;
|
|
573
|
+
const holdsPage = container.rect.height >= snap.viewportHeight / 2 || container.el.querySelector(PAGE_CONTENT) !== null;
|
|
574
|
+
if (!holdsPage) return null;
|
|
575
|
+
for (let p = rec; p && p !== container; p = p.parent) {
|
|
576
|
+
if (SIDEWAYS_CONTENT.has(p.el.tagName)) return null;
|
|
577
|
+
if (p.style.transform && p.style.transform !== "none") return null;
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
kind: "wrapper",
|
|
581
|
+
container,
|
|
582
|
+
scrolls: cs.overflowX === "auto" || cs.overflowX === "scroll"
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function sticksOut(snap, rec) {
|
|
586
|
+
if (isVisuallyHidden(rec)) return null;
|
|
587
|
+
const right = paintedRight(rec);
|
|
588
|
+
const left = paintedLeft(rec);
|
|
589
|
+
if (right <= snap.viewportWidth + TOLERANCE && left >= -TOLERANCE) return null;
|
|
590
|
+
if (rec.outOfFlow === "fixed") {
|
|
591
|
+
return rec.clipAncestor ? null : { rec, right, left, edge: { kind: "screen" } };
|
|
592
|
+
}
|
|
593
|
+
const edge = wrapperEdge(snap, rec);
|
|
594
|
+
return edge ? { rec, right, left, edge } : null;
|
|
595
|
+
}
|
|
596
|
+
function pageOverflow(snap) {
|
|
597
|
+
const hits = /* @__PURE__ */ new Map();
|
|
598
|
+
for (const rec of snap.records) {
|
|
599
|
+
const hit = sticksOut(snap, rec);
|
|
600
|
+
if (hit) hits.set(rec, hit);
|
|
601
|
+
}
|
|
602
|
+
const findings = [];
|
|
603
|
+
for (const hit of hits.values()) {
|
|
604
|
+
const { rec, edge } = hit;
|
|
605
|
+
let p = rec.parent;
|
|
606
|
+
let covered = false;
|
|
607
|
+
while (p) {
|
|
608
|
+
if (hits.has(p)) {
|
|
609
|
+
covered = true;
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
p = p.parent;
|
|
613
|
+
}
|
|
614
|
+
if (covered) continue;
|
|
615
|
+
const vw = snap.viewportWidth;
|
|
616
|
+
const clipped = edge.kind === "root" ? snap.rootClipsX : edge.kind === "screen" || !edge.scrolls;
|
|
617
|
+
const fullyOffscreen = hit.left >= vw || hit.right <= 0;
|
|
618
|
+
if (clipped && fullyOffscreen) continue;
|
|
619
|
+
const overflowRight = Math.max(0, hit.right - vw);
|
|
620
|
+
const overflowLeft = Math.max(0, -hit.left);
|
|
621
|
+
const side = overflowRight >= overflowLeft ? "right" : "left";
|
|
622
|
+
const amount = px(Math.max(overflowRight, overflowLeft));
|
|
623
|
+
if (amount < 1) continue;
|
|
624
|
+
const endSide = snap.rtl ? "left" : "right";
|
|
625
|
+
const scrolls = side === endSide && (edge.kind === "root" ? snap.rootScrollsX : edge.kind === "wrapper" && edge.scrolls);
|
|
626
|
+
if (!scrolls && rec.outOfFlow && isDecorative(rec.el)) continue;
|
|
627
|
+
if (!scrolls && isOrnament(rec)) continue;
|
|
628
|
+
const textOnly = rec.rect.right <= vw + TOLERANCE && rec.rect.left >= -TOLERANCE;
|
|
629
|
+
let title;
|
|
630
|
+
if (!scrolls) title = `Content cut off at the ${side} edge by ${amount}px`;
|
|
631
|
+
else if (edge.kind === "root")
|
|
632
|
+
title = `Page scrolls horizontally (${amount}px wider than the viewport)`;
|
|
633
|
+
else title = `Content scrolls sideways inside a full-width container (${amount}px too wide)`;
|
|
634
|
+
findings.push({
|
|
635
|
+
suggestion: shrinkSuggestion(rec, textOnly),
|
|
636
|
+
detector: "page-overflow",
|
|
637
|
+
severity: scrolls ? "error" : "warning",
|
|
638
|
+
title,
|
|
639
|
+
element: describe(rec.el),
|
|
640
|
+
lineage: lineage(rec.el),
|
|
641
|
+
...edge.kind === "wrapper" ? { related: describe(edge.container.el) } : {},
|
|
642
|
+
evidence: {
|
|
643
|
+
overflowPx: amount,
|
|
644
|
+
side,
|
|
645
|
+
viewportWidth: vw,
|
|
646
|
+
elementWidth: px(rec.rect.width),
|
|
647
|
+
causedByText: textOnly,
|
|
648
|
+
clippedBy: edge.kind === "root" ? snap.rootClipsX ? "page" : "none" : edge.kind === "screen" ? "screen" : "wrapper"
|
|
649
|
+
},
|
|
650
|
+
styles: pick(rec.style, [
|
|
651
|
+
"width",
|
|
652
|
+
"min-width",
|
|
653
|
+
"max-width",
|
|
654
|
+
"white-space",
|
|
655
|
+
"overflow-wrap",
|
|
656
|
+
"word-break",
|
|
657
|
+
"flex-shrink",
|
|
658
|
+
"position",
|
|
659
|
+
"margin-left",
|
|
660
|
+
"margin-right",
|
|
661
|
+
"transform"
|
|
662
|
+
])
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return findings;
|
|
666
|
+
}
|
|
667
|
+
function longestWord(text) {
|
|
668
|
+
let longest = "";
|
|
669
|
+
for (const raw of text.split(/\s+/)) {
|
|
670
|
+
const word = raw.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
|
671
|
+
if (word.length > longest.length) longest = word;
|
|
672
|
+
}
|
|
673
|
+
return longest;
|
|
674
|
+
}
|
|
675
|
+
function shrinkSuggestion(rec, textOnly) {
|
|
676
|
+
const s = rec.style;
|
|
677
|
+
if (textOnly) {
|
|
678
|
+
if (s.whiteSpace === "nowrap" || s.whiteSpace === "pre") {
|
|
679
|
+
return `white-space: ${s.whiteSpace} keeps this text on one line. Allow it to wrap on small screens.`;
|
|
680
|
+
}
|
|
681
|
+
const word = longestWord(rec.text);
|
|
682
|
+
if (word && /^[\p{L}\p{M}'’-]+$/u.test(word)) {
|
|
683
|
+
const size = Number.parseFloat(s.fontSize);
|
|
684
|
+
return size >= 24 ? `The word "${word}" is wider than the screen at ${Math.round(size)}px. Scale the text down on small screens, for example font-size: clamp(1.75rem, 4vw + 1rem, ${Math.round(size / 16 * 100) / 100}rem).` : `The word "${word}" is too long to fit here. Add hyphens: auto (with a lang attribute on the page) or overflow-wrap: break-word.`;
|
|
685
|
+
}
|
|
686
|
+
return "Long unbreakable text (a URL, email or code). Add overflow-wrap: anywhere to its container.";
|
|
687
|
+
}
|
|
688
|
+
const minWidth = Number.parseFloat(s.minWidth);
|
|
689
|
+
if (minWidth > 0) {
|
|
690
|
+
return `min-width: ${s.minWidth} stops it from shrinking. Lower it or drop it on small screens.`;
|
|
691
|
+
}
|
|
692
|
+
if (s.flexShrink === "0") {
|
|
693
|
+
return "flex-shrink: 0 stops it from shrinking. Let it shrink, or wrap the row.";
|
|
694
|
+
}
|
|
695
|
+
if (s.whiteSpace === "nowrap") {
|
|
696
|
+
return "white-space: nowrap keeps its content on one line. Allow wrapping on small screens.";
|
|
697
|
+
}
|
|
698
|
+
const parent = rec.parent?.style.display ?? "";
|
|
699
|
+
if (parent.includes("flex") || parent.includes("grid")) {
|
|
700
|
+
return "It is a flex/grid item that will not shrink below its content. Add min-width: 0 to it, or replace its fixed width with max-width: 100%.";
|
|
701
|
+
}
|
|
702
|
+
return "It is wider than the screen here. Replace any fixed width with max-width: 100% or a fluid width.";
|
|
703
|
+
}
|
|
704
|
+
var CONTENT_SELECTOR = "img,video,canvas,iframe,input,button,select,textarea,a[href]";
|
|
705
|
+
function isDecorative(el) {
|
|
706
|
+
if (el.matches(CONTENT_SELECTOR)) return false;
|
|
707
|
+
if (el.textContent?.trim()) return false;
|
|
708
|
+
if (el.querySelector(CONTENT_SELECTOR)) return false;
|
|
709
|
+
if (el.shadowRoot) return false;
|
|
710
|
+
for (const child of el.querySelectorAll("*")) if (child.shadowRoot) return false;
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
var DEFAULT_VALUES = {
|
|
714
|
+
"flex-shrink": "1",
|
|
715
|
+
position: "static",
|
|
716
|
+
"object-fit": "fill",
|
|
717
|
+
overflow: "visible",
|
|
718
|
+
"flex-basis": "auto",
|
|
719
|
+
transform: "none"
|
|
720
|
+
};
|
|
721
|
+
function pick(style, props) {
|
|
722
|
+
const out = {};
|
|
723
|
+
for (const prop of props) {
|
|
724
|
+
const v = style.getPropertyValue(prop);
|
|
725
|
+
if (!v || v === "none" || v === "normal" || v === "auto" || v === "0px") continue;
|
|
726
|
+
if (DEFAULT_VALUES[prop] === v) continue;
|
|
727
|
+
out[prop] = v;
|
|
728
|
+
}
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ../analyzers/src/detectors/containment.ts
|
|
733
|
+
var TOLERANCE2 = 1;
|
|
734
|
+
var VERTICAL_TOLERANCE = 2;
|
|
735
|
+
var MEDIA = /* @__PURE__ */ new Set(["IMG", "VIDEO", "IFRAME", "CANVAS", "EMBED", "OBJECT", "svg", "PICTURE"]);
|
|
736
|
+
function alpha(color) {
|
|
737
|
+
if (!color || color === "transparent") return 0;
|
|
738
|
+
const m = color.match(/rgba?\(([^)]+)\)/);
|
|
739
|
+
if (!m?.[1]) return 1;
|
|
740
|
+
const parts = m[1].split(/[\s,/]+/).filter(Boolean);
|
|
741
|
+
return parts.length >= 4 ? Number.parseFloat(parts[3] ?? "1") : 1;
|
|
742
|
+
}
|
|
743
|
+
function effectiveBackground(rec) {
|
|
744
|
+
let p = rec;
|
|
745
|
+
while (p) {
|
|
746
|
+
if (alpha(p.style.backgroundColor) > 0) return p.style.backgroundColor;
|
|
747
|
+
p = p.parent;
|
|
748
|
+
}
|
|
749
|
+
const body = getComputedStyle(document.body).backgroundColor;
|
|
750
|
+
if (alpha(body) > 0) return body;
|
|
751
|
+
return getComputedStyle(document.documentElement).backgroundColor;
|
|
752
|
+
}
|
|
753
|
+
function isBounded(rec) {
|
|
754
|
+
const s = rec.style;
|
|
755
|
+
if (clipsX(s) || isInline(s) || rec.rect.width < 24 || rec.rect.height < 16) return false;
|
|
756
|
+
const border = Number.parseFloat(s.borderLeftWidth) > 0 && alpha(s.borderLeftColor) > 0 || Number.parseFloat(s.borderRightWidth) > 0 && alpha(s.borderRightColor) > 0;
|
|
757
|
+
if (border) return true;
|
|
758
|
+
if (s.boxShadow && s.boxShadow !== "none") return true;
|
|
759
|
+
if (s.backgroundImage && s.backgroundImage !== "none") return true;
|
|
760
|
+
const bg = s.backgroundColor;
|
|
761
|
+
return alpha(bg) > 0 && bg !== effectiveBackground(rec.parent);
|
|
762
|
+
}
|
|
763
|
+
function sameContext(a, b) {
|
|
764
|
+
return a.oofRoot === b.oofRoot && a.clipAncestor === b.clipAncestor;
|
|
765
|
+
}
|
|
766
|
+
function overflowAmount(child, box) {
|
|
767
|
+
return Math.max(paintedRight(child) - box.right, box.left - paintedLeft(child), 0);
|
|
768
|
+
}
|
|
769
|
+
function overflowBelow(child, container) {
|
|
770
|
+
if (isInline(child.style)) return 0;
|
|
771
|
+
return Math.max(child.rect.bottom - container.rect.bottom, 0);
|
|
772
|
+
}
|
|
773
|
+
function offsetOnPurpose(rec, container) {
|
|
774
|
+
for (let p = rec; p && p !== container; p = p.parent) {
|
|
775
|
+
if (isDeliberateOffset(p) || p.style.float !== "none") return true;
|
|
776
|
+
}
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
function contentOverflow(snap) {
|
|
780
|
+
const bounded = /* @__PURE__ */ new Set();
|
|
781
|
+
for (const rec of snap.records) if (isBounded(rec)) bounded.add(rec);
|
|
782
|
+
const findings = [];
|
|
783
|
+
const escapes = /* @__PURE__ */ new Map();
|
|
784
|
+
for (const rec of snap.records) {
|
|
785
|
+
if (isVisuallyHidden(rec) || rec.outOfFlow === "fixed") continue;
|
|
786
|
+
let container = rec.parent;
|
|
787
|
+
while (container && !bounded.has(container)) container = container.parent;
|
|
788
|
+
if (!container || !sameContext(rec, container)) continue;
|
|
789
|
+
const sideways = overflowAmount(rec, container.rect);
|
|
790
|
+
if (sideways > TOLERANCE2) {
|
|
791
|
+
const side = paintedRight(rec) - container.rect.right >= sideways - 0.01 ? "right" : "left";
|
|
792
|
+
escapes.set(rec, { container, side, amount: sideways });
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
const below = overflowBelow(rec, container);
|
|
796
|
+
if (below > VERTICAL_TOLERANCE && !offsetOnPurpose(rec, container)) {
|
|
797
|
+
escapes.set(rec, { container, side: "bottom", amount: below });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
for (const [rec, { container, side, amount: raw }] of escapes) {
|
|
801
|
+
if (rec.parent && escapes.get(rec.parent)?.container === container) continue;
|
|
802
|
+
const amount = px(raw);
|
|
803
|
+
if (side === "bottom") {
|
|
804
|
+
findings.push({
|
|
805
|
+
detector: "content-overflow",
|
|
806
|
+
severity: "error",
|
|
807
|
+
title: `Content spills ${amount}px out of the bottom of its container`,
|
|
808
|
+
element: describe(rec.el),
|
|
809
|
+
related: describe(container.el),
|
|
810
|
+
evidence: {
|
|
811
|
+
overflowPx: amount,
|
|
812
|
+
side,
|
|
813
|
+
containerHeight: px(container.rect.height),
|
|
814
|
+
elementHeight: px(rec.rect.height)
|
|
815
|
+
},
|
|
816
|
+
styles: pick(container.style, ["height", "max-height", "min-height"]),
|
|
817
|
+
suggestion: `The container is ${px(container.rect.height)}px tall and its content needs more room at this width. Use min-height instead of a fixed height.`
|
|
818
|
+
});
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
const textOnly = rec.rect.right <= container.rect.right + TOLERANCE2 && rec.rect.left >= container.rect.left - TOLERANCE2;
|
|
822
|
+
findings.push({
|
|
823
|
+
suggestion: shrinkSuggestion(rec, textOnly),
|
|
824
|
+
detector: "content-overflow",
|
|
825
|
+
severity: "error",
|
|
826
|
+
title: textOnly ? `Text spills ${amount}px outside its container` : `Content spills ${amount}px outside its container`,
|
|
827
|
+
element: describe(rec.el),
|
|
828
|
+
lineage: lineage(rec.el),
|
|
829
|
+
related: describe(container.el),
|
|
830
|
+
evidence: {
|
|
831
|
+
overflowPx: amount,
|
|
832
|
+
side,
|
|
833
|
+
containerWidth: px(container.rect.width),
|
|
834
|
+
elementWidth: px(rec.rect.width),
|
|
835
|
+
causedByText: textOnly
|
|
836
|
+
},
|
|
837
|
+
styles: pick(rec.style, [
|
|
838
|
+
"width",
|
|
839
|
+
"min-width",
|
|
840
|
+
"white-space",
|
|
841
|
+
"overflow-wrap",
|
|
842
|
+
"word-break",
|
|
843
|
+
"flex-shrink",
|
|
844
|
+
"flex-basis"
|
|
845
|
+
])
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
return findings;
|
|
849
|
+
}
|
|
850
|
+
function isMedia(el) {
|
|
851
|
+
return MEDIA.has(el.tagName);
|
|
852
|
+
}
|
|
853
|
+
function mediaOverflow(snap) {
|
|
854
|
+
const findings = [];
|
|
855
|
+
for (const rec of snap.records) {
|
|
856
|
+
if (!isMedia(rec.el) || rec.outOfFlow) continue;
|
|
857
|
+
if (rec.el.tagName === "PICTURE") continue;
|
|
858
|
+
let container = rec.parent;
|
|
859
|
+
while (container && (isInline(container.style) || container.el.tagName === "PICTURE")) {
|
|
860
|
+
container = container.parent;
|
|
861
|
+
}
|
|
862
|
+
if (!container || container.clipAncestor !== rec.clipAncestor || clipsX(container.style))
|
|
863
|
+
continue;
|
|
864
|
+
const ml = Number.parseFloat(rec.style.marginLeft) || 0;
|
|
865
|
+
const mr = Number.parseFloat(rec.style.marginRight) || 0;
|
|
866
|
+
if (ml < 0 || mr < 0) continue;
|
|
867
|
+
const box = contentBox(container);
|
|
868
|
+
const excess = rec.rect.width - box.width;
|
|
869
|
+
if (excess <= TOLERANCE2 || rec.rect.right <= box.right + TOLERANCE2) continue;
|
|
870
|
+
findings.push({
|
|
871
|
+
detector: "media-overflow",
|
|
872
|
+
severity: "error",
|
|
873
|
+
title: `${label(rec.el)} is ${px(excess)}px wider than its container`,
|
|
874
|
+
element: describe(rec.el),
|
|
875
|
+
lineage: lineage(rec.el),
|
|
876
|
+
related: describe(container.el),
|
|
877
|
+
evidence: {
|
|
878
|
+
overflowPx: px(excess),
|
|
879
|
+
mediaWidth: px(rec.rect.width),
|
|
880
|
+
containerWidth: px(box.width)
|
|
881
|
+
},
|
|
882
|
+
styles: pick(rec.style, ["width", "min-width", "max-width", "height", "object-fit"]),
|
|
883
|
+
suggestion: "Add max-width: 100% and height: auto so it scales down with its container."
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
return findings;
|
|
887
|
+
}
|
|
888
|
+
function label(el) {
|
|
889
|
+
switch (el.tagName) {
|
|
890
|
+
case "IMG":
|
|
891
|
+
return "Image";
|
|
892
|
+
case "VIDEO":
|
|
893
|
+
return "Video";
|
|
894
|
+
case "IFRAME":
|
|
895
|
+
case "EMBED":
|
|
896
|
+
case "OBJECT":
|
|
897
|
+
return "Embed";
|
|
898
|
+
case "CANVAS":
|
|
899
|
+
return "Canvas";
|
|
900
|
+
default:
|
|
901
|
+
return "Graphic";
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ../analyzers/src/detectors/elements.ts
|
|
906
|
+
var MAX_DISTORTION = 0.05;
|
|
907
|
+
var MIN_TARGET = 24;
|
|
908
|
+
var TARGETS = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="switch"], [tabindex]:not([tabindex="-1"])';
|
|
909
|
+
function distortedMedia(snap) {
|
|
910
|
+
const findings = [];
|
|
911
|
+
for (const rec of snap.records) {
|
|
912
|
+
if (rec.el.tagName !== "IMG" || isVisuallyHidden(rec)) continue;
|
|
913
|
+
const img = rec.el;
|
|
914
|
+
if (!img.complete || !img.naturalWidth || !img.naturalHeight) continue;
|
|
915
|
+
if (/\.svg(\?|#|$)|^data:image\/svg/i.test(img.currentSrc || img.src)) continue;
|
|
916
|
+
if (rec.style.objectFit && rec.style.objectFit !== "fill") continue;
|
|
917
|
+
const s = rec.style;
|
|
918
|
+
const w = rec.rect.width - (Number.parseFloat(s.paddingLeft) || 0) - (Number.parseFloat(s.paddingRight) || 0) - (Number.parseFloat(s.borderLeftWidth) || 0) - (Number.parseFloat(s.borderRightWidth) || 0);
|
|
919
|
+
const h = rec.rect.height - (Number.parseFloat(s.paddingTop) || 0) - (Number.parseFloat(s.paddingBottom) || 0) - (Number.parseFloat(s.borderTopWidth) || 0) - (Number.parseFloat(s.borderBottomWidth) || 0);
|
|
920
|
+
if (w < 16 || h < 16) continue;
|
|
921
|
+
const natural = img.naturalWidth / img.naturalHeight;
|
|
922
|
+
const rendered = w / h;
|
|
923
|
+
const distortion = Math.abs(rendered / natural - 1);
|
|
924
|
+
if (distortion <= MAX_DISTORTION) continue;
|
|
925
|
+
findings.push({
|
|
926
|
+
detector: "distorted-media",
|
|
927
|
+
severity: "warning",
|
|
928
|
+
title: `Image is ${rendered > natural ? "stretched" : "squashed"} ${Math.round(distortion * 100)}% out of proportion`,
|
|
929
|
+
element: describe(rec.el),
|
|
930
|
+
evidence: {
|
|
931
|
+
renderedRatio: Math.round(rendered * 100) / 100,
|
|
932
|
+
naturalRatio: Math.round(natural * 100) / 100,
|
|
933
|
+
renderedWidth: px(w),
|
|
934
|
+
renderedHeight: px(h)
|
|
935
|
+
},
|
|
936
|
+
styles: pick(rec.style, ["width", "height", "max-width", "object-fit", "aspect-ratio"]),
|
|
937
|
+
suggestion: "Keep width and height proportional (height: auto), or crop with object-fit: cover."
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
return findings;
|
|
941
|
+
}
|
|
942
|
+
function center(b) {
|
|
943
|
+
return { x: (b.left + b.right) / 2, y: (b.top + b.bottom) / 2 };
|
|
944
|
+
}
|
|
945
|
+
function distanceToBox(p, b) {
|
|
946
|
+
const dx = Math.max(b.left - p.x, 0, p.x - b.right);
|
|
947
|
+
const dy = Math.max(b.top - p.y, 0, p.y - b.bottom);
|
|
948
|
+
return Math.hypot(dx, dy);
|
|
949
|
+
}
|
|
950
|
+
function isInlineTextLink(rec) {
|
|
951
|
+
if (rec.el.tagName !== "A" || !rec.style.display.startsWith("inline")) return false;
|
|
952
|
+
const parent = rec.el.parentElement;
|
|
953
|
+
if (!parent) return false;
|
|
954
|
+
const own = rec.el.textContent?.trim().length ?? 0;
|
|
955
|
+
const around = parent.textContent?.trim().length ?? 0;
|
|
956
|
+
return around > own + 10;
|
|
957
|
+
}
|
|
958
|
+
function hasLabel(el) {
|
|
959
|
+
if (!(el instanceof HTMLInputElement)) return false;
|
|
960
|
+
if (el.type !== "checkbox" && el.type !== "radio") return false;
|
|
961
|
+
return (el.labels?.length ?? 0) > 0;
|
|
962
|
+
}
|
|
963
|
+
function smallTapTargets(snap) {
|
|
964
|
+
if (snap.viewportWidth > TOUCH_MAX_WIDTH) return [];
|
|
965
|
+
const targets = [];
|
|
966
|
+
for (const rec of snap.records) {
|
|
967
|
+
if (!rec.el.matches(TARGETS) || isVisuallyHidden(rec)) continue;
|
|
968
|
+
if (rec.el.matches('[disabled], [aria-disabled="true"]')) continue;
|
|
969
|
+
if (rec.rect.width === 0 || rec.rect.height === 0) continue;
|
|
970
|
+
let nested = false;
|
|
971
|
+
for (let p = rec.parent; p; p = p.parent) {
|
|
972
|
+
if (p.el.matches(TARGETS)) {
|
|
973
|
+
nested = true;
|
|
974
|
+
break;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (!nested) targets.push(rec);
|
|
978
|
+
}
|
|
979
|
+
const failing = [];
|
|
980
|
+
for (const rec of targets) {
|
|
981
|
+
const { width, height } = rec.rect;
|
|
982
|
+
if (width >= MIN_TARGET && height >= MIN_TARGET) continue;
|
|
983
|
+
if (isInlineTextLink(rec) || hasLabel(rec.el)) continue;
|
|
984
|
+
const c = center(rec.rect);
|
|
985
|
+
const crowded = targets.some((other) => {
|
|
986
|
+
if (other === rec) return false;
|
|
987
|
+
if (distanceToBox(c, other.rect) < MIN_TARGET / 2) return true;
|
|
988
|
+
const small = other.rect.width < MIN_TARGET || other.rect.height < MIN_TARGET;
|
|
989
|
+
const oc = center(other.rect);
|
|
990
|
+
return small && Math.hypot(oc.x - c.x, oc.y - c.y) < MIN_TARGET;
|
|
991
|
+
});
|
|
992
|
+
if (crowded) failing.push(rec);
|
|
993
|
+
}
|
|
994
|
+
const siblings = /* @__PURE__ */ new Map();
|
|
995
|
+
for (const rec of targets) siblings.set(rec.parent, (siblings.get(rec.parent) ?? 0) + 1);
|
|
996
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
997
|
+
for (const rec of failing) {
|
|
998
|
+
const list = byParent.get(rec.parent);
|
|
999
|
+
if (list) list.push(rec);
|
|
1000
|
+
else byParent.set(rec.parent, [rec]);
|
|
1001
|
+
}
|
|
1002
|
+
const findings = [];
|
|
1003
|
+
for (const [parent, list] of byParent) {
|
|
1004
|
+
const first = list[0];
|
|
1005
|
+
if (!first) continue;
|
|
1006
|
+
const grouped = parent !== null && (siblings.get(parent) ?? 0) > 1;
|
|
1007
|
+
const minW = Math.min(...list.map((r) => r.rect.width));
|
|
1008
|
+
const minH = Math.min(...list.map((r) => r.rect.height));
|
|
1009
|
+
findings.push({
|
|
1010
|
+
detector: "small-tap-target",
|
|
1011
|
+
severity: "warning",
|
|
1012
|
+
title: list.length > 1 ? `${list.length} tap targets are ${px(minW)}\xD7${px(minH)}px and crowd each other` : `Tap target is ${px(first.rect.width)}\xD7${px(first.rect.height)}px and too close to its neighbours`,
|
|
1013
|
+
element: describe(grouped ? parent.el : first.el),
|
|
1014
|
+
...grouped ? { related: describe(first.el) } : {},
|
|
1015
|
+
evidence: { width: px(minW), height: px(minH), minimum: MIN_TARGET, targets: list.length },
|
|
1016
|
+
styles: pick(first.style, ["width", "height", "padding", "font-size"]),
|
|
1017
|
+
suggestion: "Make each target at least 24\xD724px (44\xD744px is better for touch), or add space between them."
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
return findings;
|
|
1021
|
+
}
|
|
1022
|
+
function viewportMeta() {
|
|
1023
|
+
const meta = document.querySelector('meta[name="viewport" i]');
|
|
1024
|
+
const content = meta?.getAttribute("content")?.replace(/\s+/g, "") ?? "";
|
|
1025
|
+
if (/width=device-width/i.test(content)) return [];
|
|
1026
|
+
const head = document.head ?? document.documentElement;
|
|
1027
|
+
return [
|
|
1028
|
+
{
|
|
1029
|
+
detector: "viewport-meta",
|
|
1030
|
+
severity: "error",
|
|
1031
|
+
title: meta ? "Viewport meta tag does not use width=device-width" : "Missing the responsive viewport meta tag",
|
|
1032
|
+
element: describe(meta ?? head),
|
|
1033
|
+
evidence: { content: content || "missing" },
|
|
1034
|
+
styles: {},
|
|
1035
|
+
suggestion: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> to <head>. Without it phones render the page at desktop width and shrink it.'
|
|
1036
|
+
}
|
|
1037
|
+
];
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// ../analyzers/src/detectors/overlap.ts
|
|
1041
|
+
var MIN_OVERLAP = 4;
|
|
1042
|
+
var BAND_PX = 200;
|
|
1043
|
+
var MIN_TEXT_LINE_SHARE = 0.3;
|
|
1044
|
+
var REPLACED = /* @__PURE__ */ new Set([
|
|
1045
|
+
"IMG",
|
|
1046
|
+
"VIDEO",
|
|
1047
|
+
"CANVAS",
|
|
1048
|
+
"IFRAME",
|
|
1049
|
+
"svg",
|
|
1050
|
+
"INPUT",
|
|
1051
|
+
"SELECT",
|
|
1052
|
+
"TEXTAREA",
|
|
1053
|
+
"BUTTON"
|
|
1054
|
+
]);
|
|
1055
|
+
function itemBox(rec) {
|
|
1056
|
+
const raw = REPLACED.has(rec.el.tagName) ? rec.rect : rec.textRect;
|
|
1057
|
+
if (!raw || raw.width <= 0 || raw.height <= 0) return null;
|
|
1058
|
+
return intersectBox(raw, rec.clip);
|
|
1059
|
+
}
|
|
1060
|
+
function ancestors(rec) {
|
|
1061
|
+
const chain = [];
|
|
1062
|
+
let p = rec;
|
|
1063
|
+
while (p) {
|
|
1064
|
+
chain.push(p);
|
|
1065
|
+
p = p.parent;
|
|
1066
|
+
}
|
|
1067
|
+
return chain;
|
|
1068
|
+
}
|
|
1069
|
+
function isExplicitlyPlacedGridItem(rec) {
|
|
1070
|
+
const s = rec.style;
|
|
1071
|
+
return s.gridRowStart !== "auto" || s.gridColumnStart !== "auto";
|
|
1072
|
+
}
|
|
1073
|
+
function branches(a, b) {
|
|
1074
|
+
const chainA = ancestors(a).reverse();
|
|
1075
|
+
const chainB = ancestors(b).reverse();
|
|
1076
|
+
let i = 0;
|
|
1077
|
+
while (i < chainA.length && i < chainB.length && chainA[i] === chainB[i]) i++;
|
|
1078
|
+
const branchA = chainA[i];
|
|
1079
|
+
const branchB = chainB[i];
|
|
1080
|
+
if (!branchA || !branchB) return null;
|
|
1081
|
+
const lca = i > 0 ? chainA[i - 1] ?? null : null;
|
|
1082
|
+
for (const rec of chainA.slice(i)) if (isDeliberateOffset(rec)) return null;
|
|
1083
|
+
for (const rec of chainB.slice(i)) if (isDeliberateOffset(rec)) return null;
|
|
1084
|
+
if (lca?.style.display.includes("grid")) {
|
|
1085
|
+
if (isExplicitlyPlacedGridItem(branchA) && isExplicitlyPlacedGridItem(branchB)) return null;
|
|
1086
|
+
}
|
|
1087
|
+
return { lca, a: branchA, b: branchB };
|
|
1088
|
+
}
|
|
1089
|
+
function intersect(a, b) {
|
|
1090
|
+
return {
|
|
1091
|
+
w: Math.min(a.right, b.right) - Math.max(a.left, b.left),
|
|
1092
|
+
h: Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
function paintedOverlap(x, y) {
|
|
1096
|
+
let best = { w: 0, h: 0 };
|
|
1097
|
+
for (const a of x.parts) {
|
|
1098
|
+
for (const b of y.parts) {
|
|
1099
|
+
const o = intersect(a, b);
|
|
1100
|
+
if (o.w > 0 && o.h > 0 && o.w * o.h > best.w * best.h) best = o;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return best;
|
|
1104
|
+
}
|
|
1105
|
+
var SPILL_PX = 2;
|
|
1106
|
+
function spillOf(branch, x, y) {
|
|
1107
|
+
for (const item of [x, y]) {
|
|
1108
|
+
if (!ancestors(item.rec).includes(branch) || item.rec === branch) continue;
|
|
1109
|
+
const box = branch.rect;
|
|
1110
|
+
if (item.box.bottom > box.bottom + SPILL_PX) return { box: branch, direction: "down" };
|
|
1111
|
+
if (item.box.right > box.right + SPILL_PX || item.box.left < box.left - SPILL_PX) {
|
|
1112
|
+
return { box: branch, direction: "across" };
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
function name(rec) {
|
|
1118
|
+
return `\`${describe(rec.el).selector}\``;
|
|
1119
|
+
}
|
|
1120
|
+
function suggestionFor(g) {
|
|
1121
|
+
if (g.spill?.direction === "down") {
|
|
1122
|
+
return `The content of ${name(g.spill.box)} is taller than its box at this width and runs down over what comes next. Its height is fixed (height or max-height) and the content no longer fits once it wraps; use min-height instead, or let it grow with its content.`;
|
|
1123
|
+
}
|
|
1124
|
+
if (g.spill?.direction === "across") {
|
|
1125
|
+
return `The content of ${name(g.spill.box)} is wider than its box at this width and runs sideways into its neighbour. Let it shrink or wrap (min-width: 0 on flex or grid items, overflow-wrap: anywhere for long words, max-width: 100% for media).`;
|
|
1126
|
+
}
|
|
1127
|
+
const lca = g.lca;
|
|
1128
|
+
const display = lca?.style.display ?? "";
|
|
1129
|
+
if (lca && display.includes("flex") && !lca.style.flexDirection.startsWith("column")) {
|
|
1130
|
+
return `They sit side by side in the flex row ${name(lca)}, which runs out of room at this width. Let the row wrap (flex-wrap: wrap), let the item that should give way shrink (min-width: 0), or stack them below this width with a media query.`;
|
|
1131
|
+
}
|
|
1132
|
+
if (lca && display.includes("grid")) {
|
|
1133
|
+
return `They share the grid ${name(lca)}, whose columns are too narrow here for what is in them. Use minmax(0, 1fr) columns so items can shrink, or switch to fewer columns below this width.`;
|
|
1134
|
+
}
|
|
1135
|
+
return "These two areas stop making room for each other at this width. Stack them earlier with a media query, or let the narrower one shrink (min-width: 0).";
|
|
1136
|
+
}
|
|
1137
|
+
function overlap(snap) {
|
|
1138
|
+
const items = [];
|
|
1139
|
+
for (const rec of snap.records) {
|
|
1140
|
+
if (rec.outOfFlow || isVisuallyHidden(rec)) continue;
|
|
1141
|
+
const box = itemBox(rec);
|
|
1142
|
+
if (!box || box.width < 1 || box.height < 1) continue;
|
|
1143
|
+
const isText = !REPLACED.has(rec.el.tagName);
|
|
1144
|
+
const parts = isText ? rec.textFragments.map((f) => intersectBox(f, rec.clip)).filter((f) => !!f && f.width > 0 && f.height > 0) : [];
|
|
1145
|
+
items.push({
|
|
1146
|
+
rec,
|
|
1147
|
+
box,
|
|
1148
|
+
parts: parts.length ? parts : [box],
|
|
1149
|
+
lineHeight: isText ? box.height / Math.max(1, rec.textLines) : 0
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1153
|
+
const index = /* @__PURE__ */ new Map();
|
|
1154
|
+
for (const [i, r] of snap.records.entries()) index.set(r, i);
|
|
1155
|
+
const bands = /* @__PURE__ */ new Map();
|
|
1156
|
+
for (const item of items) {
|
|
1157
|
+
const last = Math.floor(item.box.bottom / BAND_PX);
|
|
1158
|
+
for (let b = Math.floor(item.box.top / BAND_PX); b <= last; b++) {
|
|
1159
|
+
const list = bands.get(b);
|
|
1160
|
+
if (list) list.push(item);
|
|
1161
|
+
else bands.set(b, [item]);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
for (const [band, list] of bands) {
|
|
1165
|
+
list.sort((x, y) => x.box.left - y.box.left);
|
|
1166
|
+
for (let i = 0; i < list.length; i++) {
|
|
1167
|
+
const x = list[i];
|
|
1168
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
1169
|
+
const y = list[j];
|
|
1170
|
+
if (y.box.left >= x.box.right - MIN_OVERLAP) break;
|
|
1171
|
+
if (Math.floor(Math.max(x.box.top, y.box.top) / BAND_PX) !== band) continue;
|
|
1172
|
+
const rough = intersect(x.box, y.box);
|
|
1173
|
+
if (rough.w < MIN_OVERLAP || rough.h < MIN_OVERLAP) continue;
|
|
1174
|
+
const { w, h } = paintedOverlap(x, y);
|
|
1175
|
+
const minH = Math.max(
|
|
1176
|
+
MIN_OVERLAP,
|
|
1177
|
+
MIN_TEXT_LINE_SHARE * Math.max(x.lineHeight, y.lineHeight)
|
|
1178
|
+
);
|
|
1179
|
+
if (w < MIN_OVERLAP || h < minH) continue;
|
|
1180
|
+
const split = branches(x.rec, y.rec);
|
|
1181
|
+
if (!split) continue;
|
|
1182
|
+
const [first, second] = (index.get(split.a) ?? 0) < (index.get(split.b) ?? 0) ? [split.a, split.b] : [split.b, split.a];
|
|
1183
|
+
const key = `${index.get(first)}:${index.get(second)}`;
|
|
1184
|
+
const spill = spillOf(first, x, y) ?? spillOf(second, x, y);
|
|
1185
|
+
const g = groups.get(key);
|
|
1186
|
+
if (g) {
|
|
1187
|
+
g.w = Math.max(g.w, w);
|
|
1188
|
+
g.h = Math.max(g.h, h);
|
|
1189
|
+
g.pairs++;
|
|
1190
|
+
g.spill ??= spill;
|
|
1191
|
+
} else {
|
|
1192
|
+
groups.set(key, { a: first, b: second, lca: split.lca, w, h, pairs: 1, spill });
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
const findings = [];
|
|
1198
|
+
for (const g of groups.values()) {
|
|
1199
|
+
findings.push({
|
|
1200
|
+
detector: "overlap",
|
|
1201
|
+
severity: "error",
|
|
1202
|
+
title: `Overlaps another element (${px(g.w)}\xD7${px(g.h)}px)`,
|
|
1203
|
+
element: describe(g.a.el),
|
|
1204
|
+
related: describe(g.b.el),
|
|
1205
|
+
evidence: { overlapWidth: px(g.w), overlapHeight: px(g.h), overlappingPairs: g.pairs },
|
|
1206
|
+
suggestion: suggestionFor(g),
|
|
1207
|
+
styles: pick(g.a.style, [
|
|
1208
|
+
"display",
|
|
1209
|
+
"position",
|
|
1210
|
+
"width",
|
|
1211
|
+
"min-width",
|
|
1212
|
+
"flex-shrink",
|
|
1213
|
+
"grid-template-columns"
|
|
1214
|
+
])
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
return findings;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// ../analyzers/src/detectors/text.ts
|
|
1221
|
+
var CUT_TOLERANCE = 1;
|
|
1222
|
+
var MIN_GUTTER = 4;
|
|
1223
|
+
var MIN_TINY_FONT = 12;
|
|
1224
|
+
var MAX_TINY_FINDINGS = 8;
|
|
1225
|
+
var MIN_LARGE_FONT = 28;
|
|
1226
|
+
var MIN_LARGE_CHARS_PER_LINE = 12;
|
|
1227
|
+
var MAX_LARGE_FINDINGS = 8;
|
|
1228
|
+
function scrollsOn(style, axis) {
|
|
1229
|
+
const v = axis === "x" ? style.overflowX : style.overflowY;
|
|
1230
|
+
return v === "auto" || v === "scroll";
|
|
1231
|
+
}
|
|
1232
|
+
function clipsOn(style, axis) {
|
|
1233
|
+
return (axis === "x" ? style.overflowX : style.overflowY) !== "visible";
|
|
1234
|
+
}
|
|
1235
|
+
function truncatesOnPurpose(rec, axis) {
|
|
1236
|
+
const s = rec.style;
|
|
1237
|
+
if (axis === "x") return s.textOverflow === "ellipsis" || s.textOverflow === "clip";
|
|
1238
|
+
return !!s.webkitLineClamp && s.webkitLineClamp !== "none";
|
|
1239
|
+
}
|
|
1240
|
+
function findClipper(rec, raw, axis) {
|
|
1241
|
+
for (let p = rec; p; p = p.parent) {
|
|
1242
|
+
if (!clipsOn(p.style, axis)) continue;
|
|
1243
|
+
const cut = axis === "x" ? raw.left < p.rect.left - CUT_TOLERANCE || raw.right > p.rect.right + CUT_TOLERANCE : raw.top < p.rect.top - CUT_TOLERANCE || raw.bottom > p.rect.bottom + CUT_TOLERANCE;
|
|
1244
|
+
if (cut) return p;
|
|
1245
|
+
}
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
function clippersWithVisibleText(snap) {
|
|
1249
|
+
const out = /* @__PURE__ */ new Set();
|
|
1250
|
+
for (const rec of snap.records) {
|
|
1251
|
+
if (!rec.textRect) continue;
|
|
1252
|
+
for (let p = rec.parent; p; p = p.parent) {
|
|
1253
|
+
if (out.has(p)) break;
|
|
1254
|
+
if (clipsOn(p.style, "y")) out.add(p);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return out;
|
|
1258
|
+
}
|
|
1259
|
+
function clippedText(snap) {
|
|
1260
|
+
const byClipper = /* @__PURE__ */ new Map();
|
|
1261
|
+
const vw = snap.viewportWidth;
|
|
1262
|
+
let showing = null;
|
|
1263
|
+
for (const rec of snap.records) {
|
|
1264
|
+
const raw = rec.rawTextRect;
|
|
1265
|
+
if (!raw || rec.outOfFlow === "fixed" || isVisuallyHidden(rec) || isOrnament(rec)) continue;
|
|
1266
|
+
const visible = intersectBox(raw, rec.innerClip);
|
|
1267
|
+
let axis;
|
|
1268
|
+
let clipper;
|
|
1269
|
+
let cut;
|
|
1270
|
+
if (visible) {
|
|
1271
|
+
const cutX = raw.width - visible.width;
|
|
1272
|
+
const cutY = raw.height - visible.height;
|
|
1273
|
+
axis = cutY > CUT_TOLERANCE ? "y" : cutX > CUT_TOLERANCE ? "x" : null;
|
|
1274
|
+
if (!axis) continue;
|
|
1275
|
+
clipper = findClipper(rec, raw, axis);
|
|
1276
|
+
cut = axis === "x" ? cutX : cutY;
|
|
1277
|
+
} else {
|
|
1278
|
+
axis = "y";
|
|
1279
|
+
clipper = findClipper(rec, raw, "y");
|
|
1280
|
+
if (!clipper || raw.top < clipper.rect.bottom - CUT_TOLERANCE) continue;
|
|
1281
|
+
showing ??= clippersWithVisibleText(snap);
|
|
1282
|
+
if (!showing.has(clipper)) continue;
|
|
1283
|
+
cut = raw.bottom - clipper.rect.bottom;
|
|
1284
|
+
}
|
|
1285
|
+
if (!clipper || scrollsOn(clipper.style, axis) || clipper.rect.height < 2) continue;
|
|
1286
|
+
if (axis === "x" && clipper.rect.left <= 1 && clipper.rect.right >= vw - 1) continue;
|
|
1287
|
+
let deliberate = false;
|
|
1288
|
+
for (let p = rec; p; p = p.parent) {
|
|
1289
|
+
if (truncatesOnPurpose(p, axis) || p.style.transform && p.style.transform !== "none") {
|
|
1290
|
+
deliberate = true;
|
|
1291
|
+
break;
|
|
1292
|
+
}
|
|
1293
|
+
if (p === clipper) break;
|
|
1294
|
+
}
|
|
1295
|
+
if (deliberate) continue;
|
|
1296
|
+
const prev = byClipper.get(clipper);
|
|
1297
|
+
if (prev) {
|
|
1298
|
+
prev.count++;
|
|
1299
|
+
if (cut > prev.cut) Object.assign(prev, { rec, axis, cut });
|
|
1300
|
+
} else {
|
|
1301
|
+
byClipper.set(clipper, { rec, axis, cut, count: 1 });
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
const findings = [];
|
|
1305
|
+
for (const [clipper, { rec, axis, cut, count }] of byClipper) {
|
|
1306
|
+
const fixedSize = axis === "y" ? clipper.style.height : clipper.style.width;
|
|
1307
|
+
findings.push({
|
|
1308
|
+
detector: "clipped-text",
|
|
1309
|
+
severity: "warning",
|
|
1310
|
+
title: `Container cuts off its text (${px(cut)}px hidden ${axis === "y" ? "below" : "to the side"})`,
|
|
1311
|
+
element: describe(clipper.el),
|
|
1312
|
+
related: describe(rec.el),
|
|
1313
|
+
evidence: { hiddenPx: px(cut), axis, clippedTextElements: count },
|
|
1314
|
+
styles: pick(clipper.style, [
|
|
1315
|
+
"height",
|
|
1316
|
+
"max-height",
|
|
1317
|
+
"width",
|
|
1318
|
+
"max-width",
|
|
1319
|
+
"overflow",
|
|
1320
|
+
"white-space"
|
|
1321
|
+
]),
|
|
1322
|
+
suggestion: axis === "y" ? `The container clips its content at ${fixedSize}. Let it grow (min-height instead of height), or truncate on purpose with line-clamp.` : "The container is too narrow for this text. Let the text wrap, or truncate on purpose with text-overflow: ellipsis."
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
return findings;
|
|
1326
|
+
}
|
|
1327
|
+
function lineText(rec) {
|
|
1328
|
+
for (const child of rec.el.children) {
|
|
1329
|
+
const display = getComputedStyle(child).display;
|
|
1330
|
+
if (display !== "none" && !display.startsWith("inline")) return rec.text;
|
|
1331
|
+
}
|
|
1332
|
+
const el = rec.el;
|
|
1333
|
+
return (el.innerText ?? el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
1334
|
+
}
|
|
1335
|
+
function squishedText(snap) {
|
|
1336
|
+
const findings = [];
|
|
1337
|
+
for (const rec of snap.records) {
|
|
1338
|
+
if (!rec.textRect || rec.textLines < 3 || isVisuallyHidden(rec) || isInline(rec.style))
|
|
1339
|
+
continue;
|
|
1340
|
+
const text = lineText(rec);
|
|
1341
|
+
const chars = text.replace(/\s/g, "").length;
|
|
1342
|
+
if (chars < 8) continue;
|
|
1343
|
+
if (rec.rect.width >= 10 * fontSize(rec)) continue;
|
|
1344
|
+
const words = text.split(" ").filter(Boolean).length;
|
|
1345
|
+
const perLine = chars / rec.textLines;
|
|
1346
|
+
if ((rec.textLines <= words || breaksAnywhere(text)) && perLine >= 3) continue;
|
|
1347
|
+
findings.push({
|
|
1348
|
+
detector: "squished-text",
|
|
1349
|
+
severity: "warning",
|
|
1350
|
+
title: `Text squeezed to about ${Math.max(1, Math.round(perLine))} characters per line`,
|
|
1351
|
+
element: describe(rec.el),
|
|
1352
|
+
evidence: {
|
|
1353
|
+
lines: rec.textLines,
|
|
1354
|
+
charsPerLine: Math.round(perLine * 10) / 10,
|
|
1355
|
+
width: px(rec.rect.width)
|
|
1356
|
+
},
|
|
1357
|
+
styles: pick(rec.style, ["width", "min-width", "flex-basis", "word-break", "overflow-wrap"]),
|
|
1358
|
+
suggestion: "This column gets too narrow at this width. Give it a min-width, or stack the layout earlier."
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
return findings;
|
|
1362
|
+
}
|
|
1363
|
+
function browserDefault(rec) {
|
|
1364
|
+
const control = rec.el.closest("button, input, select, textarea");
|
|
1365
|
+
if (!control) return false;
|
|
1366
|
+
const around = control.parentElement;
|
|
1367
|
+
return !!around && fontSize(rec) < Number.parseFloat(getComputedStyle(around).fontSize);
|
|
1368
|
+
}
|
|
1369
|
+
function tinyText(snap) {
|
|
1370
|
+
if (snap.viewportWidth > TOUCH_MAX_WIDTH) return [];
|
|
1371
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
1372
|
+
for (const rec of snap.records) {
|
|
1373
|
+
if (!rec.textRect || rec.text.length < 3 || isVisuallyHidden(rec) || isOrnament(rec)) continue;
|
|
1374
|
+
const size = fontSize(rec);
|
|
1375
|
+
if (size >= MIN_TINY_FONT) continue;
|
|
1376
|
+
const g = byParent.get(rec.parent);
|
|
1377
|
+
if (g) {
|
|
1378
|
+
g.count++;
|
|
1379
|
+
if (size < g.size) Object.assign(g, { rec, size });
|
|
1380
|
+
} else {
|
|
1381
|
+
byParent.set(rec.parent, { rec, size, count: 1 });
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
const findings = [];
|
|
1385
|
+
for (const [parent, g] of byParent) {
|
|
1386
|
+
if (findings.length >= MAX_TINY_FINDINGS) break;
|
|
1387
|
+
const grouped = g.count > 1 && parent !== null;
|
|
1388
|
+
const size = Math.round(g.size * 10) / 10;
|
|
1389
|
+
findings.push({
|
|
1390
|
+
detector: "tiny-text",
|
|
1391
|
+
severity: "info",
|
|
1392
|
+
title: g.count > 1 ? `${g.count} pieces of text are ${size}px on small screens` : `Text is ${size}px on small screens`,
|
|
1393
|
+
element: describe(grouped ? parent.el : g.rec.el),
|
|
1394
|
+
...grouped ? { related: describe(g.rec.el) } : {},
|
|
1395
|
+
evidence: { fontSizePx: size, textElements: g.count },
|
|
1396
|
+
styles: pick(g.rec.style, ["font-size", "line-height"]),
|
|
1397
|
+
suggestion: browserDefault(g.rec) ? "Form controls don't take the page's font unless told to, and Safari's default is 11px. Add font: inherit to button, input, select and textarea." : "Use at least 12px (16px for body text) on small screens."
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
return findings;
|
|
1401
|
+
}
|
|
1402
|
+
function forcedLines(el, style) {
|
|
1403
|
+
let lines = 1;
|
|
1404
|
+
const keepsNewlines = /^(pre|pre-wrap|pre-line|break-spaces)$/.test(style.whiteSpace);
|
|
1405
|
+
for (const node of el.childNodes) {
|
|
1406
|
+
if (node.nodeName === "BR") lines++;
|
|
1407
|
+
else if (keepsNewlines && node.nodeType === Node.TEXT_NODE) {
|
|
1408
|
+
lines += (node.textContent?.trim().match(/\n/g) ?? []).length;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return lines;
|
|
1412
|
+
}
|
|
1413
|
+
function oversizedText(snap) {
|
|
1414
|
+
const vw = snap.viewportWidth;
|
|
1415
|
+
if (vw > TOUCH_MAX_WIDTH) return [];
|
|
1416
|
+
const findings = [];
|
|
1417
|
+
for (const rec of snap.records) {
|
|
1418
|
+
if (findings.length >= MAX_LARGE_FINDINGS) break;
|
|
1419
|
+
if (!rec.textRect || rec.textLines < 3 || isVisuallyHidden(rec)) continue;
|
|
1420
|
+
const size = fontSize(rec);
|
|
1421
|
+
if (size < MIN_LARGE_FONT) continue;
|
|
1422
|
+
const words = rec.text.split(" ").filter(Boolean).length;
|
|
1423
|
+
if (words < 2) continue;
|
|
1424
|
+
const chars = rec.text.replace(/\s/g, "").length;
|
|
1425
|
+
const perLine = chars / rec.textLines;
|
|
1426
|
+
if (perLine >= MIN_LARGE_CHARS_PER_LINE) continue;
|
|
1427
|
+
if (rec.textLines > words && rec.rect.width < 10 * size) continue;
|
|
1428
|
+
if (rec.style.writingMode && !rec.style.writingMode.startsWith("horizontal")) continue;
|
|
1429
|
+
if (rec.outOfFlow || isOrnament(rec)) continue;
|
|
1430
|
+
if (rec.textLines - forcedLines(rec.el, rec.style) < 2) continue;
|
|
1431
|
+
let block = rec;
|
|
1432
|
+
while (!isBlock(block) && block.parent) block = block.parent;
|
|
1433
|
+
const room = block.parent ? contentBox(block.parent).width : vw;
|
|
1434
|
+
if (block.rect.width < room * 0.9 || block.rect.width < vw * 0.6) continue;
|
|
1435
|
+
const rem = Math.round(size / 16 * 100) / 100;
|
|
1436
|
+
findings.push({
|
|
1437
|
+
detector: "oversized-text",
|
|
1438
|
+
severity: "warning",
|
|
1439
|
+
title: `Text is too big for the screen (${px(size)}px, ${rec.textLines} lines of a word or two)`,
|
|
1440
|
+
element: describe(rec.el),
|
|
1441
|
+
evidence: {
|
|
1442
|
+
fontSizePx: px(size),
|
|
1443
|
+
lines: rec.textLines,
|
|
1444
|
+
charsPerLine: Math.round(perLine * 10) / 10,
|
|
1445
|
+
screenShare: Math.round(rec.textRect.height / snap.viewportHeight * 100) / 100
|
|
1446
|
+
},
|
|
1447
|
+
styles: pick(rec.style, ["font-size", "line-height", "letter-spacing"]),
|
|
1448
|
+
suggestion: `Scale it down on small screens, for example font-size: clamp(1.75rem, 4vw + 1rem, ${rem}rem), or a smaller size below 640px.`
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
return findings;
|
|
1452
|
+
}
|
|
1453
|
+
function isBlock(rec) {
|
|
1454
|
+
return !rec.style.display.startsWith("inline") && rec.style.display !== "contents";
|
|
1455
|
+
}
|
|
1456
|
+
function edgeGutter(snap) {
|
|
1457
|
+
const vw = snap.viewportWidth;
|
|
1458
|
+
if (vw > TOUCH_MAX_WIDTH) return [];
|
|
1459
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1460
|
+
for (const rec of snap.records) {
|
|
1461
|
+
const t = rec.textRect;
|
|
1462
|
+
if (!t || rec.outOfFlow === "fixed" || isVisuallyHidden(rec) || rec.text.length < 2) continue;
|
|
1463
|
+
if (isOrnament(rec)) continue;
|
|
1464
|
+
if (t.left < -0.5 || t.right > vw + 0.5) continue;
|
|
1465
|
+
let moves = false;
|
|
1466
|
+
let overflows = rec.rect.right > vw + 0.5 || rec.rect.left < -0.5;
|
|
1467
|
+
for (let p = rec.parent; p && !moves && !overflows; p = p.parent) {
|
|
1468
|
+
if (scrollsOn(p.style, "x") || p.style.transform && p.style.transform !== "none") {
|
|
1469
|
+
moves = true;
|
|
1470
|
+
}
|
|
1471
|
+
if (p.rect.right > vw + 0.5 || p.rect.left < -0.5) overflows = true;
|
|
1472
|
+
}
|
|
1473
|
+
if (moves || overflows) continue;
|
|
1474
|
+
let container = rec;
|
|
1475
|
+
while (!isBlock(container) && container.parent) container = container.parent;
|
|
1476
|
+
const box = contentBox(container);
|
|
1477
|
+
const leftGap = box.left;
|
|
1478
|
+
const rightGap = vw - box.right;
|
|
1479
|
+
let side = null;
|
|
1480
|
+
if (leftGap < MIN_GUTTER && t.left < MIN_GUTTER + 0.5) side = "left";
|
|
1481
|
+
else if (rightGap < MIN_GUTTER && (rec.textLines >= 2 || t.right > vw - MIN_GUTTER))
|
|
1482
|
+
side = "right";
|
|
1483
|
+
if (!side) continue;
|
|
1484
|
+
const gap = side === "left" ? leftGap : rightGap;
|
|
1485
|
+
const g = groups.get(container);
|
|
1486
|
+
if (g) {
|
|
1487
|
+
g.count++;
|
|
1488
|
+
if (gap < g.gap) Object.assign(g, { gap, side, rec });
|
|
1489
|
+
} else {
|
|
1490
|
+
groups.set(container, { side, gap, count: 1, rec });
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
const findings = [];
|
|
1494
|
+
for (const [container, g] of groups) {
|
|
1495
|
+
findings.push({
|
|
1496
|
+
detector: "edge-gutter",
|
|
1497
|
+
severity: "warning",
|
|
1498
|
+
title: `Text touches the ${g.side} edge of the screen (${Math.max(0, px(g.gap))}px gap)`,
|
|
1499
|
+
element: describe(container.el),
|
|
1500
|
+
evidence: { gapPx: Math.max(0, px(g.gap)), side: g.side, textElements: g.count },
|
|
1501
|
+
styles: pick(container.style, [
|
|
1502
|
+
"padding-left",
|
|
1503
|
+
"padding-right",
|
|
1504
|
+
"margin-left",
|
|
1505
|
+
"margin-right",
|
|
1506
|
+
"width"
|
|
1507
|
+
]),
|
|
1508
|
+
suggestion: "Add horizontal padding (around 16px) to this container on small screens."
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
return findings;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// ../analyzers/src/detectors/wrap.ts
|
|
1515
|
+
var NAV_CONTEXT = 'nav, header, [role="navigation"], [role="tablist"], [role="menubar"], [role="banner"]';
|
|
1516
|
+
var LABEL = 'button, [role="button"], [role="tab"], [role="menuitem"]';
|
|
1517
|
+
var MAX_LABEL_CHARS = 40;
|
|
1518
|
+
function isRowContainer(rec) {
|
|
1519
|
+
const s = rec.style;
|
|
1520
|
+
if (s.display === "flex" || s.display === "inline-flex") {
|
|
1521
|
+
return s.flexDirection.startsWith("row") && s.flexWrap !== "nowrap";
|
|
1522
|
+
}
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
function inlineChildren(rec, byElement) {
|
|
1526
|
+
const out = [];
|
|
1527
|
+
for (const child of renderedChildren(rec.el)) {
|
|
1528
|
+
const c = byElement.get(child);
|
|
1529
|
+
if (!c || c.outOfFlow || isVisuallyHidden(c)) continue;
|
|
1530
|
+
out.push(c);
|
|
1531
|
+
}
|
|
1532
|
+
return out;
|
|
1533
|
+
}
|
|
1534
|
+
function allInlineLevel(children) {
|
|
1535
|
+
return children.length > 0 && children.every(
|
|
1536
|
+
(c) => c.style.display.startsWith("inline") && c.style.display !== "inline-table"
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
function countRows(children) {
|
|
1540
|
+
const tops = [];
|
|
1541
|
+
for (const c of children) {
|
|
1542
|
+
const half = Math.max(4, c.rect.height / 2);
|
|
1543
|
+
if (!tops.some((t) => Math.abs(t - c.rect.top) < half)) tops.push(c.rect.top);
|
|
1544
|
+
}
|
|
1545
|
+
return tops.length;
|
|
1546
|
+
}
|
|
1547
|
+
function textLinesDeep(el) {
|
|
1548
|
+
const tops = [];
|
|
1549
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
1550
|
+
const range = document.createRange();
|
|
1551
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
1552
|
+
if (!node.textContent?.trim()) continue;
|
|
1553
|
+
const box = node.parentElement?.getBoundingClientRect();
|
|
1554
|
+
if (box && (box.width <= 1 || box.height <= 1)) continue;
|
|
1555
|
+
range.selectNodeContents(node);
|
|
1556
|
+
for (const r of range.getClientRects()) {
|
|
1557
|
+
if (r.width === 0 || r.height === 0) continue;
|
|
1558
|
+
if (!tops.some((t) => Math.abs(t.top - r.top) < Math.max(t.height, r.height) / 2)) {
|
|
1559
|
+
tops.push({ top: r.top, height: r.height });
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return tops.length;
|
|
1564
|
+
}
|
|
1565
|
+
function wrapStats(snap) {
|
|
1566
|
+
const stats = [];
|
|
1567
|
+
for (const rec of snap.records) {
|
|
1568
|
+
if (isVisuallyHidden(rec)) continue;
|
|
1569
|
+
const children = inlineChildren(rec, snap.byElement);
|
|
1570
|
+
if (children.length >= 2 && (isRowContainer(rec) || allInlineLevel(children))) {
|
|
1571
|
+
if (closestComposed(rec.el, NAV_CONTEXT)) {
|
|
1572
|
+
stats.push({
|
|
1573
|
+
element: describe(rec.el),
|
|
1574
|
+
kind: "row",
|
|
1575
|
+
lines: countRows(children),
|
|
1576
|
+
items: children.length
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
const isLabel = rec.el.matches(LABEL) || rec.el.matches("a[href]") && closestComposed(rec.el, NAV_CONTEXT) !== null;
|
|
1581
|
+
if (isLabel) {
|
|
1582
|
+
const text = rec.el.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
|
1583
|
+
if (text && text.length <= MAX_LABEL_CHARS && text.includes(" ")) {
|
|
1584
|
+
stats.push({
|
|
1585
|
+
element: describe(rec.el),
|
|
1586
|
+
kind: "label",
|
|
1587
|
+
lines: textLinesDeep(rec.el),
|
|
1588
|
+
items: 1
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
return stats;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// ../analyzers/src/fingerprint.ts
|
|
1597
|
+
var LANDMARKS = /* @__PURE__ */ new Set([
|
|
1598
|
+
"HEADER",
|
|
1599
|
+
"NAV",
|
|
1600
|
+
"MAIN",
|
|
1601
|
+
"ASIDE",
|
|
1602
|
+
"FOOTER",
|
|
1603
|
+
"SECTION",
|
|
1604
|
+
"ARTICLE",
|
|
1605
|
+
"FORM"
|
|
1606
|
+
]);
|
|
1607
|
+
var MAX_DEPTH = 5;
|
|
1608
|
+
var MAX_ELEMENTS = 400;
|
|
1609
|
+
function isStructural(rec, depth) {
|
|
1610
|
+
if (depth <= 3) return true;
|
|
1611
|
+
if (LANDMARKS.has(rec.el.tagName)) return true;
|
|
1612
|
+
const d = rec.style.display;
|
|
1613
|
+
return depth <= MAX_DEPTH && (d.includes("flex") || d.includes("grid"));
|
|
1614
|
+
}
|
|
1615
|
+
function rowsAndColumns(children) {
|
|
1616
|
+
const rows = [];
|
|
1617
|
+
const counts = [];
|
|
1618
|
+
for (const c of children) {
|
|
1619
|
+
const tol = Math.max(4, Math.min(c.rect.height / 2, 24));
|
|
1620
|
+
const i = rows.findIndex((t) => Math.abs(t - c.rect.top) < tol);
|
|
1621
|
+
if (i === -1) {
|
|
1622
|
+
rows.push(c.rect.top);
|
|
1623
|
+
counts.push(1);
|
|
1624
|
+
} else {
|
|
1625
|
+
counts[i] = (counts[i] ?? 0) + 1;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return counts.join(".");
|
|
1629
|
+
}
|
|
1630
|
+
function layoutSignature(snap, index) {
|
|
1631
|
+
const children = /* @__PURE__ */ new Map();
|
|
1632
|
+
for (const rec of snap.records) {
|
|
1633
|
+
if (!rec.parent || rec.outOfFlow === "fixed") continue;
|
|
1634
|
+
const list = children.get(rec.parent);
|
|
1635
|
+
if (list) list.push(rec);
|
|
1636
|
+
else children.set(rec.parent, [rec]);
|
|
1637
|
+
}
|
|
1638
|
+
const branchDepth = /* @__PURE__ */ new Map();
|
|
1639
|
+
for (const rec of snap.records) {
|
|
1640
|
+
const parent = rec.parent;
|
|
1641
|
+
const parentDepth = parent ? branchDepth.get(parent) ?? 0 : 0;
|
|
1642
|
+
const branches2 = parent ? (children.get(parent)?.length ?? 0) > 1 : true;
|
|
1643
|
+
branchDepth.set(rec, parentDepth + (branches2 ? 1 : 0));
|
|
1644
|
+
}
|
|
1645
|
+
const parts = [];
|
|
1646
|
+
for (const rec of snap.records) {
|
|
1647
|
+
if (parts.length >= MAX_ELEMENTS) break;
|
|
1648
|
+
if (rec.outOfFlow === "fixed" || !isStructural(rec, branchDepth.get(rec) ?? 0)) continue;
|
|
1649
|
+
const kids = (children.get(rec) ?? []).filter(
|
|
1650
|
+
(c) => !c.outOfFlow && !c.style.display.startsWith("inline")
|
|
1651
|
+
);
|
|
1652
|
+
const s = rec.style;
|
|
1653
|
+
let mode = s.display;
|
|
1654
|
+
if (mode.includes("flex")) mode += `/${s.flexDirection}`;
|
|
1655
|
+
const wraps = mode.includes("flex") && s.flexWrap !== "nowrap";
|
|
1656
|
+
const arrangement = kids.length > 1 && !wraps ? rowsAndColumns(kids) : String(kids.length);
|
|
1657
|
+
parts.push(`${index(rec.el)}=${mode}:${arrangement}`);
|
|
1658
|
+
}
|
|
1659
|
+
return parts.join("|");
|
|
1660
|
+
}
|
|
1661
|
+
var MEDIA_EM = 16;
|
|
1662
|
+
var QUERY_UNITS = "(px|em|rem)";
|
|
1663
|
+
function parseContainerCondition(text) {
|
|
1664
|
+
const out = [];
|
|
1665
|
+
const name2 = text.match(/^\s*(?!not\b|style\b|scroll-state\b)([a-zA-Z_][\w-]*)\s*(?=\(|$)/)?.[1] ?? null;
|
|
1666
|
+
const add = (value, unit, after) => out.push({ name: name2, value: Number.parseFloat(value), unit, after });
|
|
1667
|
+
const legacy = new RegExp(`(min|max)-(?:width|inline-size)\\s*:\\s*([\\d.]+)${QUERY_UNITS}`, "g");
|
|
1668
|
+
for (let m = legacy.exec(text); m; m = legacy.exec(text)) {
|
|
1669
|
+
add(m[2] ?? "0", m[3] ?? "px", m[1] === "max");
|
|
1670
|
+
}
|
|
1671
|
+
const range = new RegExp(`(?:width|inline-size)\\s*(>=|>|<=|<)\\s*([\\d.]+)${QUERY_UNITS}`, "g");
|
|
1672
|
+
for (let m = range.exec(text); m; m = range.exec(text)) {
|
|
1673
|
+
add(m[2] ?? "0", m[3] ?? "px", m[1] === "<=" || m[1] === ">");
|
|
1674
|
+
}
|
|
1675
|
+
const reversed = new RegExp(
|
|
1676
|
+
`([\\d.]+)${QUERY_UNITS}\\s*(>=|>|<=|<)\\s*(?:width|inline-size)`,
|
|
1677
|
+
"g"
|
|
1678
|
+
);
|
|
1679
|
+
for (let m = reversed.exec(text); m; m = reversed.exec(text)) {
|
|
1680
|
+
add(m[1] ?? "0", m[2] ?? "px", m[3] === "<" || m[3] === ">=");
|
|
1681
|
+
}
|
|
1682
|
+
return out;
|
|
1683
|
+
}
|
|
1684
|
+
var queryCache = null;
|
|
1685
|
+
function containerQueries() {
|
|
1686
|
+
if (typeof CSSContainerRule === "undefined") return [];
|
|
1687
|
+
const roots = [document, ...shadowRoots()];
|
|
1688
|
+
const sheets = roots.flatMap((r) => [...r.styleSheets, ...r.adoptedStyleSheets ?? []]);
|
|
1689
|
+
const key = sheets.map((s) => {
|
|
1690
|
+
try {
|
|
1691
|
+
return s.cssRules.length;
|
|
1692
|
+
} catch {
|
|
1693
|
+
return -1;
|
|
1694
|
+
}
|
|
1695
|
+
}).join(",");
|
|
1696
|
+
if (queryCache?.key === key) return queryCache.queries;
|
|
1697
|
+
const queries = [];
|
|
1698
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1699
|
+
const walk = (rules) => {
|
|
1700
|
+
for (const rule of rules) {
|
|
1701
|
+
if (rule instanceof CSSContainerRule) {
|
|
1702
|
+
for (const q of parseContainerCondition(rule.conditionText)) {
|
|
1703
|
+
const id = `${q.name}|${q.value}${q.unit}|${q.after}`;
|
|
1704
|
+
if (!seen.has(id)) {
|
|
1705
|
+
seen.add(id);
|
|
1706
|
+
queries.push(q);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
if (rule instanceof CSSImportRule) {
|
|
1711
|
+
try {
|
|
1712
|
+
if (rule.styleSheet) walk(rule.styleSheet.cssRules);
|
|
1713
|
+
} catch {
|
|
1714
|
+
}
|
|
1715
|
+
} else if ("cssRules" in rule && rule.cssRules) {
|
|
1716
|
+
walk(rule.cssRules);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
for (const sheet of sheets) {
|
|
1721
|
+
try {
|
|
1722
|
+
walk(sheet.cssRules);
|
|
1723
|
+
} catch {
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
queryCache = { key, queries };
|
|
1727
|
+
return queries;
|
|
1728
|
+
}
|
|
1729
|
+
var MAX_CONTAINER_BITS = 256;
|
|
1730
|
+
function containerSignature(snap) {
|
|
1731
|
+
const queries = containerQueries();
|
|
1732
|
+
if (!queries.length) return void 0;
|
|
1733
|
+
const rootFont = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
1734
|
+
let bits = "";
|
|
1735
|
+
for (const rec of snap.records) {
|
|
1736
|
+
const s = rec.style;
|
|
1737
|
+
if (!s.containerType || s.containerType === "normal") continue;
|
|
1738
|
+
const names = s.containerName.split(/\s+/);
|
|
1739
|
+
const size = rec.rect.width - (Number.parseFloat(s.paddingLeft) || 0) - (Number.parseFloat(s.paddingRight) || 0) - (Number.parseFloat(s.borderLeftWidth) || 0) - (Number.parseFloat(s.borderRightWidth) || 0);
|
|
1740
|
+
for (const q of queries) {
|
|
1741
|
+
if (q.name && !names.includes(q.name)) continue;
|
|
1742
|
+
const unit = q.unit === "px" ? 1 : q.unit === "em" ? Number.parseFloat(s.fontSize) || 16 : rootFont;
|
|
1743
|
+
const threshold = q.value * unit;
|
|
1744
|
+
bits += (q.after ? size > threshold : size >= threshold) ? "1" : "0";
|
|
1745
|
+
if (bits.length >= MAX_CONTAINER_BITS) return bits;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
return bits || void 0;
|
|
1749
|
+
}
|
|
1750
|
+
function declaredBreakpoints() {
|
|
1751
|
+
const found = /* @__PURE__ */ new Set();
|
|
1752
|
+
const toPx = (value, unit) => Number.parseFloat(value) * (unit === "px" ? 1 : MEDIA_EM);
|
|
1753
|
+
const after = (w) => Math.floor(w) + 1;
|
|
1754
|
+
const read = (text) => {
|
|
1755
|
+
const legacy = /(min|max)-width\s*:\s*([\d.]+)(px|em|rem)/g;
|
|
1756
|
+
for (let m = legacy.exec(text); m; m = legacy.exec(text)) {
|
|
1757
|
+
const [, kind, value = "0", unit = "px"] = m;
|
|
1758
|
+
const w = toPx(value, unit);
|
|
1759
|
+
found.add(Math.round(kind === "max" ? after(w) : w));
|
|
1760
|
+
}
|
|
1761
|
+
const range = /width\s*(>=|>|<=|<)\s*([\d.]+)(px|em|rem)/g;
|
|
1762
|
+
for (let m = range.exec(text); m; m = range.exec(text)) {
|
|
1763
|
+
const [, op, value = "0", unit = "px"] = m;
|
|
1764
|
+
const w = toPx(value, unit);
|
|
1765
|
+
found.add(Math.round(op === "<=" || op === ">" ? after(w) : w));
|
|
1766
|
+
}
|
|
1767
|
+
const reversed = /([\d.]+)(px|em|rem)\s*(>=|>|<=|<)\s*width/g;
|
|
1768
|
+
for (let m = reversed.exec(text); m; m = reversed.exec(text)) {
|
|
1769
|
+
const [, value = "0", unit = "px", op] = m;
|
|
1770
|
+
const w = toPx(value, unit);
|
|
1771
|
+
found.add(Math.round(op === "<" || op === ">=" ? after(w) : w));
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
const walk = (rules) => {
|
|
1775
|
+
for (const rule of rules) {
|
|
1776
|
+
if (typeof CSSContainerRule !== "undefined" && rule instanceof CSSContainerRule) {
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
if (rule instanceof CSSMediaRule) read(rule.conditionText ?? rule.media.mediaText);
|
|
1780
|
+
else if (rule instanceof CSSImportRule) {
|
|
1781
|
+
read(rule.media.mediaText);
|
|
1782
|
+
readSheet(rule.styleSheet);
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
if ("cssRules" in rule && rule.cssRules)
|
|
1786
|
+
walk(rule.cssRules);
|
|
1787
|
+
}
|
|
1788
|
+
};
|
|
1789
|
+
const readSheet = (sheet) => {
|
|
1790
|
+
if (!sheet) return;
|
|
1791
|
+
read(sheet.media.mediaText);
|
|
1792
|
+
try {
|
|
1793
|
+
walk(sheet.cssRules);
|
|
1794
|
+
} catch {
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
const roots = [document, ...shadowRoots()];
|
|
1798
|
+
for (const root of roots) {
|
|
1799
|
+
for (const sheet of root.styleSheets) readSheet(sheet);
|
|
1800
|
+
for (const sheet of root.adoptedStyleSheets ?? []) readSheet(sheet);
|
|
1801
|
+
}
|
|
1802
|
+
return [...found].filter((w) => w > 0).sort((a, b) => a - b);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// ../analyzers/src/states.ts
|
|
1806
|
+
var CANDIDATES = [
|
|
1807
|
+
"[aria-expanded]",
|
|
1808
|
+
"[aria-haspopup]",
|
|
1809
|
+
"[popovertarget]",
|
|
1810
|
+
"[commandfor]",
|
|
1811
|
+
"[data-bs-toggle]",
|
|
1812
|
+
"[data-toggle]",
|
|
1813
|
+
"details > summary",
|
|
1814
|
+
"button",
|
|
1815
|
+
'[role="button"]'
|
|
1816
|
+
].join(",");
|
|
1817
|
+
var MENU_CLASS = /(hamburger|burger|navbar-toggler|nav-?toggle|toggle-?nav|menu-?(toggle|button|btn|icon|trigger)|toggle-?menu|mobile-?(menu|nav)-?(toggle|button|btn|trigger)?|drawer-?toggle|sidebar-?toggle|offcanvas-?toggle)/i;
|
|
1818
|
+
var MENU_LABEL = /^(?:(?:open|toggle|show)\s+)?(?:the\s+)?(?:(?:main|site|mobile|primary)\s+)?(?:menu|navigation|nav|sidebar)(?:\s+menu)?$|^[☰≡]$/i;
|
|
1819
|
+
var MAX_TOGGLES = 40;
|
|
1820
|
+
function nameOf(el) {
|
|
1821
|
+
const aria = el.getAttribute("aria-label")?.trim();
|
|
1822
|
+
if (aria) return aria;
|
|
1823
|
+
const by = el.getAttribute("aria-labelledby");
|
|
1824
|
+
if (by) {
|
|
1825
|
+
const text2 = by.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
|
|
1826
|
+
if (text2) return text2;
|
|
1827
|
+
}
|
|
1828
|
+
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
1829
|
+
if (text) return text;
|
|
1830
|
+
return el.getAttribute("title")?.trim() ?? "";
|
|
1831
|
+
}
|
|
1832
|
+
function shortLabel(el, kind) {
|
|
1833
|
+
const name2 = nameOf(el);
|
|
1834
|
+
if (!/[\p{L}\p{N}]/u.test(name2)) return kind;
|
|
1835
|
+
return name2.length > 40 ? `${name2.slice(0, 37)}...` : name2;
|
|
1836
|
+
}
|
|
1837
|
+
function targetOf(el, attr) {
|
|
1838
|
+
const id = el.getAttribute(attr);
|
|
1839
|
+
if (!id) return null;
|
|
1840
|
+
return el.getRootNode().getElementById?.(id) ?? null;
|
|
1841
|
+
}
|
|
1842
|
+
function bootstrapKind(value) {
|
|
1843
|
+
switch (value) {
|
|
1844
|
+
case "modal":
|
|
1845
|
+
return "dialog";
|
|
1846
|
+
case "offcanvas":
|
|
1847
|
+
case "dropdown":
|
|
1848
|
+
return "menu";
|
|
1849
|
+
case "collapse":
|
|
1850
|
+
return "disclosure";
|
|
1851
|
+
default:
|
|
1852
|
+
return null;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
function looksLikeMenu(el) {
|
|
1856
|
+
const names = [
|
|
1857
|
+
typeof el.className === "string" ? el.className : "",
|
|
1858
|
+
el.id,
|
|
1859
|
+
el.getAttribute("data-testid") ?? ""
|
|
1860
|
+
].join(" ");
|
|
1861
|
+
return MENU_CLASS.test(names) || MENU_LABEL.test(nameOf(el));
|
|
1862
|
+
}
|
|
1863
|
+
function kindOf(el) {
|
|
1864
|
+
if (el.matches("details > summary")) {
|
|
1865
|
+
const details = el.parentElement;
|
|
1866
|
+
return details.open || details.querySelector(":scope > summary") !== el ? null : "details";
|
|
1867
|
+
}
|
|
1868
|
+
if (el.matches('input, select, textarea, [role="combobox"], [contenteditable]')) return null;
|
|
1869
|
+
if (el.hasAttribute("popovertarget")) {
|
|
1870
|
+
return el.getAttribute("popovertargetaction") === "hide" ? null : "popover";
|
|
1871
|
+
}
|
|
1872
|
+
if (el.hasAttribute("commandfor")) {
|
|
1873
|
+
const command = el.getAttribute("command");
|
|
1874
|
+
if (command === "show-modal") return "dialog";
|
|
1875
|
+
if (command === "toggle-popover" || command === "show-popover") return "popover";
|
|
1876
|
+
return null;
|
|
1877
|
+
}
|
|
1878
|
+
const expanded = el.getAttribute("aria-expanded");
|
|
1879
|
+
if (expanded === "true") return null;
|
|
1880
|
+
const popup = el.getAttribute("aria-haspopup");
|
|
1881
|
+
if (popup === "dialog") return "dialog";
|
|
1882
|
+
if (popup && popup !== "false") return "menu";
|
|
1883
|
+
const bootstrap = bootstrapKind(
|
|
1884
|
+
el.getAttribute("data-bs-toggle") ?? el.getAttribute("data-toggle")
|
|
1885
|
+
);
|
|
1886
|
+
if (bootstrap) return bootstrap;
|
|
1887
|
+
if (expanded === "false") return looksLikeMenu(el) ? "menu" : "disclosure";
|
|
1888
|
+
if (el.matches('button, [role="button"]') && looksLikeMenu(el)) return "menu";
|
|
1889
|
+
return null;
|
|
1890
|
+
}
|
|
1891
|
+
function safeToClick(el) {
|
|
1892
|
+
if (el.matches(':disabled, [aria-disabled="true"]')) return false;
|
|
1893
|
+
if (el instanceof HTMLButtonElement && el.type === "submit" && el.form) return false;
|
|
1894
|
+
const href = el instanceof HTMLAnchorElement ? el.getAttribute("href") : null;
|
|
1895
|
+
return !href || href.startsWith("#") || /^javascript:/i.test(href);
|
|
1896
|
+
}
|
|
1897
|
+
function isShown(el) {
|
|
1898
|
+
const r = el.getBoundingClientRect();
|
|
1899
|
+
if (r.width < 1 || r.height < 1) return false;
|
|
1900
|
+
if (r.right <= 0 || r.left >= window.innerWidth) return false;
|
|
1901
|
+
const check = el.checkVisibility;
|
|
1902
|
+
if (check && !check.call(el, { opacityProperty: true, visibilityProperty: true })) return false;
|
|
1903
|
+
if (!check && getComputedStyle(el).visibility === "hidden") return false;
|
|
1904
|
+
return !el.closest('[aria-hidden="true"], [inert]');
|
|
1905
|
+
}
|
|
1906
|
+
function ignored(el, rules) {
|
|
1907
|
+
const marked = closestComposed(el, "[data-breakhound-ignore]");
|
|
1908
|
+
if (marked) {
|
|
1909
|
+
const scope = (marked.getAttribute("data-breakhound-ignore") ?? "").split(/[\s,]+/);
|
|
1910
|
+
if (scope.every((s) => !s) || scope.includes("all") || scope.includes("states")) return true;
|
|
1911
|
+
}
|
|
1912
|
+
return rules.some((rule) => {
|
|
1913
|
+
if (rule.detectors) return false;
|
|
1914
|
+
try {
|
|
1915
|
+
return Boolean(closestComposed(el, rule.selector));
|
|
1916
|
+
} catch {
|
|
1917
|
+
return false;
|
|
1918
|
+
}
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
function groupOf(el, kind) {
|
|
1922
|
+
const shape = (e) => e ? `${e.tagName}.${[...e.classList].map((c) => c.replace(/\d+/g, "")).sort().join(".")}` : "";
|
|
1923
|
+
const panel = targetOf(el, "aria-controls");
|
|
1924
|
+
return `${kind}|${shape(el)}|${shape(el.parentElement)}|${panel ? shape(panel) : ""}`;
|
|
1925
|
+
}
|
|
1926
|
+
var PRIORITY = {
|
|
1927
|
+
menu: 0,
|
|
1928
|
+
dialog: 0,
|
|
1929
|
+
popover: 1,
|
|
1930
|
+
disclosure: 2,
|
|
1931
|
+
details: 3
|
|
1932
|
+
};
|
|
1933
|
+
function findToggles(rules = []) {
|
|
1934
|
+
const out = [];
|
|
1935
|
+
const panels = /* @__PURE__ */ new Set();
|
|
1936
|
+
for (const el of document.querySelectorAll(CANDIDATES)) {
|
|
1937
|
+
if (out.length >= MAX_TOGGLES) break;
|
|
1938
|
+
if (el.closest(DEV_OVERLAYS)) continue;
|
|
1939
|
+
const kind = kindOf(el);
|
|
1940
|
+
if (!kind || !safeToClick(el) || !isShown(el) || ignored(el, rules)) continue;
|
|
1941
|
+
const panel = targetOf(el, "aria-controls") ?? targetOf(el, "popovertarget");
|
|
1942
|
+
if (panel) {
|
|
1943
|
+
if (panels.has(panel)) continue;
|
|
1944
|
+
panels.add(panel);
|
|
1945
|
+
}
|
|
1946
|
+
const inHeader = Boolean(el.closest('header, nav, [role="banner"], [role="navigation"]'));
|
|
1947
|
+
out.push({
|
|
1948
|
+
selector: cachedSelector(el),
|
|
1949
|
+
kind,
|
|
1950
|
+
label: shortLabel(el, kind),
|
|
1951
|
+
group: groupOf(el, kind),
|
|
1952
|
+
priority: PRIORITY[kind] * 2 + (inHeader ? 0 : 1)
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
return out;
|
|
1956
|
+
}
|
|
1957
|
+
function toggleState(selector) {
|
|
1958
|
+
const el = resolveSelector(selector);
|
|
1959
|
+
if (!el) return { shown: false, open: null };
|
|
1960
|
+
const shown = isShown(el);
|
|
1961
|
+
if (el.matches("details > summary")) {
|
|
1962
|
+
return { shown, open: el.parentElement.open };
|
|
1963
|
+
}
|
|
1964
|
+
const expanded = el.getAttribute("aria-expanded");
|
|
1965
|
+
if (expanded === "true" || expanded === "false") return { shown, open: expanded === "true" };
|
|
1966
|
+
const target = targetOf(el, "popovertarget") ?? targetOf(el, "commandfor");
|
|
1967
|
+
if (target instanceof HTMLDialogElement) return { shown, open: target.open };
|
|
1968
|
+
if (target?.hasAttribute("popover")) {
|
|
1969
|
+
try {
|
|
1970
|
+
return { shown, open: target.matches(":popover-open") };
|
|
1971
|
+
} catch {
|
|
1972
|
+
return { shown, open: null };
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
return { shown, open: null };
|
|
1976
|
+
}
|
|
1977
|
+
function setDetails(selector, open) {
|
|
1978
|
+
const details = resolveSelector(selector)?.parentElement;
|
|
1979
|
+
if (details instanceof HTMLDetailsElement) details.open = open;
|
|
1980
|
+
}
|
|
1981
|
+
function clickPoint(selector) {
|
|
1982
|
+
const el = resolveSelector(selector);
|
|
1983
|
+
if (!el) return null;
|
|
1984
|
+
let r = el.getBoundingClientRect();
|
|
1985
|
+
if (r.top < 0 || r.bottom > window.innerHeight) {
|
|
1986
|
+
el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
1987
|
+
r = el.getBoundingClientRect();
|
|
1988
|
+
}
|
|
1989
|
+
const x = Math.min(window.innerWidth - 1, Math.max(0, r.left + r.width / 2));
|
|
1990
|
+
const y = Math.min(window.innerHeight - 1, Math.max(0, r.top + r.height / 2));
|
|
1991
|
+
let hit = document.elementFromPoint(x, y);
|
|
1992
|
+
while (hit?.shadowRoot) {
|
|
1993
|
+
const inner = hit.shadowRoot.elementFromPoint(x, y);
|
|
1994
|
+
if (!inner || inner === hit) break;
|
|
1995
|
+
hit = inner;
|
|
1996
|
+
}
|
|
1997
|
+
if (!hit) return null;
|
|
1998
|
+
return hit === el || el.contains(hit) || el.getRootNode() !== document && hit.contains(el) ? { x, y } : null;
|
|
1999
|
+
}
|
|
2000
|
+
var before = null;
|
|
2001
|
+
function markShown() {
|
|
2002
|
+
before = new WeakSet([document.body]);
|
|
2003
|
+
for (const el of document.body.querySelectorAll("*")) if (isShown(el)) before.add(el);
|
|
2004
|
+
}
|
|
2005
|
+
function newlyShown(toggle) {
|
|
2006
|
+
const seen = before;
|
|
2007
|
+
before = null;
|
|
2008
|
+
if (!seen) return null;
|
|
2009
|
+
const trigger = resolveSelector(toggle);
|
|
2010
|
+
let best = null;
|
|
2011
|
+
for (const el of document.body.querySelectorAll("*")) {
|
|
2012
|
+
if (seen.has(el) || trigger && (trigger === el || trigger.contains(el))) continue;
|
|
2013
|
+
if (el.parentElement && !seen.has(el.parentElement) && isShown(el.parentElement)) continue;
|
|
2014
|
+
if (!isShown(el)) continue;
|
|
2015
|
+
const r = el.getBoundingClientRect();
|
|
2016
|
+
const area = r.width * r.height;
|
|
2017
|
+
if (!best || area > best.area) best = { el, area };
|
|
2018
|
+
}
|
|
2019
|
+
return best && best.area >= 2e3 ? cachedSelector(best.el) : null;
|
|
2020
|
+
}
|
|
2021
|
+
function selectorShown(selector) {
|
|
2022
|
+
const el = resolveSelector(selector);
|
|
2023
|
+
return el ? isShown(el) : false;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// ../analyzers/src/types.ts
|
|
2027
|
+
var DETECTOR_IDS = [
|
|
2028
|
+
"page-overflow",
|
|
2029
|
+
"content-overflow",
|
|
2030
|
+
"media-overflow",
|
|
2031
|
+
"overlap",
|
|
2032
|
+
"unexpected-wrap",
|
|
2033
|
+
"clipped-text",
|
|
2034
|
+
"squished-text",
|
|
2035
|
+
"distorted-media",
|
|
2036
|
+
"small-tap-target",
|
|
2037
|
+
"tiny-text",
|
|
2038
|
+
"oversized-text",
|
|
2039
|
+
"edge-gutter",
|
|
2040
|
+
"viewport-meta"
|
|
2041
|
+
];
|
|
2042
|
+
|
|
2043
|
+
// ../analyzers/src/highlight.ts
|
|
2044
|
+
var OVERLAY_ATTR = "data-breakhound-overlay";
|
|
2045
|
+
function find(selector) {
|
|
2046
|
+
return selector ? resolveSelector(selector) : null;
|
|
2047
|
+
}
|
|
2048
|
+
function overlay(style) {
|
|
2049
|
+
const div = document.createElement("div");
|
|
2050
|
+
div.setAttribute(OVERLAY_ATTR, "");
|
|
2051
|
+
Object.assign(div.style, {
|
|
2052
|
+
position: "absolute",
|
|
2053
|
+
zIndex: "2147483647",
|
|
2054
|
+
pointerEvents: "none",
|
|
2055
|
+
...style
|
|
2056
|
+
});
|
|
2057
|
+
document.documentElement.appendChild(div);
|
|
2058
|
+
}
|
|
2059
|
+
function highlight(opts) {
|
|
2060
|
+
const target = find(opts.selector);
|
|
2061
|
+
if (!target) return null;
|
|
2062
|
+
const boxes = [{ el: target, color: "#e11d48" }];
|
|
2063
|
+
const other = find(opts.related);
|
|
2064
|
+
if (other && other !== target) boxes.push({ el: other, color: "#f59e0b" });
|
|
2065
|
+
const sx = window.scrollX;
|
|
2066
|
+
const sy = window.scrollY;
|
|
2067
|
+
const vw = document.documentElement.clientWidth;
|
|
2068
|
+
let top = Number.POSITIVE_INFINITY;
|
|
2069
|
+
let bottom = Number.NEGATIVE_INFINITY;
|
|
2070
|
+
let right = vw;
|
|
2071
|
+
for (const { el, color } of boxes) {
|
|
2072
|
+
const r = el.getBoundingClientRect();
|
|
2073
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
2074
|
+
top = Math.min(top, r.top + sy);
|
|
2075
|
+
bottom = Math.max(bottom, r.bottom + sy);
|
|
2076
|
+
right = Math.max(right, r.right + sx);
|
|
2077
|
+
overlay({
|
|
2078
|
+
left: `${r.left + sx - 2}px`,
|
|
2079
|
+
top: `${r.top + sy - 2}px`,
|
|
2080
|
+
width: `${r.width + 4}px`,
|
|
2081
|
+
height: `${r.height + 4}px`,
|
|
2082
|
+
outline: `3px solid ${color}`,
|
|
2083
|
+
background: `${color}14`
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
if (!Number.isFinite(top)) return null;
|
|
2087
|
+
const docHeight = document.documentElement.scrollHeight;
|
|
2088
|
+
const y = Math.max(0, top - opts.pad);
|
|
2089
|
+
const end = Math.min(docHeight, bottom + opts.pad, y + opts.maxHeight);
|
|
2090
|
+
if (opts.showEdge) {
|
|
2091
|
+
overlay({
|
|
2092
|
+
left: `${vw - 1}px`,
|
|
2093
|
+
top: `${y}px`,
|
|
2094
|
+
width: "0",
|
|
2095
|
+
height: `${end - y}px`,
|
|
2096
|
+
borderLeft: "2px dashed #2563eb"
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
const scrollWidth = document.documentElement.scrollWidth;
|
|
2100
|
+
return {
|
|
2101
|
+
x: 0,
|
|
2102
|
+
y,
|
|
2103
|
+
width: Math.min(Math.max(vw, Math.ceil(right) + 16), Math.max(vw, scrollWidth)),
|
|
2104
|
+
height: Math.max(1, end - y)
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
function clearHighlights() {
|
|
2108
|
+
for (const el of document.querySelectorAll(`[${OVERLAY_ATTR}]`)) el.remove();
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// ../analyzers/src/settle.ts
|
|
2112
|
+
var frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
2113
|
+
async function settleAfterResize(maxWaitMs) {
|
|
2114
|
+
await frame();
|
|
2115
|
+
await frame();
|
|
2116
|
+
const probe = window.__breakhound_settle;
|
|
2117
|
+
if (probe && probe.pending() > 0) {
|
|
2118
|
+
const deadline = performance.now() + maxWaitMs;
|
|
2119
|
+
while (probe.pending() > 0 && performance.now() < deadline) {
|
|
2120
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2121
|
+
}
|
|
2122
|
+
await frame();
|
|
2123
|
+
await frame();
|
|
2124
|
+
}
|
|
2125
|
+
window.scrollTo(0, 0);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// ../analyzers/src/index.ts
|
|
2129
|
+
var DETECTORS = {
|
|
2130
|
+
"page-overflow": pageOverflow,
|
|
2131
|
+
"media-overflow": mediaOverflow,
|
|
2132
|
+
"content-overflow": contentOverflow,
|
|
2133
|
+
overlap,
|
|
2134
|
+
"clipped-text": clippedText,
|
|
2135
|
+
"squished-text": squishedText,
|
|
2136
|
+
"distorted-media": distortedMedia,
|
|
2137
|
+
"small-tap-target": smallTapTargets,
|
|
2138
|
+
"tiny-text": tinyText,
|
|
2139
|
+
"oversized-text": oversizedText,
|
|
2140
|
+
"edge-gutter": edgeGutter,
|
|
2141
|
+
"viewport-meta": viewportMeta
|
|
2142
|
+
};
|
|
2143
|
+
var IGNORE_ATTR = "data-breakhound-ignore";
|
|
2144
|
+
function parseDetectors(value) {
|
|
2145
|
+
const list = (value ?? "").split(/[\s,]+/).filter(Boolean);
|
|
2146
|
+
return list.length === 0 || list.includes("all") ? "all" : new Set(list);
|
|
2147
|
+
}
|
|
2148
|
+
function isIgnored(info, detector, rules) {
|
|
2149
|
+
if (!info) return false;
|
|
2150
|
+
const el = elementOf(info);
|
|
2151
|
+
if (!el) return false;
|
|
2152
|
+
for (let p = el; p; p = composedParent(p)) {
|
|
2153
|
+
if (!p.hasAttribute(IGNORE_ATTR)) continue;
|
|
2154
|
+
const scope = parseDetectors(p.getAttribute(IGNORE_ATTR));
|
|
2155
|
+
if (scope === "all" || scope.has(detector)) return true;
|
|
2156
|
+
}
|
|
2157
|
+
for (const rule of rules) {
|
|
2158
|
+
if (rule.detectors && !rule.detectors.includes(detector)) continue;
|
|
2159
|
+
try {
|
|
2160
|
+
if (closestComposed(el, rule.selector)) return true;
|
|
2161
|
+
} catch {
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
return false;
|
|
2165
|
+
}
|
|
2166
|
+
function analyze(options = {}) {
|
|
2167
|
+
const enabled = new Set(options.detectors ?? DETECTOR_IDS);
|
|
2168
|
+
const rules = options.ignore ?? [];
|
|
2169
|
+
const snap = snapshot();
|
|
2170
|
+
const findings = [];
|
|
2171
|
+
for (const [id, run] of Object.entries(DETECTORS)) {
|
|
2172
|
+
if (!enabled.has(id)) continue;
|
|
2173
|
+
for (const f of run(snap)) {
|
|
2174
|
+
if (isIgnored(f.element, id, rules) || isIgnored(f.related, id, rules)) continue;
|
|
2175
|
+
findings.push(f);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
const wraps = enabled.has("unexpected-wrap") ? wrapStats(snap).filter((w) => !isIgnored(w.element, "unexpected-wrap", rules)) : [];
|
|
2179
|
+
const containers = containerSignature(snap);
|
|
2180
|
+
return {
|
|
2181
|
+
width: snap.viewportWidth,
|
|
2182
|
+
findings,
|
|
2183
|
+
wraps,
|
|
2184
|
+
signature: layoutSignature(snap, cachedSelector),
|
|
2185
|
+
...containers ? { containers } : {},
|
|
2186
|
+
...options.toggles ? { toggles: findToggles(rules) } : {}
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
var MEDIA_CONTENT = /* @__PURE__ */ new Set([
|
|
2190
|
+
"IMG",
|
|
2191
|
+
"SVG",
|
|
2192
|
+
"svg",
|
|
2193
|
+
"CANVAS",
|
|
2194
|
+
"VIDEO",
|
|
2195
|
+
"IFRAME",
|
|
2196
|
+
"OBJECT",
|
|
2197
|
+
"EMBED"
|
|
2198
|
+
]);
|
|
2199
|
+
function hasContent() {
|
|
2200
|
+
const visit = (el) => {
|
|
2201
|
+
if (el.matches(DEV_OVERLAYS)) return false;
|
|
2202
|
+
if (MEDIA_CONTENT.has(el.tagName)) {
|
|
2203
|
+
const r = el.getBoundingClientRect();
|
|
2204
|
+
return r.width > 0 && r.height > 0;
|
|
2205
|
+
}
|
|
2206
|
+
for (const node of el.childNodes) {
|
|
2207
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
|
2208
|
+
const range = document.createRange();
|
|
2209
|
+
range.selectNodeContents(node);
|
|
2210
|
+
if (range.getClientRects().length > 0) return true;
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
return renderedChildren(el).some(visit);
|
|
2214
|
+
};
|
|
2215
|
+
return document.body ? visit(document.body) : false;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// ../analyzers/src/inject.ts
|
|
2219
|
+
var api = {
|
|
2220
|
+
analyze,
|
|
2221
|
+
declaredBreakpoints,
|
|
2222
|
+
settleAfterResize,
|
|
2223
|
+
highlight,
|
|
2224
|
+
clearHighlights,
|
|
2225
|
+
hasContent,
|
|
2226
|
+
resolveSelector,
|
|
2227
|
+
toggleState,
|
|
2228
|
+
setDetails,
|
|
2229
|
+
clickPoint,
|
|
2230
|
+
markShown,
|
|
2231
|
+
newlyShown,
|
|
2232
|
+
selectorShown,
|
|
2233
|
+
findByText
|
|
2234
|
+
};
|
|
2235
|
+
window.__breakhound = api;
|
|
2236
|
+
})();
|