@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.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// src/phone/index.ts
|
|
2
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
// src/phone/measure-in-page.ts
|
|
6
|
+
function measurePhoneInPage(floors) {
|
|
7
|
+
const out = [];
|
|
8
|
+
const vw = document.documentElement.clientWidth;
|
|
9
|
+
const shown = (e) => {
|
|
10
|
+
const s = getComputedStyle(e);
|
|
11
|
+
return s.display !== "none" && s.visibility !== "hidden" && e.getClientRects().length > 0;
|
|
12
|
+
};
|
|
13
|
+
const offPage = (e) => {
|
|
14
|
+
const r = e.getBoundingClientRect();
|
|
15
|
+
return r.right <= 0 || r.bottom + window.scrollY <= 0 || r.left >= vw;
|
|
16
|
+
};
|
|
17
|
+
const quiet = (e) => !!e.closest("[aria-hidden='true'], .sr-only, .visually-hidden, [hidden]") || offPage(e);
|
|
18
|
+
const describe = (e) => {
|
|
19
|
+
const text = (e.textContent ?? "").replace(/\s+/g, " ").trim() || e.getAttribute("aria-label") || e.getAttribute("name") || e.getAttribute("placeholder") || "";
|
|
20
|
+
return `<${e.tagName.toLowerCase()}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
|
|
21
|
+
};
|
|
22
|
+
if (vw > window.screen.width + 1) {
|
|
23
|
+
out.push({ check: "scroll", element: "<meta name=viewport>", detail: `the page lays out at ${vw}px on a ${window.screen.width}px phone: no width=device-width viewport meta` });
|
|
24
|
+
} else if (document.documentElement.scrollWidth > vw + 1) {
|
|
25
|
+
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);
|
|
26
|
+
out.push({ check: "scroll", element: "<html>", detail: `the page is ${document.documentElement.scrollWidth}px wide at ${vw}px${wide.length ? `; widest: ${wide.join(", ")}` : ""}` });
|
|
27
|
+
}
|
|
28
|
+
const inline = (e) => {
|
|
29
|
+
if (e.tagName !== "A" || getComputedStyle(e).display !== "inline") return false;
|
|
30
|
+
const block = e.parentElement?.closest("p, li, dd, td, th, blockquote, figcaption, address");
|
|
31
|
+
return !!block && (block.textContent ?? "").trim().length > (e.textContent ?? "").trim().length;
|
|
32
|
+
};
|
|
33
|
+
const targets = Array.from(document.querySelectorAll("a[href], button, input:not([type='hidden']), select, textarea, [role='button'], [role='tab'], [role='menuitem'], summary")).filter(shown);
|
|
34
|
+
for (const e of targets) {
|
|
35
|
+
const r = e.getBoundingClientRect();
|
|
36
|
+
if (quiet(e) || inline(e) || r.width <= 1 || r.height <= 1) continue;
|
|
37
|
+
const labels = "labels" in e && e.labels ? Array.from(e.labels).filter(shown) : [];
|
|
38
|
+
const boxes = [r, ...labels.map((l) => l.getBoundingClientRect())];
|
|
39
|
+
const w = Math.round(Math.max(...boxes.map((b) => b.width)));
|
|
40
|
+
const h = Math.round(Math.max(...boxes.map((b) => b.height)));
|
|
41
|
+
if (w < floors.targetPx || h < floors.targetPx) out.push({ check: "target", element: describe(e), detail: `${w}x${h}px, under ${floors.targetPx}x${floors.targetPx}` });
|
|
42
|
+
}
|
|
43
|
+
const NOT_TEXT = /* @__PURE__ */ new Set(["checkbox", "radio", "range", "color", "file", "submit", "reset", "button", "image"]);
|
|
44
|
+
for (const e of Array.from(document.querySelectorAll("input:not([type='hidden']), select, textarea"))) {
|
|
45
|
+
if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
|
|
46
|
+
const px = parseFloat(getComputedStyle(e).fontSize);
|
|
47
|
+
if (px < floors.fieldFontPx) out.push({ check: "field", element: describe(e), detail: `set at ${px}px, under ${floors.fieldFontPx}px, so Safari on iOS zooms on focus` });
|
|
48
|
+
}
|
|
49
|
+
for (const e of Array.from(document.body.querySelectorAll("*"))) {
|
|
50
|
+
if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
|
|
51
|
+
const own = Array.from(e.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
|
|
52
|
+
if (!own) continue;
|
|
53
|
+
const px = parseFloat(getComputedStyle(e).fontSize);
|
|
54
|
+
if (px < floors.textPx) out.push({ check: "text", element: `<${e.tagName.toLowerCase()}> "${own.slice(0, 40)}"`, detail: `${px}px, under ${floors.textPx}px` });
|
|
55
|
+
}
|
|
56
|
+
for (const e of Array.from(document.querySelectorAll("h1"))) {
|
|
57
|
+
if (!shown(e) || quiet(e)) continue;
|
|
58
|
+
const s = getComputedStyle(e);
|
|
59
|
+
const clipsX = e.scrollWidth > e.clientWidth + 1 && s.overflowX !== "visible";
|
|
60
|
+
const clipsY = e.scrollHeight > e.clientHeight + 1 && s.overflowY !== "visible";
|
|
61
|
+
if (clipsX || clipsY) out.push({ check: "title", element: describe(e), detail: "the page title is cut off" });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/phone/judge.ts
|
|
67
|
+
var EMPTY_BASELINE = { static: [], measured: [] };
|
|
68
|
+
var MEDIA = /@(?:custom-)?media\b([^{;]*)/g;
|
|
69
|
+
var CAPS_WIDTH = /\bmax-(?:device-)?width\s*:|\bwidth\s*<|>=?\s*(?:device-)?width\b/i;
|
|
70
|
+
var VARIANT = /(?<![\w@-])max-(\[[^\]\s]+\]|[a-z0-9]+):/g;
|
|
71
|
+
var NOT_VARIANTS = /* @__PURE__ */ new Set(["width", "height", "lines", "resolution", "zoom", "inline-size", "block-size"]);
|
|
72
|
+
var OK = /mobile-first-ok\b:?(.*)/;
|
|
73
|
+
function reasonOf(line) {
|
|
74
|
+
const m = line?.match(OK);
|
|
75
|
+
return m ? m[1].replace(/\*\/|-->|[{}]/g, "").trim() : null;
|
|
76
|
+
}
|
|
77
|
+
function scanPhoneFirst(files, exceptions = []) {
|
|
78
|
+
const excusedFiles = new Set(exceptions.filter((e) => e.reason?.trim()).map((e) => e.file));
|
|
79
|
+
const findings = [];
|
|
80
|
+
let excused = 0;
|
|
81
|
+
for (const { path, text } of files) {
|
|
82
|
+
if (excusedFiles.has(path)) continue;
|
|
83
|
+
const lines = text.split("\n");
|
|
84
|
+
const lineOf = (index) => text.slice(0, index).split("\n").length;
|
|
85
|
+
const found = [];
|
|
86
|
+
for (const m of text.matchAll(MEDIA)) {
|
|
87
|
+
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()}` });
|
|
88
|
+
}
|
|
89
|
+
for (const m of text.matchAll(VARIANT)) {
|
|
90
|
+
if (!NOT_VARIANTS.has(m[1])) found.push({ line: lineOf(m.index), what: m[0] });
|
|
91
|
+
}
|
|
92
|
+
for (const { line, what } of found.sort((a, b) => a.line - b.line)) {
|
|
93
|
+
const here = reasonOf(lines[line - 1]);
|
|
94
|
+
const above = reasonOf(lines[line - 2]);
|
|
95
|
+
if (here || above) {
|
|
96
|
+
excused++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
findings.push({ file: path, line, what: here === "" || above === "" ? `${what} (mobile-first-ok with no reason)` : what });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { findings, excused };
|
|
103
|
+
}
|
|
104
|
+
function checkExceptions(exceptions, exists) {
|
|
105
|
+
const problems = [];
|
|
106
|
+
exceptions.forEach((e, i) => {
|
|
107
|
+
const at = `exceptions[${i}]`;
|
|
108
|
+
const x = e;
|
|
109
|
+
if (typeof x?.file !== "string" || !x.file.trim()) problems.push(`${at}: names no file`);
|
|
110
|
+
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.`);
|
|
111
|
+
else if (!exists(x.file)) problems.push(`${at}: ${x.file} is not there. Remove the exception.`);
|
|
112
|
+
});
|
|
113
|
+
return problems;
|
|
114
|
+
}
|
|
115
|
+
var normaliseElement = (element) => element.replace(/\d+/g, "#");
|
|
116
|
+
var tally = (items, key) => {
|
|
117
|
+
const m = /* @__PURE__ */ new Map();
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const k = key(item);
|
|
120
|
+
const had = m.get(k);
|
|
121
|
+
if (had) had.count++;
|
|
122
|
+
else m.set(k, { item, count: 1 });
|
|
123
|
+
}
|
|
124
|
+
return m;
|
|
125
|
+
};
|
|
126
|
+
function judgeStatic(findings, baseline) {
|
|
127
|
+
const byFile = tally(findings, (f) => f.file);
|
|
128
|
+
const allowed = new Map(baseline.static.map((b) => [b.file, b.count]));
|
|
129
|
+
const fresh = [];
|
|
130
|
+
const stale = [];
|
|
131
|
+
let known = 0;
|
|
132
|
+
for (const [file, { count }] of byFile) {
|
|
133
|
+
const allow = allowed.get(file) ?? 0;
|
|
134
|
+
known += Math.min(count, allow);
|
|
135
|
+
if (count > allow) {
|
|
136
|
+
const here = findings.filter((f) => f.file === file);
|
|
137
|
+
const lead = allow ? `${file}: ${count} desktop-first rules, the baseline allows ${allow}. One of these is new:` : `${file}:`;
|
|
138
|
+
fresh.push(`${lead}
|
|
139
|
+
${here.map((f) => ` line ${f.line}: ${f.what}`).join("\n")}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const { file, count } of baseline.static) {
|
|
143
|
+
const now = byFile.get(file)?.count ?? 0;
|
|
144
|
+
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)"}.`);
|
|
145
|
+
}
|
|
146
|
+
return { fresh, stale, known };
|
|
147
|
+
}
|
|
148
|
+
var measuredKey = (route, check, element) => `${route}\0${check}\0${element}`;
|
|
149
|
+
function judgeMeasured(routes, baseline) {
|
|
150
|
+
const fresh = [];
|
|
151
|
+
const stale = [];
|
|
152
|
+
let known = 0;
|
|
153
|
+
const allowed = new Map(baseline.measured.map((b) => [measuredKey(b.route, b.check, b.element), b.count]));
|
|
154
|
+
const seen = /* @__PURE__ */ new Map();
|
|
155
|
+
const measured = new Set(routes.map((r) => r.route));
|
|
156
|
+
for (const { route, findings } of routes) {
|
|
157
|
+
const counted = tally(findings, (f) => measuredKey(route, f.check, normaliseElement(f.element)));
|
|
158
|
+
for (const [key, { item, count }] of counted) {
|
|
159
|
+
seen.set(key, count);
|
|
160
|
+
const allow = allowed.get(key) ?? 0;
|
|
161
|
+
known += Math.min(count, allow);
|
|
162
|
+
if (count > allow) {
|
|
163
|
+
const times = count - allow > 1 ? ` (${count - allow} more than the baseline's ${allow})` : allow ? ` (the baseline allows ${allow})` : "";
|
|
164
|
+
fresh.push(`${route}: ${LABEL[item.check]} ${item.element}: ${item.detail}${times}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const missing = /* @__PURE__ */ new Set();
|
|
169
|
+
for (const b of baseline.measured) {
|
|
170
|
+
const key = measuredKey(b.route, b.check, b.element);
|
|
171
|
+
if (!measured.has(b.route)) {
|
|
172
|
+
if (!missing.has(b.route)) stale.push(`${b.route}: in the baseline but not measured this run. Measure it, or remove its lines.`);
|
|
173
|
+
missing.add(b.route);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const now = seen.get(key) ?? 0;
|
|
177
|
+
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)"}.`);
|
|
178
|
+
}
|
|
179
|
+
return { fresh, stale, known };
|
|
180
|
+
}
|
|
181
|
+
var LABEL = {
|
|
182
|
+
scroll: "scrolls sideways",
|
|
183
|
+
target: "small target",
|
|
184
|
+
field: "small field",
|
|
185
|
+
text: "small text",
|
|
186
|
+
title: "title cut off"
|
|
187
|
+
};
|
|
188
|
+
function writeBaseline(findings, routes, note) {
|
|
189
|
+
const byFile = tally(findings, (f) => f.file);
|
|
190
|
+
const measured = routes.flatMap(
|
|
191
|
+
({ 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 }))
|
|
192
|
+
);
|
|
193
|
+
return {
|
|
194
|
+
...note ? { note } : {},
|
|
195
|
+
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)),
|
|
196
|
+
measured: measured.sort((a, b) => a.route.localeCompare(b.route) || a.check.localeCompare(b.check) || a.element.localeCompare(b.element))
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/phone/index.ts
|
|
201
|
+
var PHONE = {
|
|
202
|
+
viewport: { width: 375, height: 812 },
|
|
203
|
+
deviceScaleFactor: 2,
|
|
204
|
+
isMobile: true,
|
|
205
|
+
hasTouch: true
|
|
206
|
+
};
|
|
207
|
+
var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
|
|
208
|
+
var DEFAULT_MEASURED_DIR = "phone-first/measured";
|
|
209
|
+
var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
|
|
210
|
+
async function measurePhone(page, options) {
|
|
211
|
+
if (!options.route.startsWith("/")) throw new Error(`measurePhone: route "${options.route}" should start with /`);
|
|
212
|
+
await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
|
|
213
|
+
const findings = await page.evaluate(measurePhoneInPage, PHONE_FLOORS);
|
|
214
|
+
const viewport = page.viewportSize?.() ?? void 0;
|
|
215
|
+
const result = { route: options.route, ...viewport ? { viewport } : {}, findings };
|
|
216
|
+
const dir = options.out ?? DEFAULT_MEASURED_DIR;
|
|
217
|
+
mkdirSync(dir, { recursive: true });
|
|
218
|
+
writeFileSync(join(dir, routeFile(options.route)), `${JSON.stringify(result, null, 1)}
|
|
219
|
+
`);
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
export {
|
|
223
|
+
DEFAULT_MEASURED_DIR,
|
|
224
|
+
EMPTY_BASELINE,
|
|
225
|
+
PHONE,
|
|
226
|
+
PHONE_FLOORS,
|
|
227
|
+
checkExceptions,
|
|
228
|
+
judgeMeasured,
|
|
229
|
+
judgeStatic,
|
|
230
|
+
measurePhone,
|
|
231
|
+
measurePhoneInPage,
|
|
232
|
+
normaliseElement,
|
|
233
|
+
reasonOf,
|
|
234
|
+
routeFile,
|
|
235
|
+
scanPhoneFirst,
|
|
236
|
+
writeBaseline
|
|
237
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domandigital/craft",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The Doman Digital house craft standard as numbers: OKLCh colour ramps that snap to existing anchors, derived semantic tokens with measured WCAG and APCA contrast, one shared motion vocabulary for CSS and JS, and a base stylesheet. Zero dependencies.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
"default": "./dist/audit.cjs"
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
|
+
"./phone": {
|
|
39
|
+
"import": {
|
|
40
|
+
"types": "./dist/phone.d.ts",
|
|
41
|
+
"default": "./dist/phone.js"
|
|
42
|
+
},
|
|
43
|
+
"require": {
|
|
44
|
+
"types": "./dist/phone.d.cts",
|
|
45
|
+
"default": "./dist/phone.cjs"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
38
48
|
"./package.json": "./package.json",
|
|
39
49
|
"./craft.css": "./css/craft.css",
|
|
40
50
|
"./craft.tailwind.css": "./css/craft.tailwind.css",
|