rpc-agent 0.4.0 → 0.6.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/CHANGELOG.md +100 -0
- package/README.md +449 -5
- package/dist/args.js +197 -31
- package/dist/browser.js +57 -3
- package/dist/capture.js +45 -42
- package/dist/check-format.js +120 -0
- package/dist/check-page.js +351 -0
- package/dist/check.js +685 -0
- package/dist/cli.js +88 -1
- package/dist/errors.js +8 -0
- package/dist/gif.js +212 -0
- package/dist/help.js +56 -2
- package/dist/mcp.js +218 -16
- package/dist/motion.js +487 -0
- package/dist/options.js +33 -0
- package/dist/png.js +47 -0
- package/dist/record.js +333 -0
- package/dist/ui.js +199 -0
- package/package.json +2 -2
|
@@ -0,0 +1,351 @@
|
|
|
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
|
+
export 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. Inline links in a sentence are exempt
|
|
293
|
+
// (WCAG 2.5.8): display inline, with letters or digits outside any control
|
|
294
|
+
// in the same block of text. Buttons and links laid out as boxes count.
|
|
295
|
+
const interactive = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"]';
|
|
296
|
+
const blockOf = node => {
|
|
297
|
+
let p = node.parentElement;
|
|
298
|
+
while (p && p !== document.body && ['inline', 'contents'].includes(getComputedStyle(p).display)) p = p.parentElement;
|
|
299
|
+
return p;
|
|
300
|
+
};
|
|
301
|
+
const inSentence = el => {
|
|
302
|
+
const isLink = (el.localName === 'a' && !el.hasAttribute('role')) || el.getAttribute('role') === 'link';
|
|
303
|
+
if (!isLink || getComputedStyle(el).display !== 'inline') return false;
|
|
304
|
+
const block = blockOf(el);
|
|
305
|
+
if (!block) return false;
|
|
306
|
+
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
307
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
308
|
+
if (!/[\\p{L}\\p{N}]/u.test(n.textContent)) continue;
|
|
309
|
+
if (n.parentElement.closest(interactive) || blockOf(n) !== block) continue;
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
};
|
|
314
|
+
const small = [];
|
|
315
|
+
let tapTargets = 0;
|
|
316
|
+
for (const el of document.querySelectorAll(interactive)) {
|
|
317
|
+
if (!painted(el) || el.closest('[aria-hidden="true"], [inert]')) continue;
|
|
318
|
+
const outerTarget = el.parentElement && el.parentElement.closest(interactive);
|
|
319
|
+
if (outerTarget) continue;
|
|
320
|
+
if (inSentence(el)) continue;
|
|
321
|
+
tapTargets++;
|
|
322
|
+
let { width, height } = el.getBoundingClientRect();
|
|
323
|
+
if (el.labels) for (const l of el.labels) {
|
|
324
|
+
const r = l.getBoundingClientRect();
|
|
325
|
+
width = Math.max(width, r.width);
|
|
326
|
+
height = Math.max(height, r.height);
|
|
327
|
+
}
|
|
328
|
+
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 });
|
|
329
|
+
}
|
|
330
|
+
small.sort((a, b) => a.area - b.area);
|
|
331
|
+
|
|
332
|
+
// Text fields under 16px: iOS Safari zooms the page when they get focus.
|
|
333
|
+
const zoom = [];
|
|
334
|
+
const noText = new Set(['hidden', 'checkbox', 'radio', 'range', 'color', 'file', 'submit', 'reset', 'button', 'image']);
|
|
335
|
+
for (const el of document.querySelectorAll('input, select, textarea')) {
|
|
336
|
+
if (el.localName === 'input' && noText.has(el.type)) continue;
|
|
337
|
+
if (!painted(el)) continue;
|
|
338
|
+
const size = parseFloat(getComputedStyle(el).fontSize);
|
|
339
|
+
if (size < 16) zoom.push({ selector: sel(el), text: size + 'px', value: size });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
viewportWidth: vw,
|
|
344
|
+
scrollWidth,
|
|
345
|
+
vh: vhHits,
|
|
346
|
+
overflow,
|
|
347
|
+
tapTargets,
|
|
348
|
+
smallTargets: small.map(({ area, ...rest }) => rest),
|
|
349
|
+
zoom,
|
|
350
|
+
};
|
|
351
|
+
})()`;
|