@domandigital/craft 0.19.0 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +45 -1
- package/STANDARD.md +48 -18
- package/dist/{audit-X7LGATAQ.js → audit-43PC4LMS.js} +3 -3
- package/dist/{chunk-RNHV2SXC.js → chunk-4LJFZH2I.js} +2 -2
- package/dist/{chunk-4QWEAL4G.js → chunk-CZ7KT452.js} +93 -1
- package/dist/{chunk-GNVZGG3E.js → chunk-D2PEKYKH.js} +24 -12
- package/dist/{chunk-NCBCZFDY.js → chunk-EDM4L5WJ.js} +5 -5
- package/dist/{chunk-J35UYAVV.js → chunk-EKGHL4D2.js} +4 -4
- package/dist/{chunk-W7PMRSDG.js → chunk-VDUIIWG7.js} +1 -1
- package/dist/{chunk-IHT6CJOM.js → chunk-WK2WMWTH.js} +1 -1
- package/dist/{chunk-LWKMGJSW.js → chunk-ZKZCPVQ6.js} +5 -5
- package/dist/{cli-DKI37OXK.js → cli-BZSJIUQH.js} +5 -5
- package/dist/{cli-3WWHRJRG.js → cli-GUEBFFF3.js} +4 -4
- package/dist/{cli-ZGB6MUN2.js → cli-KTN5OXHZ.js} +6 -6
- package/dist/{cli-KXVJJDN5.js → cli-OS5ESEJV.js} +9 -9
- package/dist/cli-RAH4CKYY.js +249 -0
- package/dist/{cli-U7WEVPZL.js → cli-T3IA7IQK.js} +6 -6
- package/dist/{cli-YD3VN52R.js → cli-VLNOQYEL.js} +5 -5
- package/dist/{cli-JB4X5LAG.js → cli-VPARW6FY.js} +1 -1
- package/dist/cli.js +3 -3
- package/dist/index.cjs +93 -1
- package/dist/index.js +93 -1
- package/dist/phone.cjs +275 -0
- package/dist/phone.d.cts +185 -0
- package/dist/phone.d.ts +185 -0
- package/dist/phone.js +237 -0
- package/package.json +11 -1
package/dist/phone.cjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/phone/index.ts
|
|
21
|
+
var phone_exports = {};
|
|
22
|
+
__export(phone_exports, {
|
|
23
|
+
DEFAULT_MEASURED_DIR: () => DEFAULT_MEASURED_DIR,
|
|
24
|
+
EMPTY_BASELINE: () => EMPTY_BASELINE,
|
|
25
|
+
PHONE: () => PHONE,
|
|
26
|
+
PHONE_FLOORS: () => PHONE_FLOORS,
|
|
27
|
+
checkExceptions: () => checkExceptions,
|
|
28
|
+
judgeMeasured: () => judgeMeasured,
|
|
29
|
+
judgeStatic: () => judgeStatic,
|
|
30
|
+
measurePhone: () => measurePhone,
|
|
31
|
+
measurePhoneInPage: () => measurePhoneInPage,
|
|
32
|
+
normaliseElement: () => normaliseElement,
|
|
33
|
+
reasonOf: () => reasonOf,
|
|
34
|
+
routeFile: () => routeFile,
|
|
35
|
+
scanPhoneFirst: () => scanPhoneFirst,
|
|
36
|
+
writeBaseline: () => writeBaseline
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(phone_exports);
|
|
39
|
+
var import_node_fs = require("fs");
|
|
40
|
+
var import_node_path = require("path");
|
|
41
|
+
|
|
42
|
+
// src/phone/measure-in-page.ts
|
|
43
|
+
function measurePhoneInPage(floors) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const vw = document.documentElement.clientWidth;
|
|
46
|
+
const shown = (e) => {
|
|
47
|
+
const s = getComputedStyle(e);
|
|
48
|
+
return s.display !== "none" && s.visibility !== "hidden" && e.getClientRects().length > 0;
|
|
49
|
+
};
|
|
50
|
+
const offPage = (e) => {
|
|
51
|
+
const r = e.getBoundingClientRect();
|
|
52
|
+
return r.right <= 0 || r.bottom + window.scrollY <= 0 || r.left >= vw;
|
|
53
|
+
};
|
|
54
|
+
const quiet = (e) => !!e.closest("[aria-hidden='true'], .sr-only, .visually-hidden, [hidden]") || offPage(e);
|
|
55
|
+
const describe = (e) => {
|
|
56
|
+
const text = (e.textContent ?? "").replace(/\s+/g, " ").trim() || e.getAttribute("aria-label") || e.getAttribute("name") || e.getAttribute("placeholder") || "";
|
|
57
|
+
return `<${e.tagName.toLowerCase()}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
|
|
58
|
+
};
|
|
59
|
+
if (vw > window.screen.width + 1) {
|
|
60
|
+
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
|
+
} else if (document.documentElement.scrollWidth > vw + 1) {
|
|
62
|
+
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);
|
|
63
|
+
out.push({ check: "scroll", element: "<html>", detail: `the page is ${document.documentElement.scrollWidth}px wide at ${vw}px${wide.length ? `; widest: ${wide.join(", ")}` : ""}` });
|
|
64
|
+
}
|
|
65
|
+
const inline = (e) => {
|
|
66
|
+
if (e.tagName !== "A" || getComputedStyle(e).display !== "inline") return false;
|
|
67
|
+
const block = e.parentElement?.closest("p, li, dd, td, th, blockquote, figcaption, address");
|
|
68
|
+
return !!block && (block.textContent ?? "").trim().length > (e.textContent ?? "").trim().length;
|
|
69
|
+
};
|
|
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);
|
|
71
|
+
for (const e of targets) {
|
|
72
|
+
const r = e.getBoundingClientRect();
|
|
73
|
+
if (quiet(e) || inline(e) || r.width <= 1 || r.height <= 1) continue;
|
|
74
|
+
const labels = "labels" in e && e.labels ? Array.from(e.labels).filter(shown) : [];
|
|
75
|
+
const boxes = [r, ...labels.map((l) => l.getBoundingClientRect())];
|
|
76
|
+
const w = Math.round(Math.max(...boxes.map((b) => b.width)));
|
|
77
|
+
const h = Math.round(Math.max(...boxes.map((b) => b.height)));
|
|
78
|
+
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
|
+
}
|
|
80
|
+
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"))) {
|
|
82
|
+
if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
|
|
83
|
+
const px = parseFloat(getComputedStyle(e).fontSize);
|
|
84
|
+
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
|
+
}
|
|
86
|
+
for (const e of Array.from(document.body.querySelectorAll("*"))) {
|
|
87
|
+
if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
|
|
88
|
+
const own = Array.from(e.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
|
|
89
|
+
if (!own) continue;
|
|
90
|
+
const px = parseFloat(getComputedStyle(e).fontSize);
|
|
91
|
+
if (px < floors.textPx) out.push({ check: "text", element: `<${e.tagName.toLowerCase()}> "${own.slice(0, 40)}"`, detail: `${px}px, under ${floors.textPx}px` });
|
|
92
|
+
}
|
|
93
|
+
for (const e of Array.from(document.querySelectorAll("h1"))) {
|
|
94
|
+
if (!shown(e) || quiet(e)) continue;
|
|
95
|
+
const s = getComputedStyle(e);
|
|
96
|
+
const clipsX = e.scrollWidth > e.clientWidth + 1 && s.overflowX !== "visible";
|
|
97
|
+
const clipsY = e.scrollHeight > e.clientHeight + 1 && s.overflowY !== "visible";
|
|
98
|
+
if (clipsX || clipsY) out.push({ check: "title", element: describe(e), detail: "the page title is cut off" });
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/phone/judge.ts
|
|
104
|
+
var EMPTY_BASELINE = { static: [], measured: [] };
|
|
105
|
+
var MEDIA = /@(?:custom-)?media\b([^{;]*)/g;
|
|
106
|
+
var CAPS_WIDTH = /\bmax-(?:device-)?width\s*:|\bwidth\s*<|>=?\s*(?:device-)?width\b/i;
|
|
107
|
+
var VARIANT = /(?<![\w@-])max-(\[[^\]\s]+\]|[a-z0-9]+):/g;
|
|
108
|
+
var NOT_VARIANTS = /* @__PURE__ */ new Set(["width", "height", "lines", "resolution", "zoom", "inline-size", "block-size"]);
|
|
109
|
+
var OK = /mobile-first-ok\b:?(.*)/;
|
|
110
|
+
function reasonOf(line) {
|
|
111
|
+
const m = line?.match(OK);
|
|
112
|
+
return m ? m[1].replace(/\*\/|-->|[{}]/g, "").trim() : null;
|
|
113
|
+
}
|
|
114
|
+
function scanPhoneFirst(files, exceptions = []) {
|
|
115
|
+
const excusedFiles = new Set(exceptions.filter((e) => e.reason?.trim()).map((e) => e.file));
|
|
116
|
+
const findings = [];
|
|
117
|
+
let excused = 0;
|
|
118
|
+
for (const { path, text } of files) {
|
|
119
|
+
if (excusedFiles.has(path)) continue;
|
|
120
|
+
const lines = text.split("\n");
|
|
121
|
+
const lineOf = (index) => text.slice(0, index).split("\n").length;
|
|
122
|
+
const found = [];
|
|
123
|
+
for (const m of text.matchAll(MEDIA)) {
|
|
124
|
+
if (CAPS_WIDTH.test(m[1])) found.push({ line: lineOf(m.index), what: `${m[0].slice(0, m[0].length - m[1].length)}${m[1].replace(/\s+/g, " ").trimEnd()}` });
|
|
125
|
+
}
|
|
126
|
+
for (const m of text.matchAll(VARIANT)) {
|
|
127
|
+
if (!NOT_VARIANTS.has(m[1])) found.push({ line: lineOf(m.index), what: m[0] });
|
|
128
|
+
}
|
|
129
|
+
for (const { line, what } of found.sort((a, b) => a.line - b.line)) {
|
|
130
|
+
const here = reasonOf(lines[line - 1]);
|
|
131
|
+
const above = reasonOf(lines[line - 2]);
|
|
132
|
+
if (here || above) {
|
|
133
|
+
excused++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
findings.push({ file: path, line, what: here === "" || above === "" ? `${what} (mobile-first-ok with no reason)` : what });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { findings, excused };
|
|
140
|
+
}
|
|
141
|
+
function checkExceptions(exceptions, exists) {
|
|
142
|
+
const problems = [];
|
|
143
|
+
exceptions.forEach((e, i) => {
|
|
144
|
+
const at = `exceptions[${i}]`;
|
|
145
|
+
const x = e;
|
|
146
|
+
if (typeof x?.file !== "string" || !x.file.trim()) problems.push(`${at}: names no file`);
|
|
147
|
+
else if (typeof x.reason !== "string" || !x.reason.trim()) problems.push(`${at} (${x.file}): no reason. Say why this file needs a max-width query, or remove the exception.`);
|
|
148
|
+
else if (!exists(x.file)) problems.push(`${at}: ${x.file} is not there. Remove the exception.`);
|
|
149
|
+
});
|
|
150
|
+
return problems;
|
|
151
|
+
}
|
|
152
|
+
var normaliseElement = (element) => element.replace(/\d+/g, "#");
|
|
153
|
+
var tally = (items, key) => {
|
|
154
|
+
const m = /* @__PURE__ */ new Map();
|
|
155
|
+
for (const item of items) {
|
|
156
|
+
const k = key(item);
|
|
157
|
+
const had = m.get(k);
|
|
158
|
+
if (had) had.count++;
|
|
159
|
+
else m.set(k, { item, count: 1 });
|
|
160
|
+
}
|
|
161
|
+
return m;
|
|
162
|
+
};
|
|
163
|
+
function judgeStatic(findings, baseline) {
|
|
164
|
+
const byFile = tally(findings, (f) => f.file);
|
|
165
|
+
const allowed = new Map(baseline.static.map((b) => [b.file, b.count]));
|
|
166
|
+
const fresh = [];
|
|
167
|
+
const stale = [];
|
|
168
|
+
let known = 0;
|
|
169
|
+
for (const [file, { count }] of byFile) {
|
|
170
|
+
const allow = allowed.get(file) ?? 0;
|
|
171
|
+
known += Math.min(count, allow);
|
|
172
|
+
if (count > allow) {
|
|
173
|
+
const here = findings.filter((f) => f.file === file);
|
|
174
|
+
const lead = allow ? `${file}: ${count} desktop-first rules, the baseline allows ${allow}. One of these is new:` : `${file}:`;
|
|
175
|
+
fresh.push(`${lead}
|
|
176
|
+
${here.map((f) => ` line ${f.line}: ${f.what}`).join("\n")}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const { file, count } of baseline.static) {
|
|
180
|
+
const now = byFile.get(file)?.count ?? 0;
|
|
181
|
+
if (now < count) stale.push(`${file}: the baseline allows ${count}, there ${now === 1 ? "is" : "are"} ${now} now. Lower it to ${now}${now ? "" : " (remove the line)"}.`);
|
|
182
|
+
}
|
|
183
|
+
return { fresh, stale, known };
|
|
184
|
+
}
|
|
185
|
+
var measuredKey = (route, check, element) => `${route}\0${check}\0${element}`;
|
|
186
|
+
function judgeMeasured(routes, baseline) {
|
|
187
|
+
const fresh = [];
|
|
188
|
+
const stale = [];
|
|
189
|
+
let known = 0;
|
|
190
|
+
const allowed = new Map(baseline.measured.map((b) => [measuredKey(b.route, b.check, b.element), b.count]));
|
|
191
|
+
const seen = /* @__PURE__ */ new Map();
|
|
192
|
+
const measured = new Set(routes.map((r) => r.route));
|
|
193
|
+
for (const { route, findings } of routes) {
|
|
194
|
+
const counted = tally(findings, (f) => measuredKey(route, f.check, normaliseElement(f.element)));
|
|
195
|
+
for (const [key, { item, count }] of counted) {
|
|
196
|
+
seen.set(key, count);
|
|
197
|
+
const allow = allowed.get(key) ?? 0;
|
|
198
|
+
known += Math.min(count, allow);
|
|
199
|
+
if (count > allow) {
|
|
200
|
+
const times = count - allow > 1 ? ` (${count - allow} more than the baseline's ${allow})` : allow ? ` (the baseline allows ${allow})` : "";
|
|
201
|
+
fresh.push(`${route}: ${LABEL[item.check]} ${item.element}: ${item.detail}${times}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const missing = /* @__PURE__ */ new Set();
|
|
206
|
+
for (const b of baseline.measured) {
|
|
207
|
+
const key = measuredKey(b.route, b.check, b.element);
|
|
208
|
+
if (!measured.has(b.route)) {
|
|
209
|
+
if (!missing.has(b.route)) stale.push(`${b.route}: in the baseline but not measured this run. Measure it, or remove its lines.`);
|
|
210
|
+
missing.add(b.route);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const now = seen.get(key) ?? 0;
|
|
214
|
+
if (now < b.count) stale.push(`${b.route}: ${LABEL[b.check]} ${b.element}: the baseline allows ${b.count}, ${now} found. Lower it to ${now}${now ? "" : " (remove the line)"}.`);
|
|
215
|
+
}
|
|
216
|
+
return { fresh, stale, known };
|
|
217
|
+
}
|
|
218
|
+
var LABEL = {
|
|
219
|
+
scroll: "scrolls sideways",
|
|
220
|
+
target: "small target",
|
|
221
|
+
field: "small field",
|
|
222
|
+
text: "small text",
|
|
223
|
+
title: "title cut off"
|
|
224
|
+
};
|
|
225
|
+
function writeBaseline(findings, routes, note) {
|
|
226
|
+
const byFile = tally(findings, (f) => f.file);
|
|
227
|
+
const measured = routes.flatMap(
|
|
228
|
+
({ route, findings: findings2 }) => [...tally(findings2, (f) => measuredKey(route, f.check, normaliseElement(f.element))).values()].map(({ item, count }) => ({ route, check: item.check, element: normaliseElement(item.element), count }))
|
|
229
|
+
);
|
|
230
|
+
return {
|
|
231
|
+
...note ? { note } : {},
|
|
232
|
+
static: [...byFile.values()].map(({ item, count }) => ({ file: item.file, count, found: findings.filter((f) => f.file === item.file).map((f) => f.what) })).sort((a, b) => a.file.localeCompare(b.file)),
|
|
233
|
+
measured: measured.sort((a, b) => a.route.localeCompare(b.route) || a.check.localeCompare(b.check) || a.element.localeCompare(b.element))
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/phone/index.ts
|
|
238
|
+
var PHONE = {
|
|
239
|
+
viewport: { width: 375, height: 812 },
|
|
240
|
+
deviceScaleFactor: 2,
|
|
241
|
+
isMobile: true,
|
|
242
|
+
hasTouch: true
|
|
243
|
+
};
|
|
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`;
|
|
247
|
+
async function measurePhone(page, options) {
|
|
248
|
+
if (!options.route.startsWith("/")) throw new Error(`measurePhone: route "${options.route}" should start with /`);
|
|
249
|
+
await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
|
|
250
|
+
const findings = await page.evaluate(measurePhoneInPage, PHONE_FLOORS);
|
|
251
|
+
const viewport = page.viewportSize?.() ?? void 0;
|
|
252
|
+
const result = { route: options.route, ...viewport ? { viewport } : {}, findings };
|
|
253
|
+
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)}
|
|
256
|
+
`);
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
260
|
+
0 && (module.exports = {
|
|
261
|
+
DEFAULT_MEASURED_DIR,
|
|
262
|
+
EMPTY_BASELINE,
|
|
263
|
+
PHONE,
|
|
264
|
+
PHONE_FLOORS,
|
|
265
|
+
checkExceptions,
|
|
266
|
+
judgeMeasured,
|
|
267
|
+
judgeStatic,
|
|
268
|
+
measurePhone,
|
|
269
|
+
measurePhoneInPage,
|
|
270
|
+
normaliseElement,
|
|
271
|
+
reasonOf,
|
|
272
|
+
routeFile,
|
|
273
|
+
scanPhoneFirst,
|
|
274
|
+
writeBaseline
|
|
275
|
+
});
|
package/dist/phone.d.cts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-page half of the phone-first gate.
|
|
3
|
+
*
|
|
4
|
+
* `measurePhoneInPage` runs inside the browser through `page.evaluate`, so it
|
|
5
|
+
* must be self-contained: no imports, no references to anything outside its
|
|
6
|
+
* own body. It measures the page as it stands at whatever viewport the
|
|
7
|
+
* browser has; the caller sets 375 x 812 (`PHONE` in ./index.ts).
|
|
8
|
+
*
|
|
9
|
+
* The floors are STANDARD.md section 10 and portal standard rule 10: nothing
|
|
10
|
+
* scrolls sideways, every target a thumb has to hit is at least 44 x 44 CSS
|
|
11
|
+
* px, every text field is set at 16px or more so Safari on iOS does not zoom
|
|
12
|
+
* on focus, no visible text is under 12px, and no page title is cut off.
|
|
13
|
+
* Lifted from create-site's measure.mjs (DOM-774) so a site and a portal are
|
|
14
|
+
* measured the same way.
|
|
15
|
+
*/
|
|
16
|
+
interface PhoneFloors {
|
|
17
|
+
targetPx: number;
|
|
18
|
+
fieldFontPx: number;
|
|
19
|
+
textPx: number;
|
|
20
|
+
}
|
|
21
|
+
type PhoneCheck = "scroll" | "target" | "field" | "text" | "title";
|
|
22
|
+
interface RawPhoneFinding {
|
|
23
|
+
check: PhoneCheck;
|
|
24
|
+
/** `<tag> "text"`: what a person looks for on the page. */
|
|
25
|
+
element: string;
|
|
26
|
+
detail: string;
|
|
27
|
+
}
|
|
28
|
+
declare function measurePhoneInPage(floors: PhoneFloors): RawPhoneFinding[];
|
|
29
|
+
|
|
30
|
+
interface SourceFile {
|
|
31
|
+
/** Repo-relative path. The extension decides how the file is read. */
|
|
32
|
+
path: string;
|
|
33
|
+
text: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The judging half of the phone-first gate: pure text work, tested without a
|
|
38
|
+
* browser or a disk.
|
|
39
|
+
*
|
|
40
|
+
* Two kinds of breach, one baseline:
|
|
41
|
+
*
|
|
42
|
+
* - **Static.** A `max-width` media query (`max-width:`, or the range syntax
|
|
43
|
+
* `width <`, `width <=`, `600px > width`) or a Tailwind `max-*` variant
|
|
44
|
+
* (`max-sm:`, `max-[40rem]:`) in source. Base styles are the phone; wider
|
|
45
|
+
* layouts are added with `min-width`. The same patterns as create-site's
|
|
46
|
+
* mobile-first.mjs (DOM-774).
|
|
47
|
+
* - **Measured.** What `measurePhone` found on a rendered route at 375px.
|
|
48
|
+
*
|
|
49
|
+
* A repo that already breaks the rule records what it breaks in a baseline,
|
|
50
|
+
* counted per file and per route, so the gate blocks every new breach from
|
|
51
|
+
* the day it goes in while the existing ones stay listed in the open. The
|
|
52
|
+
* baseline only shrinks: a count higher than what is found fails as well
|
|
53
|
+
* ("lower the baseline"), so a fix cannot leave room for the next breach.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
interface StaticFinding {
|
|
57
|
+
file: string;
|
|
58
|
+
line: number;
|
|
59
|
+
what: string;
|
|
60
|
+
}
|
|
61
|
+
interface FileException {
|
|
62
|
+
file: string;
|
|
63
|
+
reason: string;
|
|
64
|
+
}
|
|
65
|
+
interface MeasuredRoute {
|
|
66
|
+
route: string;
|
|
67
|
+
viewport?: {
|
|
68
|
+
width: number;
|
|
69
|
+
height: number;
|
|
70
|
+
};
|
|
71
|
+
findings: RawPhoneFinding[];
|
|
72
|
+
}
|
|
73
|
+
interface Baseline {
|
|
74
|
+
/** Why the baseline exists and which issue burns it down. Free text, for a person. */
|
|
75
|
+
note?: string;
|
|
76
|
+
static: {
|
|
77
|
+
file: string;
|
|
78
|
+
count: number;
|
|
79
|
+
found?: string[];
|
|
80
|
+
}[];
|
|
81
|
+
measured: {
|
|
82
|
+
route: string;
|
|
83
|
+
check: PhoneCheck;
|
|
84
|
+
element: string;
|
|
85
|
+
count: number;
|
|
86
|
+
}[];
|
|
87
|
+
}
|
|
88
|
+
declare const EMPTY_BASELINE: Baseline;
|
|
89
|
+
/** The reason in a `mobile-first-ok: <reason>` comment, "" for the marker with no reason, null for none. */
|
|
90
|
+
declare function reasonOf(line: string | undefined): string | null;
|
|
91
|
+
/**
|
|
92
|
+
* Every desktop-first rule in the files that is not excused. An excuse is
|
|
93
|
+
* `mobile-first-ok: <reason>` on the line or the line above, or the whole file
|
|
94
|
+
* in `exceptions` with a reason. A marker with no reason is itself a finding.
|
|
95
|
+
*/
|
|
96
|
+
declare function scanPhoneFirst(files: SourceFile[], exceptions?: FileException[]): {
|
|
97
|
+
findings: StaticFinding[];
|
|
98
|
+
excused: number;
|
|
99
|
+
};
|
|
100
|
+
/** Problems with the exceptions themselves: no file, no reason, or a file that is not there. */
|
|
101
|
+
declare function checkExceptions(exceptions: unknown[], exists: (file: string) => boolean): string[];
|
|
102
|
+
/**
|
|
103
|
+
* Digits become `#`, so a date or an amount in fixture data does not make the
|
|
104
|
+
* same button a different one.
|
|
105
|
+
*/
|
|
106
|
+
declare const normaliseElement: (element: string) => string;
|
|
107
|
+
interface Judgement {
|
|
108
|
+
/** Breaches the baseline does not cover: the gate fails on these. */
|
|
109
|
+
fresh: string[];
|
|
110
|
+
/** Baseline lines that claim more than is there now: the gate fails on these too, so the baseline only shrinks. */
|
|
111
|
+
stale: string[];
|
|
112
|
+
/** How many breaches the baseline covers. Printed every run, so the debt stays in sight. */
|
|
113
|
+
known: number;
|
|
114
|
+
}
|
|
115
|
+
declare function judgeStatic(findings: StaticFinding[], baseline: Baseline): Judgement;
|
|
116
|
+
declare function judgeMeasured(routes: MeasuredRoute[], baseline: Baseline): Judgement;
|
|
117
|
+
/** The baseline that makes this run pass: what is there now, counted. */
|
|
118
|
+
declare function writeBaseline(findings: StaticFinding[], routes: MeasuredRoute[], note?: string): Baseline;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `@domandigital/craft/phone`: the phone-first gate for an app behind a
|
|
122
|
+
* sign-in, such as a client portal (DOM-775).
|
|
123
|
+
*
|
|
124
|
+
* A site is measured by create-site's measure.mjs, which can open any page.
|
|
125
|
+
* A portal's pages need a signed-in user and fixture data, which only the
|
|
126
|
+
* portal's own Playwright suite knows how to set up. So the portal's suite
|
|
127
|
+
* opens each key route at `PHONE` and calls `measurePhone`, which measures
|
|
128
|
+
* the page and writes what it found; `craft phone-first --measured <dir>`
|
|
129
|
+
* then judges every route against the repo's baseline in one place, with the
|
|
130
|
+
* static check for max-width rules.
|
|
131
|
+
*
|
|
132
|
+
* import { test } from "@playwright/test";
|
|
133
|
+
* import { PHONE, measurePhone } from "@domandigital/craft/phone";
|
|
134
|
+
*
|
|
135
|
+
* test.use(PHONE);
|
|
136
|
+
* for (const route of ["/portal", "/portal/invoices"]) {
|
|
137
|
+
* test(`phone first: ${route}`, async ({ page }) => {
|
|
138
|
+
* await page.goto(route);
|
|
139
|
+
* await measurePhone(page, { route });
|
|
140
|
+
* });
|
|
141
|
+
* }
|
|
142
|
+
*
|
|
143
|
+
* Nothing here imports Playwright: the page is whatever the caller drives.
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The phone every surface is designed at (STANDARD.md section 10): 375 x 812,
|
|
148
|
+
* touch, at 2x. Spread into Playwright's `test.use` or `browser.newContext`.
|
|
149
|
+
*/
|
|
150
|
+
declare const PHONE: {
|
|
151
|
+
readonly viewport: {
|
|
152
|
+
readonly width: 375;
|
|
153
|
+
readonly height: 812;
|
|
154
|
+
};
|
|
155
|
+
readonly deviceScaleFactor: 2;
|
|
156
|
+
readonly isMobile: true;
|
|
157
|
+
readonly hasTouch: true;
|
|
158
|
+
};
|
|
159
|
+
/** The house floors at phone width. Not per-repo settings. */
|
|
160
|
+
declare const PHONE_FLOORS: PhoneFloors;
|
|
161
|
+
/** Where `measurePhone` writes and `craft phone-first --measured` reads, unless told otherwise. */
|
|
162
|
+
declare const DEFAULT_MEASURED_DIR = "phone-first/measured";
|
|
163
|
+
interface MinimalPage {
|
|
164
|
+
evaluate<R, A>(fn: (arg: A) => R, arg: A): Promise<R>;
|
|
165
|
+
viewportSize?(): {
|
|
166
|
+
width: number;
|
|
167
|
+
height: number;
|
|
168
|
+
} | null;
|
|
169
|
+
}
|
|
170
|
+
interface MeasurePhoneOptions {
|
|
171
|
+
/** The route as the baseline names it, without host or query: `/portal/invoices`. */
|
|
172
|
+
route: string;
|
|
173
|
+
/** The folder to write `<route>.json` into. Default `phone-first/measured`, from the working directory. */
|
|
174
|
+
out?: string;
|
|
175
|
+
}
|
|
176
|
+
/** A route as a file name: `/portal/invoices` is `portal-invoices.json`, `/` is `home.json`. */
|
|
177
|
+
declare const routeFile: (route: string) => string;
|
|
178
|
+
/**
|
|
179
|
+
* Measure the page as it is now and write the result for `craft phone-first`.
|
|
180
|
+
* Waits for web fonts first, so text is measured in the face it is set in.
|
|
181
|
+
* Does not judge or throw on a finding: the baseline decides.
|
|
182
|
+
*/
|
|
183
|
+
declare function measurePhone(page: MinimalPage, options: MeasurePhoneOptions): Promise<MeasuredRoute>;
|
|
184
|
+
|
|
185
|
+
export { type Baseline, DEFAULT_MEASURED_DIR, EMPTY_BASELINE, type FileException, type Judgement, type MeasurePhoneOptions, type MeasuredRoute, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, type StaticFinding, checkExceptions, judgeMeasured, judgeStatic, measurePhone, measurePhoneInPage, normaliseElement, reasonOf, routeFile, scanPhoneFirst, writeBaseline };
|
package/dist/phone.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-page half of the phone-first gate.
|
|
3
|
+
*
|
|
4
|
+
* `measurePhoneInPage` runs inside the browser through `page.evaluate`, so it
|
|
5
|
+
* must be self-contained: no imports, no references to anything outside its
|
|
6
|
+
* own body. It measures the page as it stands at whatever viewport the
|
|
7
|
+
* browser has; the caller sets 375 x 812 (`PHONE` in ./index.ts).
|
|
8
|
+
*
|
|
9
|
+
* The floors are STANDARD.md section 10 and portal standard rule 10: nothing
|
|
10
|
+
* scrolls sideways, every target a thumb has to hit is at least 44 x 44 CSS
|
|
11
|
+
* px, every text field is set at 16px or more so Safari on iOS does not zoom
|
|
12
|
+
* on focus, no visible text is under 12px, and no page title is cut off.
|
|
13
|
+
* Lifted from create-site's measure.mjs (DOM-774) so a site and a portal are
|
|
14
|
+
* measured the same way.
|
|
15
|
+
*/
|
|
16
|
+
interface PhoneFloors {
|
|
17
|
+
targetPx: number;
|
|
18
|
+
fieldFontPx: number;
|
|
19
|
+
textPx: number;
|
|
20
|
+
}
|
|
21
|
+
type PhoneCheck = "scroll" | "target" | "field" | "text" | "title";
|
|
22
|
+
interface RawPhoneFinding {
|
|
23
|
+
check: PhoneCheck;
|
|
24
|
+
/** `<tag> "text"`: what a person looks for on the page. */
|
|
25
|
+
element: string;
|
|
26
|
+
detail: string;
|
|
27
|
+
}
|
|
28
|
+
declare function measurePhoneInPage(floors: PhoneFloors): RawPhoneFinding[];
|
|
29
|
+
|
|
30
|
+
interface SourceFile {
|
|
31
|
+
/** Repo-relative path. The extension decides how the file is read. */
|
|
32
|
+
path: string;
|
|
33
|
+
text: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The judging half of the phone-first gate: pure text work, tested without a
|
|
38
|
+
* browser or a disk.
|
|
39
|
+
*
|
|
40
|
+
* Two kinds of breach, one baseline:
|
|
41
|
+
*
|
|
42
|
+
* - **Static.** A `max-width` media query (`max-width:`, or the range syntax
|
|
43
|
+
* `width <`, `width <=`, `600px > width`) or a Tailwind `max-*` variant
|
|
44
|
+
* (`max-sm:`, `max-[40rem]:`) in source. Base styles are the phone; wider
|
|
45
|
+
* layouts are added with `min-width`. The same patterns as create-site's
|
|
46
|
+
* mobile-first.mjs (DOM-774).
|
|
47
|
+
* - **Measured.** What `measurePhone` found on a rendered route at 375px.
|
|
48
|
+
*
|
|
49
|
+
* A repo that already breaks the rule records what it breaks in a baseline,
|
|
50
|
+
* counted per file and per route, so the gate blocks every new breach from
|
|
51
|
+
* the day it goes in while the existing ones stay listed in the open. The
|
|
52
|
+
* baseline only shrinks: a count higher than what is found fails as well
|
|
53
|
+
* ("lower the baseline"), so a fix cannot leave room for the next breach.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
interface StaticFinding {
|
|
57
|
+
file: string;
|
|
58
|
+
line: number;
|
|
59
|
+
what: string;
|
|
60
|
+
}
|
|
61
|
+
interface FileException {
|
|
62
|
+
file: string;
|
|
63
|
+
reason: string;
|
|
64
|
+
}
|
|
65
|
+
interface MeasuredRoute {
|
|
66
|
+
route: string;
|
|
67
|
+
viewport?: {
|
|
68
|
+
width: number;
|
|
69
|
+
height: number;
|
|
70
|
+
};
|
|
71
|
+
findings: RawPhoneFinding[];
|
|
72
|
+
}
|
|
73
|
+
interface Baseline {
|
|
74
|
+
/** Why the baseline exists and which issue burns it down. Free text, for a person. */
|
|
75
|
+
note?: string;
|
|
76
|
+
static: {
|
|
77
|
+
file: string;
|
|
78
|
+
count: number;
|
|
79
|
+
found?: string[];
|
|
80
|
+
}[];
|
|
81
|
+
measured: {
|
|
82
|
+
route: string;
|
|
83
|
+
check: PhoneCheck;
|
|
84
|
+
element: string;
|
|
85
|
+
count: number;
|
|
86
|
+
}[];
|
|
87
|
+
}
|
|
88
|
+
declare const EMPTY_BASELINE: Baseline;
|
|
89
|
+
/** The reason in a `mobile-first-ok: <reason>` comment, "" for the marker with no reason, null for none. */
|
|
90
|
+
declare function reasonOf(line: string | undefined): string | null;
|
|
91
|
+
/**
|
|
92
|
+
* Every desktop-first rule in the files that is not excused. An excuse is
|
|
93
|
+
* `mobile-first-ok: <reason>` on the line or the line above, or the whole file
|
|
94
|
+
* in `exceptions` with a reason. A marker with no reason is itself a finding.
|
|
95
|
+
*/
|
|
96
|
+
declare function scanPhoneFirst(files: SourceFile[], exceptions?: FileException[]): {
|
|
97
|
+
findings: StaticFinding[];
|
|
98
|
+
excused: number;
|
|
99
|
+
};
|
|
100
|
+
/** Problems with the exceptions themselves: no file, no reason, or a file that is not there. */
|
|
101
|
+
declare function checkExceptions(exceptions: unknown[], exists: (file: string) => boolean): string[];
|
|
102
|
+
/**
|
|
103
|
+
* Digits become `#`, so a date or an amount in fixture data does not make the
|
|
104
|
+
* same button a different one.
|
|
105
|
+
*/
|
|
106
|
+
declare const normaliseElement: (element: string) => string;
|
|
107
|
+
interface Judgement {
|
|
108
|
+
/** Breaches the baseline does not cover: the gate fails on these. */
|
|
109
|
+
fresh: string[];
|
|
110
|
+
/** Baseline lines that claim more than is there now: the gate fails on these too, so the baseline only shrinks. */
|
|
111
|
+
stale: string[];
|
|
112
|
+
/** How many breaches the baseline covers. Printed every run, so the debt stays in sight. */
|
|
113
|
+
known: number;
|
|
114
|
+
}
|
|
115
|
+
declare function judgeStatic(findings: StaticFinding[], baseline: Baseline): Judgement;
|
|
116
|
+
declare function judgeMeasured(routes: MeasuredRoute[], baseline: Baseline): Judgement;
|
|
117
|
+
/** The baseline that makes this run pass: what is there now, counted. */
|
|
118
|
+
declare function writeBaseline(findings: StaticFinding[], routes: MeasuredRoute[], note?: string): Baseline;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `@domandigital/craft/phone`: the phone-first gate for an app behind a
|
|
122
|
+
* sign-in, such as a client portal (DOM-775).
|
|
123
|
+
*
|
|
124
|
+
* A site is measured by create-site's measure.mjs, which can open any page.
|
|
125
|
+
* A portal's pages need a signed-in user and fixture data, which only the
|
|
126
|
+
* portal's own Playwright suite knows how to set up. So the portal's suite
|
|
127
|
+
* opens each key route at `PHONE` and calls `measurePhone`, which measures
|
|
128
|
+
* the page and writes what it found; `craft phone-first --measured <dir>`
|
|
129
|
+
* then judges every route against the repo's baseline in one place, with the
|
|
130
|
+
* static check for max-width rules.
|
|
131
|
+
*
|
|
132
|
+
* import { test } from "@playwright/test";
|
|
133
|
+
* import { PHONE, measurePhone } from "@domandigital/craft/phone";
|
|
134
|
+
*
|
|
135
|
+
* test.use(PHONE);
|
|
136
|
+
* for (const route of ["/portal", "/portal/invoices"]) {
|
|
137
|
+
* test(`phone first: ${route}`, async ({ page }) => {
|
|
138
|
+
* await page.goto(route);
|
|
139
|
+
* await measurePhone(page, { route });
|
|
140
|
+
* });
|
|
141
|
+
* }
|
|
142
|
+
*
|
|
143
|
+
* Nothing here imports Playwright: the page is whatever the caller drives.
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The phone every surface is designed at (STANDARD.md section 10): 375 x 812,
|
|
148
|
+
* touch, at 2x. Spread into Playwright's `test.use` or `browser.newContext`.
|
|
149
|
+
*/
|
|
150
|
+
declare const PHONE: {
|
|
151
|
+
readonly viewport: {
|
|
152
|
+
readonly width: 375;
|
|
153
|
+
readonly height: 812;
|
|
154
|
+
};
|
|
155
|
+
readonly deviceScaleFactor: 2;
|
|
156
|
+
readonly isMobile: true;
|
|
157
|
+
readonly hasTouch: true;
|
|
158
|
+
};
|
|
159
|
+
/** The house floors at phone width. Not per-repo settings. */
|
|
160
|
+
declare const PHONE_FLOORS: PhoneFloors;
|
|
161
|
+
/** Where `measurePhone` writes and `craft phone-first --measured` reads, unless told otherwise. */
|
|
162
|
+
declare const DEFAULT_MEASURED_DIR = "phone-first/measured";
|
|
163
|
+
interface MinimalPage {
|
|
164
|
+
evaluate<R, A>(fn: (arg: A) => R, arg: A): Promise<R>;
|
|
165
|
+
viewportSize?(): {
|
|
166
|
+
width: number;
|
|
167
|
+
height: number;
|
|
168
|
+
} | null;
|
|
169
|
+
}
|
|
170
|
+
interface MeasurePhoneOptions {
|
|
171
|
+
/** The route as the baseline names it, without host or query: `/portal/invoices`. */
|
|
172
|
+
route: string;
|
|
173
|
+
/** The folder to write `<route>.json` into. Default `phone-first/measured`, from the working directory. */
|
|
174
|
+
out?: string;
|
|
175
|
+
}
|
|
176
|
+
/** A route as a file name: `/portal/invoices` is `portal-invoices.json`, `/` is `home.json`. */
|
|
177
|
+
declare const routeFile: (route: string) => string;
|
|
178
|
+
/**
|
|
179
|
+
* Measure the page as it is now and write the result for `craft phone-first`.
|
|
180
|
+
* Waits for web fonts first, so text is measured in the face it is set in.
|
|
181
|
+
* Does not judge or throw on a finding: the baseline decides.
|
|
182
|
+
*/
|
|
183
|
+
declare function measurePhone(page: MinimalPage, options: MeasurePhoneOptions): Promise<MeasuredRoute>;
|
|
184
|
+
|
|
185
|
+
export { type Baseline, DEFAULT_MEASURED_DIR, EMPTY_BASELINE, type FileException, type Judgement, type MeasurePhoneOptions, type MeasuredRoute, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, type StaticFinding, checkExceptions, judgeMeasured, judgeStatic, measurePhone, measurePhoneInPage, normaliseElement, reasonOf, routeFile, scanPhoneFirst, writeBaseline };
|