@domandigital/craft 0.19.3 → 0.20.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/dist/phone.js CHANGED
@@ -1,10 +1,23 @@
1
1
  // src/phone/index.ts
2
- import { mkdirSync, writeFileSync } from "fs";
3
- import { join } from "path";
2
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
3
+ import { join as join2 } from "path";
4
+
5
+ // src/phone/constants.ts
6
+ var PHONE = {
7
+ viewport: { width: 375, height: 812 },
8
+ deviceScaleFactor: 2,
9
+ isMobile: true,
10
+ hasTouch: true
11
+ };
12
+ var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
13
+ var DEFAULT_MEASURED_DIR = "phone-first/measured";
14
+ var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
4
15
 
5
16
  // src/phone/measure-in-page.ts
6
17
  function measurePhoneInPage(floors) {
7
18
  const out = [];
19
+ const only = floors.only;
20
+ const on = (c) => !only || only.includes(c);
8
21
  const vw = document.documentElement.clientWidth;
9
22
  const shown = (e) => {
10
23
  const s = getComputedStyle(e);
@@ -19,7 +32,8 @@ function measurePhoneInPage(floors) {
19
32
  const text = (e.textContent ?? "").replace(/\s+/g, " ").trim() || e.getAttribute("aria-label") || e.getAttribute("name") || e.getAttribute("placeholder") || "";
20
33
  return `<${e.tagName.toLowerCase()}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
21
34
  };
22
- if (vw > window.screen.width + 1) {
35
+ if (!on("scroll")) {
36
+ } else if (vw > window.screen.width + 1) {
23
37
  out.push({ check: "scroll", element: "<meta name=viewport>", detail: `the page lays out at ${vw}px on a ${window.screen.width}px phone: no width=device-width viewport meta` });
24
38
  } else if (document.documentElement.scrollWidth > vw + 1) {
25
39
  const wide = Array.from(document.body.querySelectorAll("*")).filter((e) => shown(e) && e.getBoundingClientRect().right > vw + 1).filter((e, _, all) => !all.some((o) => o !== e && o.contains(e))).slice(0, 3).map(describe);
@@ -30,7 +44,7 @@ function measurePhoneInPage(floors) {
30
44
  const block = e.parentElement?.closest("p, li, dd, td, th, blockquote, figcaption, address");
31
45
  return !!block && (block.textContent ?? "").trim().length > (e.textContent ?? "").trim().length;
32
46
  };
33
- const targets = Array.from(document.querySelectorAll("a[href], button, input:not([type='hidden']), select, textarea, [role='button'], [role='tab'], [role='menuitem'], summary")).filter(shown);
47
+ const targets = (!on("target") ? [] : Array.from(document.querySelectorAll("a[href], button, input:not([type='hidden']), select, textarea, [role='button'], [role='tab'], [role='menuitem'], summary"))).filter(shown);
34
48
  for (const e of targets) {
35
49
  const r = e.getBoundingClientRect();
36
50
  if (quiet(e) || inline(e) || r.width <= 1 || r.height <= 1) continue;
@@ -41,19 +55,19 @@ function measurePhoneInPage(floors) {
41
55
  if (w < floors.targetPx || h < floors.targetPx) out.push({ check: "target", element: describe(e), detail: `${w}x${h}px, under ${floors.targetPx}x${floors.targetPx}` });
42
56
  }
43
57
  const NOT_TEXT = /* @__PURE__ */ new Set(["checkbox", "radio", "range", "color", "file", "submit", "reset", "button", "image"]);
44
- for (const e of Array.from(document.querySelectorAll("input:not([type='hidden']), select, textarea"))) {
58
+ for (const e of on("field") ? Array.from(document.querySelectorAll("input:not([type='hidden']), select, textarea")) : []) {
45
59
  if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
46
60
  const px = parseFloat(getComputedStyle(e).fontSize);
47
61
  if (px < floors.fieldFontPx) out.push({ check: "field", element: describe(e), detail: `set at ${px}px, under ${floors.fieldFontPx}px, so Safari on iOS zooms on focus` });
48
62
  }
49
- for (const e of Array.from(document.body.querySelectorAll("*"))) {
63
+ for (const e of on("text") ? Array.from(document.body.querySelectorAll("*")) : []) {
50
64
  if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
51
65
  const own = Array.from(e.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
52
66
  if (!own) continue;
53
67
  const px = parseFloat(getComputedStyle(e).fontSize);
54
68
  if (px < floors.textPx) out.push({ check: "text", element: `<${e.tagName.toLowerCase()}> "${own.slice(0, 40)}"`, detail: `${px}px, under ${floors.textPx}px` });
55
69
  }
56
- for (const e of Array.from(document.querySelectorAll("h1"))) {
70
+ for (const e of on("title") ? Array.from(document.querySelectorAll("h1")) : []) {
57
71
  if (!shown(e) || quiet(e)) continue;
58
72
  const s = getComputedStyle(e);
59
73
  const clipsX = e.scrollWidth > e.clientWidth + 1 && s.overflowX !== "visible";
@@ -63,6 +77,294 @@ function measurePhoneInPage(floors) {
63
77
  return out;
64
78
  }
65
79
 
80
+ // src/phone/measure-viewport-in-page.ts
81
+ function measureViewportInPage(arg) {
82
+ const { groups, thresholds: t } = arg;
83
+ const out = [];
84
+ const vw = document.documentElement.clientWidth;
85
+ const vh = document.documentElement.clientHeight;
86
+ const pct = (n) => `${Math.round(n * 100)}%`;
87
+ const px = (n) => `${Math.round(n)}px`;
88
+ const shown = (e) => {
89
+ const s = getComputedStyle(e);
90
+ if (s.display === "none" || s.visibility === "hidden" || e.getClientRects().length === 0) return false;
91
+ return typeof e.checkVisibility === "function" ? e.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true }) : true;
92
+ };
93
+ const hiddenByMarkup = (e) => !!e.closest("[aria-hidden='true'], .sr-only, .visually-hidden, [hidden]");
94
+ const faded = (e) => {
95
+ for (let n = e; n && n !== document.documentElement; n = n.parentElement) if (getComputedStyle(n).opacity === "0") return true;
96
+ return false;
97
+ };
98
+ const describe = (e) => {
99
+ const text = (e.textContent ?? "").replace(/\s+/g, " ").trim() || e.getAttribute("aria-label") || e.getAttribute("name") || e.getAttribute("placeholder") || "";
100
+ return `<${e.tagName.toLowerCase()}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
101
+ };
102
+ const add = (check, element, detail) => {
103
+ out.push({ check, element, detail });
104
+ };
105
+ const clipBoxes = /* @__PURE__ */ new Map();
106
+ const clipsOf = (e) => {
107
+ const had = clipBoxes.get(e);
108
+ if (had) return had;
109
+ const boxes = [];
110
+ for (let n = e.parentElement; n && n !== document.documentElement && n !== document.body; n = n.parentElement) {
111
+ const s = getComputedStyle(n);
112
+ if (/hidden|clip|auto|scroll/.test(s.overflowX + s.overflowY)) boxes.push(n.getBoundingClientRect());
113
+ }
114
+ clipBoxes.set(e, boxes);
115
+ return boxes;
116
+ };
117
+ const hardClipsOf = (e) => {
118
+ const boxes = [];
119
+ for (let n = e.parentElement; n && n !== document.documentElement && n !== document.body; n = n.parentElement) {
120
+ const s = getComputedStyle(n);
121
+ if (/hidden|clip/.test(s.overflowX) || /hidden|clip/.test(s.overflowY)) boxes.push(n.getBoundingClientRect());
122
+ }
123
+ return boxes;
124
+ };
125
+ const inLayers = /* @__PURE__ */ new Map();
126
+ const inLayer = (e) => {
127
+ const had = inLayers.get(e);
128
+ if (had !== void 0) return had;
129
+ const p = getComputedStyle(e).position;
130
+ const v = p === "fixed" || p === "sticky" || !!e.parentElement && inLayer(e.parentElement);
131
+ inLayers.set(e, v);
132
+ return v;
133
+ };
134
+ const textBoxes = () => {
135
+ const boxes = [];
136
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
137
+ const range = document.createRange();
138
+ const skip = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "OPTION", "TEXTAREA", "TITLE"]);
139
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
140
+ const owner = n.parentElement;
141
+ if (!owner || skip.has(owner.tagName) || !(n.textContent ?? "").trim() || !shown(owner) || hiddenByMarkup(owner) || faded(owner)) continue;
142
+ range.selectNodeContents(n);
143
+ for (const rect of Array.from(range.getClientRects())) {
144
+ let { left: l, top: tp, right: r, bottom: b } = rect;
145
+ for (const c of clipsOf(owner)) {
146
+ l = Math.max(l, c.left);
147
+ tp = Math.max(tp, c.top);
148
+ r = Math.min(r, c.right);
149
+ b = Math.min(b, c.bottom);
150
+ }
151
+ if (r - l >= 1 && b - tp >= 1) boxes.push({ l, t: tp, r, b, owner, node: n });
152
+ }
153
+ }
154
+ return boxes;
155
+ };
156
+ const nameOf = (b) => `<${b.owner.tagName.toLowerCase()}> "${(b.node.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 40)}"`;
157
+ const layerCover = () => {
158
+ const painted = (e) => {
159
+ const s = getComputedStyle(e);
160
+ const bg = s.backgroundColor;
161
+ const clear = bg === "rgba(0, 0, 0, 0)" || bg === "transparent";
162
+ return !clear || s.backgroundImage !== "none" || parseFloat(s.borderTopWidth) > 0 || parseFloat(s.borderBottomWidth) > 0 || s.boxShadow !== "none" || /^(IMG|SVG|VIDEO|CANVAS|IFRAME|BUTTON|INPUT)$/i.test(e.tagName) || Array.from(e.childNodes).some((c) => c.nodeType === 3 && (c.textContent ?? "").trim());
163
+ };
164
+ const paintedBoxes = (e, depth) => {
165
+ if (!shown(e) || getComputedStyle(e).pointerEvents === "none" || faded(e)) return [];
166
+ if (painted(e)) return [{ e, r: e.getBoundingClientRect() }];
167
+ return depth > 6 ? [] : Array.from(e.children).flatMap((c) => paintedBoxes(c, depth + 1));
168
+ };
169
+ const read = () => {
170
+ const boxes = Array.from(document.body.querySelectorAll("*")).filter((e) => ["fixed", "sticky"].includes(getComputedStyle(e).position)).flatMap((e) => paintedBoxes(e, 0)).map(({ e, r }) => ({ e, l: Math.max(0, r.left), t: Math.max(0, r.top), r: Math.min(vw, r.right), b: Math.min(vh, r.bottom) })).filter((b) => b.r - b.l > 1 && b.b - b.t > 1);
171
+ const xs = [...new Set(boxes.flatMap((b) => [b.l, b.r]))].sort((a, b) => a - b);
172
+ const ys = [...new Set(boxes.flatMap((b) => [b.t, b.b]))].sort((a, b) => a - b);
173
+ let area = 0;
174
+ for (let i = 0; i < xs.length - 1; i++) {
175
+ for (let j = 0; j < ys.length - 1; j++) {
176
+ if (boxes.some((b) => b.l <= xs[i] && b.r >= xs[i + 1] && b.t <= ys[j] && b.b >= ys[j + 1])) area += (xs[i + 1] - xs[i]) * (ys[j + 1] - ys[j]);
177
+ }
178
+ }
179
+ return { share: area / (vw * vh), names: [...new Set(boxes.map((b) => describe(b.e)))].slice(0, 4) };
180
+ };
181
+ const top = read();
182
+ const home = window.scrollY;
183
+ window.scrollTo({ top: Math.min(document.documentElement.scrollHeight - vh, Math.round(vh * 1.5)), behavior: "instant" });
184
+ const moved = read();
185
+ window.scrollTo({ top: home, behavior: "instant" });
186
+ return moved.share > top.share ? moved : top;
187
+ };
188
+ const imageName = (img) => {
189
+ const alt = (img.getAttribute("alt") ?? "").trim();
190
+ const file = (img.currentSrc || img.src).split("?")[0].split("/").pop() ?? "";
191
+ return `<img> "${(alt || file).slice(0, 40)}"`;
192
+ };
193
+ const images = (kinds) => {
194
+ for (const img of Array.from(document.images)) {
195
+ const src = img.currentSrc || img.src;
196
+ if (!img.complete || !img.naturalWidth || !src || /\.svg(\?|$)|^data:image\/svg/i.test(src) || !shown(img) || hiddenByMarkup(img) || faded(img)) continue;
197
+ const shownPx = img.getBoundingClientRect().width;
198
+ if (shownPx < t.minImagePx) continue;
199
+ const ratio = img.naturalWidth / shownPx;
200
+ if (kinds.includes("oversized") && ratio > t.oversizeRatio) {
201
+ add("image", `${imageName(img)} oversized`, `the file is ${img.naturalWidth}px wide, shown at ${px(shownPx)} (${ratio.toFixed(1)}x; a 2x phone needs ${px(shownPx * 2)}). Serve a smaller one with srcset and sizes`);
202
+ }
203
+ if (kinds.includes("upscaled") && ratio < t.upscaleRatio) {
204
+ add("image", `${imageName(img)} upscaled`, `the file is ${img.naturalWidth}px wide, stretched to ${px(shownPx)}. Serve one at least that wide`);
205
+ }
206
+ }
207
+ };
208
+ if (groups.includes("sweep")) {
209
+ if (arg.metaViewport && vw > window.screen.width + 1) {
210
+ add("scroll", "<meta name=viewport>", `the page lays out at ${vw}px on a ${window.screen.width}px phone: no width=device-width viewport meta`);
211
+ } else if (document.documentElement.scrollWidth > vw + 1) {
212
+ const wide = Array.from(document.body.querySelectorAll("*")).filter((e) => shown(e) && e.getBoundingClientRect().right > vw + 1).filter((e, _, all) => !all.some((o) => o !== e && o.contains(e))).slice(0, 3).map(describe);
213
+ add("scroll", "<html>", `the page is ${document.documentElement.scrollWidth}px wide at ${vw}px${wide.length ? `; widest: ${wide.join(", ")}` : ""}`);
214
+ }
215
+ const boxes = textBoxes().sort((a, b) => a.t - b.t);
216
+ const seen = /* @__PURE__ */ new Set();
217
+ for (let i = 0; i < boxes.length && seen.size < 15; i++) {
218
+ const a = boxes[i];
219
+ for (let j = i + 1; j < boxes.length && boxes[j].t < a.b; j++) {
220
+ const b = boxes[j];
221
+ if (a.node === b.node) continue;
222
+ const w = Math.min(a.r, b.r) - Math.max(a.l, b.l);
223
+ const h = Math.min(a.b, b.b) - Math.max(a.t, b.t);
224
+ if (w < 3 || h < t.overlapShare * Math.min(a.b - a.t, b.b - b.t)) continue;
225
+ const x = Math.max(a.l, b.l) + w / 2;
226
+ const y = Math.max(a.t, b.t) + h / 2;
227
+ const onScreen = x >= 0 && x < vw && y >= 0 && y < vh;
228
+ if (!onScreen && inLayer(a.owner) !== inLayer(b.owner)) continue;
229
+ if (onScreen && getComputedStyle(a.owner).pointerEvents !== "none" && getComputedStyle(b.owner).pointerEvents !== "none") {
230
+ const stack = document.elementsFromPoint(x, y);
231
+ const at = (o) => stack.findIndex((e) => e === o || o.contains(e));
232
+ const [ia, ib] = [at(a.owner), at(b.owner)];
233
+ if (ia < 0 || ib < 0) continue;
234
+ const lower = ia > ib ? a.owner : b.owner;
235
+ const covers = (e) => {
236
+ const s = getComputedStyle(e);
237
+ const alpha = s.backgroundColor === "transparent" ? 0 : parseFloat(s.backgroundColor.match(/[,/]\s*([\d.]+)%?\)$/)?.[1] ?? "1");
238
+ return !e.contains(lower) && (alpha >= 0.9 || s.backgroundImage !== "none");
239
+ };
240
+ if (stack.slice(0, Math.max(ia, ib)).some(covers)) continue;
241
+ }
242
+ const names = [nameOf(a), nameOf(b)].sort();
243
+ const key = names.join(" / ");
244
+ if (seen.has(key)) continue;
245
+ seen.add(key);
246
+ add("overlap", key, `visible text overlaps other text by ${px(w)} x ${px(h)}`);
247
+ }
248
+ }
249
+ for (const e of Array.from(document.querySelectorAll("h1, [role='heading'][aria-level='1']"))) {
250
+ if (!shown(e) || hiddenByMarkup(e)) continue;
251
+ const s = getComputedStyle(e);
252
+ const clipsX = e.scrollWidth > e.clientWidth + 1 && s.overflowX !== "visible";
253
+ const clipsY = e.scrollHeight > e.clientHeight + 1 && s.overflowY !== "visible";
254
+ const range = document.createRange();
255
+ range.selectNodeContents(e);
256
+ const r = range.getBoundingClientRect();
257
+ const cutByParent = r.width > 0 && hardClipsOf(e).some((c) => r.right > c.right + 1 || r.left < c.left - 1 || r.bottom > c.bottom + 1);
258
+ if (clipsX || clipsY || cutByParent) add("title", describe(e), "the page title is cut off");
259
+ }
260
+ }
261
+ if (groups.includes("optimise")) {
262
+ if (arg.mainAction) {
263
+ const matches = Array.from(document.querySelectorAll(arg.mainAction)).filter((e) => shown(e) && !hiddenByMarkup(e));
264
+ if (matches.length === 0) {
265
+ add("action", arg.mainAction, "no visible element matches the main action selector");
266
+ } else {
267
+ const pinned = (e) => ["fixed", "sticky"].includes(getComputedStyle(e).position) || !!e.parentElement && e.parentElement !== document.body && pinned(e.parentElement);
268
+ const fits = matches.some((e) => {
269
+ const r = e.getBoundingClientRect();
270
+ return pinned(e) || r.top >= 0 && r.bottom <= vh + 1;
271
+ });
272
+ if (!fits) {
273
+ const r = matches[0].getBoundingClientRect();
274
+ add("action", describe(matches[0]), `the main action starts ${px(r.top)} down a ${px(vh)} screen: it is not on the first screen or pinned`);
275
+ }
276
+ }
277
+ }
278
+ const cover = layerCover();
279
+ if (cover.share > t.maxLayerShare) add("layers", "fixed and sticky layers", `together cover ${pct(cover.share)} of a ${px(vh)} screen, over the ${pct(t.maxLayerShare)} allowed: ${cover.names.join(", ")}`);
280
+ images(["oversized", "upscaled"]);
281
+ const clean = (s) => s.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_\-.[\]]+/g, " ").toLowerCase();
282
+ const PHONE2 = /\b(phone|tel|telephone|mobile)\b/;
283
+ const EMAIL = /\be ?mail\b/;
284
+ const NUMBER = /\b(otp|one time code|verification code|security code|quantity|qty|age|cvc|cvv|pin|card number|cc number|number of|amount)\b/;
285
+ const PERSONAL = /\b(name|first name|last name|given|family|surname|email|e mail|phone|tel|telephone|mobile|address|street|city|town|postcode|post code|zip|country|county|company|organisation|organization|username|user name|password|card|birth|dob)\b/;
286
+ const NOT_TEXT = /* @__PURE__ */ new Set(["hidden", "checkbox", "radio", "range", "color", "file", "submit", "reset", "button", "image", "date", "time", "datetime-local", "month", "week"]);
287
+ for (const e of Array.from(document.querySelectorAll("input, textarea"))) {
288
+ const field = e;
289
+ if (!shown(e) || hiddenByMarkup(e) || NOT_TEXT.has(field.type) || field.type === "search" || e.closest("[role='search']")) continue;
290
+ const label = Array.from(field.labels ?? []).map((l) => l.textContent ?? "").join(" ");
291
+ const named = e.getAttribute("aria-label") ?? "";
292
+ const signals = clean([e.getAttribute("name"), e.id, named, e.getAttribute("placeholder"), e.getAttribute("autocomplete"), label].filter(Boolean).join(" "));
293
+ const inputmode = (e.getAttribute("inputmode") ?? "").toLowerCase();
294
+ const type = e.tagName === "TEXTAREA" ? "textarea" : field.type;
295
+ const kinds = [];
296
+ if (PHONE2.test(signals)) kinds.push("phone");
297
+ if (EMAIL.test(signals)) kinds.push("email");
298
+ if (NUMBER.test(signals)) kinds.push("number");
299
+ const who = `<${e.tagName.toLowerCase()}> "${(label.replace(/\s+/g, " ").trim() || named || e.getAttribute("name") || e.id || e.getAttribute("placeholder") || type).slice(0, 40)}"`;
300
+ if (e.tagName === "INPUT" && kinds.length === 1) {
301
+ const [kind] = kinds;
302
+ const good = kind === "phone" ? type === "tel" || inputmode === "tel" : kind === "email" ? type === "email" || inputmode === "email" : type === "number" || ["numeric", "decimal", "tel"].includes(inputmode);
303
+ if (!good) {
304
+ const use = kind === "phone" ? 'type="tel"' : kind === "email" ? 'type="email"' : 'inputmode="numeric" (or "decimal")';
305
+ add("input", who, `looks like ${kind === "number" ? "a number" : kind === "email" ? "an email" : "a phone"} field but is type="${type}"${inputmode ? ` inputmode="${inputmode}"` : ""}, so the phone shows the wrong keyboard. Use ${use}`);
306
+ }
307
+ }
308
+ const personal = e.tagName === "INPUT" && (["email", "tel", "password"].includes(type) || PERSONAL.test(signals));
309
+ const auto = (e.getAttribute("autocomplete") ?? "").trim().toLowerCase();
310
+ if (personal && (auto === "" || auto === "off")) {
311
+ add("autocomplete", who, `${auto ? 'autocomplete="off" stops the phone filling it in' : "the autocomplete attribute is missing, so the phone cannot fill it in"}. Name what it holds, as in autocomplete="email"`);
312
+ }
313
+ }
314
+ const UK = /(?<![\w+])(?:\+44[\s-]?(?:\(0\))?|0)\d(?:[\s-]?\d){8,9}(?!\d)/g;
315
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
316
+ const dials = /* @__PURE__ */ new Set();
317
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
318
+ const owner = n.parentElement;
319
+ if (!owner || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "TEXTAREA"].includes(owner.tagName) || !shown(owner) || hiddenByMarkup(owner) || owner.closest("a[href^='tel:' i]")) continue;
320
+ for (const m of (n.textContent ?? "").matchAll(UK)) {
321
+ const digits = m[0].replace(/\D/g, "");
322
+ if (!(digits.length === 11 && digits.startsWith("0")) && !(digits.length === 12 && digits.startsWith("44")) && !(digits.length === 13 && digits.startsWith("440"))) continue;
323
+ const shownAs = m[0].replace(/\s+/g, " ").trim();
324
+ if (dials.has(shownAs)) continue;
325
+ dials.add(shownAs);
326
+ add("tel", `<${owner.tagName.toLowerCase()}> "${shownAs}"`, `a phone number that is not a tel: link. Wrap it in <a href="tel:${digits.startsWith("0") ? `+44${digits.slice(1)}` : `+${digits.replace(/^440/, "44")}`}">`);
327
+ }
328
+ }
329
+ }
330
+ if (groups.includes("landscape")) {
331
+ const cover = layerCover();
332
+ if (cover.share > t.landscapeMaxLayerShare) {
333
+ add("landscape", "header and fixed layers", `together cover ${pct(cover.share)} of a ${px(vh)} landscape screen, over the ${pct(t.landscapeMaxLayerShare)} allowed: ${cover.names.join(", ")}`);
334
+ }
335
+ }
336
+ if (groups.includes("wide")) {
337
+ images(["upscaled"]);
338
+ const paragraphs = Array.from(document.querySelectorAll("p, li:not(:has(p)), dd, blockquote:not(:has(p))"));
339
+ for (const e of paragraphs) {
340
+ if (!shown(e) || hiddenByMarkup(e) || faded(e) || e.closest("nav, header, footer, aside, pre, table, [role='navigation'], [role='menu'], [role='banner'], [role='contentinfo'], [role='dialog'], [role='alertdialog']") || inLayer(e)) continue;
341
+ const range = document.createRange();
342
+ range.selectNodeContents(e);
343
+ const lines = /* @__PURE__ */ new Map();
344
+ for (const r of Array.from(range.getClientRects())) {
345
+ if (r.width < 1 || r.height < 1) continue;
346
+ const key = Math.round(r.top / 4);
347
+ const had = lines.get(key);
348
+ if (had) {
349
+ had.l = Math.min(had.l, r.left);
350
+ had.r = Math.max(had.r, r.right);
351
+ } else lines.set(key, { l: r.left, r: r.right });
352
+ }
353
+ const longest = Math.max(0, ...Array.from(lines.values()).map((l) => l.r - l.l));
354
+ if (longest < 1) continue;
355
+ const probe = document.createElement("span");
356
+ probe.style.cssText = "position:absolute;visibility:hidden;display:inline-block;width:1ch;height:1px;font:inherit;letter-spacing:normal";
357
+ e.appendChild(probe);
358
+ const ch = probe.getBoundingClientRect().width;
359
+ probe.remove();
360
+ if (ch <= 0) continue;
361
+ const chars = longest / ch;
362
+ if (chars > t.maxLineCh) add("linelength", describe(e), `lines run ${Math.round(chars)} characters at ${vw}px, over the ${t.maxLineCh} allowed. Cap the measure with max-width in ch`);
363
+ }
364
+ }
365
+ return out;
366
+ }
367
+
66
368
  // src/phone/judge.ts
67
369
  var EMPTY_BASELINE = { static: [], measured: [] };
68
370
  var MEDIA = /@(?:custom-)?media\b([^{;]*)/g;
@@ -183,7 +485,16 @@ var LABEL = {
183
485
  target: "small target",
184
486
  field: "small field",
185
487
  text: "small text",
186
- title: "title cut off"
488
+ title: "title cut off",
489
+ overlap: "text overlaps text",
490
+ action: "main action out of reach",
491
+ layers: "fixed layers cover too much",
492
+ landscape: "landscape screen eaten by fixed layers",
493
+ image: "image not sized for its place",
494
+ input: "wrong keyboard",
495
+ autocomplete: "field the phone cannot fill in",
496
+ tel: "phone number is not a link",
497
+ linelength: "line too long"
187
498
  };
188
499
  function writeBaseline(findings, routes, note) {
189
500
  const byFile = tally(findings, (f) => f.file);
@@ -197,16 +508,204 @@ function writeBaseline(findings, routes, note) {
197
508
  };
198
509
  }
199
510
 
200
- // src/phone/index.ts
201
- var PHONE = {
202
- viewport: { width: 375, height: 812 },
203
- deviceScaleFactor: 2,
204
- isMobile: true,
205
- hasTouch: true
511
+ // src/phone/viewports.ts
512
+ var BANDS = [
513
+ { band: "phone", from: 320, to: 479 },
514
+ { band: "large-phone", from: 480, to: 767 },
515
+ { band: "tablet", from: 768, to: 1023 },
516
+ { band: "laptop", from: 1024, to: 1439 },
517
+ { band: "wide", from: 1440, to: null }
518
+ ];
519
+ var bandOf = (width) => [...BANDS].reverse().find((b) => width >= b.from)?.band ?? "phone";
520
+ var LADDER = [
521
+ { name: "320", width: 320, height: 568, phone: true },
522
+ { name: "375", width: 375, height: 812, phone: true },
523
+ { name: "390", width: 390, height: 844, phone: true },
524
+ { name: "430", width: 430, height: 932, phone: true },
525
+ { name: "844x390", width: 844, height: 390, phone: true, landscape: true },
526
+ { name: "768", width: 768, height: 1024, phone: false },
527
+ { name: "1024", width: 1024, height: 768, phone: false },
528
+ { name: "1280", width: 1280, height: 800, phone: false },
529
+ { name: "1440", width: 1440, height: 900, phone: false },
530
+ { name: "1920", width: 1920, height: 1080, phone: false }
531
+ ];
532
+ var SHORT_PHONE = { name: "375x667", width: 375, height: 667, phone: true };
533
+ var WIDE_WIDTHS = [1440, 1920];
534
+ var DEFAULT_SWEEP = { from: 320, to: 1920, step: 16, wideStep: 32, wideFrom: 1024, height: 800 };
535
+ function sweepWidths(sweep = DEFAULT_SWEEP) {
536
+ const out = [];
537
+ for (let w = sweep.from; w <= sweep.to; w += w < sweep.wideFrom ? sweep.step : sweep.wideStep) out.push(w);
538
+ if (out.at(-1) !== sweep.to) out.push(sweep.to);
539
+ return out;
540
+ }
541
+ var DEFAULT_THRESHOLDS = {
542
+ maxLayerShare: 0.25,
543
+ landscapeMaxLayerShare: 0.5,
544
+ oversizeRatio: 2.5,
545
+ upscaleRatio: 1,
546
+ maxLineCh: 80,
547
+ overlapShare: 0.4,
548
+ minImagePx: 32
206
549
  };
207
- var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
208
- var DEFAULT_MEASURED_DIR = "phone-first/measured";
209
- var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
550
+ var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
551
+ function formatWidths(failing, tested) {
552
+ const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
553
+ const rank = new Map(plain.map((name, i) => [name, i]));
554
+ const names = [...new Set(failing)];
555
+ const runs = [];
556
+ for (const name of names.filter((n) => rank.has(n)).sort((a, b) => rank.get(a) - rank.get(b))) {
557
+ const last = runs.at(-1);
558
+ if (last && rank.get(name) === rank.get(last.at(-1)) + 1) last.push(name);
559
+ else runs.push([name]);
560
+ }
561
+ const out = runs.map((r) => r.length > 1 ? `${r[0]}-${r.at(-1)}` : r[0]);
562
+ return [...out, ...names.filter((n) => !rank.has(n))].join(", ");
563
+ }
564
+ var keyOf = (f) => `${f.check}\0${f.element.replace(/("[^]*)$/, (t) => t.replace(/\d+/g, "#"))}`;
565
+ function mergeViewportFindings(perViewport, tested) {
566
+ const merged = /* @__PURE__ */ new Map();
567
+ for (const { viewport, findings } of perViewport) {
568
+ const counts = /* @__PURE__ */ new Map();
569
+ for (const f of findings) {
570
+ const k = keyOf(f);
571
+ const had = counts.get(k);
572
+ if (had) had.n++;
573
+ else counts.set(k, { first: f, n: 1 });
574
+ }
575
+ for (const [k, { first, n }] of counts) {
576
+ const had = merged.get(k);
577
+ if (had) {
578
+ had.at.push(viewport);
579
+ had.most = Math.max(had.most, n);
580
+ } else merged.set(k, { first, at: [viewport], most: n });
581
+ }
582
+ }
583
+ const out = [];
584
+ for (const { first, at, most } of merged.values()) {
585
+ for (let i = 0; i < most; i++) out.push({ ...first, detail: `${first.detail} (at ${formatWidths(at, tested)})` });
586
+ }
587
+ return out;
588
+ }
589
+
590
+ // src/phone/run.ts
591
+ import { mkdirSync, writeFileSync } from "fs";
592
+ import { join } from "path";
593
+ function plan(options) {
594
+ const ladder = options.ladder ?? LADDER;
595
+ const scope = options.scope ?? "all";
596
+ const phoneSide = scope !== "wide";
597
+ const wideSide = scope !== "phone";
598
+ const stops = [];
599
+ const widthsDone = /* @__PURE__ */ new Set();
600
+ for (const v of ladder.filter((x) => x.phone && !x.landscape)) {
601
+ if (!phoneSide) continue;
602
+ stops.push({ name: v.name, width: v.width, height: v.height, ladder: true, groups: ["sweep"], touch: true });
603
+ widthsDone.add(v.width);
604
+ }
605
+ if (phoneSide) {
606
+ stops.push({ name: SHORT_PHONE.name, width: SHORT_PHONE.width, height: SHORT_PHONE.height, ladder: true, groups: ["optimise"], touch: false });
607
+ for (const v of ladder.filter((x) => x.landscape)) stops.push({ name: v.name, width: v.width, height: v.height, ladder: true, groups: ["sweep", "landscape"], touch: true });
608
+ }
609
+ const rest = [];
610
+ for (const v of ladder.filter((x) => !x.phone)) {
611
+ if (!wideSide || widthsDone.has(v.width)) continue;
612
+ rest.push({ name: v.name, width: v.width, height: v.height, ladder: true, groups: v.width >= 1440 ? ["sweep", "wide"] : ["sweep"], touch: false });
613
+ widthsDone.add(v.width);
614
+ }
615
+ if (options.sweep !== false) {
616
+ const sweep = { ...DEFAULT_SWEEP, ...options.sweep ?? {} };
617
+ for (const w of sweepWidths(sweep)) {
618
+ if (widthsDone.has(w) || (w < 768 ? !phoneSide : !wideSide)) continue;
619
+ rest.push({ name: String(w), width: w, height: sweep.height, ladder: false, groups: ["sweep"], touch: false });
620
+ widthsDone.add(w);
621
+ }
622
+ }
623
+ return [...stops, ...rest.sort((a, b) => a.width - b.width)];
624
+ }
625
+ var settle = async (page, ms) => void await page.evaluate((wait) => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, wait)))), ms);
626
+ async function loadEverything(page) {
627
+ await page.evaluate(async () => {
628
+ const h = document.documentElement.scrollHeight;
629
+ for (let y = 0; y < h; y += Math.max(300, window.innerHeight - 100)) {
630
+ window.scrollTo({ top: y, behavior: "instant" });
631
+ await new Promise((r) => setTimeout(r, 40));
632
+ }
633
+ await Promise.all(Array.from(document.images).map((i) => i.complete ? null : new Promise((r) => {
634
+ i.onload = i.onerror = () => r(null);
635
+ setTimeout(r, 3e3);
636
+ })));
637
+ await document.fonts?.ready;
638
+ window.scrollTo({ top: 0, behavior: "instant" });
639
+ }, void 0);
640
+ }
641
+ async function collect(page, options) {
642
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds ?? {} };
643
+ const floors = { ...PHONE_FLOORS, ...options.floors ?? {} };
644
+ const mainAction = mainActionFor(options.mainAction, options.route);
645
+ const perViewport = [];
646
+ const tested = [];
647
+ for (const stop of plan(options)) {
648
+ await page.setViewportSize({ width: stop.width, height: stop.height });
649
+ await settle(page, stop.ladder ? 150 : 60);
650
+ if (stop.ladder) await loadEverything(page);
651
+ if (stop.ladder) await options.onViewport?.(page, { name: stop.name, width: stop.width, height: stop.height, phone: stop.touch || stop.width < 768, landscape: stop.groups.includes("landscape") }, options.route);
652
+ const findings = [];
653
+ if (stop.ladder) findings.push(...await page.evaluate(measurePhoneInPage, { ...floors, only: stop.touch ? ["target", "field", "text"] : ["text"] }));
654
+ findings.push(...await page.evaluate(measureViewportInPage, { groups: stop.groups, thresholds, mainAction, metaViewport: stop.width < 768 }));
655
+ perViewport.push({ viewport: stop.name, findings });
656
+ tested.push(stop.name);
657
+ }
658
+ return { perViewport, tested };
659
+ }
660
+ function finish(route, collected, out) {
661
+ const result = { route, viewports: collected.tested, findings: mergeViewportFindings(collected.perViewport, collected.tested), perViewport: collected.perViewport };
662
+ if (out !== false) writeMeasured(result, out ?? DEFAULT_MEASURED_DIR);
663
+ return result;
664
+ }
665
+ function writeMeasured(result, dir) {
666
+ mkdirSync(dir, { recursive: true });
667
+ const { route, viewport, viewports, findings } = result;
668
+ writeFileSync(join(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, findings }, null, 1)}
669
+ `);
670
+ }
671
+ async function measureViewports(page, options) {
672
+ if (!options.route.startsWith("/")) throw new Error(`measureViewports: route "${options.route}" should start with /`);
673
+ await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
674
+ return finish(options.route, await collect(page, options), options.out);
675
+ }
676
+ async function measureSite(browser, options) {
677
+ const result = { routes: [], errors: [] };
678
+ for (const route of options.routes) {
679
+ const collected = [];
680
+ let failed = null;
681
+ for (const [scope, context] of [
682
+ ["phone", PHONE],
683
+ ["wide", { viewport: { width: 1024, height: 768 }, deviceScaleFactor: 1 }]
684
+ ]) {
685
+ const ctx = await browser.newContext({ ...context });
686
+ try {
687
+ const page = await ctx.newPage();
688
+ const resp = await page.goto(options.base + route).catch((error) => ({ ok: () => false, status: () => error.message }));
689
+ if (!resp || !resp.ok()) {
690
+ failed = `HTTP ${resp?.status()}`;
691
+ break;
692
+ }
693
+ await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
694
+ collected.push(await collect(page, { ...options, route, scope }));
695
+ } finally {
696
+ await ctx.close();
697
+ }
698
+ }
699
+ if (failed) {
700
+ result.errors.push({ route, error: failed });
701
+ continue;
702
+ }
703
+ result.routes.push(finish(route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out));
704
+ }
705
+ return result;
706
+ }
707
+
708
+ // src/phone/index.ts
210
709
  async function measurePhone(page, options) {
211
710
  if (!options.route.startsWith("/")) throw new Error(`measurePhone: route "${options.route}" should start with /`);
212
711
  await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
@@ -214,24 +713,38 @@ async function measurePhone(page, options) {
214
713
  const viewport = page.viewportSize?.() ?? void 0;
215
714
  const result = { route: options.route, ...viewport ? { viewport } : {}, findings };
216
715
  const dir = options.out ?? DEFAULT_MEASURED_DIR;
217
- mkdirSync(dir, { recursive: true });
218
- writeFileSync(join(dir, routeFile(options.route)), `${JSON.stringify(result, null, 1)}
716
+ mkdirSync2(dir, { recursive: true });
717
+ writeFileSync2(join2(dir, routeFile(options.route)), `${JSON.stringify(result, null, 1)}
219
718
  `);
220
719
  return result;
221
720
  }
222
721
  export {
722
+ BANDS,
223
723
  DEFAULT_MEASURED_DIR,
724
+ DEFAULT_SWEEP,
725
+ DEFAULT_THRESHOLDS,
224
726
  EMPTY_BASELINE,
727
+ LADDER,
225
728
  PHONE,
226
729
  PHONE_FLOORS,
730
+ SHORT_PHONE,
731
+ WIDE_WIDTHS,
732
+ bandOf,
227
733
  checkExceptions,
734
+ formatWidths,
228
735
  judgeMeasured,
229
736
  judgeStatic,
737
+ mainActionFor,
230
738
  measurePhone,
231
739
  measurePhoneInPage,
740
+ measureSite,
741
+ measureViewportInPage,
742
+ measureViewports,
743
+ mergeViewportFindings,
232
744
  normaliseElement,
233
745
  reasonOf,
234
746
  routeFile,
235
747
  scanPhoneFirst,
748
+ sweepWidths,
236
749
  writeBaseline
237
750
  };