@domandigital/craft 0.19.2 → 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.cjs CHANGED
@@ -20,28 +20,55 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/phone/index.ts
21
21
  var phone_exports = {};
22
22
  __export(phone_exports, {
23
+ BANDS: () => BANDS,
23
24
  DEFAULT_MEASURED_DIR: () => DEFAULT_MEASURED_DIR,
25
+ DEFAULT_SWEEP: () => DEFAULT_SWEEP,
26
+ DEFAULT_THRESHOLDS: () => DEFAULT_THRESHOLDS,
24
27
  EMPTY_BASELINE: () => EMPTY_BASELINE,
28
+ LADDER: () => LADDER,
25
29
  PHONE: () => PHONE,
26
30
  PHONE_FLOORS: () => PHONE_FLOORS,
31
+ SHORT_PHONE: () => SHORT_PHONE,
32
+ WIDE_WIDTHS: () => WIDE_WIDTHS,
33
+ bandOf: () => bandOf,
27
34
  checkExceptions: () => checkExceptions,
35
+ formatWidths: () => formatWidths,
28
36
  judgeMeasured: () => judgeMeasured,
29
37
  judgeStatic: () => judgeStatic,
38
+ mainActionFor: () => mainActionFor,
30
39
  measurePhone: () => measurePhone,
31
40
  measurePhoneInPage: () => measurePhoneInPage,
41
+ measureSite: () => measureSite,
42
+ measureViewportInPage: () => measureViewportInPage,
43
+ measureViewports: () => measureViewports,
44
+ mergeViewportFindings: () => mergeViewportFindings,
32
45
  normaliseElement: () => normaliseElement,
33
46
  reasonOf: () => reasonOf,
34
47
  routeFile: () => routeFile,
35
48
  scanPhoneFirst: () => scanPhoneFirst,
49
+ sweepWidths: () => sweepWidths,
36
50
  writeBaseline: () => writeBaseline
37
51
  });
38
52
  module.exports = __toCommonJS(phone_exports);
39
- var import_node_fs = require("fs");
40
- var import_node_path = require("path");
53
+ var import_node_fs2 = require("fs");
54
+ var import_node_path2 = require("path");
55
+
56
+ // src/phone/constants.ts
57
+ var PHONE = {
58
+ viewport: { width: 375, height: 812 },
59
+ deviceScaleFactor: 2,
60
+ isMobile: true,
61
+ hasTouch: true
62
+ };
63
+ var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
64
+ var DEFAULT_MEASURED_DIR = "phone-first/measured";
65
+ var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
41
66
 
42
67
  // src/phone/measure-in-page.ts
43
68
  function measurePhoneInPage(floors) {
44
69
  const out = [];
70
+ const only = floors.only;
71
+ const on = (c) => !only || only.includes(c);
45
72
  const vw = document.documentElement.clientWidth;
46
73
  const shown = (e) => {
47
74
  const s = getComputedStyle(e);
@@ -56,7 +83,8 @@ function measurePhoneInPage(floors) {
56
83
  const text = (e.textContent ?? "").replace(/\s+/g, " ").trim() || e.getAttribute("aria-label") || e.getAttribute("name") || e.getAttribute("placeholder") || "";
57
84
  return `<${e.tagName.toLowerCase()}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
58
85
  };
59
- if (vw > window.screen.width + 1) {
86
+ if (!on("scroll")) {
87
+ } else if (vw > window.screen.width + 1) {
60
88
  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` });
61
89
  } else if (document.documentElement.scrollWidth > vw + 1) {
62
90
  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);
@@ -67,7 +95,7 @@ function measurePhoneInPage(floors) {
67
95
  const block = e.parentElement?.closest("p, li, dd, td, th, blockquote, figcaption, address");
68
96
  return !!block && (block.textContent ?? "").trim().length > (e.textContent ?? "").trim().length;
69
97
  };
70
- const targets = Array.from(document.querySelectorAll("a[href], button, input:not([type='hidden']), select, textarea, [role='button'], [role='tab'], [role='menuitem'], summary")).filter(shown);
98
+ 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);
71
99
  for (const e of targets) {
72
100
  const r = e.getBoundingClientRect();
73
101
  if (quiet(e) || inline(e) || r.width <= 1 || r.height <= 1) continue;
@@ -78,19 +106,19 @@ function measurePhoneInPage(floors) {
78
106
  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}` });
79
107
  }
80
108
  const NOT_TEXT = /* @__PURE__ */ new Set(["checkbox", "radio", "range", "color", "file", "submit", "reset", "button", "image"]);
81
- for (const e of Array.from(document.querySelectorAll("input:not([type='hidden']), select, textarea"))) {
109
+ for (const e of on("field") ? Array.from(document.querySelectorAll("input:not([type='hidden']), select, textarea")) : []) {
82
110
  if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
83
111
  const px = parseFloat(getComputedStyle(e).fontSize);
84
112
  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` });
85
113
  }
86
- for (const e of Array.from(document.body.querySelectorAll("*"))) {
114
+ for (const e of on("text") ? Array.from(document.body.querySelectorAll("*")) : []) {
87
115
  if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
88
116
  const own = Array.from(e.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
89
117
  if (!own) continue;
90
118
  const px = parseFloat(getComputedStyle(e).fontSize);
91
119
  if (px < floors.textPx) out.push({ check: "text", element: `<${e.tagName.toLowerCase()}> "${own.slice(0, 40)}"`, detail: `${px}px, under ${floors.textPx}px` });
92
120
  }
93
- for (const e of Array.from(document.querySelectorAll("h1"))) {
121
+ for (const e of on("title") ? Array.from(document.querySelectorAll("h1")) : []) {
94
122
  if (!shown(e) || quiet(e)) continue;
95
123
  const s = getComputedStyle(e);
96
124
  const clipsX = e.scrollWidth > e.clientWidth + 1 && s.overflowX !== "visible";
@@ -100,6 +128,294 @@ function measurePhoneInPage(floors) {
100
128
  return out;
101
129
  }
102
130
 
131
+ // src/phone/measure-viewport-in-page.ts
132
+ function measureViewportInPage(arg) {
133
+ const { groups, thresholds: t } = arg;
134
+ const out = [];
135
+ const vw = document.documentElement.clientWidth;
136
+ const vh = document.documentElement.clientHeight;
137
+ const pct = (n) => `${Math.round(n * 100)}%`;
138
+ const px = (n) => `${Math.round(n)}px`;
139
+ const shown = (e) => {
140
+ const s = getComputedStyle(e);
141
+ if (s.display === "none" || s.visibility === "hidden" || e.getClientRects().length === 0) return false;
142
+ return typeof e.checkVisibility === "function" ? e.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true }) : true;
143
+ };
144
+ const hiddenByMarkup = (e) => !!e.closest("[aria-hidden='true'], .sr-only, .visually-hidden, [hidden]");
145
+ const faded = (e) => {
146
+ for (let n = e; n && n !== document.documentElement; n = n.parentElement) if (getComputedStyle(n).opacity === "0") return true;
147
+ return false;
148
+ };
149
+ const describe = (e) => {
150
+ const text = (e.textContent ?? "").replace(/\s+/g, " ").trim() || e.getAttribute("aria-label") || e.getAttribute("name") || e.getAttribute("placeholder") || "";
151
+ return `<${e.tagName.toLowerCase()}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
152
+ };
153
+ const add = (check, element, detail) => {
154
+ out.push({ check, element, detail });
155
+ };
156
+ const clipBoxes = /* @__PURE__ */ new Map();
157
+ const clipsOf = (e) => {
158
+ const had = clipBoxes.get(e);
159
+ if (had) return had;
160
+ const boxes = [];
161
+ for (let n = e.parentElement; n && n !== document.documentElement && n !== document.body; n = n.parentElement) {
162
+ const s = getComputedStyle(n);
163
+ if (/hidden|clip|auto|scroll/.test(s.overflowX + s.overflowY)) boxes.push(n.getBoundingClientRect());
164
+ }
165
+ clipBoxes.set(e, boxes);
166
+ return boxes;
167
+ };
168
+ const hardClipsOf = (e) => {
169
+ const boxes = [];
170
+ for (let n = e.parentElement; n && n !== document.documentElement && n !== document.body; n = n.parentElement) {
171
+ const s = getComputedStyle(n);
172
+ if (/hidden|clip/.test(s.overflowX) || /hidden|clip/.test(s.overflowY)) boxes.push(n.getBoundingClientRect());
173
+ }
174
+ return boxes;
175
+ };
176
+ const inLayers = /* @__PURE__ */ new Map();
177
+ const inLayer = (e) => {
178
+ const had = inLayers.get(e);
179
+ if (had !== void 0) return had;
180
+ const p = getComputedStyle(e).position;
181
+ const v = p === "fixed" || p === "sticky" || !!e.parentElement && inLayer(e.parentElement);
182
+ inLayers.set(e, v);
183
+ return v;
184
+ };
185
+ const textBoxes = () => {
186
+ const boxes = [];
187
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
188
+ const range = document.createRange();
189
+ const skip = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "OPTION", "TEXTAREA", "TITLE"]);
190
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
191
+ const owner = n.parentElement;
192
+ if (!owner || skip.has(owner.tagName) || !(n.textContent ?? "").trim() || !shown(owner) || hiddenByMarkup(owner) || faded(owner)) continue;
193
+ range.selectNodeContents(n);
194
+ for (const rect of Array.from(range.getClientRects())) {
195
+ let { left: l, top: tp, right: r, bottom: b } = rect;
196
+ for (const c of clipsOf(owner)) {
197
+ l = Math.max(l, c.left);
198
+ tp = Math.max(tp, c.top);
199
+ r = Math.min(r, c.right);
200
+ b = Math.min(b, c.bottom);
201
+ }
202
+ if (r - l >= 1 && b - tp >= 1) boxes.push({ l, t: tp, r, b, owner, node: n });
203
+ }
204
+ }
205
+ return boxes;
206
+ };
207
+ const nameOf = (b) => `<${b.owner.tagName.toLowerCase()}> "${(b.node.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 40)}"`;
208
+ const layerCover = () => {
209
+ const painted = (e) => {
210
+ const s = getComputedStyle(e);
211
+ const bg = s.backgroundColor;
212
+ const clear = bg === "rgba(0, 0, 0, 0)" || bg === "transparent";
213
+ 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());
214
+ };
215
+ const paintedBoxes = (e, depth) => {
216
+ if (!shown(e) || getComputedStyle(e).pointerEvents === "none" || faded(e)) return [];
217
+ if (painted(e)) return [{ e, r: e.getBoundingClientRect() }];
218
+ return depth > 6 ? [] : Array.from(e.children).flatMap((c) => paintedBoxes(c, depth + 1));
219
+ };
220
+ const read = () => {
221
+ 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);
222
+ const xs = [...new Set(boxes.flatMap((b) => [b.l, b.r]))].sort((a, b) => a - b);
223
+ const ys = [...new Set(boxes.flatMap((b) => [b.t, b.b]))].sort((a, b) => a - b);
224
+ let area = 0;
225
+ for (let i = 0; i < xs.length - 1; i++) {
226
+ for (let j = 0; j < ys.length - 1; j++) {
227
+ 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]);
228
+ }
229
+ }
230
+ return { share: area / (vw * vh), names: [...new Set(boxes.map((b) => describe(b.e)))].slice(0, 4) };
231
+ };
232
+ const top = read();
233
+ const home = window.scrollY;
234
+ window.scrollTo({ top: Math.min(document.documentElement.scrollHeight - vh, Math.round(vh * 1.5)), behavior: "instant" });
235
+ const moved = read();
236
+ window.scrollTo({ top: home, behavior: "instant" });
237
+ return moved.share > top.share ? moved : top;
238
+ };
239
+ const imageName = (img) => {
240
+ const alt = (img.getAttribute("alt") ?? "").trim();
241
+ const file = (img.currentSrc || img.src).split("?")[0].split("/").pop() ?? "";
242
+ return `<img> "${(alt || file).slice(0, 40)}"`;
243
+ };
244
+ const images = (kinds) => {
245
+ for (const img of Array.from(document.images)) {
246
+ const src = img.currentSrc || img.src;
247
+ if (!img.complete || !img.naturalWidth || !src || /\.svg(\?|$)|^data:image\/svg/i.test(src) || !shown(img) || hiddenByMarkup(img) || faded(img)) continue;
248
+ const shownPx = img.getBoundingClientRect().width;
249
+ if (shownPx < t.minImagePx) continue;
250
+ const ratio = img.naturalWidth / shownPx;
251
+ if (kinds.includes("oversized") && ratio > t.oversizeRatio) {
252
+ 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`);
253
+ }
254
+ if (kinds.includes("upscaled") && ratio < t.upscaleRatio) {
255
+ add("image", `${imageName(img)} upscaled`, `the file is ${img.naturalWidth}px wide, stretched to ${px(shownPx)}. Serve one at least that wide`);
256
+ }
257
+ }
258
+ };
259
+ if (groups.includes("sweep")) {
260
+ if (arg.metaViewport && vw > window.screen.width + 1) {
261
+ 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`);
262
+ } else if (document.documentElement.scrollWidth > vw + 1) {
263
+ 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);
264
+ add("scroll", "<html>", `the page is ${document.documentElement.scrollWidth}px wide at ${vw}px${wide.length ? `; widest: ${wide.join(", ")}` : ""}`);
265
+ }
266
+ const boxes = textBoxes().sort((a, b) => a.t - b.t);
267
+ const seen = /* @__PURE__ */ new Set();
268
+ for (let i = 0; i < boxes.length && seen.size < 15; i++) {
269
+ const a = boxes[i];
270
+ for (let j = i + 1; j < boxes.length && boxes[j].t < a.b; j++) {
271
+ const b = boxes[j];
272
+ if (a.node === b.node) continue;
273
+ const w = Math.min(a.r, b.r) - Math.max(a.l, b.l);
274
+ const h = Math.min(a.b, b.b) - Math.max(a.t, b.t);
275
+ if (w < 3 || h < t.overlapShare * Math.min(a.b - a.t, b.b - b.t)) continue;
276
+ const x = Math.max(a.l, b.l) + w / 2;
277
+ const y = Math.max(a.t, b.t) + h / 2;
278
+ const onScreen = x >= 0 && x < vw && y >= 0 && y < vh;
279
+ if (!onScreen && inLayer(a.owner) !== inLayer(b.owner)) continue;
280
+ if (onScreen && getComputedStyle(a.owner).pointerEvents !== "none" && getComputedStyle(b.owner).pointerEvents !== "none") {
281
+ const stack = document.elementsFromPoint(x, y);
282
+ const at = (o) => stack.findIndex((e) => e === o || o.contains(e));
283
+ const [ia, ib] = [at(a.owner), at(b.owner)];
284
+ if (ia < 0 || ib < 0) continue;
285
+ const lower = ia > ib ? a.owner : b.owner;
286
+ const covers = (e) => {
287
+ const s = getComputedStyle(e);
288
+ const alpha = s.backgroundColor === "transparent" ? 0 : parseFloat(s.backgroundColor.match(/[,/]\s*([\d.]+)%?\)$/)?.[1] ?? "1");
289
+ return !e.contains(lower) && (alpha >= 0.9 || s.backgroundImage !== "none");
290
+ };
291
+ if (stack.slice(0, Math.max(ia, ib)).some(covers)) continue;
292
+ }
293
+ const names = [nameOf(a), nameOf(b)].sort();
294
+ const key = names.join(" / ");
295
+ if (seen.has(key)) continue;
296
+ seen.add(key);
297
+ add("overlap", key, `visible text overlaps other text by ${px(w)} x ${px(h)}`);
298
+ }
299
+ }
300
+ for (const e of Array.from(document.querySelectorAll("h1, [role='heading'][aria-level='1']"))) {
301
+ if (!shown(e) || hiddenByMarkup(e)) continue;
302
+ const s = getComputedStyle(e);
303
+ const clipsX = e.scrollWidth > e.clientWidth + 1 && s.overflowX !== "visible";
304
+ const clipsY = e.scrollHeight > e.clientHeight + 1 && s.overflowY !== "visible";
305
+ const range = document.createRange();
306
+ range.selectNodeContents(e);
307
+ const r = range.getBoundingClientRect();
308
+ const cutByParent = r.width > 0 && hardClipsOf(e).some((c) => r.right > c.right + 1 || r.left < c.left - 1 || r.bottom > c.bottom + 1);
309
+ if (clipsX || clipsY || cutByParent) add("title", describe(e), "the page title is cut off");
310
+ }
311
+ }
312
+ if (groups.includes("optimise")) {
313
+ if (arg.mainAction) {
314
+ const matches = Array.from(document.querySelectorAll(arg.mainAction)).filter((e) => shown(e) && !hiddenByMarkup(e));
315
+ if (matches.length === 0) {
316
+ add("action", arg.mainAction, "no visible element matches the main action selector");
317
+ } else {
318
+ const pinned = (e) => ["fixed", "sticky"].includes(getComputedStyle(e).position) || !!e.parentElement && e.parentElement !== document.body && pinned(e.parentElement);
319
+ const fits = matches.some((e) => {
320
+ const r = e.getBoundingClientRect();
321
+ return pinned(e) || r.top >= 0 && r.bottom <= vh + 1;
322
+ });
323
+ if (!fits) {
324
+ const r = matches[0].getBoundingClientRect();
325
+ 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`);
326
+ }
327
+ }
328
+ }
329
+ const cover = layerCover();
330
+ 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(", ")}`);
331
+ images(["oversized", "upscaled"]);
332
+ const clean = (s) => s.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_\-.[\]]+/g, " ").toLowerCase();
333
+ const PHONE2 = /\b(phone|tel|telephone|mobile)\b/;
334
+ const EMAIL = /\be ?mail\b/;
335
+ 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/;
336
+ 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/;
337
+ const NOT_TEXT = /* @__PURE__ */ new Set(["hidden", "checkbox", "radio", "range", "color", "file", "submit", "reset", "button", "image", "date", "time", "datetime-local", "month", "week"]);
338
+ for (const e of Array.from(document.querySelectorAll("input, textarea"))) {
339
+ const field = e;
340
+ if (!shown(e) || hiddenByMarkup(e) || NOT_TEXT.has(field.type) || field.type === "search" || e.closest("[role='search']")) continue;
341
+ const label = Array.from(field.labels ?? []).map((l) => l.textContent ?? "").join(" ");
342
+ const named = e.getAttribute("aria-label") ?? "";
343
+ const signals = clean([e.getAttribute("name"), e.id, named, e.getAttribute("placeholder"), e.getAttribute("autocomplete"), label].filter(Boolean).join(" "));
344
+ const inputmode = (e.getAttribute("inputmode") ?? "").toLowerCase();
345
+ const type = e.tagName === "TEXTAREA" ? "textarea" : field.type;
346
+ const kinds = [];
347
+ if (PHONE2.test(signals)) kinds.push("phone");
348
+ if (EMAIL.test(signals)) kinds.push("email");
349
+ if (NUMBER.test(signals)) kinds.push("number");
350
+ const who = `<${e.tagName.toLowerCase()}> "${(label.replace(/\s+/g, " ").trim() || named || e.getAttribute("name") || e.id || e.getAttribute("placeholder") || type).slice(0, 40)}"`;
351
+ if (e.tagName === "INPUT" && kinds.length === 1) {
352
+ const [kind] = kinds;
353
+ const good = kind === "phone" ? type === "tel" || inputmode === "tel" : kind === "email" ? type === "email" || inputmode === "email" : type === "number" || ["numeric", "decimal", "tel"].includes(inputmode);
354
+ if (!good) {
355
+ const use = kind === "phone" ? 'type="tel"' : kind === "email" ? 'type="email"' : 'inputmode="numeric" (or "decimal")';
356
+ 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}`);
357
+ }
358
+ }
359
+ const personal = e.tagName === "INPUT" && (["email", "tel", "password"].includes(type) || PERSONAL.test(signals));
360
+ const auto = (e.getAttribute("autocomplete") ?? "").trim().toLowerCase();
361
+ if (personal && (auto === "" || auto === "off")) {
362
+ 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"`);
363
+ }
364
+ }
365
+ const UK = /(?<![\w+])(?:\+44[\s-]?(?:\(0\))?|0)\d(?:[\s-]?\d){8,9}(?!\d)/g;
366
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
367
+ const dials = /* @__PURE__ */ new Set();
368
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
369
+ const owner = n.parentElement;
370
+ if (!owner || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "TEXTAREA"].includes(owner.tagName) || !shown(owner) || hiddenByMarkup(owner) || owner.closest("a[href^='tel:' i]")) continue;
371
+ for (const m of (n.textContent ?? "").matchAll(UK)) {
372
+ const digits = m[0].replace(/\D/g, "");
373
+ if (!(digits.length === 11 && digits.startsWith("0")) && !(digits.length === 12 && digits.startsWith("44")) && !(digits.length === 13 && digits.startsWith("440"))) continue;
374
+ const shownAs = m[0].replace(/\s+/g, " ").trim();
375
+ if (dials.has(shownAs)) continue;
376
+ dials.add(shownAs);
377
+ 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")}`}">`);
378
+ }
379
+ }
380
+ }
381
+ if (groups.includes("landscape")) {
382
+ const cover = layerCover();
383
+ if (cover.share > t.landscapeMaxLayerShare) {
384
+ 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(", ")}`);
385
+ }
386
+ }
387
+ if (groups.includes("wide")) {
388
+ images(["upscaled"]);
389
+ const paragraphs = Array.from(document.querySelectorAll("p, li:not(:has(p)), dd, blockquote:not(:has(p))"));
390
+ for (const e of paragraphs) {
391
+ 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;
392
+ const range = document.createRange();
393
+ range.selectNodeContents(e);
394
+ const lines = /* @__PURE__ */ new Map();
395
+ for (const r of Array.from(range.getClientRects())) {
396
+ if (r.width < 1 || r.height < 1) continue;
397
+ const key = Math.round(r.top / 4);
398
+ const had = lines.get(key);
399
+ if (had) {
400
+ had.l = Math.min(had.l, r.left);
401
+ had.r = Math.max(had.r, r.right);
402
+ } else lines.set(key, { l: r.left, r: r.right });
403
+ }
404
+ const longest = Math.max(0, ...Array.from(lines.values()).map((l) => l.r - l.l));
405
+ if (longest < 1) continue;
406
+ const probe = document.createElement("span");
407
+ probe.style.cssText = "position:absolute;visibility:hidden;display:inline-block;width:1ch;height:1px;font:inherit;letter-spacing:normal";
408
+ e.appendChild(probe);
409
+ const ch = probe.getBoundingClientRect().width;
410
+ probe.remove();
411
+ if (ch <= 0) continue;
412
+ const chars = longest / ch;
413
+ 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`);
414
+ }
415
+ }
416
+ return out;
417
+ }
418
+
103
419
  // src/phone/judge.ts
104
420
  var EMPTY_BASELINE = { static: [], measured: [] };
105
421
  var MEDIA = /@(?:custom-)?media\b([^{;]*)/g;
@@ -149,7 +465,7 @@ function checkExceptions(exceptions, exists) {
149
465
  });
150
466
  return problems;
151
467
  }
152
- var normaliseElement = (element) => element.replace(/\d+/g, "#");
468
+ var normaliseElement = (element) => element.replace(/("[^]*)$/, (text) => text.replace(/\d+/g, "#"));
153
469
  var tally = (items, key) => {
154
470
  const m = /* @__PURE__ */ new Map();
155
471
  for (const item of items) {
@@ -197,7 +513,7 @@ function judgeMeasured(routes, baseline) {
197
513
  const allow = allowed.get(key) ?? 0;
198
514
  known += Math.min(count, allow);
199
515
  if (count > allow) {
200
- const times = count - allow > 1 ? ` (${count - allow} more than the baseline's ${allow})` : allow ? ` (the baseline allows ${allow})` : "";
516
+ const times = allow ? ` (${count} found, the baseline allows ${allow})` : count > 1 ? ` (${count} of them)` : "";
201
517
  fresh.push(`${route}: ${LABEL[item.check]} ${item.element}: ${item.detail}${times}`);
202
518
  }
203
519
  }
@@ -220,7 +536,16 @@ var LABEL = {
220
536
  target: "small target",
221
537
  field: "small field",
222
538
  text: "small text",
223
- title: "title cut off"
539
+ title: "title cut off",
540
+ overlap: "text overlaps text",
541
+ action: "main action out of reach",
542
+ layers: "fixed layers cover too much",
543
+ landscape: "landscape screen eaten by fixed layers",
544
+ image: "image not sized for its place",
545
+ input: "wrong keyboard",
546
+ autocomplete: "field the phone cannot fill in",
547
+ tel: "phone number is not a link",
548
+ linelength: "line too long"
224
549
  };
225
550
  function writeBaseline(findings, routes, note) {
226
551
  const byFile = tally(findings, (f) => f.file);
@@ -234,16 +559,204 @@ function writeBaseline(findings, routes, note) {
234
559
  };
235
560
  }
236
561
 
237
- // src/phone/index.ts
238
- var PHONE = {
239
- viewport: { width: 375, height: 812 },
240
- deviceScaleFactor: 2,
241
- isMobile: true,
242
- hasTouch: true
562
+ // src/phone/viewports.ts
563
+ var BANDS = [
564
+ { band: "phone", from: 320, to: 479 },
565
+ { band: "large-phone", from: 480, to: 767 },
566
+ { band: "tablet", from: 768, to: 1023 },
567
+ { band: "laptop", from: 1024, to: 1439 },
568
+ { band: "wide", from: 1440, to: null }
569
+ ];
570
+ var bandOf = (width) => [...BANDS].reverse().find((b) => width >= b.from)?.band ?? "phone";
571
+ var LADDER = [
572
+ { name: "320", width: 320, height: 568, phone: true },
573
+ { name: "375", width: 375, height: 812, phone: true },
574
+ { name: "390", width: 390, height: 844, phone: true },
575
+ { name: "430", width: 430, height: 932, phone: true },
576
+ { name: "844x390", width: 844, height: 390, phone: true, landscape: true },
577
+ { name: "768", width: 768, height: 1024, phone: false },
578
+ { name: "1024", width: 1024, height: 768, phone: false },
579
+ { name: "1280", width: 1280, height: 800, phone: false },
580
+ { name: "1440", width: 1440, height: 900, phone: false },
581
+ { name: "1920", width: 1920, height: 1080, phone: false }
582
+ ];
583
+ var SHORT_PHONE = { name: "375x667", width: 375, height: 667, phone: true };
584
+ var WIDE_WIDTHS = [1440, 1920];
585
+ var DEFAULT_SWEEP = { from: 320, to: 1920, step: 16, wideStep: 32, wideFrom: 1024, height: 800 };
586
+ function sweepWidths(sweep = DEFAULT_SWEEP) {
587
+ const out = [];
588
+ for (let w = sweep.from; w <= sweep.to; w += w < sweep.wideFrom ? sweep.step : sweep.wideStep) out.push(w);
589
+ if (out.at(-1) !== sweep.to) out.push(sweep.to);
590
+ return out;
591
+ }
592
+ var DEFAULT_THRESHOLDS = {
593
+ maxLayerShare: 0.25,
594
+ landscapeMaxLayerShare: 0.5,
595
+ oversizeRatio: 2.5,
596
+ upscaleRatio: 1,
597
+ maxLineCh: 80,
598
+ overlapShare: 0.4,
599
+ minImagePx: 32
243
600
  };
244
- var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
245
- var DEFAULT_MEASURED_DIR = "phone-first/measured";
246
- var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
601
+ var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
602
+ function formatWidths(failing, tested) {
603
+ const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
604
+ const rank = new Map(plain.map((name, i) => [name, i]));
605
+ const names = [...new Set(failing)];
606
+ const runs = [];
607
+ for (const name of names.filter((n) => rank.has(n)).sort((a, b) => rank.get(a) - rank.get(b))) {
608
+ const last = runs.at(-1);
609
+ if (last && rank.get(name) === rank.get(last.at(-1)) + 1) last.push(name);
610
+ else runs.push([name]);
611
+ }
612
+ const out = runs.map((r) => r.length > 1 ? `${r[0]}-${r.at(-1)}` : r[0]);
613
+ return [...out, ...names.filter((n) => !rank.has(n))].join(", ");
614
+ }
615
+ var keyOf = (f) => `${f.check}\0${f.element.replace(/("[^]*)$/, (t) => t.replace(/\d+/g, "#"))}`;
616
+ function mergeViewportFindings(perViewport, tested) {
617
+ const merged = /* @__PURE__ */ new Map();
618
+ for (const { viewport, findings } of perViewport) {
619
+ const counts = /* @__PURE__ */ new Map();
620
+ for (const f of findings) {
621
+ const k = keyOf(f);
622
+ const had = counts.get(k);
623
+ if (had) had.n++;
624
+ else counts.set(k, { first: f, n: 1 });
625
+ }
626
+ for (const [k, { first, n }] of counts) {
627
+ const had = merged.get(k);
628
+ if (had) {
629
+ had.at.push(viewport);
630
+ had.most = Math.max(had.most, n);
631
+ } else merged.set(k, { first, at: [viewport], most: n });
632
+ }
633
+ }
634
+ const out = [];
635
+ for (const { first, at, most } of merged.values()) {
636
+ for (let i = 0; i < most; i++) out.push({ ...first, detail: `${first.detail} (at ${formatWidths(at, tested)})` });
637
+ }
638
+ return out;
639
+ }
640
+
641
+ // src/phone/run.ts
642
+ var import_node_fs = require("fs");
643
+ var import_node_path = require("path");
644
+ function plan(options) {
645
+ const ladder = options.ladder ?? LADDER;
646
+ const scope = options.scope ?? "all";
647
+ const phoneSide = scope !== "wide";
648
+ const wideSide = scope !== "phone";
649
+ const stops = [];
650
+ const widthsDone = /* @__PURE__ */ new Set();
651
+ for (const v of ladder.filter((x) => x.phone && !x.landscape)) {
652
+ if (!phoneSide) continue;
653
+ stops.push({ name: v.name, width: v.width, height: v.height, ladder: true, groups: ["sweep"], touch: true });
654
+ widthsDone.add(v.width);
655
+ }
656
+ if (phoneSide) {
657
+ stops.push({ name: SHORT_PHONE.name, width: SHORT_PHONE.width, height: SHORT_PHONE.height, ladder: true, groups: ["optimise"], touch: false });
658
+ 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 });
659
+ }
660
+ const rest = [];
661
+ for (const v of ladder.filter((x) => !x.phone)) {
662
+ if (!wideSide || widthsDone.has(v.width)) continue;
663
+ rest.push({ name: v.name, width: v.width, height: v.height, ladder: true, groups: v.width >= 1440 ? ["sweep", "wide"] : ["sweep"], touch: false });
664
+ widthsDone.add(v.width);
665
+ }
666
+ if (options.sweep !== false) {
667
+ const sweep = { ...DEFAULT_SWEEP, ...options.sweep ?? {} };
668
+ for (const w of sweepWidths(sweep)) {
669
+ if (widthsDone.has(w) || (w < 768 ? !phoneSide : !wideSide)) continue;
670
+ rest.push({ name: String(w), width: w, height: sweep.height, ladder: false, groups: ["sweep"], touch: false });
671
+ widthsDone.add(w);
672
+ }
673
+ }
674
+ return [...stops, ...rest.sort((a, b) => a.width - b.width)];
675
+ }
676
+ var settle = async (page, ms) => void await page.evaluate((wait) => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, wait)))), ms);
677
+ async function loadEverything(page) {
678
+ await page.evaluate(async () => {
679
+ const h = document.documentElement.scrollHeight;
680
+ for (let y = 0; y < h; y += Math.max(300, window.innerHeight - 100)) {
681
+ window.scrollTo({ top: y, behavior: "instant" });
682
+ await new Promise((r) => setTimeout(r, 40));
683
+ }
684
+ await Promise.all(Array.from(document.images).map((i) => i.complete ? null : new Promise((r) => {
685
+ i.onload = i.onerror = () => r(null);
686
+ setTimeout(r, 3e3);
687
+ })));
688
+ await document.fonts?.ready;
689
+ window.scrollTo({ top: 0, behavior: "instant" });
690
+ }, void 0);
691
+ }
692
+ async function collect(page, options) {
693
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds ?? {} };
694
+ const floors = { ...PHONE_FLOORS, ...options.floors ?? {} };
695
+ const mainAction = mainActionFor(options.mainAction, options.route);
696
+ const perViewport = [];
697
+ const tested = [];
698
+ for (const stop of plan(options)) {
699
+ await page.setViewportSize({ width: stop.width, height: stop.height });
700
+ await settle(page, stop.ladder ? 150 : 60);
701
+ if (stop.ladder) await loadEverything(page);
702
+ 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);
703
+ const findings = [];
704
+ if (stop.ladder) findings.push(...await page.evaluate(measurePhoneInPage, { ...floors, only: stop.touch ? ["target", "field", "text"] : ["text"] }));
705
+ findings.push(...await page.evaluate(measureViewportInPage, { groups: stop.groups, thresholds, mainAction, metaViewport: stop.width < 768 }));
706
+ perViewport.push({ viewport: stop.name, findings });
707
+ tested.push(stop.name);
708
+ }
709
+ return { perViewport, tested };
710
+ }
711
+ function finish(route, collected, out) {
712
+ const result = { route, viewports: collected.tested, findings: mergeViewportFindings(collected.perViewport, collected.tested), perViewport: collected.perViewport };
713
+ if (out !== false) writeMeasured(result, out ?? DEFAULT_MEASURED_DIR);
714
+ return result;
715
+ }
716
+ function writeMeasured(result, dir) {
717
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true });
718
+ const { route, viewport, viewports, findings } = result;
719
+ (0, import_node_fs.writeFileSync)((0, import_node_path.join)(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, findings }, null, 1)}
720
+ `);
721
+ }
722
+ async function measureViewports(page, options) {
723
+ if (!options.route.startsWith("/")) throw new Error(`measureViewports: route "${options.route}" should start with /`);
724
+ await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
725
+ return finish(options.route, await collect(page, options), options.out);
726
+ }
727
+ async function measureSite(browser, options) {
728
+ const result = { routes: [], errors: [] };
729
+ for (const route of options.routes) {
730
+ const collected = [];
731
+ let failed = null;
732
+ for (const [scope, context] of [
733
+ ["phone", PHONE],
734
+ ["wide", { viewport: { width: 1024, height: 768 }, deviceScaleFactor: 1 }]
735
+ ]) {
736
+ const ctx = await browser.newContext({ ...context });
737
+ try {
738
+ const page = await ctx.newPage();
739
+ const resp = await page.goto(options.base + route).catch((error) => ({ ok: () => false, status: () => error.message }));
740
+ if (!resp || !resp.ok()) {
741
+ failed = `HTTP ${resp?.status()}`;
742
+ break;
743
+ }
744
+ await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
745
+ collected.push(await collect(page, { ...options, route, scope }));
746
+ } finally {
747
+ await ctx.close();
748
+ }
749
+ }
750
+ if (failed) {
751
+ result.errors.push({ route, error: failed });
752
+ continue;
753
+ }
754
+ result.routes.push(finish(route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out));
755
+ }
756
+ return result;
757
+ }
758
+
759
+ // src/phone/index.ts
247
760
  async function measurePhone(page, options) {
248
761
  if (!options.route.startsWith("/")) throw new Error(`measurePhone: route "${options.route}" should start with /`);
249
762
  await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
@@ -251,25 +764,39 @@ async function measurePhone(page, options) {
251
764
  const viewport = page.viewportSize?.() ?? void 0;
252
765
  const result = { route: options.route, ...viewport ? { viewport } : {}, findings };
253
766
  const dir = options.out ?? DEFAULT_MEASURED_DIR;
254
- (0, import_node_fs.mkdirSync)(dir, { recursive: true });
255
- (0, import_node_fs.writeFileSync)((0, import_node_path.join)(dir, routeFile(options.route)), `${JSON.stringify(result, null, 1)}
767
+ (0, import_node_fs2.mkdirSync)(dir, { recursive: true });
768
+ (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(dir, routeFile(options.route)), `${JSON.stringify(result, null, 1)}
256
769
  `);
257
770
  return result;
258
771
  }
259
772
  // Annotate the CommonJS export names for ESM import in node:
260
773
  0 && (module.exports = {
774
+ BANDS,
261
775
  DEFAULT_MEASURED_DIR,
776
+ DEFAULT_SWEEP,
777
+ DEFAULT_THRESHOLDS,
262
778
  EMPTY_BASELINE,
779
+ LADDER,
263
780
  PHONE,
264
781
  PHONE_FLOORS,
782
+ SHORT_PHONE,
783
+ WIDE_WIDTHS,
784
+ bandOf,
265
785
  checkExceptions,
786
+ formatWidths,
266
787
  judgeMeasured,
267
788
  judgeStatic,
789
+ mainActionFor,
268
790
  measurePhone,
269
791
  measurePhoneInPage,
792
+ measureSite,
793
+ measureViewportInPage,
794
+ measureViewports,
795
+ mergeViewportFindings,
270
796
  normaliseElement,
271
797
  reasonOf,
272
798
  routeFile,
273
799
  scanPhoneFirst,
800
+ sweepWidths,
274
801
  writeBaseline
275
802
  });