@domandigital/craft 0.19.3 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/CHARACTER.md +4 -2
  3. package/COPY.md +3 -1
  4. package/README.md +51 -0
  5. package/STANDARD.md +86 -3
  6. package/dist/{audit-43PC4LMS.js → audit-BN2GSFKT.js} +3 -3
  7. package/dist/audit.cjs +80 -5
  8. package/dist/audit.js +80 -5
  9. package/dist/{chunk-4LJFZH2I.js → chunk-2OBDCIVW.js} +2 -2
  10. package/dist/{chunk-BK4AS2RU.js → chunk-6NXPDCUO.js} +26 -13
  11. package/dist/{chunk-CZ7KT452.js → chunk-7DQDF3VL.js} +76 -4
  12. package/dist/{chunk-6M2LK7QE.js → chunk-FQYKKKHI.js} +5 -5
  13. package/dist/{chunk-XVMKZ4SM.js → chunk-GOPHZI6O.js} +4 -4
  14. package/dist/{cli-6LCOSHMW.js → chunk-IJUZ7RV4.js} +16 -114
  15. package/dist/{chunk-D2FTTM4F.js → chunk-IZ6GTU54.js} +5 -5
  16. package/dist/{chunk-WK2WMWTH.js → chunk-JGWOA4LI.js} +5 -2
  17. package/dist/{chunk-VDUIIWG7.js → chunk-YOZLPNJS.js} +1 -1
  18. package/dist/{cli-Q5X3W6I4.js → cli-DVYLDCEA.js} +5 -5
  19. package/dist/{cli-QAYH2OX5.js → cli-HUN5ZTH5.js} +6 -6
  20. package/dist/{cli-WN2GHYPF.js → cli-IREKGVSM.js} +6 -6
  21. package/dist/cli-LSJI7QRD.js +122 -0
  22. package/dist/{cli-APYEOHFV.js → cli-MKZJMZUJ.js} +5 -5
  23. package/dist/{cli-TMLHMNWU.js → cli-NTNUZUFP.js} +4 -4
  24. package/dist/{cli-4LEAEOOV.js → cli-VFKKFRB4.js} +9 -9
  25. package/dist/{cli-VPARW6FY.js → cli-ZXDGVDYU.js} +1 -1
  26. package/dist/cli.js +3 -3
  27. package/dist/index.cjs +80 -5
  28. package/dist/index.d.cts +2 -2
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.js +80 -5
  31. package/dist/phone.cjs +546 -19
  32. package/dist/phone.d.cts +265 -28
  33. package/dist/phone.d.ts +265 -28
  34. package/dist/phone.js +532 -19
  35. package/dist/viewports-cli-C4VXMPKK.js +683 -0
  36. package/package.json +1 -1
  37. package/word-lists.json +3 -1
@@ -0,0 +1,683 @@
1
+ import {
2
+ EMPTY_BASELINE,
3
+ judgeMeasured,
4
+ writeBaseline
5
+ } from "./chunk-IJUZ7RV4.js";
6
+ import {
7
+ loadPlaywright
8
+ } from "./chunk-YOZLPNJS.js";
9
+ import "./chunk-JYNXGCYZ.js";
10
+ import {
11
+ toJson
12
+ } from "./chunk-QIEDBL4J.js";
13
+ import "./chunk-JGWOA4LI.js";
14
+ import "./chunk-7DQDF3VL.js";
15
+ import "./chunk-QYBAVU3P.js";
16
+
17
+ // src/phone/viewports-cli.ts
18
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
19
+ import { join as join2, resolve } from "path";
20
+
21
+ // src/phone/constants.ts
22
+ var PHONE = {
23
+ viewport: { width: 375, height: 812 },
24
+ deviceScaleFactor: 2,
25
+ isMobile: true,
26
+ hasTouch: true
27
+ };
28
+ var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
29
+ var DEFAULT_MEASURED_DIR = "phone-first/measured";
30
+ var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
31
+
32
+ // src/phone/run.ts
33
+ import { mkdirSync, writeFileSync } from "fs";
34
+ import { join } from "path";
35
+
36
+ // src/phone/measure-in-page.ts
37
+ function measurePhoneInPage(floors) {
38
+ const out = [];
39
+ const only = floors.only;
40
+ const on = (c) => !only || only.includes(c);
41
+ const vw = document.documentElement.clientWidth;
42
+ const shown = (e) => {
43
+ const s = getComputedStyle(e);
44
+ return s.display !== "none" && s.visibility !== "hidden" && e.getClientRects().length > 0;
45
+ };
46
+ const offPage = (e) => {
47
+ const r = e.getBoundingClientRect();
48
+ return r.right <= 0 || r.bottom + window.scrollY <= 0 || r.left >= vw;
49
+ };
50
+ const quiet = (e) => !!e.closest("[aria-hidden='true'], .sr-only, .visually-hidden, [hidden]") || offPage(e);
51
+ const describe = (e) => {
52
+ const text = (e.textContent ?? "").replace(/\s+/g, " ").trim() || e.getAttribute("aria-label") || e.getAttribute("name") || e.getAttribute("placeholder") || "";
53
+ return `<${e.tagName.toLowerCase()}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
54
+ };
55
+ if (!on("scroll")) {
56
+ } else if (vw > window.screen.width + 1) {
57
+ 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` });
58
+ } else if (document.documentElement.scrollWidth > vw + 1) {
59
+ 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);
60
+ out.push({ check: "scroll", element: "<html>", detail: `the page is ${document.documentElement.scrollWidth}px wide at ${vw}px${wide.length ? `; widest: ${wide.join(", ")}` : ""}` });
61
+ }
62
+ const inline = (e) => {
63
+ if (e.tagName !== "A" || getComputedStyle(e).display !== "inline") return false;
64
+ const block = e.parentElement?.closest("p, li, dd, td, th, blockquote, figcaption, address");
65
+ return !!block && (block.textContent ?? "").trim().length > (e.textContent ?? "").trim().length;
66
+ };
67
+ 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);
68
+ for (const e of targets) {
69
+ const r = e.getBoundingClientRect();
70
+ if (quiet(e) || inline(e) || r.width <= 1 || r.height <= 1) continue;
71
+ const labels = "labels" in e && e.labels ? Array.from(e.labels).filter(shown) : [];
72
+ const boxes = [r, ...labels.map((l) => l.getBoundingClientRect())];
73
+ const w = Math.round(Math.max(...boxes.map((b) => b.width)));
74
+ const h = Math.round(Math.max(...boxes.map((b) => b.height)));
75
+ 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}` });
76
+ }
77
+ const NOT_TEXT = /* @__PURE__ */ new Set(["checkbox", "radio", "range", "color", "file", "submit", "reset", "button", "image"]);
78
+ for (const e of on("field") ? Array.from(document.querySelectorAll("input:not([type='hidden']), select, textarea")) : []) {
79
+ if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
80
+ const px = parseFloat(getComputedStyle(e).fontSize);
81
+ 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` });
82
+ }
83
+ for (const e of on("text") ? Array.from(document.body.querySelectorAll("*")) : []) {
84
+ if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
85
+ const own = Array.from(e.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
86
+ if (!own) continue;
87
+ const px = parseFloat(getComputedStyle(e).fontSize);
88
+ if (px < floors.textPx) out.push({ check: "text", element: `<${e.tagName.toLowerCase()}> "${own.slice(0, 40)}"`, detail: `${px}px, under ${floors.textPx}px` });
89
+ }
90
+ for (const e of on("title") ? Array.from(document.querySelectorAll("h1")) : []) {
91
+ if (!shown(e) || quiet(e)) continue;
92
+ const s = getComputedStyle(e);
93
+ const clipsX = e.scrollWidth > e.clientWidth + 1 && s.overflowX !== "visible";
94
+ const clipsY = e.scrollHeight > e.clientHeight + 1 && s.overflowY !== "visible";
95
+ if (clipsX || clipsY) out.push({ check: "title", element: describe(e), detail: "the page title is cut off" });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // src/phone/measure-viewport-in-page.ts
101
+ function measureViewportInPage(arg) {
102
+ const { groups, thresholds: t } = arg;
103
+ const out = [];
104
+ const vw = document.documentElement.clientWidth;
105
+ const vh = document.documentElement.clientHeight;
106
+ const pct = (n) => `${Math.round(n * 100)}%`;
107
+ const px = (n) => `${Math.round(n)}px`;
108
+ const shown = (e) => {
109
+ const s = getComputedStyle(e);
110
+ if (s.display === "none" || s.visibility === "hidden" || e.getClientRects().length === 0) return false;
111
+ return typeof e.checkVisibility === "function" ? e.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true }) : true;
112
+ };
113
+ const hiddenByMarkup = (e) => !!e.closest("[aria-hidden='true'], .sr-only, .visually-hidden, [hidden]");
114
+ const faded = (e) => {
115
+ for (let n = e; n && n !== document.documentElement; n = n.parentElement) if (getComputedStyle(n).opacity === "0") return true;
116
+ return false;
117
+ };
118
+ const describe = (e) => {
119
+ const text = (e.textContent ?? "").replace(/\s+/g, " ").trim() || e.getAttribute("aria-label") || e.getAttribute("name") || e.getAttribute("placeholder") || "";
120
+ return `<${e.tagName.toLowerCase()}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
121
+ };
122
+ const add = (check, element, detail) => {
123
+ out.push({ check, element, detail });
124
+ };
125
+ const clipBoxes = /* @__PURE__ */ new Map();
126
+ const clipsOf = (e) => {
127
+ const had = clipBoxes.get(e);
128
+ if (had) return had;
129
+ const boxes = [];
130
+ for (let n = e.parentElement; n && n !== document.documentElement && n !== document.body; n = n.parentElement) {
131
+ const s = getComputedStyle(n);
132
+ if (/hidden|clip|auto|scroll/.test(s.overflowX + s.overflowY)) boxes.push(n.getBoundingClientRect());
133
+ }
134
+ clipBoxes.set(e, boxes);
135
+ return boxes;
136
+ };
137
+ const hardClipsOf = (e) => {
138
+ const boxes = [];
139
+ for (let n = e.parentElement; n && n !== document.documentElement && n !== document.body; n = n.parentElement) {
140
+ const s = getComputedStyle(n);
141
+ if (/hidden|clip/.test(s.overflowX) || /hidden|clip/.test(s.overflowY)) boxes.push(n.getBoundingClientRect());
142
+ }
143
+ return boxes;
144
+ };
145
+ const inLayers = /* @__PURE__ */ new Map();
146
+ const inLayer = (e) => {
147
+ const had = inLayers.get(e);
148
+ if (had !== void 0) return had;
149
+ const p = getComputedStyle(e).position;
150
+ const v = p === "fixed" || p === "sticky" || !!e.parentElement && inLayer(e.parentElement);
151
+ inLayers.set(e, v);
152
+ return v;
153
+ };
154
+ const textBoxes = () => {
155
+ const boxes = [];
156
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
157
+ const range = document.createRange();
158
+ const skip = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "OPTION", "TEXTAREA", "TITLE"]);
159
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
160
+ const owner = n.parentElement;
161
+ if (!owner || skip.has(owner.tagName) || !(n.textContent ?? "").trim() || !shown(owner) || hiddenByMarkup(owner) || faded(owner)) continue;
162
+ range.selectNodeContents(n);
163
+ for (const rect of Array.from(range.getClientRects())) {
164
+ let { left: l, top: tp, right: r, bottom: b } = rect;
165
+ for (const c of clipsOf(owner)) {
166
+ l = Math.max(l, c.left);
167
+ tp = Math.max(tp, c.top);
168
+ r = Math.min(r, c.right);
169
+ b = Math.min(b, c.bottom);
170
+ }
171
+ if (r - l >= 1 && b - tp >= 1) boxes.push({ l, t: tp, r, b, owner, node: n });
172
+ }
173
+ }
174
+ return boxes;
175
+ };
176
+ const nameOf = (b) => `<${b.owner.tagName.toLowerCase()}> "${(b.node.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 40)}"`;
177
+ const layerCover = () => {
178
+ const painted = (e) => {
179
+ const s = getComputedStyle(e);
180
+ const bg = s.backgroundColor;
181
+ const clear = bg === "rgba(0, 0, 0, 0)" || bg === "transparent";
182
+ 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());
183
+ };
184
+ const paintedBoxes = (e, depth) => {
185
+ if (!shown(e) || getComputedStyle(e).pointerEvents === "none" || faded(e)) return [];
186
+ if (painted(e)) return [{ e, r: e.getBoundingClientRect() }];
187
+ return depth > 6 ? [] : Array.from(e.children).flatMap((c) => paintedBoxes(c, depth + 1));
188
+ };
189
+ const read = () => {
190
+ 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);
191
+ const xs = [...new Set(boxes.flatMap((b) => [b.l, b.r]))].sort((a, b) => a - b);
192
+ const ys = [...new Set(boxes.flatMap((b) => [b.t, b.b]))].sort((a, b) => a - b);
193
+ let area = 0;
194
+ for (let i = 0; i < xs.length - 1; i++) {
195
+ for (let j = 0; j < ys.length - 1; j++) {
196
+ 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]);
197
+ }
198
+ }
199
+ return { share: area / (vw * vh), names: [...new Set(boxes.map((b) => describe(b.e)))].slice(0, 4) };
200
+ };
201
+ const top = read();
202
+ const home = window.scrollY;
203
+ window.scrollTo({ top: Math.min(document.documentElement.scrollHeight - vh, Math.round(vh * 1.5)), behavior: "instant" });
204
+ const moved = read();
205
+ window.scrollTo({ top: home, behavior: "instant" });
206
+ return moved.share > top.share ? moved : top;
207
+ };
208
+ const imageName = (img) => {
209
+ const alt = (img.getAttribute("alt") ?? "").trim();
210
+ const file = (img.currentSrc || img.src).split("?")[0].split("/").pop() ?? "";
211
+ return `<img> "${(alt || file).slice(0, 40)}"`;
212
+ };
213
+ const images = (kinds) => {
214
+ for (const img of Array.from(document.images)) {
215
+ const src = img.currentSrc || img.src;
216
+ if (!img.complete || !img.naturalWidth || !src || /\.svg(\?|$)|^data:image\/svg/i.test(src) || !shown(img) || hiddenByMarkup(img) || faded(img)) continue;
217
+ const shownPx = img.getBoundingClientRect().width;
218
+ if (shownPx < t.minImagePx) continue;
219
+ const ratio = img.naturalWidth / shownPx;
220
+ if (kinds.includes("oversized") && ratio > t.oversizeRatio) {
221
+ 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`);
222
+ }
223
+ if (kinds.includes("upscaled") && ratio < t.upscaleRatio) {
224
+ add("image", `${imageName(img)} upscaled`, `the file is ${img.naturalWidth}px wide, stretched to ${px(shownPx)}. Serve one at least that wide`);
225
+ }
226
+ }
227
+ };
228
+ if (groups.includes("sweep")) {
229
+ if (arg.metaViewport && vw > window.screen.width + 1) {
230
+ 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`);
231
+ } else if (document.documentElement.scrollWidth > vw + 1) {
232
+ 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);
233
+ add("scroll", "<html>", `the page is ${document.documentElement.scrollWidth}px wide at ${vw}px${wide.length ? `; widest: ${wide.join(", ")}` : ""}`);
234
+ }
235
+ const boxes = textBoxes().sort((a, b) => a.t - b.t);
236
+ const seen = /* @__PURE__ */ new Set();
237
+ for (let i = 0; i < boxes.length && seen.size < 15; i++) {
238
+ const a = boxes[i];
239
+ for (let j = i + 1; j < boxes.length && boxes[j].t < a.b; j++) {
240
+ const b = boxes[j];
241
+ if (a.node === b.node) continue;
242
+ const w = Math.min(a.r, b.r) - Math.max(a.l, b.l);
243
+ const h = Math.min(a.b, b.b) - Math.max(a.t, b.t);
244
+ if (w < 3 || h < t.overlapShare * Math.min(a.b - a.t, b.b - b.t)) continue;
245
+ const x = Math.max(a.l, b.l) + w / 2;
246
+ const y = Math.max(a.t, b.t) + h / 2;
247
+ const onScreen = x >= 0 && x < vw && y >= 0 && y < vh;
248
+ if (!onScreen && inLayer(a.owner) !== inLayer(b.owner)) continue;
249
+ if (onScreen && getComputedStyle(a.owner).pointerEvents !== "none" && getComputedStyle(b.owner).pointerEvents !== "none") {
250
+ const stack = document.elementsFromPoint(x, y);
251
+ const at = (o) => stack.findIndex((e) => e === o || o.contains(e));
252
+ const [ia, ib] = [at(a.owner), at(b.owner)];
253
+ if (ia < 0 || ib < 0) continue;
254
+ const lower = ia > ib ? a.owner : b.owner;
255
+ const covers = (e) => {
256
+ const s = getComputedStyle(e);
257
+ const alpha = s.backgroundColor === "transparent" ? 0 : parseFloat(s.backgroundColor.match(/[,/]\s*([\d.]+)%?\)$/)?.[1] ?? "1");
258
+ return !e.contains(lower) && (alpha >= 0.9 || s.backgroundImage !== "none");
259
+ };
260
+ if (stack.slice(0, Math.max(ia, ib)).some(covers)) continue;
261
+ }
262
+ const names = [nameOf(a), nameOf(b)].sort();
263
+ const key = names.join(" / ");
264
+ if (seen.has(key)) continue;
265
+ seen.add(key);
266
+ add("overlap", key, `visible text overlaps other text by ${px(w)} x ${px(h)}`);
267
+ }
268
+ }
269
+ for (const e of Array.from(document.querySelectorAll("h1, [role='heading'][aria-level='1']"))) {
270
+ if (!shown(e) || hiddenByMarkup(e)) continue;
271
+ const s = getComputedStyle(e);
272
+ const clipsX = e.scrollWidth > e.clientWidth + 1 && s.overflowX !== "visible";
273
+ const clipsY = e.scrollHeight > e.clientHeight + 1 && s.overflowY !== "visible";
274
+ const range = document.createRange();
275
+ range.selectNodeContents(e);
276
+ const r = range.getBoundingClientRect();
277
+ const cutByParent = r.width > 0 && hardClipsOf(e).some((c) => r.right > c.right + 1 || r.left < c.left - 1 || r.bottom > c.bottom + 1);
278
+ if (clipsX || clipsY || cutByParent) add("title", describe(e), "the page title is cut off");
279
+ }
280
+ }
281
+ if (groups.includes("optimise")) {
282
+ if (arg.mainAction) {
283
+ const matches = Array.from(document.querySelectorAll(arg.mainAction)).filter((e) => shown(e) && !hiddenByMarkup(e));
284
+ if (matches.length === 0) {
285
+ add("action", arg.mainAction, "no visible element matches the main action selector");
286
+ } else {
287
+ const pinned = (e) => ["fixed", "sticky"].includes(getComputedStyle(e).position) || !!e.parentElement && e.parentElement !== document.body && pinned(e.parentElement);
288
+ const fits = matches.some((e) => {
289
+ const r = e.getBoundingClientRect();
290
+ return pinned(e) || r.top >= 0 && r.bottom <= vh + 1;
291
+ });
292
+ if (!fits) {
293
+ const r = matches[0].getBoundingClientRect();
294
+ 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`);
295
+ }
296
+ }
297
+ }
298
+ const cover = layerCover();
299
+ 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(", ")}`);
300
+ images(["oversized", "upscaled"]);
301
+ const clean = (s) => s.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_\-.[\]]+/g, " ").toLowerCase();
302
+ const PHONE2 = /\b(phone|tel|telephone|mobile)\b/;
303
+ const EMAIL = /\be ?mail\b/;
304
+ 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/;
305
+ 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/;
306
+ const NOT_TEXT = /* @__PURE__ */ new Set(["hidden", "checkbox", "radio", "range", "color", "file", "submit", "reset", "button", "image", "date", "time", "datetime-local", "month", "week"]);
307
+ for (const e of Array.from(document.querySelectorAll("input, textarea"))) {
308
+ const field = e;
309
+ if (!shown(e) || hiddenByMarkup(e) || NOT_TEXT.has(field.type) || field.type === "search" || e.closest("[role='search']")) continue;
310
+ const label = Array.from(field.labels ?? []).map((l) => l.textContent ?? "").join(" ");
311
+ const named = e.getAttribute("aria-label") ?? "";
312
+ const signals = clean([e.getAttribute("name"), e.id, named, e.getAttribute("placeholder"), e.getAttribute("autocomplete"), label].filter(Boolean).join(" "));
313
+ const inputmode = (e.getAttribute("inputmode") ?? "").toLowerCase();
314
+ const type = e.tagName === "TEXTAREA" ? "textarea" : field.type;
315
+ const kinds = [];
316
+ if (PHONE2.test(signals)) kinds.push("phone");
317
+ if (EMAIL.test(signals)) kinds.push("email");
318
+ if (NUMBER.test(signals)) kinds.push("number");
319
+ const who = `<${e.tagName.toLowerCase()}> "${(label.replace(/\s+/g, " ").trim() || named || e.getAttribute("name") || e.id || e.getAttribute("placeholder") || type).slice(0, 40)}"`;
320
+ if (e.tagName === "INPUT" && kinds.length === 1) {
321
+ const [kind] = kinds;
322
+ const good = kind === "phone" ? type === "tel" || inputmode === "tel" : kind === "email" ? type === "email" || inputmode === "email" : type === "number" || ["numeric", "decimal", "tel"].includes(inputmode);
323
+ if (!good) {
324
+ const use = kind === "phone" ? 'type="tel"' : kind === "email" ? 'type="email"' : 'inputmode="numeric" (or "decimal")';
325
+ 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}`);
326
+ }
327
+ }
328
+ const personal = e.tagName === "INPUT" && (["email", "tel", "password"].includes(type) || PERSONAL.test(signals));
329
+ const auto = (e.getAttribute("autocomplete") ?? "").trim().toLowerCase();
330
+ if (personal && (auto === "" || auto === "off")) {
331
+ 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"`);
332
+ }
333
+ }
334
+ const UK = /(?<![\w+])(?:\+44[\s-]?(?:\(0\))?|0)\d(?:[\s-]?\d){8,9}(?!\d)/g;
335
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
336
+ const dials = /* @__PURE__ */ new Set();
337
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
338
+ const owner = n.parentElement;
339
+ if (!owner || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "TEXTAREA"].includes(owner.tagName) || !shown(owner) || hiddenByMarkup(owner) || owner.closest("a[href^='tel:' i]")) continue;
340
+ for (const m of (n.textContent ?? "").matchAll(UK)) {
341
+ const digits = m[0].replace(/\D/g, "");
342
+ if (!(digits.length === 11 && digits.startsWith("0")) && !(digits.length === 12 && digits.startsWith("44")) && !(digits.length === 13 && digits.startsWith("440"))) continue;
343
+ const shownAs = m[0].replace(/\s+/g, " ").trim();
344
+ if (dials.has(shownAs)) continue;
345
+ dials.add(shownAs);
346
+ 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")}`}">`);
347
+ }
348
+ }
349
+ }
350
+ if (groups.includes("landscape")) {
351
+ const cover = layerCover();
352
+ if (cover.share > t.landscapeMaxLayerShare) {
353
+ 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(", ")}`);
354
+ }
355
+ }
356
+ if (groups.includes("wide")) {
357
+ images(["upscaled"]);
358
+ const paragraphs = Array.from(document.querySelectorAll("p, li:not(:has(p)), dd, blockquote:not(:has(p))"));
359
+ for (const e of paragraphs) {
360
+ 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;
361
+ const range = document.createRange();
362
+ range.selectNodeContents(e);
363
+ const lines = /* @__PURE__ */ new Map();
364
+ for (const r of Array.from(range.getClientRects())) {
365
+ if (r.width < 1 || r.height < 1) continue;
366
+ const key = Math.round(r.top / 4);
367
+ const had = lines.get(key);
368
+ if (had) {
369
+ had.l = Math.min(had.l, r.left);
370
+ had.r = Math.max(had.r, r.right);
371
+ } else lines.set(key, { l: r.left, r: r.right });
372
+ }
373
+ const longest = Math.max(0, ...Array.from(lines.values()).map((l) => l.r - l.l));
374
+ if (longest < 1) continue;
375
+ const probe = document.createElement("span");
376
+ probe.style.cssText = "position:absolute;visibility:hidden;display:inline-block;width:1ch;height:1px;font:inherit;letter-spacing:normal";
377
+ e.appendChild(probe);
378
+ const ch = probe.getBoundingClientRect().width;
379
+ probe.remove();
380
+ if (ch <= 0) continue;
381
+ const chars = longest / ch;
382
+ 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`);
383
+ }
384
+ }
385
+ return out;
386
+ }
387
+
388
+ // src/phone/viewports.ts
389
+ var LADDER = [
390
+ { name: "320", width: 320, height: 568, phone: true },
391
+ { name: "375", width: 375, height: 812, phone: true },
392
+ { name: "390", width: 390, height: 844, phone: true },
393
+ { name: "430", width: 430, height: 932, phone: true },
394
+ { name: "844x390", width: 844, height: 390, phone: true, landscape: true },
395
+ { name: "768", width: 768, height: 1024, phone: false },
396
+ { name: "1024", width: 1024, height: 768, phone: false },
397
+ { name: "1280", width: 1280, height: 800, phone: false },
398
+ { name: "1440", width: 1440, height: 900, phone: false },
399
+ { name: "1920", width: 1920, height: 1080, phone: false }
400
+ ];
401
+ var SHORT_PHONE = { name: "375x667", width: 375, height: 667, phone: true };
402
+ var DEFAULT_SWEEP = { from: 320, to: 1920, step: 16, wideStep: 32, wideFrom: 1024, height: 800 };
403
+ function sweepWidths(sweep = DEFAULT_SWEEP) {
404
+ const out = [];
405
+ for (let w = sweep.from; w <= sweep.to; w += w < sweep.wideFrom ? sweep.step : sweep.wideStep) out.push(w);
406
+ if (out.at(-1) !== sweep.to) out.push(sweep.to);
407
+ return out;
408
+ }
409
+ var DEFAULT_THRESHOLDS = {
410
+ maxLayerShare: 0.25,
411
+ landscapeMaxLayerShare: 0.5,
412
+ oversizeRatio: 2.5,
413
+ upscaleRatio: 1,
414
+ maxLineCh: 80,
415
+ overlapShare: 0.4,
416
+ minImagePx: 32
417
+ };
418
+ var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
419
+ function formatWidths(failing, tested) {
420
+ const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
421
+ const rank = new Map(plain.map((name, i) => [name, i]));
422
+ const names = [...new Set(failing)];
423
+ const runs = [];
424
+ for (const name of names.filter((n) => rank.has(n)).sort((a, b) => rank.get(a) - rank.get(b))) {
425
+ const last = runs.at(-1);
426
+ if (last && rank.get(name) === rank.get(last.at(-1)) + 1) last.push(name);
427
+ else runs.push([name]);
428
+ }
429
+ const out = runs.map((r) => r.length > 1 ? `${r[0]}-${r.at(-1)}` : r[0]);
430
+ return [...out, ...names.filter((n) => !rank.has(n))].join(", ");
431
+ }
432
+ var keyOf = (f) => `${f.check}\0${f.element.replace(/("[^]*)$/, (t) => t.replace(/\d+/g, "#"))}`;
433
+ function mergeViewportFindings(perViewport, tested) {
434
+ const merged = /* @__PURE__ */ new Map();
435
+ for (const { viewport, findings } of perViewport) {
436
+ const counts = /* @__PURE__ */ new Map();
437
+ for (const f of findings) {
438
+ const k = keyOf(f);
439
+ const had = counts.get(k);
440
+ if (had) had.n++;
441
+ else counts.set(k, { first: f, n: 1 });
442
+ }
443
+ for (const [k, { first, n }] of counts) {
444
+ const had = merged.get(k);
445
+ if (had) {
446
+ had.at.push(viewport);
447
+ had.most = Math.max(had.most, n);
448
+ } else merged.set(k, { first, at: [viewport], most: n });
449
+ }
450
+ }
451
+ const out = [];
452
+ for (const { first, at, most } of merged.values()) {
453
+ for (let i = 0; i < most; i++) out.push({ ...first, detail: `${first.detail} (at ${formatWidths(at, tested)})` });
454
+ }
455
+ return out;
456
+ }
457
+
458
+ // src/phone/run.ts
459
+ function plan(options) {
460
+ const ladder = options.ladder ?? LADDER;
461
+ const scope = options.scope ?? "all";
462
+ const phoneSide = scope !== "wide";
463
+ const wideSide = scope !== "phone";
464
+ const stops = [];
465
+ const widthsDone = /* @__PURE__ */ new Set();
466
+ for (const v of ladder.filter((x) => x.phone && !x.landscape)) {
467
+ if (!phoneSide) continue;
468
+ stops.push({ name: v.name, width: v.width, height: v.height, ladder: true, groups: ["sweep"], touch: true });
469
+ widthsDone.add(v.width);
470
+ }
471
+ if (phoneSide) {
472
+ stops.push({ name: SHORT_PHONE.name, width: SHORT_PHONE.width, height: SHORT_PHONE.height, ladder: true, groups: ["optimise"], touch: false });
473
+ 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 });
474
+ }
475
+ const rest = [];
476
+ for (const v of ladder.filter((x) => !x.phone)) {
477
+ if (!wideSide || widthsDone.has(v.width)) continue;
478
+ rest.push({ name: v.name, width: v.width, height: v.height, ladder: true, groups: v.width >= 1440 ? ["sweep", "wide"] : ["sweep"], touch: false });
479
+ widthsDone.add(v.width);
480
+ }
481
+ if (options.sweep !== false) {
482
+ const sweep = { ...DEFAULT_SWEEP, ...options.sweep ?? {} };
483
+ for (const w of sweepWidths(sweep)) {
484
+ if (widthsDone.has(w) || (w < 768 ? !phoneSide : !wideSide)) continue;
485
+ rest.push({ name: String(w), width: w, height: sweep.height, ladder: false, groups: ["sweep"], touch: false });
486
+ widthsDone.add(w);
487
+ }
488
+ }
489
+ return [...stops, ...rest.sort((a, b) => a.width - b.width)];
490
+ }
491
+ var settle = async (page, ms) => void await page.evaluate((wait) => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, wait)))), ms);
492
+ async function loadEverything(page) {
493
+ await page.evaluate(async () => {
494
+ const h = document.documentElement.scrollHeight;
495
+ for (let y = 0; y < h; y += Math.max(300, window.innerHeight - 100)) {
496
+ window.scrollTo({ top: y, behavior: "instant" });
497
+ await new Promise((r) => setTimeout(r, 40));
498
+ }
499
+ await Promise.all(Array.from(document.images).map((i) => i.complete ? null : new Promise((r) => {
500
+ i.onload = i.onerror = () => r(null);
501
+ setTimeout(r, 3e3);
502
+ })));
503
+ await document.fonts?.ready;
504
+ window.scrollTo({ top: 0, behavior: "instant" });
505
+ }, void 0);
506
+ }
507
+ async function collect(page, options) {
508
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds ?? {} };
509
+ const floors = { ...PHONE_FLOORS, ...options.floors ?? {} };
510
+ const mainAction = mainActionFor(options.mainAction, options.route);
511
+ const perViewport = [];
512
+ const tested = [];
513
+ for (const stop of plan(options)) {
514
+ await page.setViewportSize({ width: stop.width, height: stop.height });
515
+ await settle(page, stop.ladder ? 150 : 60);
516
+ if (stop.ladder) await loadEverything(page);
517
+ 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);
518
+ const findings = [];
519
+ if (stop.ladder) findings.push(...await page.evaluate(measurePhoneInPage, { ...floors, only: stop.touch ? ["target", "field", "text"] : ["text"] }));
520
+ findings.push(...await page.evaluate(measureViewportInPage, { groups: stop.groups, thresholds, mainAction, metaViewport: stop.width < 768 }));
521
+ perViewport.push({ viewport: stop.name, findings });
522
+ tested.push(stop.name);
523
+ }
524
+ return { perViewport, tested };
525
+ }
526
+ function finish(route, collected, out) {
527
+ const result = { route, viewports: collected.tested, findings: mergeViewportFindings(collected.perViewport, collected.tested), perViewport: collected.perViewport };
528
+ if (out !== false) writeMeasured(result, out ?? DEFAULT_MEASURED_DIR);
529
+ return result;
530
+ }
531
+ function writeMeasured(result, dir) {
532
+ mkdirSync(dir, { recursive: true });
533
+ const { route, viewport, viewports, findings } = result;
534
+ writeFileSync(join(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, findings }, null, 1)}
535
+ `);
536
+ }
537
+ async function measureSite(browser, options) {
538
+ const result = { routes: [], errors: [] };
539
+ for (const route of options.routes) {
540
+ const collected = [];
541
+ let failed = null;
542
+ for (const [scope, context] of [
543
+ ["phone", PHONE],
544
+ ["wide", { viewport: { width: 1024, height: 768 }, deviceScaleFactor: 1 }]
545
+ ]) {
546
+ const ctx = await browser.newContext({ ...context });
547
+ try {
548
+ const page = await ctx.newPage();
549
+ const resp = await page.goto(options.base + route).catch((error) => ({ ok: () => false, status: () => error.message }));
550
+ if (!resp || !resp.ok()) {
551
+ failed = `HTTP ${resp?.status()}`;
552
+ break;
553
+ }
554
+ await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
555
+ collected.push(await collect(page, { ...options, route, scope }));
556
+ } finally {
557
+ await ctx.close();
558
+ }
559
+ }
560
+ if (failed) {
561
+ result.errors.push({ route, error: failed });
562
+ continue;
563
+ }
564
+ result.routes.push(finish(route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out));
565
+ }
566
+ return result;
567
+ }
568
+
569
+ // src/phone/viewports-cli.ts
570
+ var USAGE = "usage: craft viewports [--url <base>] [--route <path>]... [--config viewports.json] [--out <dir>] [--captures <dir>] [--baseline <file>] [--update-baseline] [--no-sweep] [--json]";
571
+ function parse(args) {
572
+ const flags = { routes: [], update: false, sweep: true, json: false };
573
+ for (let i = 0; i < args.length; i++) {
574
+ const a = args[i];
575
+ if (a === "--update-baseline") flags.update = true;
576
+ else if (a === "--no-sweep") flags.sweep = false;
577
+ else if (a === "--json") flags.json = true;
578
+ else if (["--config", "--url", "--route", "--routes", "--out", "--captures", "--baseline"].includes(a)) {
579
+ const v = args[++i];
580
+ if (!v || v.startsWith("--")) return `${a} needs a value`;
581
+ if (a === "--route") flags.routes.push(v);
582
+ else if (a === "--routes") flags.routes.push(...v.split(",").filter(Boolean));
583
+ else flags[a.slice(2)] = v;
584
+ } else return `unknown argument ${a}`;
585
+ }
586
+ return flags;
587
+ }
588
+ function loadConfig(path, cwd) {
589
+ const file = resolve(cwd, path ?? "viewports.json");
590
+ if (!existsSync(file)) {
591
+ if (path) throw new Error(`no ${path}`);
592
+ return {};
593
+ }
594
+ return JSON.parse(readFileSync(file, "utf8"));
595
+ }
596
+ function loadBaseline(path) {
597
+ if (!existsSync(path)) return EMPTY_BASELINE;
598
+ const b = JSON.parse(readFileSync(path, "utf8"));
599
+ return { ...b.note ? { note: b.note } : {}, static: b.static ?? [], measured: b.measured ?? [] };
600
+ }
601
+ async function runViewports(args, io) {
602
+ const flags = parse(args);
603
+ if (typeof flags === "string") {
604
+ io.err(`craft viewports: ${flags}
605
+ ${USAGE}`);
606
+ return 2;
607
+ }
608
+ try {
609
+ const config = loadConfig(flags.config, io.cwd);
610
+ const base = (flags.url ?? config.url)?.replace(/\/$/, "");
611
+ const routes = flags.routes.length ? flags.routes : config.routes ?? ["/"];
612
+ if (!base) throw new Error(`no site to open. Pass --url <base>, or set "url" in viewports.json.
613
+ ${USAGE}`);
614
+ for (const r of routes) if (!r.startsWith("/")) throw new Error(`route "${r}" should start with /`);
615
+ const out = resolve(io.cwd, flags.out ?? config.out ?? DEFAULT_MEASURED_DIR);
616
+ const baselinePath = resolve(io.cwd, flags.baseline ?? config.baseline ?? "phone-first.baseline.json");
617
+ const captures = flags.captures ? resolve(io.cwd, flags.captures) : void 0;
618
+ if (captures) mkdirSync2(captures, { recursive: true });
619
+ const pw = await loadPlaywright();
620
+ const executablePath = process.env.CRAFT_CHROMIUM || void 0;
621
+ const browser = await pw.chromium.launch({ headless: true, ...executablePath ? { executablePath } : {} });
622
+ let site;
623
+ try {
624
+ site = await measureSite(browser, {
625
+ base,
626
+ routes,
627
+ out,
628
+ ...config.mainAction ? { mainAction: config.mainAction } : {},
629
+ ...config.thresholds ? { thresholds: config.thresholds } : {},
630
+ ...config.floors ? { floors: config.floors } : {},
631
+ sweep: flags.sweep ? config.sweep : false,
632
+ ...captures ? {
633
+ onViewport: async (page, viewport, route) => {
634
+ await page.screenshot({ path: join2(captures, `${routeFile(route).replace(/\.json$/, "")}-${viewport.name}.png`), fullPage: true });
635
+ }
636
+ } : {}
637
+ });
638
+ } finally {
639
+ await browser.close();
640
+ }
641
+ const baseline = loadBaseline(baselinePath);
642
+ const measuredRoutes = new Set(site.routes.map((r) => r.route));
643
+ if (flags.update) {
644
+ const next = writeBaseline([], site.routes, baseline.note);
645
+ next.static = baseline.static;
646
+ next.measured = [...baseline.measured.filter((m) => !measuredRoutes.has(m.route)), ...next.measured].sort((a, b) => a.route.localeCompare(b.route) || a.check.localeCompare(b.check) || a.element.localeCompare(b.element));
647
+ writeFileSync2(baselinePath, `${JSON.stringify(next, null, 2)}
648
+ `);
649
+ io.out(`craft viewports: wrote ${flags.baseline ?? config.baseline ?? "phone-first.baseline.json"} (${next.measured.reduce((n, m) => n + m.count, 0)} measured)`);
650
+ return site.errors.length ? 1 : 0;
651
+ }
652
+ const judged = judgeMeasured(site.routes, { ...baseline, measured: baseline.measured.filter((m) => measuredRoutes.has(m.route)) });
653
+ const failed = site.errors.length + judged.fresh.length + judged.stale.length > 0;
654
+ if (flags.json) {
655
+ io.out(toJson({ pass: !failed, routes: site.routes.map((r) => ({ route: r.route, viewports: r.viewports, findings: r.findings.length })), errors: site.errors, measured: judged }));
656
+ return failed ? 1 : 0;
657
+ }
658
+ const section = (title, lines) => lines.length ? `
659
+ ${title}
660
+ ${lines.map((l) => ` ${l}`).join("\n")}` : "";
661
+ const viewports = site.routes[0]?.viewports ?? [];
662
+ const summary = `${site.routes.length} route${site.routes.length === 1 ? "" : "s"} at ${viewports.length} viewports${judged.known ? `, ${judged.known} known breach${judged.known === 1 ? "" : "es"} in the baseline` : ""}`;
663
+ if (!failed) {
664
+ io.out(`craft viewports: pass. ${summary}.`);
665
+ return 0;
666
+ }
667
+ io.err(
668
+ [
669
+ `craft viewports: fail. ${summary}.`,
670
+ section("Did not answer (not measured, so not passed):", site.errors.map((e) => `${e.route}: ${e.error}`)),
671
+ section("Measured across the viewports, phone first:", judged.fresh),
672
+ section("The baseline claims more than is there (good: lower it, so the fix stays fixed):", judged.stale)
673
+ ].join("")
674
+ );
675
+ return 1;
676
+ } catch (error) {
677
+ io.err(`craft viewports: ${error.message}`);
678
+ return 2;
679
+ }
680
+ }
681
+ export {
682
+ runViewports
683
+ };