@domandigital/craft 0.20.5 → 0.22.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CHARACTER.md +2 -1
  3. package/COPY.md +6 -2
  4. package/README.md +87 -4
  5. package/dist/{audit-5I2WWTL6.js → audit-LQ2TDISV.js} +3 -3
  6. package/dist/audit.cjs +64 -1
  7. package/dist/audit.js +64 -1
  8. package/dist/{chunk-YFS32BZ2.js → chunk-6DRBFDWT.js} +1 -1
  9. package/dist/{chunk-BBJOLR3R.js → chunk-6PZG4MJS.js} +5 -5
  10. package/dist/{chunk-PFVSKIPM.js → chunk-DE2ZAV4G.js} +4 -2
  11. package/dist/{chunk-GJY2VMPD.js → chunk-G47ZCBD5.js} +2 -2
  12. package/dist/{chunk-FBWBDBW4.js → chunk-ONA73THJ.js} +61 -0
  13. package/dist/{chunk-TSO5W32N.js → chunk-OVWVKMPS.js} +14 -14
  14. package/dist/{chunk-N32UUN3S.js → chunk-RBQCGDQQ.js} +5 -5
  15. package/dist/chunk-XSKLVEI6.js +368 -0
  16. package/dist/{chunk-DMAMGFV7.js → chunk-ZS7PQNJU.js} +4 -4
  17. package/dist/{cli-OH6427MC.js → cli-7DL4BKA7.js} +6 -6
  18. package/dist/{cli-NIH3WTNE.js → cli-7QL26YPR.js} +1 -1
  19. package/dist/{cli-BI6XCMCL.js → cli-AM4FLXGM.js} +5 -5
  20. package/dist/{cli-JQYZXFRH.js → cli-R2HBESHI.js} +5 -5
  21. package/dist/{cli-V4QXTBWR.js → cli-SZ4SZLUN.js} +4 -4
  22. package/dist/{cli-W6FNSHRN.js → cli-T7A3BU5J.js} +9 -9
  23. package/dist/{cli-SQC27VYC.js → cli-WZA5IXSR.js} +6 -6
  24. package/dist/cli-YNIJOEJK.js +244 -0
  25. package/dist/cli.js +3 -3
  26. package/dist/index.cjs +64 -1
  27. package/dist/index.d.cts +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +64 -1
  30. package/dist/phone.cjs +194 -18
  31. package/dist/phone.d.cts +113 -13
  32. package/dist/phone.d.ts +113 -13
  33. package/dist/phone.js +182 -18
  34. package/dist/{viewports-cli-C4DWICYI.js → viewports-cli-F7KJIVZL.js} +51 -86
  35. package/package.json +1 -1
  36. package/word-lists.json +1 -1
  37. package/dist/chunk-IJUZ7RV4.js +0 -151
  38. package/dist/cli-56H5IPWT.js +0 -122
package/dist/phone.cjs CHANGED
@@ -33,8 +33,15 @@ __export(phone_exports, {
33
33
  WIDE_WIDTHS: () => WIDE_WIDTHS,
34
34
  bandOf: () => bandOf,
35
35
  checkExceptions: () => checkExceptions,
36
+ checkScreenBudgets: () => checkScreenBudgets,
37
+ cleanRoute: () => cleanRoute,
38
+ formatScreens: () => formatScreens,
36
39
  formatWidths: () => formatWidths,
40
+ hasReason: () => hasReason,
41
+ hasUnexcusedEntries: () => hasUnexcusedEntries,
42
+ judgeGrowth: () => judgeGrowth,
37
43
  judgeMeasured: () => judgeMeasured,
44
+ judgeRoutes: () => judgeRoutes,
38
45
  judgeStatic: () => judgeStatic,
39
46
  mainActionFor: () => mainActionFor,
40
47
  measurePhone: () => measurePhone,
@@ -45,9 +52,14 @@ __export(phone_exports, {
45
52
  measureViewports: () => measureViewports,
46
53
  mergeViewportFindings: () => mergeViewportFindings,
47
54
  normaliseElement: () => normaliseElement,
55
+ parseBaseline: () => parseBaseline,
48
56
  reasonOf: () => reasonOf,
49
57
  routeFile: () => routeFile,
58
+ routeMatches: () => routeMatches,
59
+ routesFromManifest: () => routesFromManifest,
60
+ routesFromPages: () => routesFromPages,
50
61
  scanPhoneFirst: () => scanPhoneFirst,
62
+ screenBudgetFor: () => screenBudgetFor,
51
63
  sweepWidths: () => sweepWidths,
52
64
  writeBaseline: () => writeBaseline
53
65
  });
@@ -62,13 +74,14 @@ var PHONE = {
62
74
  isMobile: true,
63
75
  hasTouch: true
64
76
  };
65
- var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
77
+ var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12, fieldHeightPx: 48 };
66
78
  var DEFAULT_MEASURED_DIR = "phone-first/measured";
67
79
  var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
68
80
 
69
81
  // src/phone/measure-in-page.ts
70
82
  function measurePhoneInPage(floors) {
71
83
  const out = [];
84
+ const fieldHeightPx = floors.fieldHeightPx ?? 48;
72
85
  const only = floors.only;
73
86
  const on = (c) => !only || only.includes(c);
74
87
  const vw = document.documentElement.clientWidth;
@@ -112,6 +125,9 @@ function measurePhoneInPage(floors) {
112
125
  if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
113
126
  const px = parseFloat(getComputedStyle(e).fontSize);
114
127
  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` });
128
+ const box = e.getBoundingClientRect();
129
+ const height = Math.round(box.height);
130
+ 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)` });
115
131
  }
116
132
  for (const e of on("text") ? Array.from(document.body.querySelectorAll("*")) : []) {
117
133
  if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
@@ -419,17 +435,26 @@ function measureViewportInPage(arg) {
419
435
  }
420
436
 
421
437
  // src/phone/judge.ts
438
+ var hasReason = (entry) => typeof entry.reason === "string" && entry.reason.trim() !== "";
439
+ function parseBaseline(raw, where) {
440
+ const b = raw ?? {};
441
+ const entries = [...b.static ?? [], ...b.measured ?? []];
442
+ const bad = entries.find((e) => e.reason !== void 0 && typeof e.reason !== "string");
443
+ if (bad) throw new Error(`${where}: an entry has a "reason" that is not text: ${JSON.stringify(bad)}`);
444
+ return { ...b.note ? { note: b.note } : {}, static: b.static ?? [], measured: b.measured ?? [] };
445
+ }
422
446
  var EMPTY_BASELINE = { static: [], measured: [] };
423
447
  var MEDIA = /@(?:custom-)?media\b([^{;]*)/g;
424
448
  var CAPS_WIDTH = /\bmax-(?:device-)?width\s*:|\bwidth\s*<|>=?\s*(?:device-)?width\b/i;
425
449
  var VARIANT = /(?<![\w@-])max-(\[[^\]\s]+\]|[a-z0-9]+):/g;
426
450
  var NOT_VARIANTS = /* @__PURE__ */ new Set(["width", "height", "lines", "resolution", "zoom", "inline-size", "block-size"]);
427
451
  var OK = /mobile-first-ok\b:?(.*)/;
452
+ var VAR_OPACITY = /[^\s"'`]*var\([^)]*\)\]\/[0-9]+/g;
428
453
  function reasonOf(line) {
429
454
  const m = line?.match(OK);
430
455
  return m ? m[1].replace(/\*\/|-->|[{}]/g, "").trim() : null;
431
456
  }
432
- function scanPhoneFirst(files, exceptions = []) {
457
+ function scanPhoneFirst(files, exceptions = [], options = {}) {
433
458
  const excusedFiles = new Set(exceptions.filter((e) => e.reason?.trim()).map((e) => e.file));
434
459
  const findings = [];
435
460
  let excused = 0;
@@ -444,7 +469,18 @@ function scanPhoneFirst(files, exceptions = []) {
444
469
  for (const m of text.matchAll(VARIANT)) {
445
470
  if (!NOT_VARIANTS.has(m[1])) found.push({ line: lineOf(m.index), what: m[0] });
446
471
  }
447
- for (const { line, what } of found.sort((a, b) => a.line - b.line)) {
472
+ const silent = /* @__PURE__ */ new Set();
473
+ if (options.tailwind3?.(path)) {
474
+ for (const m of text.matchAll(VAR_OPACITY)) {
475
+ silent.add(found.push({ line: lineOf(m.index), what: `${m[0]} (Tailwind 3 emits no CSS for opacity on a var() colour)` }) - 1);
476
+ }
477
+ }
478
+ const order = found.map((f, i) => ({ ...f, silent: silent.has(i) })).sort((a, b) => a.line - b.line);
479
+ for (const { line, what, silent: cannotExcuse } of order) {
480
+ if (cannotExcuse) {
481
+ findings.push({ file: path, line, what });
482
+ continue;
483
+ }
448
484
  const here = reasonOf(lines[line - 1]);
449
485
  const above = reasonOf(lines[line - 2]);
450
486
  if (here || above) {
@@ -547,19 +583,104 @@ var LABEL = {
547
583
  input: "wrong keyboard",
548
584
  autocomplete: "field the phone cannot fill in",
549
585
  tel: "phone number is not a link",
550
- linelength: "line too long"
586
+ linelength: "line too long",
587
+ screens: "page too long for a phone"
551
588
  };
552
- function writeBaseline(findings, routes, note) {
589
+ function writeBaseline(findings, routes, note, previous) {
553
590
  const byFile = tally(findings, (f) => f.file);
554
591
  const measured = routes.flatMap(
555
592
  ({ 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 }))
556
593
  );
594
+ const staticReasons = new Map((previous?.static ?? []).filter(hasReason).map((b) => [b.file, b.reason]));
595
+ const measuredReasons = new Map((previous?.measured ?? []).filter(hasReason).map((b) => [measuredKey(b.route, b.check, b.element), b.reason]));
557
596
  return {
558
597
  ...note ? { note } : {},
559
- 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)),
560
- measured: measured.sort((a, b) => a.route.localeCompare(b.route) || a.check.localeCompare(b.check) || a.element.localeCompare(b.element))
598
+ 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)),
599
+ 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))
561
600
  };
562
601
  }
602
+ function judgeGrowth(current, base, baseName = "main") {
603
+ const out = [];
604
+ const baseStatic = new Map(base.static.map((b) => [b.file, b.count]));
605
+ for (const b of current.static) {
606
+ const was = baseStatic.get(b.file) ?? 0;
607
+ 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`}`);
608
+ }
609
+ const baseMeasured = new Map(base.measured.map((b) => [measuredKey(b.route, b.check, b.element), b.count]));
610
+ for (const b of current.measured) {
611
+ const was = baseMeasured.get(measuredKey(b.route, b.check, b.element)) ?? 0;
612
+ 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})`}`);
613
+ }
614
+ return out;
615
+ }
616
+ var hasUnexcusedEntries = (b) => [...b.static, ...b.measured].some((e) => !hasReason(e));
617
+ var PAGE_FILE = /(?:^|\/)page\.(?:tsx|ts|jsx|js|mdx)$/;
618
+ var INTERCEPTING = /^\(\.{1,3}\)/;
619
+ function routesFromPages(files) {
620
+ const routes = /* @__PURE__ */ new Set();
621
+ for (const file of files) {
622
+ const rel = file.replace(/\\/g, "/");
623
+ if (!PAGE_FILE.test(rel)) continue;
624
+ const dirs = rel.split("/").slice(0, -1);
625
+ if (dirs.some((d) => INTERCEPTING.test(d) || d.startsWith("_"))) continue;
626
+ const url = dirs.filter((d) => !/^\(.*\)$/.test(d) && !d.startsWith("@"));
627
+ routes.add(`/${url.join("/")}`);
628
+ }
629
+ return [...routes].sort();
630
+ }
631
+ function routesFromManifest(manifest) {
632
+ const routes = /* @__PURE__ */ new Set();
633
+ for (const [key, route] of Object.entries(manifest ?? {})) {
634
+ if (!/(?:^|\/)page$/.test(key) || typeof route !== "string") continue;
635
+ if (route.split("/").some((d) => d.startsWith("_"))) continue;
636
+ routes.add(route);
637
+ }
638
+ return [...routes].sort();
639
+ }
640
+ var segmentsOf = (route) => route.split("/").filter(Boolean);
641
+ var rank = (seg) => seg.startsWith("[[...") ? 3 : seg.startsWith("[...") ? 2 : seg.startsWith("[") ? 1 : 0;
642
+ var cleanRoute = (route) => {
643
+ const path = route.replace(/[?#].*$/, "").replace(/\/+$/, "");
644
+ return path === "" ? "/" : path;
645
+ };
646
+ function routeMatches(pattern, route) {
647
+ if (pattern === route) return true;
648
+ const want = segmentsOf(pattern);
649
+ const have = segmentsOf(route);
650
+ for (let i = 0; i < want.length; i++) {
651
+ const seg = want[i];
652
+ if (seg.startsWith("[[...")) return true;
653
+ if (seg.startsWith("[...")) return have.length > i;
654
+ if (have[i] === void 0) return false;
655
+ if (!seg.startsWith("[") && seg !== have[i]) return false;
656
+ }
657
+ return want.length === have.length;
658
+ }
659
+ var moreSpecific = (a, b) => {
660
+ const x = segmentsOf(a).map(rank);
661
+ const y = segmentsOf(b).map(rank);
662
+ for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] < y[i];
663
+ return x.length > y.length;
664
+ };
665
+ var excludes = (entry, page) => entry.endsWith("/*") ? page === entry.slice(0, -2) || page.startsWith(entry.slice(0, -1)) : entry === page;
666
+ function judgeRoutes(pages, measuredRoutes, excluded = []) {
667
+ const problems = [];
668
+ excluded.forEach((e, i) => {
669
+ const at = `excludeRoutes[${i}]`;
670
+ const x = e;
671
+ if (typeof x?.route !== "string" || !x.route.startsWith("/")) problems.push(`${at}: names no route (start it with /)`);
672
+ 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.`);
673
+ 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.`);
674
+ });
675
+ const named = excluded.filter((e) => typeof e?.route === "string" && typeof e.reason === "string" && e.reason.trim());
676
+ const covered = /* @__PURE__ */ new Set();
677
+ for (const route of measuredRoutes.map(cleanRoute)) {
678
+ const hit = pages.filter((p) => routeMatches(p, route)).sort((a, b) => moreSpecific(a, b) ? -1 : moreSpecific(b, a) ? 1 : 0)[0];
679
+ if (hit) covered.add(hit);
680
+ }
681
+ for (const page of pages) if (named.some((e) => excludes(e.route, page))) covered.add(page);
682
+ return { missing: pages.filter((p) => !covered.has(p)), problems, covered: covered.size };
683
+ }
563
684
 
564
685
  // src/phone/viewports.ts
565
686
  var BANDS = [
@@ -601,18 +722,44 @@ var DEFAULT_THRESHOLDS = {
601
722
  minImagePx: 32
602
723
  };
603
724
  var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
725
+ function screenBudgetFor(budgets, route) {
726
+ if (!budgets) return void 0;
727
+ const path = cleanRoute(route);
728
+ let best;
729
+ for (const key of Object.keys(budgets)) {
730
+ const prefix = key.endsWith("/*") ? key.slice(0, -1) : null;
731
+ const hit = key === "*" ? 0 : prefix ? path === key.slice(0, -2) || path.startsWith(prefix) ? 1 + prefix.length : -1 : routeMatches(key, path) ? key === path ? 1e4 : 5e3 + key.length : -1;
732
+ if (hit >= 0 && (!best || hit > best.score)) best = { key, score: hit };
733
+ }
734
+ return best ? budgets[best.key] : void 0;
735
+ }
736
+ function checkScreenBudgets(budgets, where = "screens") {
737
+ if (budgets === void 0) return void 0;
738
+ if (!budgets || typeof budgets !== "object" || Array.isArray(budgets)) throw new Error(`${where} should map a route to its budget in phone screens, such as { "/": 6.5, "/services/*": 6 }`);
739
+ for (const [route, value] of Object.entries(budgets)) {
740
+ if (route !== "*" && !route.startsWith("/")) throw new Error(`${where}: "${route}" should start with / (or be *)`);
741
+ if (typeof value !== "number" || !(value > 0)) throw new Error(`${where}: "${route}" should be a number of phone screens above 0, not ${JSON.stringify(value)}`);
742
+ }
743
+ return budgets;
744
+ }
745
+ function formatScreens(routes, budgets) {
746
+ return routes.filter((r) => r.screens !== void 0).map((r) => {
747
+ const budget = screenBudgetFor(budgets, r.route);
748
+ return `${r.route} ${r.screens}${budget === void 0 ? "" : ` of ${budget}`}`;
749
+ }).join(", ");
750
+ }
604
751
  function formatWidths(failing, tested) {
605
752
  const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
606
- const rank = new Map(plain.map((name, i) => [name, i]));
753
+ const rank2 = new Map(plain.map((name, i) => [name, i]));
607
754
  const names = [...new Set(failing)];
608
755
  const runs = [];
609
- for (const name of names.filter((n) => rank.has(n)).sort((a, b) => rank.get(a) - rank.get(b))) {
756
+ for (const name of names.filter((n) => rank2.has(n)).sort((a, b) => rank2.get(a) - rank2.get(b))) {
610
757
  const last = runs.at(-1);
611
- if (last && rank.get(name) === rank.get(last.at(-1)) + 1) last.push(name);
758
+ if (last && rank2.get(name) === rank2.get(last.at(-1)) + 1) last.push(name);
612
759
  else runs.push([name]);
613
760
  }
614
761
  const out = runs.map((r) => r.length > 1 ? `${r[0]}-${r.at(-1)}` : r[0]);
615
- return [...out, ...names.filter((n) => !rank.has(n))].join(", ");
762
+ return [...out, ...names.filter((n) => !rank2.has(n))].join(", ");
616
763
  }
617
764
  var keyOf = (f) => `${f.check}\0${f.element.replace(/("[^]*)$/, (t) => t.replace(/\d+/g, "#"))}`;
618
765
  function mergeViewportFindings(perViewport, tested) {
@@ -697,6 +844,7 @@ async function collect(page, options) {
697
844
  const mainAction = mainActionFor(options.mainAction, options.route);
698
845
  const perViewport = [];
699
846
  const tested = [];
847
+ let screens;
700
848
  for (const stop of plan(options)) {
701
849
  await page.setViewportSize({ width: stop.width, height: stop.height });
702
850
  await settle(page, stop.ladder ? 150 : 60);
@@ -705,20 +853,36 @@ async function collect(page, options) {
705
853
  const findings = [];
706
854
  if (stop.ladder) findings.push(...await page.evaluate(measurePhoneInPage, { ...floors, only: stop.touch ? ["target", "field", "text"] : ["text"] }));
707
855
  findings.push(...await page.evaluate(measureViewportInPage, { groups: stop.groups, thresholds, mainAction, metaViewport: stop.width < 768 }));
856
+ if (stop.name === SHORT_PHONE.name) {
857
+ const height = await page.evaluate(() => document.documentElement.scrollHeight, void 0);
858
+ screens = Math.round(height / stop.height * 10) / 10;
859
+ const budget = screenBudgetFor(options.screens, options.route);
860
+ if (budget !== void 0 && screens > budget) findings.push({ check: "screens", element: "<page>", detail: `${screens} phone screens to the footer at ${stop.width} x ${stop.height}, over the budget of ${budget} for ${options.route}` });
861
+ }
708
862
  perViewport.push({ viewport: stop.name, findings });
709
863
  tested.push(stop.name);
710
864
  }
711
- return { perViewport, tested };
865
+ return { perViewport, tested, ...screens !== void 0 ? { screens } : {} };
866
+ }
867
+ function mergeCollected(collected) {
868
+ const screens = collected.find((c) => c.screens !== void 0)?.screens;
869
+ return { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested), ...screens !== void 0 ? { screens } : {} };
712
870
  }
713
871
  function finish(route, collected, out) {
714
- const result = { route, viewports: collected.tested, findings: mergeViewportFindings(collected.perViewport, collected.tested), perViewport: collected.perViewport };
872
+ const result = {
873
+ route,
874
+ viewports: collected.tested,
875
+ ...collected.screens !== void 0 ? { screens: collected.screens } : {},
876
+ findings: mergeViewportFindings(collected.perViewport, collected.tested),
877
+ perViewport: collected.perViewport
878
+ };
715
879
  if (out !== false) writeMeasured(result, out ?? DEFAULT_MEASURED_DIR);
716
880
  return result;
717
881
  }
718
882
  function writeMeasured(result, dir) {
719
883
  (0, import_node_fs.mkdirSync)(dir, { recursive: true });
720
- const { route, viewport, viewports, findings } = result;
721
- (0, import_node_fs.writeFileSync)((0, import_node_path.join)(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, findings }, null, 1)}
884
+ const { route, viewport, viewports, screens, findings } = result;
885
+ (0, import_node_fs.writeFileSync)((0, import_node_path.join)(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, ...screens !== void 0 ? { screens } : {}, findings }, null, 1)}
722
886
  `);
723
887
  }
724
888
  async function measureViewports(page, options) {
@@ -739,7 +903,7 @@ async function measureRoute(open, options) {
739
903
  await close?.();
740
904
  }
741
905
  }
742
- return finish(options.route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out);
906
+ return finish(options.route, mergeCollected(collected), options.out);
743
907
  }
744
908
  async function measureSite(browser, options) {
745
909
  const result = { routes: [], errors: [] };
@@ -768,7 +932,7 @@ async function measureSite(browser, options) {
768
932
  result.errors.push({ route, error: failed });
769
933
  continue;
770
934
  }
771
- result.routes.push(finish(route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out));
935
+ result.routes.push(finish(route, mergeCollected(collected), options.out));
772
936
  }
773
937
  return result;
774
938
  }
@@ -777,7 +941,7 @@ async function measureSite(browser, options) {
777
941
  async function measurePhone(page, options) {
778
942
  if (!options.route.startsWith("/")) throw new Error(`measurePhone: route "${options.route}" should start with /`);
779
943
  await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
780
- const findings = await page.evaluate(measurePhoneInPage, PHONE_FLOORS);
944
+ const findings = await page.evaluate(measurePhoneInPage, { ...PHONE_FLOORS, ...options.floors ?? {} });
781
945
  const viewport = page.viewportSize?.() ?? void 0;
782
946
  const result = { route: options.route, ...viewport ? { viewport } : {}, findings };
783
947
  const dir = options.out ?? DEFAULT_MEASURED_DIR;
@@ -801,8 +965,15 @@ async function measurePhone(page, options) {
801
965
  WIDE_WIDTHS,
802
966
  bandOf,
803
967
  checkExceptions,
968
+ checkScreenBudgets,
969
+ cleanRoute,
970
+ formatScreens,
804
971
  formatWidths,
972
+ hasReason,
973
+ hasUnexcusedEntries,
974
+ judgeGrowth,
805
975
  judgeMeasured,
976
+ judgeRoutes,
806
977
  judgeStatic,
807
978
  mainActionFor,
808
979
  measurePhone,
@@ -813,9 +984,14 @@ async function measurePhone(page, options) {
813
984
  measureViewports,
814
985
  mergeViewportFindings,
815
986
  normaliseElement,
987
+ parseBaseline,
816
988
  reasonOf,
817
989
  routeFile,
990
+ routeMatches,
991
+ routesFromManifest,
992
+ routesFromPages,
818
993
  scanPhoneFirst,
994
+ screenBudgetFor,
819
995
  sweepWidths,
820
996
  writeBaseline
821
997
  });
package/dist/phone.d.cts CHANGED
@@ -1,9 +1,3 @@
1
- interface SourceFile {
2
- /** Repo-relative path. The extension decides how the file is read. */
3
- path: string;
4
- text: string;
5
- }
6
-
7
1
  /**
8
2
  * The in-page half of the phone-first gate.
9
3
  *
@@ -23,12 +17,18 @@ interface PhoneFloors {
23
17
  targetPx: number;
24
18
  fieldFontPx: number;
25
19
  textPx: number;
20
+ /**
21
+ * The height of a text field, select or textarea (portal standard rule 5:
22
+ * 48px inputs). Left out, 48. Below `targetPx` the field also fails the
23
+ * target check; this floor is what catches 44 to 47px.
24
+ */
25
+ fieldHeightPx?: number;
26
26
  }
27
27
  /**
28
28
  * What a measure can find. The first five are the 375px floors (DOM-775); the
29
29
  * rest are the every-viewport and phone-optimisation checks (DOM-787).
30
30
  */
31
- type PhoneCheck = "scroll" | "target" | "field" | "text" | "title" | "overlap" | "action" | "layers" | "landscape" | "image" | "input" | "autocomplete" | "tel" | "linelength";
31
+ type PhoneCheck = "scroll" | "target" | "field" | "text" | "title" | "overlap" | "action" | "layers" | "landscape" | "image" | "input" | "autocomplete" | "tel" | "linelength" | "screens";
32
32
  interface RawPhoneFinding {
33
33
  check: PhoneCheck;
34
34
  /** `<tag> "text"`: what a person looks for on the page. */
@@ -44,6 +44,12 @@ declare function measurePhoneInPage(floors: PhoneFloors & {
44
44
  only?: PhoneCheck[];
45
45
  }): RawPhoneFinding[];
46
46
 
47
+ interface SourceFile {
48
+ /** Repo-relative path. The extension decides how the file is read. */
49
+ path: string;
50
+ text: string;
51
+ }
52
+
47
53
  /**
48
54
  * The judging half of the phone-first gate: pure text work, tested without a
49
55
  * browser or a disk.
@@ -62,6 +68,11 @@ declare function measurePhoneInPage(floors: PhoneFloors & {
62
68
  * the day it goes in while the existing ones stay listed in the open. The
63
69
  * baseline only shrinks: a count higher than what is found fails as well
64
70
  * ("lower the baseline"), so a fix cannot leave room for the next breach.
71
+ *
72
+ * Shrinking is also enforced against the base branch (DOM-1053): a baseline
73
+ * entry, or a higher count, that main's copy does not have fails, unless the
74
+ * entry carries a `reason` (a named exception: third-party UI, a
75
+ * destructive-only form).
65
76
  */
66
77
 
67
78
  interface StaticFinding {
@@ -81,23 +92,37 @@ interface MeasuredRoute {
81
92
  };
82
93
  /** Set by the every-viewport measure: the viewports and widths the findings were gathered across, phone first. */
83
94
  viewports?: string[];
95
+ /** Phone screens to the footer (page height at 375 x 667 over 667), from the every-viewport measure. Absent when the short phone was not measured. */
96
+ screens?: number;
84
97
  findings: RawPhoneFinding[];
85
98
  }
86
99
  interface Baseline {
87
100
  /** Why the baseline exists and which issue burns it down. Free text, for a person. */
88
101
  note?: string;
102
+ /**
103
+ * `reason` makes an entry a named exception: it may be added, or raised,
104
+ * after the baseline went in. Absent on the faults that were there first.
105
+ */
89
106
  static: {
90
107
  file: string;
91
108
  count: number;
92
109
  found?: string[];
110
+ reason?: string;
93
111
  }[];
94
112
  measured: {
95
113
  route: string;
96
114
  check: PhoneCheck;
97
115
  element: string;
98
116
  count: number;
117
+ reason?: string;
99
118
  }[];
100
119
  }
120
+ /** An entry is a named exception when its `reason` says something. */
121
+ declare const hasReason: (entry: {
122
+ reason?: unknown;
123
+ }) => boolean;
124
+ /** A baseline file, read. Old files (no `reason`, no `note`) read as before; a `reason` that is not text is an error. */
125
+ declare function parseBaseline(raw: unknown, where: string): Baseline;
101
126
  declare const EMPTY_BASELINE: Baseline;
102
127
  /** The reason in a `mobile-first-ok: <reason>` comment, "" for the marker with no reason, null for none. */
103
128
  declare function reasonOf(line: string | undefined): string | null;
@@ -106,7 +131,10 @@ declare function reasonOf(line: string | undefined): string | null;
106
131
  * `mobile-first-ok: <reason>` on the line or the line above, or the whole file
107
132
  * in `exceptions` with a reason. A marker with no reason is itself a finding.
108
133
  */
109
- declare function scanPhoneFirst(files: SourceFile[], exceptions?: FileException[]): {
134
+ declare function scanPhoneFirst(files: SourceFile[], exceptions?: FileException[], options?: {
135
+ /** True for a file in an app whose tailwindcss is v3: it is also scanned for `/NN` opacity on a `var()` colour. */
136
+ tailwind3?: (path: string) => boolean;
137
+ }): {
110
138
  findings: StaticFinding[];
111
139
  excused: number;
112
140
  };
@@ -128,7 +156,48 @@ interface Judgement {
128
156
  declare function judgeStatic(findings: StaticFinding[], baseline: Baseline): Judgement;
129
157
  declare function judgeMeasured(routes: MeasuredRoute[], baseline: Baseline): Judgement;
130
158
  /** The baseline that makes this run pass: what is there now, counted. */
131
- declare function writeBaseline(findings: StaticFinding[], routes: MeasuredRoute[], note?: string): Baseline;
159
+ declare function writeBaseline(findings: StaticFinding[], routes: MeasuredRoute[], note?: string, previous?: Baseline): Baseline;
160
+ /**
161
+ * Entries the baseline holds that the base branch's copy does not, or holds
162
+ * at a lower count, and that name no reason. The baseline lists the faults
163
+ * that were there when the gate arrived; a new breach is fixed where it is.
164
+ * An entry with a `reason` is a named exception and may be added or raised.
165
+ */
166
+ declare function judgeGrowth(current: Baseline, base: Baseline, baseName?: string): string[];
167
+ /** Whether the baseline holds anything a reason does not name: only then is the base branch's copy needed. */
168
+ declare const hasUnexcusedEntries: (b: Baseline) => boolean;
169
+ /**
170
+ * Page routes from the page files of a Next app directory, as paths relative
171
+ * to it (`portal/(shell)/invoices/[id]/page.tsx`). Route groups `(x)`, parallel
172
+ * slots `@x` and private folders `_x` are not part of the URL; an intercepting
173
+ * route `(.)x` is a second way into a page, not a page of its own.
174
+ */
175
+ declare function routesFromPages(files: string[]): string[];
176
+ /** Page routes from Next's `app-path-routes-manifest.json` (`{ "/portal/page": "/portal" }`). Route handlers and internal `_` routes are not pages. */
177
+ declare function routesFromManifest(manifest: unknown): string[];
178
+ interface ExcludedRoute {
179
+ route: string;
180
+ reason: string;
181
+ }
182
+ /** A route as the gate names it: no query, no hash, no trailing slash. */
183
+ declare const cleanRoute: (route: string) => string;
184
+ /** Whether a measured route (`/portal/projects/p1`, or the pattern itself) is a page route (`/portal/projects/[id]`). */
185
+ declare function routeMatches(pattern: string, route: string): boolean;
186
+ interface RouteJudgement {
187
+ /** Page routes nothing measured and no exclusion covers: the gate fails on these. */
188
+ missing: string[];
189
+ /** Problems with `excludeRoutes` itself: no reason, or names no page. */
190
+ problems: string[];
191
+ /** Pages measured or excluded with a reason. */
192
+ covered: number;
193
+ }
194
+ /**
195
+ * Every page route of the app is in the gate's route list. A measured route
196
+ * counts for the most specific page it matches, so `/portal/projects/new` does
197
+ * not cover `/portal/projects/[id]`. A page that only redirects measures
198
+ * nothing: it is excluded with that reason.
199
+ */
200
+ declare function judgeRoutes(pages: string[], measuredRoutes: string[], excluded?: ExcludedRoute[]): RouteJudgement;
132
201
 
133
202
  /**
134
203
  * What "every viewport" means (STANDARD.md section 10, DOM-787): the ladder
@@ -199,6 +268,23 @@ declare const DEFAULT_THRESHOLDS: Thresholds;
199
268
  /** The main action, as a selector: one for the whole site, or one per route (`*` is the fallback). */
200
269
  type MainAction = string | Record<string, string>;
201
270
  declare const mainActionFor: (config: MainAction | undefined, route: string) => string | undefined;
271
+ /**
272
+ * A length budget per page type, in phone screens to the footer (page height at
273
+ * 375 x 667 over 667). Keys are a route (`/`), a route pattern (`/services/[slug]`),
274
+ * a prefix (`/services/*`: the route and everything under it) or `*` for the
275
+ * rest; the most specific match wins. No match is no budget: the count is
276
+ * reported and never fails.
277
+ */
278
+ type ScreenBudgets = Record<string, number>;
279
+ /** The budget for a route, or undefined when none of the keys covers it. */
280
+ declare function screenBudgetFor(budgets: ScreenBudgets | undefined, route: string): number | undefined;
281
+ /** `screens` as a person types it into config: every budget a positive number. */
282
+ declare function checkScreenBudgets(budgets: unknown, where?: string): ScreenBudgets | undefined;
283
+ /** `/ 12.7, /services 4.1`: the count per route, for the line a run prints whether or not a budget is set. */
284
+ declare function formatScreens(routes: {
285
+ route: string;
286
+ screens?: number;
287
+ }[], budgets?: ScreenBudgets): string;
202
288
  /**
203
289
  * Widths as a person reads them: `320-352, 400, 1440`. A run is consecutive
204
290
  * tested widths that all fail. A landscape or short-phone name (`844x390`)
@@ -263,8 +349,13 @@ declare const PHONE: {
263
349
  readonly isMobile: true;
264
350
  readonly hasTouch: true;
265
351
  };
266
- /** The house floors at phone width. Not per-repo settings. */
267
- declare const PHONE_FLOORS: PhoneFloors;
352
+ /**
353
+ * The house floors at phone width. A site may raise `textPx`, or lower
354
+ * `fieldHeightPx` with a reason; they are not per-repo taste. The field height
355
+ * is portal standard rule 5 (48px inputs); until DOM-1053 the gate held fields
356
+ * to the 44px target floor only.
357
+ */
358
+ declare const PHONE_FLOORS: Required<PhoneFloors>;
268
359
  /** Where `measurePhone` writes and `craft phone-first --measured` reads, unless told otherwise. */
269
360
  declare const DEFAULT_MEASURED_DIR = "phone-first/measured";
270
361
  /** A route as a file name: `/portal/invoices` is `portal-invoices.json`, `/` is `home.json`. */
@@ -309,10 +400,17 @@ interface MeasureViewportsOptions {
309
400
  scope?: "all" | "phone" | "wide";
310
401
  /** Override house thresholds. */
311
402
  thresholds?: Partial<Thresholds>;
312
- /** Override the 12 / 16 / 44px floors. The defaults are the house standard; a site may set `textPx` higher. */
403
+ /** Override the 12 / 16 / 44px floors and the 48px field height (`fieldHeightPx`). The defaults are the house standard; a site may set `textPx` higher. */
313
404
  floors?: Partial<PhoneFloors>;
314
405
  /** The main action's selector: one for all routes, or per route with `*` as the fallback. Absent: not checked. */
315
406
  mainAction?: MainAction;
407
+ /**
408
+ * A length budget per route, in phone screens to the footer (page height at
409
+ * 375 x 667 over 667): `{ "/": 6.5, "/services/*": 6 }`. A route over its
410
+ * budget fails the `screens` check. Without a budget the count is only
411
+ * reported, in `ViewportsResult.screens`.
412
+ */
413
+ screens?: ScreenBudgets;
316
414
  /** The width sweep, or `false` to measure the ladder only. */
317
415
  sweep?: Partial<Sweep> | false;
318
416
  /** Replace the ladder. */
@@ -440,6 +538,8 @@ interface MeasurePhoneOptions {
440
538
  route: string;
441
539
  /** The folder to write `<route>.json` into. Default `phone-first/measured`, from the working directory. */
442
540
  out?: string;
541
+ /** Override the 12 / 16 / 44 / 48px floors (text, field text, target, field height). The defaults are the house standard. */
542
+ floors?: Partial<PhoneFloors>;
443
543
  }
444
544
  /**
445
545
  * Measure the page as it is now and write the result for `craft phone-first`.
@@ -448,4 +548,4 @@ interface MeasurePhoneOptions {
448
548
  */
449
549
  declare function measurePhone(page: MinimalPage, options: MeasurePhoneOptions): Promise<MeasuredRoute>;
450
550
 
451
- export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, DESKTOP, EMPTY_BASELINE, type FileException, type InPageGroup, type Judgement, LADDER, type MainAction, type MeasurePhoneOptions, type MeasureSiteOptions, type MeasureViewportsOptions, type MeasuredRoute, type OpenedPage, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, SHORT_PHONE, type SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, formatWidths, judgeMeasured, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureRoute, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, reasonOf, routeFile, scanPhoneFirst, sweepWidths, writeBaseline };
551
+ export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, DESKTOP, EMPTY_BASELINE, type ExcludedRoute, type FileException, type InPageGroup, type Judgement, LADDER, type MainAction, type MeasurePhoneOptions, type MeasureSiteOptions, type MeasureViewportsOptions, type MeasuredRoute, type OpenedPage, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, type RouteJudgement, SHORT_PHONE, type ScreenBudgets, type SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, checkScreenBudgets, cleanRoute, formatScreens, formatWidths, hasReason, hasUnexcusedEntries, judgeGrowth, judgeMeasured, judgeRoutes, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureRoute, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, parseBaseline, reasonOf, routeFile, routeMatches, routesFromManifest, routesFromPages, scanPhoneFirst, screenBudgetFor, sweepWidths, writeBaseline };