phyll 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ // Best-effort map of an app's structure from its source: framework, routes, forms and modals.
2
+ // The reviewer uses it to plan the browser walk; nothing here needs to be exact.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { extractClassLists, makeLineIndex } from "./detectors.mjs";
6
+
7
+ // ---------- framework ----------
8
+
9
+ function findPackageJson(root, levels = 3) {
10
+ let dir = root;
11
+ for (let i = 0; i <= levels; i++) {
12
+ const candidate = join(dir, "package.json");
13
+ if (existsSync(candidate)) {
14
+ try {
15
+ return JSON.parse(readFileSync(candidate, "utf8"));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+ const parent = dirname(dir);
21
+ if (parent === dir) break;
22
+ dir = parent;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export function detectFramework(root) {
28
+ const pkg = findPackageJson(root);
29
+ if (!pkg) return "html";
30
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
31
+ if (deps.next) return "next";
32
+ if (deps.nuxt) return "nuxt";
33
+ if (deps["@sveltejs/kit"]) return "sveltekit";
34
+ if (deps.astro) return "astro";
35
+ if (deps["@angular/core"]) return "angular";
36
+ if (deps["vue-router"]) return "vue";
37
+ if (deps["react-router"] || deps["react-router-dom"] || deps["@tanstack/react-router"]) return "react-router";
38
+ if (deps.react) return "react";
39
+ if (deps.vue) return "vue";
40
+ if (deps.svelte) return "svelte";
41
+ return "unknown";
42
+ }
43
+
44
+ // ---------- routes ----------
45
+
46
+ const dropGroups = (segments) => segments.filter((s) => s && !/^\(.*\)$/.test(s) && !s.startsWith("@"));
47
+ const toPath = (segments) => "/" + dropGroups(segments).join("/");
48
+
49
+ const NEXT_APP = /^(?:.*\/)?app\/((?:[^/]+\/)*)page\.(?:jsx|tsx|js|ts|mdx)$/;
50
+ const NEXT_PAGES = /^(?:.*\/)?pages\/(.+)\.(?:jsx|tsx|js|ts|mdx)$/;
51
+ const NUXT_PAGES = /^(?:.*\/)?pages\/(.+)\.vue$/;
52
+ const SVELTEKIT = /^(?:.*\/)?routes\/((?:[^/]+\/)*)\+page\.svelte$/;
53
+ const ASTRO_PAGES = /^(?:.*\/)?pages\/(.+)\.(?:astro|md|mdx|html)$/;
54
+
55
+ function fileRoute(rel, pattern, { skip } = {}) {
56
+ const m = rel.match(pattern);
57
+ if (!m) return null;
58
+ const segments = m[1].split("/").filter(Boolean);
59
+ if (skip && skip(segments)) return null;
60
+ if (segments.at(-1) === "index") segments.pop();
61
+ return toPath(segments);
62
+ }
63
+
64
+ const skipNextPages = (segments) => segments[0] === "api" || segments.some((s) => s.startsWith("_"));
65
+
66
+ const JSX_ROUTE = /<Route\b[^>]*?\bpath\s*=\s*(?:\{\s*)?["'`]([^"'`]+)["'`]/g;
67
+ const OBJECT_ROUTE = /\bpath\s*:\s*["'`]([^"'`]+)["'`]/g;
68
+ const FILE_ROUTE = /createFileRoute\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
69
+ const ROUTER_IMPORT = /from\s+["'](?:react-router(?:-dom)?|vue-router|@tanstack\/react-router)["']|createBrowserRouter|createRouter\s*\(/;
70
+
71
+ // Paths written without a leading slash are relative to a parent route; they get one here
72
+ // and are marked so the scan can warn that the full path may be longer.
73
+ function codeRoute(raw, file, line) {
74
+ if (raw === "*" || raw.startsWith("/")) return { path: raw, file, line };
75
+ return { path: "/" + raw, file, line, relative: true };
76
+ }
77
+
78
+ function codeRoutes(files, readText) {
79
+ const out = [];
80
+ for (const file of files) {
81
+ if (file.category === "style") continue;
82
+ const text = readText(file);
83
+ if (!text.includes("path") && !text.includes("createFileRoute")) continue;
84
+ const lineOf = makeLineIndex(text);
85
+ const patterns = ROUTER_IMPORT.test(text) ? [JSX_ROUTE, FILE_ROUTE, OBJECT_ROUTE] : [JSX_ROUTE, FILE_ROUTE];
86
+ for (const pattern of patterns) {
87
+ for (const m of text.matchAll(pattern)) out.push(codeRoute(m[1], file.rel, lineOf(m.index)));
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function htmlRoutes(files) {
94
+ return files
95
+ .filter((f) => f.ext === ".html" || f.ext === ".htm")
96
+ .map((f) => {
97
+ const path = "/" + f.rel.replace(/(^|\/)index\.html?$/, "$1");
98
+ return { path, file: f.rel, line: 1 };
99
+ });
100
+ }
101
+
102
+ export function findRoutes(files, framework, readText) {
103
+ let routes = [];
104
+ const add = (path, file) => {
105
+ if (path) routes.push({ path, file: file.rel, line: 1 });
106
+ };
107
+ for (const file of files) {
108
+ if (framework === "next") {
109
+ add(fileRoute(file.rel, NEXT_APP), file);
110
+ add(fileRoute(file.rel, NEXT_PAGES, { skip: skipNextPages }), file);
111
+ } else if (framework === "nuxt") {
112
+ add(fileRoute(file.rel, NUXT_PAGES), file);
113
+ } else if (framework === "sveltekit") {
114
+ add(fileRoute(file.rel, SVELTEKIT), file);
115
+ } else if (framework === "astro") {
116
+ add(fileRoute(file.rel, ASTRO_PAGES), file);
117
+ }
118
+ }
119
+ if (["react-router", "react", "vue", "angular", "svelte", "unknown"].includes(framework)) {
120
+ routes.push(...codeRoutes(files, readText));
121
+ }
122
+ if (routes.length === 0) routes = htmlRoutes(files);
123
+
124
+ const seen = new Map();
125
+ for (const r of routes) if (!seen.has(r.path)) seen.set(r.path, r);
126
+ return [...seen.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
127
+ }
128
+
129
+ // ---------- forms ----------
130
+
131
+ const FORM_OPEN = /<(form|Form)\b[^>]*>/g;
132
+ const FIELD = /<(input|select|textarea|Input|Select|Textarea|Checkbox|Switch|RadioGroup|Combobox|DatePicker|Slider)\b([^>]*)>/g;
133
+ const NON_FIELD_TYPE = /\btype\s*=\s*["'{]?\s*["']?(hidden|submit|button|reset|image)\b/;
134
+
135
+ // Fields inside a closed <details> wait until the person opens it, so they are not asked up front.
136
+ const CLOSED_DETAILS = /<details\b(?![^>]*\bopen\b)[^>]*>[\s\S]*?<\/details>/g;
137
+
138
+ const RADIO = /\btype\s*=\s*["']radio["']/;
139
+ const NAME = /\bname\s*=\s*["']([^"']+)["']/;
140
+ // A label that says the field can stay empty: "Complemento (opcional)".
141
+ const OPTIONAL_MARK = /\((?:opcional|optional|opcionales)\)/gi;
142
+
143
+ // Counts questions, not tags: the radio buttons of one group are one question.
144
+ function countAll(chunk) {
145
+ let n = 0;
146
+ const groups = new Set();
147
+ for (const m of chunk.matchAll(FIELD)) {
148
+ if ((m[1] === "input" || m[1] === "Input") && NON_FIELD_TYPE.test(m[2])) continue;
149
+ const group = RADIO.test(m[2]) ? m[2].match(NAME)?.[1] : null;
150
+ if (group && groups.has(group)) continue;
151
+ if (group) groups.add(group);
152
+ n++;
153
+ }
154
+ return n;
155
+ }
156
+
157
+ // fields: asked up front and not marked optional. deferred: behind a closed <details>.
158
+ // optional: on screen, with a label that says so. Both only appear when there are some.
159
+ function formEntry(file, line, chunk, implicit) {
160
+ const visible = chunk.replace(CLOSED_DETAILS, "");
161
+ const shown = countAll(visible);
162
+ const optional = Math.min(shown, (visible.match(OPTIONAL_MARK) ?? []).length);
163
+ const deferred = countAll(chunk) - shown;
164
+ const entry = { file, line, fields: shown - optional };
165
+ if (deferred > 0) entry.deferred = deferred;
166
+ if (optional > 0) entry.optional = optional;
167
+ entry.implicit = implicit;
168
+ return entry;
169
+ }
170
+
171
+ export function findForms(files, readText) {
172
+ const forms = [];
173
+ for (const file of files) {
174
+ if (file.category === "style") continue;
175
+ const text = readText(file);
176
+ if (!/<(form|Form|input|Input|select|Select|textarea|Textarea)\b/.test(text)) continue;
177
+ const lineOf = makeLineIndex(text);
178
+ let found = false;
179
+ for (const m of text.matchAll(FORM_OPEN)) {
180
+ found = true;
181
+ const close = text.indexOf(`</${m[1]}>`, m.index);
182
+ const chunk = text.slice(m.index, close === -1 ? text.length : close);
183
+ forms.push(formEntry(file.rel, lineOf(m.index), chunk, false));
184
+ }
185
+ if (!found) {
186
+ const first = text.search(/<(input|select|textarea|Input|Select|Textarea)\b/);
187
+ const entry = formEntry(file.rel, lineOf(Math.max(first, 0)), text, true);
188
+ if (entry.fields + (entry.deferred ?? 0) + (entry.optional ?? 0) >= 5) forms.push(entry);
189
+ }
190
+ }
191
+ return forms;
192
+ }
193
+
194
+ // ---------- modals ----------
195
+
196
+ const MODAL = /<(?:Dialog|AlertDialog|Modal|Sheet|Drawer)(?:\.Root)?(?=[\s>/])|<dialog\b|\.showModal\(|\brole\s*=\s*["']dialog["']/g;
197
+ // A hand-made modal: a full-screen layer with a dark, see-through backdrop, in any class order.
198
+ const OVERLAY =
199
+ /\bclass(?:Name)?\s*=\s*\{?\s*["'`](?=[^"'`]*(?<![\w:-])fixed\b)(?=[^"'`]*(?<![\w:-])inset-0\b)(?=[^"'`]*(?<![\w:-])bg-(?:black|(?:zinc|gray|slate|neutral|stone)-9[05]0)\/\d{1,3}\b)/g;
200
+
201
+ const DIALOG_MARK = /\brole\s*=\s*["']dialog["']|\baria-modal\b|<dialog\b|<(?:Dialog|AlertDialog|Modal|Sheet|Drawer)(?:\.\w+)?[\s>/]/;
202
+
203
+ // Index of the ">" that closes the tag opening at `start`, skipping quotes and JSX braces,
204
+ // so onClick={() => close()} does not end the tag early.
205
+ function tagEnd(text, start) {
206
+ let depth = 0;
207
+ let quote = null;
208
+ for (let i = start + 1; i < text.length; i++) {
209
+ const c = text[i];
210
+ if (quote) {
211
+ if (c === quote) quote = null;
212
+ } else if (c === '"' || c === "'" || c === "`") quote = c;
213
+ else if (c === "{") depth++;
214
+ else if (c === "}") depth--;
215
+ else if (c === ">" && depth === 0) return i;
216
+ }
217
+ return text.length - 1;
218
+ }
219
+
220
+ export function findModals(files, readText) {
221
+ const locations = [];
222
+ for (const file of files) {
223
+ if (file.category === "style") continue;
224
+ const text = readText(file);
225
+ if (!/Dialog|Modal|Sheet|Drawer|dialog|inset-0/.test(text)) continue;
226
+ const lineOf = makeLineIndex(text);
227
+ const found = [];
228
+ for (const m of text.matchAll(MODAL)) found.push({ index: m.index, match: m[0].replace(/\.Root$/, "") });
229
+ for (const m of text.matchAll(OVERLAY)) {
230
+ // A dialog marked on the overlay itself, or on its first child, was already counted above.
231
+ const start = text.lastIndexOf("<", m.index);
232
+ const end = tagEnd(text, start);
233
+ const child = text.indexOf("<", end);
234
+ const scope = text.slice(start, (child === -1 ? end : tagEnd(text, child)) + 1);
235
+ if (!DIALOG_MARK.test(scope)) found.push({ index: m.index, match: "fixed inset-0 overlay" });
236
+ }
237
+ found.sort((a, b) => a.index - b.index);
238
+ for (const f of found) locations.push({ file: file.rel, line: lineOf(f.index), match: f.match });
239
+ }
240
+ return { count: locations.length, locations: locations.slice(0, 20) };
241
+ }
242
+
243
+ // ---------- theme ----------
244
+
245
+ const NEUTRALS = "(?:zinc|gray|slate|neutral|stone)";
246
+ const DARK_BG = new RegExp(`^bg-(?:${NEUTRALS}-(?:800|900|950)|black)$`);
247
+ const LIGHT_BG = /^bg-(?:white|[a-z]+-(?:50|100))$/;
248
+
249
+ // "dark" when opaque dark backgrounds outnumber light ones in the class lists, "light" when the
250
+ // reverse is true, "unknown" otherwise. Variants and translucent colors (bg-white/5) do not count.
251
+ export function detectTheme(files, readText) {
252
+ let dark = 0;
253
+ let light = 0;
254
+ for (const file of files) {
255
+ if (file.category === "style") continue;
256
+ const text = readText(file);
257
+ if (/<html[^>]*class\s*=\s*["'][^"']*\bdark\b/.test(text)) dark += 5;
258
+ for (const { classes } of extractClassLists(text, file.category)) {
259
+ for (const token of classes) {
260
+ if (DARK_BG.test(token)) dark++;
261
+ else if (LIGHT_BG.test(token)) light++;
262
+ }
263
+ }
264
+ }
265
+ if (dark === 0 && light === 0) return "unknown";
266
+ if (dark > light) return "dark";
267
+ return light > dark ? "light" : "unknown";
268
+ }
269
+
270
+ // ---------- all together ----------
271
+
272
+ export function analyzeStructure(root, files, readText) {
273
+ const framework = detectFramework(root);
274
+ const routes = findRoutes(files, framework, readText);
275
+ const forms = findForms(files, readText);
276
+ const modals = findModals(files, readText);
277
+ const theme = detectTheme(files, readText);
278
+ const notes = [];
279
+ if (routes.length === 0) notes.push("No routes found in the source. Map the screens from the browser instead.");
280
+ if (routes.some((r) => r.relative)) {
281
+ notes.push("Some route paths are relative to a parent route, so the full URL may be longer than shown.");
282
+ }
283
+ if (theme === "dark") {
284
+ notes.push("The interface looks dark, so light gray text is not flagged from the source. The probe measures real contrast.");
285
+ }
286
+ return { framework, theme, routes, forms, modals, notes };
287
+ }
@@ -0,0 +1,3 @@
1
+ // Single source for the tool name and version used in scan output and reports.
2
+ export const NAME = "phyll";
3
+ export const VERSION = "0.4.1";
@@ -0,0 +1,384 @@
1
+ // Phyll probe: in-page measurements for a UX review.
2
+ //
3
+ // The whole file is one JavaScript expression that returns a plain object and changes nothing
4
+ // on the page. Run it with whatever evaluates JavaScript in your browser tool:
5
+ // Claude Code browser: javascript_tool with the file content as the code
6
+ // Playwright MCP: browser_evaluate with () => <file content>
7
+ // Playwright: await page.evaluate(fileContent) (capture.mjs does this)
8
+ // DevTools console: paste the file
9
+ (() => {
10
+ const VERSION = "0.1.0";
11
+ const vw = window.innerWidth;
12
+ const vh = window.innerHeight;
13
+ const errors = [];
14
+ const clip = (s, n = 80) => String(s ?? "").replace(/\s+/g, " ").trim().slice(0, n);
15
+ const styleOf = (el) => getComputedStyle(el);
16
+
17
+ // ---------- visibility ----------
18
+ const hasBox = (el) => {
19
+ const r = el.getBoundingClientRect();
20
+ return r.width >= 1 && r.height >= 1;
21
+ };
22
+ const opacityChain = (el) => {
23
+ let o = 1;
24
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
25
+ o *= parseFloat(styleOf(n).opacity) || 0;
26
+ if (o < 0.05) return 0;
27
+ }
28
+ return o;
29
+ };
30
+ const isShown = (el) => {
31
+ if (!hasBox(el)) return false;
32
+ const s = styleOf(el);
33
+ return s.visibility !== "hidden" && s.display !== "none";
34
+ };
35
+
36
+ // ---------- color ----------
37
+ // Computed colors can be rgb(), oklch(), color(...) and more. A 1x1 canvas converts any of them to sRGB.
38
+ const canvas = document.createElement("canvas");
39
+ canvas.width = 1;
40
+ canvas.height = 1;
41
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
42
+ const colorCache = new Map();
43
+ const toRGBA = (css) => {
44
+ if (colorCache.has(css)) return colorCache.get(css);
45
+ let out = [0, 0, 0, 0];
46
+ if (css && css !== "transparent") {
47
+ ctx.clearRect(0, 0, 1, 1);
48
+ ctx.fillStyle = "rgba(0, 0, 0, 0)";
49
+ ctx.fillStyle = css;
50
+ ctx.fillRect(0, 0, 1, 1);
51
+ const d = ctx.getImageData(0, 0, 1, 1).data;
52
+ out = [d[0], d[1], d[2], d[3] / 255];
53
+ }
54
+ colorCache.set(css, out);
55
+ return out;
56
+ };
57
+ const blend = (top, bottom) => {
58
+ const a = top[3];
59
+ return [
60
+ top[0] * a + bottom[0] * (1 - a),
61
+ top[1] * a + bottom[1] * (1 - a),
62
+ top[2] * a + bottom[2] * (1 - a),
63
+ 1,
64
+ ];
65
+ };
66
+ const channel = (c) => {
67
+ const v = c / 255;
68
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
69
+ };
70
+ const luminance = (c) => 0.2126 * channel(c[0]) + 0.7152 * channel(c[1]) + 0.0722 * channel(c[2]);
71
+ const contrast = (a, b) => {
72
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
73
+ return (hi + 0.05) / (lo + 0.05);
74
+ };
75
+ const hex = (c) => "#" + c.slice(0, 3).map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
76
+
77
+ // Color tokens inside a CSS gradient, such as rgb(...), oklch(...) or #hex.
78
+ const COLOR_TOKEN = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^()]*\)|#[0-9a-f]{3,8}\b/gi;
79
+ const gradientStops = (image) => (image.match(COLOR_TOKEN) ?? []).map(toRGBA).filter((c) => c[3] > 0);
80
+
81
+ // The colors that can sit behind an element, composited up the tree. A gradient contributes
82
+ // each of its stops, so callers can take the worst case. null when a picture is behind it.
83
+ const backgroundsOf = (el) => {
84
+ const layers = [];
85
+ for (let n = el; n; n = n.parentElement) {
86
+ const s = styleOf(n);
87
+ const image = s.backgroundImage;
88
+ if (image && image !== "none" && !(n === el && (s.webkitBackgroundClip === "text" || s.backgroundClip === "text"))) {
89
+ if (image.includes("url(")) return null;
90
+ const stops = gradientStops(image);
91
+ if (stops.length) {
92
+ layers.push(stops);
93
+ if (stops.every((c) => c[3] >= 0.99)) break;
94
+ }
95
+ }
96
+ const c = toRGBA(s.backgroundColor);
97
+ if (c[3] > 0) {
98
+ layers.push([c]);
99
+ if (c[3] >= 0.99) break;
100
+ }
101
+ }
102
+ let candidates = [[255, 255, 255, 1]];
103
+ for (let i = layers.length - 1; i >= 0; i--) {
104
+ const next = [];
105
+ for (const top of layers[i]) for (const bottom of candidates) next.push(blend(top, bottom));
106
+ candidates = next.slice(0, 16);
107
+ }
108
+ return candidates;
109
+ };
110
+ const backgroundOf = (el) => backgroundsOf(el)?.[0] ?? null;
111
+
112
+ // ---------- text ----------
113
+ const textEls = [];
114
+ try {
115
+ const seen = new Set();
116
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
117
+ acceptNode: (t) => (t.nodeValue.trim().length > 1 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
118
+ });
119
+ while (walker.nextNode() && textEls.length < 500) {
120
+ const el = walker.currentNode.parentElement;
121
+ if (!el || seen.has(el) || el.closest("script, style, noscript, svg, template")) continue;
122
+ seen.add(el);
123
+ if (isShown(el) && opacityChain(el) > 0.05) textEls.push(el);
124
+ }
125
+ } catch (e) {
126
+ errors.push("text: " + e.message);
127
+ }
128
+ const ownText = (el) =>
129
+ [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.nodeValue).join(" ");
130
+
131
+ // Worst-case contrast of each text element. Gradient text is measured stop by stop against
132
+ // what is behind it; text over a gradient is measured against each stop of that gradient.
133
+ const gradientTextOf = (el) => {
134
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
135
+ const s = styleOf(n);
136
+ if ((s.webkitBackgroundClip === "text" || s.backgroundClip === "text") && s.backgroundImage.includes("gradient")) {
137
+ return { node: n, stops: gradientStops(s.backgroundImage) };
138
+ }
139
+ }
140
+ return null;
141
+ };
142
+
143
+ const contrastResult = { checked: 0, failures: 0, skipped: 0, samples: [] };
144
+ try {
145
+ for (const el of textEls) {
146
+ if (el.closest("[disabled], [aria-disabled='true']")) continue;
147
+ const s = styleOf(el);
148
+ const gradientText = gradientTextOf(el);
149
+ const backgrounds = backgroundsOf(gradientText ? gradientText.node.parentElement ?? el : el);
150
+ if (!backgrounds || !backgrounds.length) {
151
+ contrastResult.skipped++;
152
+ continue;
153
+ }
154
+ const alpha = opacityChain(el);
155
+ const raw = toRGBA(s.color);
156
+ const inks = gradientText?.stops.length ? gradientText.stops : [raw];
157
+ let worst = null;
158
+ for (const bg of backgrounds) {
159
+ for (const ink of inks) {
160
+ const fg = blend([ink[0], ink[1], ink[2], ink[3] * alpha], bg);
161
+ const ratio = contrast(fg, bg);
162
+ if (!worst || ratio < worst.ratio) worst = { ratio, fg, bg };
163
+ }
164
+ }
165
+ const size = parseFloat(s.fontSize) || 16;
166
+ const weight = parseInt(s.fontWeight, 10) || 400;
167
+ const large = size >= 24 || (size >= 18.66 && weight >= 700);
168
+ const required = large ? 3 : 4.5;
169
+ contrastResult.checked++;
170
+ if (worst.ratio < required) {
171
+ contrastResult.failures++;
172
+ if (contrastResult.samples.length < 20) {
173
+ contrastResult.samples.push({
174
+ text: clip(ownText(el), 60),
175
+ ratio: Math.round(worst.ratio * 100) / 100,
176
+ required,
177
+ color: hex(worst.fg),
178
+ background: hex(worst.bg),
179
+ fontSize: size,
180
+ });
181
+ }
182
+ }
183
+ }
184
+ } catch (e) {
185
+ errors.push("contrast: " + e.message);
186
+ }
187
+
188
+ const firstViewText = [];
189
+ let firstViewLength = 0;
190
+ for (const el of textEls) {
191
+ const r = el.getBoundingClientRect();
192
+ if (r.top >= vh || r.bottom <= 0) continue;
193
+ const t = clip(ownText(el), 200);
194
+ if (!t) continue;
195
+ firstViewText.push(t);
196
+ firstViewLength += t.length;
197
+ if (firstViewLength > 700) break;
198
+ }
199
+
200
+ const headings = [...document.querySelectorAll("h1, h2, h3, [role='heading']")]
201
+ .filter(isShown)
202
+ .slice(0, 20)
203
+ .map((h) => ({
204
+ level: Number(h.getAttribute("aria-level")) || Number(h.tagName.slice(1)) || 2,
205
+ text: clip(h.textContent, 80),
206
+ inFirstView: h.getBoundingClientRect().top < vh,
207
+ }));
208
+
209
+ // ---------- actions ----------
210
+ const ACTION_SELECTOR = [
211
+ "a[href]", "button", "[role='button']", "[role='link']", "[role='menuitem']", "[role='tab']",
212
+ "input[type='button']", "input[type='submit']", "input[type='reset']", "summary", "[onclick]",
213
+ ].join(", ");
214
+
215
+ const accessibleName = (el) => {
216
+ const ids = el.getAttribute("aria-labelledby");
217
+ const labelled = ids
218
+ ? ids.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? "").join(" ")
219
+ : "";
220
+ return clip(
221
+ el.getAttribute("aria-label") ||
222
+ labelled ||
223
+ el.getAttribute("title") ||
224
+ el.innerText ||
225
+ el.value ||
226
+ el.querySelector("img[alt]")?.getAttribute("alt") ||
227
+ el.querySelector("svg title")?.textContent ||
228
+ "",
229
+ 60,
230
+ );
231
+ };
232
+
233
+ const isPrimaryStyle = (el) => {
234
+ if (el.closest("nav, [role='navigation'], aside, [role='tablist']")) return false;
235
+ const s = styleOf(el);
236
+ if (s.backgroundImage.includes("gradient")) return true;
237
+ const fill = toRGBA(s.backgroundColor);
238
+ if (fill[3] < 0.8) return false;
239
+ const behind = (el.parentElement && backgroundOf(el.parentElement)) || [255, 255, 255, 1];
240
+ return contrast(fill, behind) >= 2;
241
+ };
242
+
243
+ const actions = {
244
+ total: 0,
245
+ primaryInFirstView: 0,
246
+ iconOnlyUnnamed: 0,
247
+ smallTargets: 0,
248
+ deadLinks: 0,
249
+ hiddenUntilHover: 0,
250
+ items: [],
251
+ };
252
+ try {
253
+ for (const el of document.querySelectorAll(ACTION_SELECTOR)) {
254
+ if (!hasBox(el)) continue;
255
+ const s = styleOf(el);
256
+ if (s.visibility === "hidden" || s.display === "none") continue;
257
+ if (opacityChain(el) === 0) {
258
+ actions.hiddenUntilHover++;
259
+ continue;
260
+ }
261
+ const r = el.getBoundingClientRect();
262
+ const text = clip(el.innerText || el.value || "", 60);
263
+ const name = accessibleName(el);
264
+ const iconOnly = !text && !!el.querySelector("svg, img, i, [class*='icon']");
265
+ const href = el.tagName === "A" ? (el.getAttribute("href") ?? "").trim() : null;
266
+ const deadLink = href !== null && (href === "" || href === "#" || /^javascript:/i.test(href));
267
+ const inlineLink = el.tagName === "A" && s.display === "inline";
268
+ const small = !inlineLink && (r.width < 24 || r.height < 24);
269
+ const inFirstView = r.top < vh && r.bottom > 0 && r.left < vw && r.right > 0;
270
+ const primary = isPrimaryStyle(el);
271
+ const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true";
272
+
273
+ actions.total++;
274
+ if (primary && inFirstView && !disabled) actions.primaryInFirstView++;
275
+ if (iconOnly && !name) actions.iconOnlyUnnamed++;
276
+ if (small) actions.smallTargets++;
277
+ if (deadLink) actions.deadLinks++;
278
+ if (actions.items.length < 60) {
279
+ actions.items.push({
280
+ tag: el.tagName.toLowerCase(),
281
+ text,
282
+ name: name !== text ? name : undefined,
283
+ iconOnly: iconOnly || undefined,
284
+ primary: primary || undefined,
285
+ disabled: disabled || undefined,
286
+ deadLink: deadLink || undefined,
287
+ small: small || undefined,
288
+ inFirstView,
289
+ box: { x: Math.round(r.left), y: Math.round(r.top + window.scrollY), w: Math.round(r.width), h: Math.round(r.height) },
290
+ });
291
+ }
292
+ }
293
+ } catch (e) {
294
+ errors.push("actions: " + e.message);
295
+ }
296
+
297
+ // ---------- forms ----------
298
+ const FIELD_SELECTOR = [
299
+ "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='reset']):not([type='image'])",
300
+ "select", "textarea", "[role='combobox']", "[role='switch']", "[role='checkbox']", "[role='radiogroup']",
301
+ "[contenteditable='true']",
302
+ ].join(", ");
303
+ const describeFields = (list) => {
304
+ const fields = [...list].filter(isShown);
305
+ const unlabeled = fields.filter(
306
+ (f) =>
307
+ !(f.labels && f.labels.length) &&
308
+ !f.getAttribute("aria-label") &&
309
+ !f.getAttribute("aria-labelledby") &&
310
+ !f.getAttribute("title"),
311
+ ).length;
312
+ const required = fields.filter((f) => f.required || f.getAttribute("aria-required") === "true").length;
313
+ return { fields: fields.length, required, unlabeled };
314
+ };
315
+ let forms = [];
316
+ let looseFields = 0;
317
+ try {
318
+ forms = [...document.querySelectorAll("form")]
319
+ .filter(isShown)
320
+ .map((f) => describeFields(f.querySelectorAll(FIELD_SELECTOR)));
321
+ looseFields = [...document.querySelectorAll(FIELD_SELECTOR)].filter((f) => !f.closest("form") && isShown(f)).length;
322
+ } catch (e) {
323
+ errors.push("forms: " + e.message);
324
+ }
325
+
326
+ // ---------- decoration and typography ----------
327
+ const decor = { gradients: 0, gradientText: 0, blur: 0, roundedShadow: 0, emoji: 0 };
328
+ const families = new Map();
329
+ const sizes = new Set();
330
+ try {
331
+ const all = document.body.querySelectorAll("*");
332
+ for (let i = 0; i < all.length && i < 4000; i++) {
333
+ const el = all[i];
334
+ if (!hasBox(el)) continue;
335
+ const s = styleOf(el);
336
+ if (s.backgroundImage.includes("gradient")) {
337
+ decor.gradients++;
338
+ if (s.webkitBackgroundClip === "text" || s.backgroundClip === "text") decor.gradientText++;
339
+ }
340
+ if ((s.backdropFilter && s.backdropFilter !== "none") || s.filter.includes("blur")) decor.blur++;
341
+ if (s.boxShadow !== "none" && parseFloat(s.borderTopLeftRadius) >= 12) {
342
+ const r = el.getBoundingClientRect();
343
+ if (r.width * r.height > 4000) decor.roundedShadow++;
344
+ }
345
+ }
346
+ for (const el of textEls) {
347
+ const s = styleOf(el);
348
+ const family = s.fontFamily.split(",")[0].replace(/["']/g, "").trim();
349
+ families.set(family, (families.get(family) ?? 0) + 1);
350
+ sizes.add(Math.round(parseFloat(s.fontSize)));
351
+ decor.emoji += (ownText(el).match(/\p{Extended_Pictographic}/gu) ?? []).length;
352
+ }
353
+ } catch (e) {
354
+ errors.push("decor: " + e.message);
355
+ }
356
+
357
+ const dialogsOpen = [...document.querySelectorAll("[role='dialog'], [role='alertdialog'], dialog[open]")].filter(
358
+ (d) => isShown(d) && d.getAttribute("aria-hidden") !== "true",
359
+ ).length;
360
+
361
+ return {
362
+ probe: VERSION,
363
+ url: location.href,
364
+ title: document.title,
365
+ lang: document.documentElement.lang || null,
366
+ viewport: { width: vw, height: vh },
367
+ page: {
368
+ height: document.documentElement.scrollHeight,
369
+ horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
370
+ },
371
+ firstView: { text: firstViewText.join(" | ").slice(0, 700), headings },
372
+ actions,
373
+ contrast: contrastResult,
374
+ forms,
375
+ looseFields,
376
+ decor,
377
+ typography: {
378
+ families: [...families.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([f]) => f),
379
+ sizes: [...sizes].sort((a, b) => b - a).slice(0, 12),
380
+ },
381
+ dialogsOpen,
382
+ errors,
383
+ };
384
+ })()