rpc-agent 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,120 @@
1
+ // Text for `rpc check` results: plain lines (pipes, CI, agents), the rich
2
+ // terminal boxes and the MCP summary. No clack here, so `rpc mcp` and the
3
+ // plain paths can use it.
4
+ import { relative } from 'node:path';
5
+ import { CHECKS } from './check.js';
6
+ /** Findings by check, in report order; checks with nothing to say are left out. */
7
+ export function groupFindings(report) {
8
+ return CHECKS.map(c => ({ check: c.id, title: c.title, findings: report.findings.filter(f => f.check === c.id) })).filter(g => g.findings.length > 0);
9
+ }
10
+ /** `2 errors, 1 warning` with zero counts left out, or `no issues`. */
11
+ export function countsPhrase(findings) {
12
+ const n = (s) => findings.filter(f => f.severity === s).length;
13
+ const parts = [
14
+ n('error') ? `${n('error')} error${n('error') === 1 ? '' : 's'}` : '',
15
+ n('warn') ? `${n('warn')} warning${n('warn') === 1 ? '' : 's'}` : '',
16
+ n('info') ? `${n('info')} info` : '',
17
+ ].filter(Boolean);
18
+ return parts.length ? parts.join(', ') : 'no issues';
19
+ }
20
+ export function severityMark(s, c, icons) {
21
+ if (icons)
22
+ return s === 'error' ? c.red('✗') : s === 'warn' ? c.yellow('▲') : c.cyan('●');
23
+ return s === 'error' ? c.red('error') : s === 'warn' ? c.yellow('warn ') : c.cyan('info ');
24
+ }
25
+ /** One finding: mark and message, devices when not all of them, items, then the fix. */
26
+ export function findingLines(f, allDevices, c, o) {
27
+ const mark = severityMark(f.severity, c, o.icons);
28
+ const indent = ' '.repeat(o.icons ? 2 : 6);
29
+ const onSome = f.devices.length < allDevices.length ? c.dim(` (${f.devices.join(', ')})`) : '';
30
+ const [first = '', ...rest] = wrap(f.message, o.width - indent.length);
31
+ const out = [`${mark} ${first}${rest.length ? '' : onSome}`];
32
+ rest.forEach((l, i) => out.push(`${indent}${l}${i === rest.length - 1 ? onSome : ''}`));
33
+ const max = o.maxItems ?? 5;
34
+ const items = f.items ?? [];
35
+ if (!items.length && (f.selector || f.url))
36
+ out.push(`${indent}${c.cyan(f.selector ?? f.url)}`);
37
+ for (const item of items.slice(0, max)) {
38
+ const full = item.selector ?? item.url ?? '';
39
+ const extra = item.text && item.text !== full ? item.text : '';
40
+ // Long selectors lose their start: the end names the element.
41
+ const room = Math.max(20, o.width - indent.length - 2 - (extra ? extra.length + 2 : 0));
42
+ const where = full.length > room ? `…${full.slice(full.length - room + 1)}` : full;
43
+ const line = [where ? c.cyan(where) : '', extra ? c.dim(extra) : ''].filter(Boolean).join(' ');
44
+ out.push(`${indent}${c.dim('·')} ${line}`);
45
+ if (item.screenshot)
46
+ out.push(`${indent} ${c.dim('shot:')} ${rel(item.screenshot)}`);
47
+ }
48
+ if (items.length > max)
49
+ out.push(`${indent}${c.dim(`+${items.length - max} more (see --json)`)}`);
50
+ const [fix = '', ...more] = wrap(f.fix, o.width - indent.length - 5);
51
+ out.push(`${indent}${c.dim(`fix: ${fix}`)}`);
52
+ for (const l of more)
53
+ out.push(`${indent}${c.dim(` ${l}`)}`);
54
+ return out;
55
+ }
56
+ /** Lines for one check's findings. */
57
+ export function groupLines(g, allDevices, c, o) {
58
+ return g.findings.flatMap(f => findingLines(f, allDevices, c, o));
59
+ }
60
+ /** `iphone-15-pro (393x852 @3x) HTTP 200, CLS 0.000, title "…"` or the failure. */
61
+ export function deviceLine(d, c) {
62
+ const label = `${d.device} (${d.viewport.width}x${d.viewport.height} @${d.dpr}x)`;
63
+ if (d.error)
64
+ return `${c.red('x')} ${label}: ${d.error}`;
65
+ const status = d.status == null ? 'no status' : d.status >= 400 ? c.red(`HTTP ${d.status}`) : `HTTP ${d.status}`;
66
+ const cls = d.cls === undefined ? '' : `, CLS ${d.cls.toFixed(3)}`;
67
+ return `${c.green('ok')} ${label} ${status}${cls}${d.mobile ? '' : c.dim(' (no phone checks)')}`;
68
+ }
69
+ /** Plain output for pipes and CI: device lines, findings by check, the summary line. */
70
+ export function plainReport(report, c, width = 100) {
71
+ const all = report.devices.map(d => d.device);
72
+ const out = [];
73
+ for (const g of groupFindings(report)) {
74
+ out.push('', `${g.title}: ${countsPhrase(g.findings)}`);
75
+ for (const l of groupLines(g, all, c, { icons: false, width }))
76
+ out.push(` ${l}`);
77
+ }
78
+ return out.join('\n');
79
+ }
80
+ /** Short text for agents (MCP): the counts, then one line per error and warning. */
81
+ export function textSummary(report) {
82
+ const s = report.summary;
83
+ const lines = [
84
+ `${report.url}: ${s.errors} error(s), ${s.warnings} warning(s), ${s.info} info on ${report.devices.map(d => d.device).join(', ')}.`,
85
+ ];
86
+ for (const d of report.devices) {
87
+ if (d.error)
88
+ lines.push(`FAILED ${d.device}: ${d.error}${d.hint ? `\n hint: ${d.hint}` : ''}`);
89
+ }
90
+ const top = report.findings.filter(f => f.severity !== 'info');
91
+ for (const f of top.slice(0, 25)) {
92
+ const where = f.selector ?? f.url;
93
+ lines.push(`${f.severity === 'error' ? 'ERROR' : 'WARN'} [${f.id}] ${f.message}${where ? ` at ${where}` : ''}`);
94
+ }
95
+ if (top.length > 25)
96
+ lines.push(`… ${top.length - 25} more in the JSON report.`);
97
+ if (!top.length)
98
+ lines.push('No errors or warnings.');
99
+ return lines.join('\n');
100
+ }
101
+ function rel(p) {
102
+ const r = relative(process.cwd(), p);
103
+ return r.startsWith('..') ? p : r;
104
+ }
105
+ function wrap(text, width) {
106
+ const out = [];
107
+ let line = '';
108
+ for (const word of text.split(/\s+/)) {
109
+ if (line && line.length + 1 + word.length > width) {
110
+ out.push(line);
111
+ line = word;
112
+ }
113
+ else {
114
+ line = line ? `${line} ${word}` : word;
115
+ }
116
+ }
117
+ if (line)
118
+ out.push(line);
119
+ return out;
120
+ }
@@ -0,0 +1,336 @@
1
+ // In-page scripts for `rpc check`. Plain strings, like the ones in
2
+ // capture.ts: under tsx/vitest, esbuild wraps named functions in a
3
+ // `__name()` helper that doesn't exist inside the page.
4
+ /** Shared helpers: a short unique selector, rendering tests and accessible names. */
5
+ const HELPERS = `
6
+ const esc = s => (window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/[^\\w-]/g, '\\\\$&'));
7
+ const uniq = s => { try { return document.querySelectorAll(s).length === 1; } catch (e) { return false; } };
8
+ const sel = el => {
9
+ if (!el || el.nodeType !== 1) return '';
10
+ if (el.id && uniq('#' + esc(el.id))) return '#' + esc(el.id);
11
+ const parts = [];
12
+ for (let node = el; node && node.nodeType === 1 && parts.length < 6; node = node.parentElement) {
13
+ if (node === document.documentElement) { parts.unshift('html'); break; }
14
+ if (node === document.body) { parts.unshift('body'); break; }
15
+ if (node.id && uniq('#' + esc(node.id))) { parts.unshift('#' + esc(node.id)); break; }
16
+ let part = node.localName;
17
+ const cls = Array.from(node.classList).filter(c => /^[A-Za-z_-][\\w-]*$/.test(c) && c.length <= 32).slice(0, 2);
18
+ if (cls.length) part += '.' + cls.join('.');
19
+ const parent = node.parentElement;
20
+ if (parent && !uniq([part, ...parts].join(' > '))) {
21
+ const same = Array.from(parent.children).filter(c => c.localName === node.localName);
22
+ if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(node) + 1) + ')';
23
+ }
24
+ parts.unshift(part);
25
+ if (uniq(parts.join(' > '))) break;
26
+ }
27
+ return parts.join(' > ');
28
+ };
29
+ const tag = el => {
30
+ const t = el.outerHTML.slice(0, el.outerHTML.indexOf('>') + 1);
31
+ return t.length > 90 ? t.slice(0, 89) + '…>' : t;
32
+ };
33
+ /** In the accessibility tree: rendered, not visibility:hidden, not aria-hidden or inert. */
34
+ const exposed = el => {
35
+ if (el.closest('[aria-hidden="true"], [inert]')) return false;
36
+ return el.checkVisibility ? el.checkVisibility({ visibilityProperty: true }) : el.getClientRects().length > 0;
37
+ };
38
+ /** Painted and big enough to see or tap. */
39
+ const painted = el => {
40
+ if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
41
+ const r = el.getBoundingClientRect();
42
+ return r.width >= 2 && r.height >= 2;
43
+ };
44
+ const hiddenNode = e => {
45
+ if (e.getAttribute('aria-hidden') === 'true') return true;
46
+ const cs = getComputedStyle(e);
47
+ return cs.display === 'none' || cs.visibility === 'hidden';
48
+ };
49
+ const pseudoText = (e, which) => {
50
+ const c = getComputedStyle(e, which).content;
51
+ if (!c || c === 'none' || c === 'normal') return '';
52
+ const m = /^"(.*)"$/.exec(c);
53
+ // Icon fonts put private-use code points in content; those aren't names.
54
+ return m ? m[1].replace(/[\\uE000-\\uF8FF]/g, '') : '';
55
+ };
56
+ const textOf = node => {
57
+ let out = node.nodeType === 1 ? pseudoText(node, '::before') : '';
58
+ for (const n of node.childNodes) {
59
+ if (n.nodeType === 3) { out += n.textContent; continue; }
60
+ if (n.nodeType !== 1 || hiddenNode(n)) continue;
61
+ const label = n.getAttribute('aria-label');
62
+ if (label && label.trim()) { out += ' ' + label + ' '; continue; }
63
+ if (n.localName === 'img' || n.localName === 'area' || (n.localName === 'input' && n.type === 'image')) {
64
+ out += ' ' + (n.getAttribute('alt') || '') + ' ';
65
+ continue;
66
+ }
67
+ if (n.localName === 'svg') {
68
+ const t = n.querySelector('title');
69
+ if (t) out += ' ' + t.textContent + ' ';
70
+ continue;
71
+ }
72
+ out += ' ' + textOf(n) + ' ';
73
+ }
74
+ if (node.nodeType === 1) out += pseudoText(node, '::after');
75
+ return out;
76
+ };
77
+ const clean = s => (s || '').replace(/\\s+/g, ' ').trim();
78
+ /** Accessible name, close enough to the accname algorithm for "is it empty?". */
79
+ const nameOf = el => {
80
+ const by = el.getAttribute('aria-labelledby');
81
+ if (by) {
82
+ const t = clean(by.split(/\\s+/).map(id => document.getElementById(id)).filter(Boolean).map(n => textOf(n)).join(' '));
83
+ if (t) return t;
84
+ }
85
+ const label = clean(el.getAttribute('aria-label'));
86
+ if (label) return label;
87
+ const field = el.localName === 'input' || el.localName === 'select' || el.localName === 'textarea';
88
+ if (field && el.labels && el.labels.length) {
89
+ const t = clean(Array.from(el.labels).map(l => textOf(l)).join(' '));
90
+ if (t) return t;
91
+ }
92
+ if (el.localName === 'input') {
93
+ if (el.type === 'submit' || el.type === 'reset' || el.type === 'button') return clean(el.value) || (el.type === 'button' ? '' : el.type);
94
+ if (el.type === 'image') return clean(el.getAttribute('alt'));
95
+ }
96
+ if (el.localName === 'img') return clean(el.getAttribute('alt'));
97
+ if (!field) {
98
+ const t = clean(textOf(el));
99
+ if (t) return t;
100
+ }
101
+ return clean(el.getAttribute('title'));
102
+ };
103
+ `;
104
+ /**
105
+ * Init script: record every layout-shift entry with its sources' selectors
106
+ * (taken when the entry arrives, before the node can be removed).
107
+ */
108
+ export const LAYOUT_SHIFT_INIT_JS = `(() => {
109
+ if (window.top !== window || !window.PerformanceObserver) return;
110
+ ${HELPERS}
111
+ const shifts = (window.__rpcShifts = []);
112
+ const rect = r => r ? { x: r.x, y: r.y, width: r.width, height: r.height } : null;
113
+ try {
114
+ new PerformanceObserver(list => {
115
+ for (const e of list.getEntries()) {
116
+ shifts.push({
117
+ value: e.value,
118
+ startTime: e.startTime,
119
+ hadRecentInput: e.hadRecentInput,
120
+ sources: (e.sources || []).map(s => ({
121
+ selector: s.node && s.node.nodeType === 1 ? sel(s.node) : s.node && s.node.parentElement ? sel(s.node.parentElement) : '',
122
+ previousRect: rect(s.previousRect),
123
+ currentRect: rect(s.currentRect),
124
+ })),
125
+ });
126
+ }
127
+ }).observe({ type: 'layout-shift', buffered: true });
128
+ } catch (e) {}
129
+ })()`;
130
+ export const LAYOUT_SHIFTS_JS = `(window.__rpcShifts || [])`;
131
+ /** Scroll down one viewport and back, pausing so lazy content and shifts land. */
132
+ export const SCROLL_ONCE_JS = `(async () => {
133
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
134
+ window.scrollTo({ top: window.innerHeight, behavior: 'instant' });
135
+ await sleep(300);
136
+ window.scrollTo({ top: 0, behavior: 'instant' });
137
+ await sleep(300);
138
+ })()`;
139
+ export const META_JS = `(() => {
140
+ const attr = (s, a) => { const e = document.querySelector(s); return e ? e.getAttribute(a) : null; };
141
+ const meta = key => attr('meta[property="' + key + '"], meta[name="' + key + '"]', 'content');
142
+ return {
143
+ title: document.querySelector('title') ? document.title : null,
144
+ lang: document.documentElement.getAttribute('lang'),
145
+ description: attr('meta[name="description"]', 'content'),
146
+ canonical: attr('link[rel~="canonical"]', 'href'),
147
+ viewport: attr('meta[name="viewport"]', 'content'),
148
+ icons: Array.from(document.querySelectorAll('link[rel~="icon"]')).map(l => l.href),
149
+ appleTouchIcon: Boolean(document.querySelector('link[rel~="apple-touch-icon"], link[rel~="apple-touch-icon-precomposed"]')),
150
+ og: { title: meta('og:title'), description: meta('og:description'), image: meta('og:image'), url: meta('og:url') },
151
+ twitterCard: meta('twitter:card'),
152
+ themeColor: attr('meta[name="theme-color"]', 'content'),
153
+ baseUrl: document.baseURI,
154
+ };
155
+ })()`;
156
+ export const LABELS_JS = `(() => {
157
+ ${HELPERS}
158
+ const controls = [];
159
+ for (const el of document.querySelectorAll('button, a[href], [role="button"], [role="link"], input[type="button"], input[type="submit"], input[type="reset"], input[type="image"]')) {
160
+ if (!exposed(el)) continue;
161
+ if (!nameOf(el)) controls.push({ selector: sel(el), text: tag(el), kind: el.localName === 'a' || el.getAttribute('role') === 'link' ? 'link' : 'button' });
162
+ }
163
+ const images = [];
164
+ for (const el of document.querySelectorAll('img:not([alt])')) {
165
+ const role = el.getAttribute('role');
166
+ if (role === 'presentation' || role === 'none' || !exposed(el)) continue;
167
+ images.push({ selector: sel(el), text: tag(el) });
168
+ }
169
+ const fields = [];
170
+ const skip = new Set(['hidden', 'submit', 'reset', 'button', 'image']);
171
+ for (const el of document.querySelectorAll('input, select, textarea')) {
172
+ if (el.localName === 'input' && skip.has(el.type)) continue;
173
+ if (!exposed(el) || nameOf(el)) continue;
174
+ fields.push({ selector: sel(el), text: tag(el), placeholder: Boolean(clean(el.getAttribute('placeholder'))) });
175
+ }
176
+ return { controls, images, fields };
177
+ })()`;
178
+ export const FONTS_JS = `(() => {
179
+ ${HELPERS}
180
+ const faces = [];
181
+ const unreadable = [];
182
+ const unquote = s => clean(s).replace(/^["']|["']$/g, '');
183
+ const walk = (rules, base) => {
184
+ for (const r of rules) {
185
+ if (r.type === 5) {
186
+ const s = r.style;
187
+ const src = s.getPropertyValue('src');
188
+ const urls = [];
189
+ src.replace(/url\\(\\s*(['"]?)(.*?)\\1\\s*\\)/g, (_, q, u) => { try { urls.push(new URL(u, base).href); } catch (e) {} return ''; });
190
+ faces.push({ family: unquote(s.getPropertyValue('font-family')), display: clean(s.getPropertyValue('font-display')), urls, sheet: base });
191
+ } else if (r.type === 3 && r.styleSheet) {
192
+ try { walk(r.styleSheet.cssRules, r.styleSheet.href || base); } catch (e) { if (r.styleSheet.href) unreadable.push(r.styleSheet.href); }
193
+ } else if (r.cssRules) {
194
+ walk(r.cssRules, base);
195
+ }
196
+ }
197
+ };
198
+ for (const sheet of document.styleSheets) {
199
+ try { walk(sheet.cssRules, sheet.href || location.href); } catch (e) { if (sheet.href) unreadable.push(sheet.href); }
200
+ }
201
+ // Families that render text in the first (layout) viewport.
202
+ const vw = document.documentElement.clientWidth;
203
+ const vh = document.documentElement.clientHeight;
204
+ const aboveFold = new Set();
205
+ const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
206
+ const range = document.createRange();
207
+ for (let n = walker.nextNode(), i = 0; n && i < 3000; n = walker.nextNode(), i++) {
208
+ if (!n.textContent.trim() || !n.parentElement) continue;
209
+ range.selectNodeContents(n);
210
+ const r = range.getBoundingClientRect();
211
+ if (r.width === 0 || r.bottom <= 0 || r.top >= vh || r.right <= 0 || r.left >= vw) continue;
212
+ const cs = getComputedStyle(n.parentElement);
213
+ if (cs.visibility === 'hidden') continue;
214
+ for (const f of cs.fontFamily.split(',')) aboveFold.add(unquote(f).toLowerCase());
215
+ }
216
+ const loaded = [];
217
+ if (document.fonts) for (const f of document.fonts) if (f.status === 'loaded') loaded.push(unquote(f.family).toLowerCase());
218
+ const preloads = Array.from(document.querySelectorAll('link[rel~="preload"][as="font"]')).map(l => ({
219
+ href: l.href,
220
+ crossorigin: l.hasAttribute('crossorigin'),
221
+ }));
222
+ return { faces, unreadable: Array.from(new Set(unreadable)), aboveFold: Array.from(aboveFold), loaded: Array.from(new Set(loaded)), preloads };
223
+ })()`;
224
+ export const MOBILE_JS = `(() => {
225
+ ${HELPERS}
226
+ // 100vh in height properties of rules that apply, and inline styles.
227
+ const vh = /(^|[^\\d.])100(\\.0+)?vh/;
228
+ const props = ['height', 'min-height', 'max-height', 'block-size', 'min-block-size', 'max-block-size'];
229
+ const rules = [];
230
+ const walk = list => {
231
+ for (const r of list) {
232
+ if (r.type === 4) { if (matchMedia(r.media.mediaText).matches) walk(r.cssRules); continue; }
233
+ if (r.type === 12) { if (CSS.supports(r.conditionText)) walk(r.cssRules); continue; }
234
+ if (r.type === 3) { try { if (r.styleSheet) walk(r.styleSheet.cssRules); } catch (e) {} continue; }
235
+ if (r.type === 1) {
236
+ for (const p of props) {
237
+ const v = r.style.getPropertyValue(p);
238
+ if (vh.test(v)) rules.push({ selector: r.selectorText, decl: p + ': ' + v });
239
+ }
240
+ }
241
+ if (r.cssRules) walk(r.cssRules);
242
+ }
243
+ };
244
+ for (const sheet of document.styleSheets) { try { walk(sheet.cssRules); } catch (e) {} }
245
+ const vhHits = [];
246
+ const seen = new Set();
247
+ const add = (el, decl) => {
248
+ if (seen.has(el) || !painted(el)) return;
249
+ seen.add(el);
250
+ vhHits.push({ selector: sel(el), text: decl, value: Math.round(el.getBoundingClientRect().height) });
251
+ };
252
+ for (const r of rules) {
253
+ let els = [];
254
+ try { els = document.querySelectorAll(r.selector); } catch (e) {}
255
+ for (const el of els) add(el, r.decl + ' (' + r.selector + ')');
256
+ }
257
+ for (const el of document.querySelectorAll('[style*="vh"]')) {
258
+ for (const p of props) { const v = el.style.getPropertyValue(p); if (vh.test(v)) add(el, p + ': ' + v + ' (inline style)'); }
259
+ }
260
+
261
+ // Horizontal overflow: the outermost elements that stick out past the
262
+ // layout viewport. Not innerWidth: a phone zooms out to fit wide content,
263
+ // which makes innerWidth the content width.
264
+ const vw = document.documentElement.clientWidth;
265
+ const root = document.scrollingElement || document.documentElement;
266
+ const scrollWidth = Math.max(root.scrollWidth, document.body ? document.body.scrollWidth : 0);
267
+ const overflow = [];
268
+ if (scrollWidth > vw + 1) {
269
+ const offenders = new Set();
270
+ const clipped = el => {
271
+ for (let p = el.parentElement; p && p !== document.body && p !== document.documentElement; p = p.parentElement) {
272
+ const cs = getComputedStyle(p);
273
+ if (cs.position === 'fixed' || cs.overflowX !== 'visible') return true;
274
+ }
275
+ return false;
276
+ };
277
+ for (const el of document.body.querySelectorAll('*')) {
278
+ const r = el.getBoundingClientRect();
279
+ if (r.width === 0 || r.height === 0 || (r.right <= vw + 1 && r.left >= -1)) continue;
280
+ if (getComputedStyle(el).position === 'fixed' || clipped(el)) continue;
281
+ offenders.add(el);
282
+ }
283
+ const outer = Array.from(offenders).filter(el => !offenders.has(el.parentElement));
284
+ outer.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right);
285
+ for (const el of outer.slice(0, 5)) {
286
+ const r = el.getBoundingClientRect();
287
+ const by = Math.round(Math.max(r.right - vw, -r.left));
288
+ overflow.push({ selector: sel(el), text: Math.round(r.width) + 'px wide, ' + by + 'px past the edge', value: by });
289
+ }
290
+ }
291
+
292
+ // Tap targets under 44x44 CSS px. Links inside running text are exempt.
293
+ const interactive = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"]';
294
+ const small = [];
295
+ let tapTargets = 0;
296
+ for (const el of document.querySelectorAll(interactive)) {
297
+ if (!painted(el) || el.closest('[aria-hidden="true"], [inert]')) continue;
298
+ const outerTarget = el.parentElement && el.parentElement.closest(interactive);
299
+ if (outerTarget) continue;
300
+ const cs = getComputedStyle(el);
301
+ if (cs.display === 'inline' && el.parentElement) {
302
+ const own = clean(el.textContent).length;
303
+ const around = clean(el.parentElement.textContent).length;
304
+ if (around > own + 10) continue;
305
+ }
306
+ tapTargets++;
307
+ let { width, height } = el.getBoundingClientRect();
308
+ if (el.labels) for (const l of el.labels) {
309
+ const r = l.getBoundingClientRect();
310
+ width = Math.max(width, r.width);
311
+ height = Math.max(height, r.height);
312
+ }
313
+ if (width < 44 || height < 44) small.push({ selector: sel(el), text: Math.round(width) + '×' + Math.round(height), value: Math.round(Math.min(width, height)), area: width * height });
314
+ }
315
+ small.sort((a, b) => a.area - b.area);
316
+
317
+ // Text fields under 16px: iOS Safari zooms the page when they get focus.
318
+ const zoom = [];
319
+ const noText = new Set(['hidden', 'checkbox', 'radio', 'range', 'color', 'file', 'submit', 'reset', 'button', 'image']);
320
+ for (const el of document.querySelectorAll('input, select, textarea')) {
321
+ if (el.localName === 'input' && noText.has(el.type)) continue;
322
+ if (!painted(el)) continue;
323
+ const size = parseFloat(getComputedStyle(el).fontSize);
324
+ if (size < 16) zoom.push({ selector: sel(el), text: size + 'px', value: size });
325
+ }
326
+
327
+ return {
328
+ viewportWidth: vw,
329
+ scrollWidth,
330
+ vh: vhHits,
331
+ overflow,
332
+ tapTargets,
333
+ smallTargets: small.map(({ area, ...rest }) => rest),
334
+ zoom,
335
+ };
336
+ })()`;