@domandigital/craft 0.20.5 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/CHARACTER.md +2 -1
- package/COPY.md +6 -2
- package/README.md +73 -3
- package/dist/{audit-5I2WWTL6.js → audit-LQ2TDISV.js} +3 -3
- package/dist/audit.cjs +64 -1
- package/dist/audit.js +64 -1
- package/dist/{chunk-TSO5W32N.js → chunk-4WAIOZ4W.js} +14 -14
- package/dist/{chunk-YFS32BZ2.js → chunk-6DRBFDWT.js} +1 -1
- package/dist/chunk-BQ7H23QP.js +261 -0
- package/dist/{chunk-DMAMGFV7.js → chunk-BYXDX66D.js} +4 -4
- package/dist/{chunk-PFVSKIPM.js → chunk-DE2ZAV4G.js} +4 -2
- package/dist/{chunk-BBJOLR3R.js → chunk-FNB3NP57.js} +5 -5
- package/dist/{chunk-GJY2VMPD.js → chunk-G47ZCBD5.js} +2 -2
- package/dist/{chunk-FBWBDBW4.js → chunk-ONA73THJ.js} +61 -0
- package/dist/{chunk-N32UUN3S.js → chunk-STWH5EYU.js} +5 -5
- package/dist/{cli-SQC27VYC.js → cli-5QI6NIZN.js} +6 -6
- package/dist/{cli-BI6XCMCL.js → cli-5VHX5EJF.js} +5 -5
- package/dist/{cli-NIH3WTNE.js → cli-7QL26YPR.js} +1 -1
- package/dist/cli-EYHRJJAX.js +241 -0
- package/dist/{cli-V4QXTBWR.js → cli-PBCU7Z64.js} +4 -4
- package/dist/{cli-JQYZXFRH.js → cli-RHDNHL4U.js} +5 -5
- package/dist/{cli-OH6427MC.js → cli-XAWJWK7I.js} +6 -6
- package/dist/{cli-W6FNSHRN.js → cli-YSGPGQ7R.js} +9 -9
- package/dist/cli.js +3 -3
- package/dist/index.cjs +64 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +64 -1
- package/dist/phone.cjs +137 -11
- package/dist/phone.d.cts +86 -12
- package/dist/phone.d.ts +86 -12
- package/dist/phone.js +128 -11
- package/dist/{viewports-cli-C4DWICYI.js → viewports-cli-XYYX4SKV.js} +12 -8
- package/package.json +1 -1
- package/word-lists.json +1 -1
- package/dist/chunk-IJUZ7RV4.js +0 -151
- package/dist/cli-56H5IPWT.js +0 -122
package/dist/phone.js
CHANGED
|
@@ -9,13 +9,14 @@ var PHONE = {
|
|
|
9
9
|
isMobile: true,
|
|
10
10
|
hasTouch: true
|
|
11
11
|
};
|
|
12
|
-
var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
|
|
12
|
+
var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12, fieldHeightPx: 48 };
|
|
13
13
|
var DEFAULT_MEASURED_DIR = "phone-first/measured";
|
|
14
14
|
var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
|
|
15
15
|
|
|
16
16
|
// src/phone/measure-in-page.ts
|
|
17
17
|
function measurePhoneInPage(floors) {
|
|
18
18
|
const out = [];
|
|
19
|
+
const fieldHeightPx = floors.fieldHeightPx ?? 48;
|
|
19
20
|
const only = floors.only;
|
|
20
21
|
const on = (c) => !only || only.includes(c);
|
|
21
22
|
const vw = document.documentElement.clientWidth;
|
|
@@ -59,6 +60,9 @@ function measurePhoneInPage(floors) {
|
|
|
59
60
|
if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
|
|
60
61
|
const px = parseFloat(getComputedStyle(e).fontSize);
|
|
61
62
|
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` });
|
|
63
|
+
const box = e.getBoundingClientRect();
|
|
64
|
+
const height = Math.round(box.height);
|
|
65
|
+
if (box.width > 1 && height > 1 && height < fieldHeightPx) out.push({ check: "field", element: describe(e), detail: `${height}px tall, under ${fieldHeightPx}px (use min-h-12)` });
|
|
62
66
|
}
|
|
63
67
|
for (const e of on("text") ? Array.from(document.body.querySelectorAll("*")) : []) {
|
|
64
68
|
if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
|
|
@@ -366,17 +370,26 @@ function measureViewportInPage(arg) {
|
|
|
366
370
|
}
|
|
367
371
|
|
|
368
372
|
// src/phone/judge.ts
|
|
373
|
+
var hasReason = (entry) => typeof entry.reason === "string" && entry.reason.trim() !== "";
|
|
374
|
+
function parseBaseline(raw, where) {
|
|
375
|
+
const b = raw ?? {};
|
|
376
|
+
const entries = [...b.static ?? [], ...b.measured ?? []];
|
|
377
|
+
const bad = entries.find((e) => e.reason !== void 0 && typeof e.reason !== "string");
|
|
378
|
+
if (bad) throw new Error(`${where}: an entry has a "reason" that is not text: ${JSON.stringify(bad)}`);
|
|
379
|
+
return { ...b.note ? { note: b.note } : {}, static: b.static ?? [], measured: b.measured ?? [] };
|
|
380
|
+
}
|
|
369
381
|
var EMPTY_BASELINE = { static: [], measured: [] };
|
|
370
382
|
var MEDIA = /@(?:custom-)?media\b([^{;]*)/g;
|
|
371
383
|
var CAPS_WIDTH = /\bmax-(?:device-)?width\s*:|\bwidth\s*<|>=?\s*(?:device-)?width\b/i;
|
|
372
384
|
var VARIANT = /(?<![\w@-])max-(\[[^\]\s]+\]|[a-z0-9]+):/g;
|
|
373
385
|
var NOT_VARIANTS = /* @__PURE__ */ new Set(["width", "height", "lines", "resolution", "zoom", "inline-size", "block-size"]);
|
|
374
386
|
var OK = /mobile-first-ok\b:?(.*)/;
|
|
387
|
+
var VAR_OPACITY = /[^\s"'`]*var\([^)]*\)\]\/[0-9]+/g;
|
|
375
388
|
function reasonOf(line) {
|
|
376
389
|
const m = line?.match(OK);
|
|
377
390
|
return m ? m[1].replace(/\*\/|-->|[{}]/g, "").trim() : null;
|
|
378
391
|
}
|
|
379
|
-
function scanPhoneFirst(files, exceptions = []) {
|
|
392
|
+
function scanPhoneFirst(files, exceptions = [], options = {}) {
|
|
380
393
|
const excusedFiles = new Set(exceptions.filter((e) => e.reason?.trim()).map((e) => e.file));
|
|
381
394
|
const findings = [];
|
|
382
395
|
let excused = 0;
|
|
@@ -391,7 +404,18 @@ function scanPhoneFirst(files, exceptions = []) {
|
|
|
391
404
|
for (const m of text.matchAll(VARIANT)) {
|
|
392
405
|
if (!NOT_VARIANTS.has(m[1])) found.push({ line: lineOf(m.index), what: m[0] });
|
|
393
406
|
}
|
|
394
|
-
|
|
407
|
+
const silent = /* @__PURE__ */ new Set();
|
|
408
|
+
if (options.tailwind3?.(path)) {
|
|
409
|
+
for (const m of text.matchAll(VAR_OPACITY)) {
|
|
410
|
+
silent.add(found.push({ line: lineOf(m.index), what: `${m[0]} (Tailwind 3 emits no CSS for opacity on a var() colour)` }) - 1);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const order = found.map((f, i) => ({ ...f, silent: silent.has(i) })).sort((a, b) => a.line - b.line);
|
|
414
|
+
for (const { line, what, silent: cannotExcuse } of order) {
|
|
415
|
+
if (cannotExcuse) {
|
|
416
|
+
findings.push({ file: path, line, what });
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
395
419
|
const here = reasonOf(lines[line - 1]);
|
|
396
420
|
const above = reasonOf(lines[line - 2]);
|
|
397
421
|
if (here || above) {
|
|
@@ -496,17 +520,101 @@ var LABEL = {
|
|
|
496
520
|
tel: "phone number is not a link",
|
|
497
521
|
linelength: "line too long"
|
|
498
522
|
};
|
|
499
|
-
function writeBaseline(findings, routes, note) {
|
|
523
|
+
function writeBaseline(findings, routes, note, previous) {
|
|
500
524
|
const byFile = tally(findings, (f) => f.file);
|
|
501
525
|
const measured = routes.flatMap(
|
|
502
526
|
({ 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 }))
|
|
503
527
|
);
|
|
528
|
+
const staticReasons = new Map((previous?.static ?? []).filter(hasReason).map((b) => [b.file, b.reason]));
|
|
529
|
+
const measuredReasons = new Map((previous?.measured ?? []).filter(hasReason).map((b) => [measuredKey(b.route, b.check, b.element), b.reason]));
|
|
504
530
|
return {
|
|
505
531
|
...note ? { note } : {},
|
|
506
|
-
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)),
|
|
507
|
-
measured: measured.sort((a, b) => a.route.localeCompare(b.route) || a.check.localeCompare(b.check) || a.element.localeCompare(b.element))
|
|
532
|
+
static: [...byFile.values()].map(({ item, count }) => ({ file: item.file, count, found: findings.filter((f) => f.file === item.file).map((f) => f.what), ...staticReasons.has(item.file) ? { reason: staticReasons.get(item.file) } : {} })).sort((a, b) => a.file.localeCompare(b.file)),
|
|
533
|
+
measured: measured.map((m) => measuredReasons.has(measuredKey(m.route, m.check, m.element)) ? { ...m, reason: measuredReasons.get(measuredKey(m.route, m.check, m.element)) } : m).sort((a, b) => a.route.localeCompare(b.route) || a.check.localeCompare(b.check) || a.element.localeCompare(b.element))
|
|
508
534
|
};
|
|
509
535
|
}
|
|
536
|
+
function judgeGrowth(current, base, baseName = "main") {
|
|
537
|
+
const out = [];
|
|
538
|
+
const baseStatic = new Map(base.static.map((b) => [b.file, b.count]));
|
|
539
|
+
for (const b of current.static) {
|
|
540
|
+
const was = baseStatic.get(b.file) ?? 0;
|
|
541
|
+
if (b.count > was && !hasReason(b)) out.push(`${b.file}: ${was ? `${b.count} desktop-first rules, ${baseName} allows ${was}` : `${b.count} desktop-first rule${b.count === 1 ? "" : "s"} added to the baseline`}`);
|
|
542
|
+
}
|
|
543
|
+
const baseMeasured = new Map(base.measured.map((b) => [measuredKey(b.route, b.check, b.element), b.count]));
|
|
544
|
+
for (const b of current.measured) {
|
|
545
|
+
const was = baseMeasured.get(measuredKey(b.route, b.check, b.element)) ?? 0;
|
|
546
|
+
if (b.count > was && !hasReason(b)) out.push(`${b.route}: ${LABEL[b.check]} ${b.element}: ${was ? `${b.count} in the baseline, ${baseName} allows ${was}` : `added to the baseline (${b.count})`}`);
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
var hasUnexcusedEntries = (b) => [...b.static, ...b.measured].some((e) => !hasReason(e));
|
|
551
|
+
var PAGE_FILE = /(?:^|\/)page\.(?:tsx|ts|jsx|js|mdx)$/;
|
|
552
|
+
var INTERCEPTING = /^\(\.{1,3}\)/;
|
|
553
|
+
function routesFromPages(files) {
|
|
554
|
+
const routes = /* @__PURE__ */ new Set();
|
|
555
|
+
for (const file of files) {
|
|
556
|
+
const rel = file.replace(/\\/g, "/");
|
|
557
|
+
if (!PAGE_FILE.test(rel)) continue;
|
|
558
|
+
const dirs = rel.split("/").slice(0, -1);
|
|
559
|
+
if (dirs.some((d) => INTERCEPTING.test(d) || d.startsWith("_"))) continue;
|
|
560
|
+
const url = dirs.filter((d) => !/^\(.*\)$/.test(d) && !d.startsWith("@"));
|
|
561
|
+
routes.add(`/${url.join("/")}`);
|
|
562
|
+
}
|
|
563
|
+
return [...routes].sort();
|
|
564
|
+
}
|
|
565
|
+
function routesFromManifest(manifest) {
|
|
566
|
+
const routes = /* @__PURE__ */ new Set();
|
|
567
|
+
for (const [key, route] of Object.entries(manifest ?? {})) {
|
|
568
|
+
if (!/(?:^|\/)page$/.test(key) || typeof route !== "string") continue;
|
|
569
|
+
if (route.split("/").some((d) => d.startsWith("_"))) continue;
|
|
570
|
+
routes.add(route);
|
|
571
|
+
}
|
|
572
|
+
return [...routes].sort();
|
|
573
|
+
}
|
|
574
|
+
var segmentsOf = (route) => route.split("/").filter(Boolean);
|
|
575
|
+
var rank = (seg) => seg.startsWith("[[...") ? 3 : seg.startsWith("[...") ? 2 : seg.startsWith("[") ? 1 : 0;
|
|
576
|
+
var cleanRoute = (route) => {
|
|
577
|
+
const path = route.replace(/[?#].*$/, "").replace(/\/+$/, "");
|
|
578
|
+
return path === "" ? "/" : path;
|
|
579
|
+
};
|
|
580
|
+
function routeMatches(pattern, route) {
|
|
581
|
+
if (pattern === route) return true;
|
|
582
|
+
const want = segmentsOf(pattern);
|
|
583
|
+
const have = segmentsOf(route);
|
|
584
|
+
for (let i = 0; i < want.length; i++) {
|
|
585
|
+
const seg = want[i];
|
|
586
|
+
if (seg.startsWith("[[...")) return true;
|
|
587
|
+
if (seg.startsWith("[...")) return have.length > i;
|
|
588
|
+
if (have[i] === void 0) return false;
|
|
589
|
+
if (!seg.startsWith("[") && seg !== have[i]) return false;
|
|
590
|
+
}
|
|
591
|
+
return want.length === have.length;
|
|
592
|
+
}
|
|
593
|
+
var moreSpecific = (a, b) => {
|
|
594
|
+
const x = segmentsOf(a).map(rank);
|
|
595
|
+
const y = segmentsOf(b).map(rank);
|
|
596
|
+
for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] < y[i];
|
|
597
|
+
return x.length > y.length;
|
|
598
|
+
};
|
|
599
|
+
var excludes = (entry, page) => entry.endsWith("/*") ? page === entry.slice(0, -2) || page.startsWith(entry.slice(0, -1)) : entry === page;
|
|
600
|
+
function judgeRoutes(pages, measuredRoutes, excluded = []) {
|
|
601
|
+
const problems = [];
|
|
602
|
+
excluded.forEach((e, i) => {
|
|
603
|
+
const at = `excludeRoutes[${i}]`;
|
|
604
|
+
const x = e;
|
|
605
|
+
if (typeof x?.route !== "string" || !x.route.startsWith("/")) problems.push(`${at}: names no route (start it with /)`);
|
|
606
|
+
else if (typeof x.reason !== "string" || !x.reason.trim()) problems.push(`${at} (${x.route}): no reason. Say why the gate cannot measure this page, or remove the entry.`);
|
|
607
|
+
else if (!pages.some((p) => excludes(x.route, p))) problems.push(`${at}: ${x.route} is not a page route of the app. Remove the entry.`);
|
|
608
|
+
});
|
|
609
|
+
const named = excluded.filter((e) => typeof e?.route === "string" && typeof e.reason === "string" && e.reason.trim());
|
|
610
|
+
const covered = /* @__PURE__ */ new Set();
|
|
611
|
+
for (const route of measuredRoutes.map(cleanRoute)) {
|
|
612
|
+
const hit = pages.filter((p) => routeMatches(p, route)).sort((a, b) => moreSpecific(a, b) ? -1 : moreSpecific(b, a) ? 1 : 0)[0];
|
|
613
|
+
if (hit) covered.add(hit);
|
|
614
|
+
}
|
|
615
|
+
for (const page of pages) if (named.some((e) => excludes(e.route, page))) covered.add(page);
|
|
616
|
+
return { missing: pages.filter((p) => !covered.has(p)), problems, covered: covered.size };
|
|
617
|
+
}
|
|
510
618
|
|
|
511
619
|
// src/phone/viewports.ts
|
|
512
620
|
var BANDS = [
|
|
@@ -550,16 +658,16 @@ var DEFAULT_THRESHOLDS = {
|
|
|
550
658
|
var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
|
|
551
659
|
function formatWidths(failing, tested) {
|
|
552
660
|
const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
|
|
553
|
-
const
|
|
661
|
+
const rank2 = new Map(plain.map((name, i) => [name, i]));
|
|
554
662
|
const names = [...new Set(failing)];
|
|
555
663
|
const runs = [];
|
|
556
|
-
for (const name of names.filter((n) =>
|
|
664
|
+
for (const name of names.filter((n) => rank2.has(n)).sort((a, b) => rank2.get(a) - rank2.get(b))) {
|
|
557
665
|
const last = runs.at(-1);
|
|
558
|
-
if (last &&
|
|
666
|
+
if (last && rank2.get(name) === rank2.get(last.at(-1)) + 1) last.push(name);
|
|
559
667
|
else runs.push([name]);
|
|
560
668
|
}
|
|
561
669
|
const out = runs.map((r) => r.length > 1 ? `${r[0]}-${r.at(-1)}` : r[0]);
|
|
562
|
-
return [...out, ...names.filter((n) => !
|
|
670
|
+
return [...out, ...names.filter((n) => !rank2.has(n))].join(", ");
|
|
563
671
|
}
|
|
564
672
|
var keyOf = (f) => `${f.check}\0${f.element.replace(/("[^]*)$/, (t) => t.replace(/\d+/g, "#"))}`;
|
|
565
673
|
function mergeViewportFindings(perViewport, tested) {
|
|
@@ -724,7 +832,7 @@ async function measureSite(browser, options) {
|
|
|
724
832
|
async function measurePhone(page, options) {
|
|
725
833
|
if (!options.route.startsWith("/")) throw new Error(`measurePhone: route "${options.route}" should start with /`);
|
|
726
834
|
await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
|
|
727
|
-
const findings = await page.evaluate(measurePhoneInPage, PHONE_FLOORS);
|
|
835
|
+
const findings = await page.evaluate(measurePhoneInPage, { ...PHONE_FLOORS, ...options.floors ?? {} });
|
|
728
836
|
const viewport = page.viewportSize?.() ?? void 0;
|
|
729
837
|
const result = { route: options.route, ...viewport ? { viewport } : {}, findings };
|
|
730
838
|
const dir = options.out ?? DEFAULT_MEASURED_DIR;
|
|
@@ -747,8 +855,13 @@ export {
|
|
|
747
855
|
WIDE_WIDTHS,
|
|
748
856
|
bandOf,
|
|
749
857
|
checkExceptions,
|
|
858
|
+
cleanRoute,
|
|
750
859
|
formatWidths,
|
|
860
|
+
hasReason,
|
|
861
|
+
hasUnexcusedEntries,
|
|
862
|
+
judgeGrowth,
|
|
751
863
|
judgeMeasured,
|
|
864
|
+
judgeRoutes,
|
|
752
865
|
judgeStatic,
|
|
753
866
|
mainActionFor,
|
|
754
867
|
measurePhone,
|
|
@@ -759,8 +872,12 @@ export {
|
|
|
759
872
|
measureViewports,
|
|
760
873
|
mergeViewportFindings,
|
|
761
874
|
normaliseElement,
|
|
875
|
+
parseBaseline,
|
|
762
876
|
reasonOf,
|
|
763
877
|
routeFile,
|
|
878
|
+
routeMatches,
|
|
879
|
+
routesFromManifest,
|
|
880
|
+
routesFromPages,
|
|
764
881
|
scanPhoneFirst,
|
|
765
882
|
sweepWidths,
|
|
766
883
|
writeBaseline
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
2
|
EMPTY_BASELINE,
|
|
3
3
|
judgeMeasured,
|
|
4
|
+
parseBaseline,
|
|
4
5
|
writeBaseline
|
|
5
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-BQ7H23QP.js";
|
|
6
7
|
import {
|
|
7
8
|
loadPlaywright
|
|
8
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-6DRBFDWT.js";
|
|
9
10
|
import "./chunk-JYNXGCYZ.js";
|
|
10
11
|
import {
|
|
11
12
|
toJson
|
|
12
13
|
} from "./chunk-QIEDBL4J.js";
|
|
13
|
-
import "./chunk-
|
|
14
|
-
import "./chunk-
|
|
14
|
+
import "./chunk-DE2ZAV4G.js";
|
|
15
|
+
import "./chunk-ONA73THJ.js";
|
|
15
16
|
import "./chunk-QYBAVU3P.js";
|
|
16
17
|
|
|
17
18
|
// src/phone/viewports-cli.ts
|
|
@@ -25,7 +26,7 @@ var PHONE = {
|
|
|
25
26
|
isMobile: true,
|
|
26
27
|
hasTouch: true
|
|
27
28
|
};
|
|
28
|
-
var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
|
|
29
|
+
var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12, fieldHeightPx: 48 };
|
|
29
30
|
var DEFAULT_MEASURED_DIR = "phone-first/measured";
|
|
30
31
|
var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
|
|
31
32
|
|
|
@@ -36,6 +37,7 @@ import { join } from "path";
|
|
|
36
37
|
// src/phone/measure-in-page.ts
|
|
37
38
|
function measurePhoneInPage(floors) {
|
|
38
39
|
const out = [];
|
|
40
|
+
const fieldHeightPx = floors.fieldHeightPx ?? 48;
|
|
39
41
|
const only = floors.only;
|
|
40
42
|
const on = (c) => !only || only.includes(c);
|
|
41
43
|
const vw = document.documentElement.clientWidth;
|
|
@@ -79,6 +81,9 @@ function measurePhoneInPage(floors) {
|
|
|
79
81
|
if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
|
|
80
82
|
const px = parseFloat(getComputedStyle(e).fontSize);
|
|
81
83
|
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` });
|
|
84
|
+
const box = e.getBoundingClientRect();
|
|
85
|
+
const height = Math.round(box.height);
|
|
86
|
+
if (box.width > 1 && height > 1 && height < fieldHeightPx) out.push({ check: "field", element: describe(e), detail: `${height}px tall, under ${fieldHeightPx}px (use min-h-12)` });
|
|
82
87
|
}
|
|
83
88
|
for (const e of on("text") ? Array.from(document.body.querySelectorAll("*")) : []) {
|
|
84
89
|
if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
|
|
@@ -596,8 +601,7 @@ function loadConfig(path, cwd) {
|
|
|
596
601
|
}
|
|
597
602
|
function loadBaseline(path) {
|
|
598
603
|
if (!existsSync(path)) return EMPTY_BASELINE;
|
|
599
|
-
|
|
600
|
-
return { ...b.note ? { note: b.note } : {}, static: b.static ?? [], measured: b.measured ?? [] };
|
|
604
|
+
return parseBaseline(JSON.parse(readFileSync(path, "utf8")), path);
|
|
601
605
|
}
|
|
602
606
|
async function runViewports(args, io) {
|
|
603
607
|
const flags = parse(args);
|
|
@@ -642,7 +646,7 @@ ${USAGE}`);
|
|
|
642
646
|
const baseline = loadBaseline(baselinePath);
|
|
643
647
|
const measuredRoutes = new Set(site.routes.map((r) => r.route));
|
|
644
648
|
if (flags.update) {
|
|
645
|
-
const next = writeBaseline([], site.routes, baseline.note);
|
|
649
|
+
const next = writeBaseline([], site.routes, baseline.note, baseline);
|
|
646
650
|
next.static = baseline.static;
|
|
647
651
|
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));
|
|
648
652
|
writeFileSync2(baselinePath, `${JSON.stringify(next, null, 2)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domandigital/craft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
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",
|
package/word-lists.json
CHANGED
package/dist/chunk-IJUZ7RV4.js
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
// src/phone/judge.ts
|
|
2
|
-
var EMPTY_BASELINE = { static: [], measured: [] };
|
|
3
|
-
var MEDIA = /@(?:custom-)?media\b([^{;]*)/g;
|
|
4
|
-
var CAPS_WIDTH = /\bmax-(?:device-)?width\s*:|\bwidth\s*<|>=?\s*(?:device-)?width\b/i;
|
|
5
|
-
var VARIANT = /(?<![\w@-])max-(\[[^\]\s]+\]|[a-z0-9]+):/g;
|
|
6
|
-
var NOT_VARIANTS = /* @__PURE__ */ new Set(["width", "height", "lines", "resolution", "zoom", "inline-size", "block-size"]);
|
|
7
|
-
var OK = /mobile-first-ok\b:?(.*)/;
|
|
8
|
-
function reasonOf(line) {
|
|
9
|
-
const m = line?.match(OK);
|
|
10
|
-
return m ? m[1].replace(/\*\/|-->|[{}]/g, "").trim() : null;
|
|
11
|
-
}
|
|
12
|
-
function scanPhoneFirst(files, exceptions = []) {
|
|
13
|
-
const excusedFiles = new Set(exceptions.filter((e) => e.reason?.trim()).map((e) => e.file));
|
|
14
|
-
const findings = [];
|
|
15
|
-
let excused = 0;
|
|
16
|
-
for (const { path, text } of files) {
|
|
17
|
-
if (excusedFiles.has(path)) continue;
|
|
18
|
-
const lines = text.split("\n");
|
|
19
|
-
const lineOf = (index) => text.slice(0, index).split("\n").length;
|
|
20
|
-
const found = [];
|
|
21
|
-
for (const m of text.matchAll(MEDIA)) {
|
|
22
|
-
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()}` });
|
|
23
|
-
}
|
|
24
|
-
for (const m of text.matchAll(VARIANT)) {
|
|
25
|
-
if (!NOT_VARIANTS.has(m[1])) found.push({ line: lineOf(m.index), what: m[0] });
|
|
26
|
-
}
|
|
27
|
-
for (const { line, what } of found.sort((a, b) => a.line - b.line)) {
|
|
28
|
-
const here = reasonOf(lines[line - 1]);
|
|
29
|
-
const above = reasonOf(lines[line - 2]);
|
|
30
|
-
if (here || above) {
|
|
31
|
-
excused++;
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
findings.push({ file: path, line, what: here === "" || above === "" ? `${what} (mobile-first-ok with no reason)` : what });
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return { findings, excused };
|
|
38
|
-
}
|
|
39
|
-
function checkExceptions(exceptions, exists) {
|
|
40
|
-
const problems = [];
|
|
41
|
-
exceptions.forEach((e, i) => {
|
|
42
|
-
const at = `exceptions[${i}]`;
|
|
43
|
-
const x = e;
|
|
44
|
-
if (typeof x?.file !== "string" || !x.file.trim()) problems.push(`${at}: names no file`);
|
|
45
|
-
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.`);
|
|
46
|
-
else if (!exists(x.file)) problems.push(`${at}: ${x.file} is not there. Remove the exception.`);
|
|
47
|
-
});
|
|
48
|
-
return problems;
|
|
49
|
-
}
|
|
50
|
-
var normaliseElement = (element) => element.replace(/("[^]*)$/, (text) => text.replace(/\d+/g, "#"));
|
|
51
|
-
var tally = (items, key) => {
|
|
52
|
-
const m = /* @__PURE__ */ new Map();
|
|
53
|
-
for (const item of items) {
|
|
54
|
-
const k = key(item);
|
|
55
|
-
const had = m.get(k);
|
|
56
|
-
if (had) had.count++;
|
|
57
|
-
else m.set(k, { item, count: 1 });
|
|
58
|
-
}
|
|
59
|
-
return m;
|
|
60
|
-
};
|
|
61
|
-
function judgeStatic(findings, baseline) {
|
|
62
|
-
const byFile = tally(findings, (f) => f.file);
|
|
63
|
-
const allowed = new Map(baseline.static.map((b) => [b.file, b.count]));
|
|
64
|
-
const fresh = [];
|
|
65
|
-
const stale = [];
|
|
66
|
-
let known = 0;
|
|
67
|
-
for (const [file, { count }] of byFile) {
|
|
68
|
-
const allow = allowed.get(file) ?? 0;
|
|
69
|
-
known += Math.min(count, allow);
|
|
70
|
-
if (count > allow) {
|
|
71
|
-
const here = findings.filter((f) => f.file === file);
|
|
72
|
-
const lead = allow ? `${file}: ${count} desktop-first rules, the baseline allows ${allow}. One of these is new:` : `${file}:`;
|
|
73
|
-
fresh.push(`${lead}
|
|
74
|
-
${here.map((f) => ` line ${f.line}: ${f.what}`).join("\n")}`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
for (const { file, count } of baseline.static) {
|
|
78
|
-
const now = byFile.get(file)?.count ?? 0;
|
|
79
|
-
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)"}.`);
|
|
80
|
-
}
|
|
81
|
-
return { fresh, stale, known };
|
|
82
|
-
}
|
|
83
|
-
var measuredKey = (route, check, element) => `${route}\0${check}\0${element}`;
|
|
84
|
-
function judgeMeasured(routes, baseline) {
|
|
85
|
-
const fresh = [];
|
|
86
|
-
const stale = [];
|
|
87
|
-
let known = 0;
|
|
88
|
-
const allowed = new Map(baseline.measured.map((b) => [measuredKey(b.route, b.check, b.element), b.count]));
|
|
89
|
-
const seen = /* @__PURE__ */ new Map();
|
|
90
|
-
const measured = new Set(routes.map((r) => r.route));
|
|
91
|
-
for (const { route, findings } of routes) {
|
|
92
|
-
const counted = tally(findings, (f) => measuredKey(route, f.check, normaliseElement(f.element)));
|
|
93
|
-
for (const [key, { item, count }] of counted) {
|
|
94
|
-
seen.set(key, count);
|
|
95
|
-
const allow = allowed.get(key) ?? 0;
|
|
96
|
-
known += Math.min(count, allow);
|
|
97
|
-
if (count > allow) {
|
|
98
|
-
const times = allow ? ` (${count} found, the baseline allows ${allow})` : count > 1 ? ` (${count} of them)` : "";
|
|
99
|
-
fresh.push(`${route}: ${LABEL[item.check]} ${item.element}: ${item.detail}${times}`);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const missing = /* @__PURE__ */ new Set();
|
|
104
|
-
for (const b of baseline.measured) {
|
|
105
|
-
const key = measuredKey(b.route, b.check, b.element);
|
|
106
|
-
if (!measured.has(b.route)) {
|
|
107
|
-
if (!missing.has(b.route)) stale.push(`${b.route}: in the baseline but not measured this run. Measure it, or remove its lines.`);
|
|
108
|
-
missing.add(b.route);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
const now = seen.get(key) ?? 0;
|
|
112
|
-
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)"}.`);
|
|
113
|
-
}
|
|
114
|
-
return { fresh, stale, known };
|
|
115
|
-
}
|
|
116
|
-
var LABEL = {
|
|
117
|
-
scroll: "scrolls sideways",
|
|
118
|
-
target: "small target",
|
|
119
|
-
field: "small field",
|
|
120
|
-
text: "small text",
|
|
121
|
-
title: "title cut off",
|
|
122
|
-
overlap: "text overlaps text",
|
|
123
|
-
action: "main action out of reach",
|
|
124
|
-
layers: "fixed layers cover too much",
|
|
125
|
-
landscape: "landscape screen eaten by fixed layers",
|
|
126
|
-
image: "image not sized for its place",
|
|
127
|
-
input: "wrong keyboard",
|
|
128
|
-
autocomplete: "field the phone cannot fill in",
|
|
129
|
-
tel: "phone number is not a link",
|
|
130
|
-
linelength: "line too long"
|
|
131
|
-
};
|
|
132
|
-
function writeBaseline(findings, routes, note) {
|
|
133
|
-
const byFile = tally(findings, (f) => f.file);
|
|
134
|
-
const measured = routes.flatMap(
|
|
135
|
-
({ 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 }))
|
|
136
|
-
);
|
|
137
|
-
return {
|
|
138
|
-
...note ? { note } : {},
|
|
139
|
-
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)),
|
|
140
|
-
measured: measured.sort((a, b) => a.route.localeCompare(b.route) || a.check.localeCompare(b.check) || a.element.localeCompare(b.element))
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export {
|
|
145
|
-
EMPTY_BASELINE,
|
|
146
|
-
scanPhoneFirst,
|
|
147
|
-
checkExceptions,
|
|
148
|
-
judgeStatic,
|
|
149
|
-
judgeMeasured,
|
|
150
|
-
writeBaseline
|
|
151
|
-
};
|
package/dist/cli-56H5IPWT.js
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
EMPTY_BASELINE,
|
|
3
|
-
checkExceptions,
|
|
4
|
-
judgeMeasured,
|
|
5
|
-
judgeStatic,
|
|
6
|
-
scanPhoneFirst,
|
|
7
|
-
writeBaseline
|
|
8
|
-
} from "./chunk-IJUZ7RV4.js";
|
|
9
|
-
import {
|
|
10
|
-
SOURCE_FILE,
|
|
11
|
-
readPaths
|
|
12
|
-
} from "./chunk-TSO5W32N.js";
|
|
13
|
-
import {
|
|
14
|
-
toJson
|
|
15
|
-
} from "./chunk-QIEDBL4J.js";
|
|
16
|
-
import "./chunk-PFVSKIPM.js";
|
|
17
|
-
import "./chunk-FBWBDBW4.js";
|
|
18
|
-
import "./chunk-QYBAVU3P.js";
|
|
19
|
-
|
|
20
|
-
// src/phone/cli.ts
|
|
21
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
22
|
-
import { join, resolve } from "path";
|
|
23
|
-
var USAGE = "usage: craft phone-first [--config phone-first.json] [--measured <dir>] [--update-baseline] [--json]";
|
|
24
|
-
function parse(args) {
|
|
25
|
-
const flags = { config: "phone-first.json", update: false, json: false };
|
|
26
|
-
for (let i = 0; i < args.length; i++) {
|
|
27
|
-
const a = args[i];
|
|
28
|
-
if (a === "--update-baseline") flags.update = true;
|
|
29
|
-
else if (a === "--json") flags.json = true;
|
|
30
|
-
else if (a === "--config" || a === "--measured") {
|
|
31
|
-
const v = args[++i];
|
|
32
|
-
if (!v || v.startsWith("--")) return `${a} needs a value`;
|
|
33
|
-
if (a === "--config") flags.config = v;
|
|
34
|
-
else flags.measured = v;
|
|
35
|
-
} else return `unknown argument ${a}`;
|
|
36
|
-
}
|
|
37
|
-
return flags;
|
|
38
|
-
}
|
|
39
|
-
function loadConfig(path) {
|
|
40
|
-
if (!existsSync(path)) throw new Error(`no ${path}. Write one: { "source": ["<dirs with styles and components>"], "baseline": "phone-first.baseline.json" }`);
|
|
41
|
-
const c = JSON.parse(readFileSync(path, "utf8"));
|
|
42
|
-
if (!Array.isArray(c.source) || c.source.length === 0 || c.source.some((s) => typeof s !== "string")) throw new Error(`${path}: "source" should list the folders to scan`);
|
|
43
|
-
if (c.ignore !== void 0 && (!Array.isArray(c.ignore) || c.ignore.some((s) => typeof s !== "string"))) throw new Error(`${path}: "ignore" should be a list of globs`);
|
|
44
|
-
if (c.exceptions !== void 0 && !Array.isArray(c.exceptions)) throw new Error(`${path}: "exceptions" should be a list of { "file", "reason" }`);
|
|
45
|
-
return { source: c.source, ignore: c.ignore ?? [], exceptions: c.exceptions ?? [], baseline: c.baseline ?? "phone-first.baseline.json" };
|
|
46
|
-
}
|
|
47
|
-
function loadBaseline(path) {
|
|
48
|
-
if (!existsSync(path)) return EMPTY_BASELINE;
|
|
49
|
-
const b = JSON.parse(readFileSync(path, "utf8"));
|
|
50
|
-
return { ...b.note ? { note: b.note } : {}, static: b.static ?? [], measured: b.measured ?? [] };
|
|
51
|
-
}
|
|
52
|
-
function loadMeasured(dir) {
|
|
53
|
-
if (!existsSync(dir)) throw new Error(`--measured ${dir}: no such folder. Run the portal's phone-first specs first; they write it.`);
|
|
54
|
-
const routes = readdirSync(dir).filter((f) => f.endsWith(".json")).sort().map((f) => JSON.parse(readFileSync(join(dir, f), "utf8")));
|
|
55
|
-
if (routes.length === 0) throw new Error(`--measured ${dir}: no measured routes in it. Did the phone-first specs run?`);
|
|
56
|
-
return routes;
|
|
57
|
-
}
|
|
58
|
-
function runPhoneFirst(args, io) {
|
|
59
|
-
const flags = parse(args);
|
|
60
|
-
if (typeof flags === "string") {
|
|
61
|
-
io.err(`craft phone-first: ${flags}
|
|
62
|
-
${USAGE}`);
|
|
63
|
-
return 2;
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
const config = loadConfig(resolve(io.cwd, flags.config));
|
|
67
|
-
const baselinePath = resolve(io.cwd, config.baseline);
|
|
68
|
-
const baseline = loadBaseline(baselinePath);
|
|
69
|
-
const exceptionProblems = checkExceptions(config.exceptions, (f) => existsSync(resolve(io.cwd, f)));
|
|
70
|
-
const sources = config.source.filter((s2) => existsSync(resolve(io.cwd, s2)));
|
|
71
|
-
const missing = config.source.filter((s2) => !sources.includes(s2));
|
|
72
|
-
if (missing.length) throw new Error(`${flags.config}: source ${missing.join(", ")} not found`);
|
|
73
|
-
const files = readPaths(sources, io.cwd, SOURCE_FILE, void 0, config.ignore);
|
|
74
|
-
const { findings, excused } = scanPhoneFirst(files, config.exceptions);
|
|
75
|
-
const routes = flags.measured ? loadMeasured(resolve(io.cwd, flags.measured)) : null;
|
|
76
|
-
if (flags.update) {
|
|
77
|
-
const next = writeBaseline(findings, routes ?? [], baseline.note);
|
|
78
|
-
if (!routes) next.measured = baseline.measured;
|
|
79
|
-
writeFileSync(baselinePath, `${JSON.stringify(next, null, 2)}
|
|
80
|
-
`);
|
|
81
|
-
io.out(`craft phone-first: wrote ${config.baseline} (${next.static.reduce((n, s2) => n + s2.count, 0)} static, ${next.measured.reduce((n, m2) => n + m2.count, 0)} measured)`);
|
|
82
|
-
return exceptionProblems.length ? 1 : 0;
|
|
83
|
-
}
|
|
84
|
-
const s = judgeStatic(findings, baseline);
|
|
85
|
-
const m = routes ? judgeMeasured(routes, baseline) : { fresh: [], stale: [], known: 0 };
|
|
86
|
-
const failed = exceptionProblems.length + s.fresh.length + s.stale.length + m.fresh.length + m.stale.length > 0;
|
|
87
|
-
if (flags.json) {
|
|
88
|
-
io.out(toJson({ pass: !failed, files: files.length, excused, routes: routes?.map((r) => r.route) ?? null, exceptionProblems, static: s, measured: m }));
|
|
89
|
-
return failed ? 1 : 0;
|
|
90
|
-
}
|
|
91
|
-
const wide = routes?.some((r) => r.viewports) ?? false;
|
|
92
|
-
const section = (title, lines) => lines.length ? `
|
|
93
|
-
${title}
|
|
94
|
-
${lines.map((l) => ` ${l}`).join("\n")}` : "";
|
|
95
|
-
const known = s.known + m.known;
|
|
96
|
-
const summary = [
|
|
97
|
-
`${files.length} files${routes ? ` and ${routes.length} route${routes.length === 1 ? "" : "s"} at ${wide ? "every viewport" : "375px"}` : ""}`,
|
|
98
|
-
excused ? `${excused} recorded exception${excused === 1 ? "" : "s"}` : null,
|
|
99
|
-
known ? `${known} known breach${known === 1 ? "" : "es"} in ${config.baseline}` : null
|
|
100
|
-
].filter(Boolean).join(", ");
|
|
101
|
-
if (!failed) {
|
|
102
|
-
io.out(`craft phone-first: pass. ${summary}.`);
|
|
103
|
-
return 0;
|
|
104
|
-
}
|
|
105
|
-
io.err(
|
|
106
|
-
[
|
|
107
|
-
`craft phone-first: fail. ${summary}.`,
|
|
108
|
-
section("The exceptions:", exceptionProblems),
|
|
109
|
-
section("Built desktop first (write the phone as the base styles and widen with min-width; a genuine exception takes `mobile-first-ok: <reason>` on its line):", s.fresh),
|
|
110
|
-
section(wide ? "Measured across the viewports, phone first:" : "Measured at 375px:", m.fresh),
|
|
111
|
-
section("The baseline claims more than is there (good: lower it, so the fix stays fixed):", [...s.stale, ...m.stale])
|
|
112
|
-
].join("")
|
|
113
|
-
);
|
|
114
|
-
return 1;
|
|
115
|
-
} catch (error) {
|
|
116
|
-
io.err(`craft phone-first: ${error.message}`);
|
|
117
|
-
return 2;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
export {
|
|
121
|
-
runPhoneFirst
|
|
122
|
-
};
|