@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
@@ -1,17 +1,28 @@
1
1
  import {
2
+ DEFAULT_SWEEP,
3
+ DEFAULT_THRESHOLDS,
2
4
  EMPTY_BASELINE,
5
+ LADDER,
6
+ SHORT_PHONE,
7
+ checkScreenBudgets,
8
+ formatScreens,
3
9
  judgeMeasured,
10
+ mainActionFor,
11
+ mergeViewportFindings,
12
+ parseBaseline,
13
+ screenBudgetFor,
14
+ sweepWidths,
4
15
  writeBaseline
5
- } from "./chunk-IJUZ7RV4.js";
16
+ } from "./chunk-XSKLVEI6.js";
6
17
  import {
7
18
  loadPlaywright
8
- } from "./chunk-YFS32BZ2.js";
19
+ } from "./chunk-6DRBFDWT.js";
9
20
  import "./chunk-JYNXGCYZ.js";
10
21
  import {
11
22
  toJson
12
23
  } from "./chunk-QIEDBL4J.js";
13
- import "./chunk-PFVSKIPM.js";
14
- import "./chunk-FBWBDBW4.js";
24
+ import "./chunk-DE2ZAV4G.js";
25
+ import "./chunk-ONA73THJ.js";
15
26
  import "./chunk-QYBAVU3P.js";
16
27
 
17
28
  // src/phone/viewports-cli.ts
@@ -25,7 +36,7 @@ var PHONE = {
25
36
  isMobile: true,
26
37
  hasTouch: true
27
38
  };
28
- var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
39
+ var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12, fieldHeightPx: 48 };
29
40
  var DEFAULT_MEASURED_DIR = "phone-first/measured";
30
41
  var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
31
42
 
@@ -36,6 +47,7 @@ import { join } from "path";
36
47
  // src/phone/measure-in-page.ts
37
48
  function measurePhoneInPage(floors) {
38
49
  const out = [];
50
+ const fieldHeightPx = floors.fieldHeightPx ?? 48;
39
51
  const only = floors.only;
40
52
  const on = (c) => !only || only.includes(c);
41
53
  const vw = document.documentElement.clientWidth;
@@ -79,6 +91,9 @@ function measurePhoneInPage(floors) {
79
91
  if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
80
92
  const px = parseFloat(getComputedStyle(e).fontSize);
81
93
  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` });
94
+ const box = e.getBoundingClientRect();
95
+ const height = Math.round(box.height);
96
+ 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
97
  }
83
98
  for (const e of on("text") ? Array.from(document.body.querySelectorAll("*")) : []) {
84
99
  if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
@@ -385,76 +400,6 @@ function measureViewportInPage(arg) {
385
400
  return out;
386
401
  }
387
402
 
388
- // src/phone/viewports.ts
389
- var LADDER = [
390
- { name: "320", width: 320, height: 568, phone: true },
391
- { name: "375", width: 375, height: 812, phone: true },
392
- { name: "390", width: 390, height: 844, phone: true },
393
- { name: "430", width: 430, height: 932, phone: true },
394
- { name: "844x390", width: 844, height: 390, phone: true, landscape: true },
395
- { name: "768", width: 768, height: 1024, phone: false },
396
- { name: "1024", width: 1024, height: 768, phone: false },
397
- { name: "1280", width: 1280, height: 800, phone: false },
398
- { name: "1440", width: 1440, height: 900, phone: false },
399
- { name: "1920", width: 1920, height: 1080, phone: false }
400
- ];
401
- var SHORT_PHONE = { name: "375x667", width: 375, height: 667, phone: true };
402
- var DEFAULT_SWEEP = { from: 320, to: 1920, step: 16, wideStep: 32, wideFrom: 1024, height: 800 };
403
- function sweepWidths(sweep = DEFAULT_SWEEP) {
404
- const out = [];
405
- for (let w = sweep.from; w <= sweep.to; w += w < sweep.wideFrom ? sweep.step : sweep.wideStep) out.push(w);
406
- if (out.at(-1) !== sweep.to) out.push(sweep.to);
407
- return out;
408
- }
409
- var DEFAULT_THRESHOLDS = {
410
- maxLayerShare: 0.25,
411
- landscapeMaxLayerShare: 0.5,
412
- oversizeRatio: 2.5,
413
- upscaleRatio: 1,
414
- maxLineCh: 80,
415
- overlapShare: 0.4,
416
- minImagePx: 32
417
- };
418
- var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
419
- function formatWidths(failing, tested) {
420
- const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
421
- const rank = new Map(plain.map((name, i) => [name, i]));
422
- const names = [...new Set(failing)];
423
- const runs = [];
424
- for (const name of names.filter((n) => rank.has(n)).sort((a, b) => rank.get(a) - rank.get(b))) {
425
- const last = runs.at(-1);
426
- if (last && rank.get(name) === rank.get(last.at(-1)) + 1) last.push(name);
427
- else runs.push([name]);
428
- }
429
- const out = runs.map((r) => r.length > 1 ? `${r[0]}-${r.at(-1)}` : r[0]);
430
- return [...out, ...names.filter((n) => !rank.has(n))].join(", ");
431
- }
432
- var keyOf = (f) => `${f.check}\0${f.element.replace(/("[^]*)$/, (t) => t.replace(/\d+/g, "#"))}`;
433
- function mergeViewportFindings(perViewport, tested) {
434
- const merged = /* @__PURE__ */ new Map();
435
- for (const { viewport, findings } of perViewport) {
436
- const counts = /* @__PURE__ */ new Map();
437
- for (const f of findings) {
438
- const k = keyOf(f);
439
- const had = counts.get(k);
440
- if (had) had.n++;
441
- else counts.set(k, { first: f, n: 1 });
442
- }
443
- for (const [k, { first, n }] of counts) {
444
- const had = merged.get(k);
445
- if (had) {
446
- had.at.push(viewport);
447
- had.most = Math.max(had.most, n);
448
- } else merged.set(k, { first, at: [viewport], most: n });
449
- }
450
- }
451
- const out = [];
452
- for (const { first, at, most } of merged.values()) {
453
- for (let i = 0; i < most; i++) out.push({ ...first, detail: `${first.detail} (at ${formatWidths(at, tested)})` });
454
- }
455
- return out;
456
- }
457
-
458
403
  // src/phone/run.ts
459
404
  function plan(options) {
460
405
  const ladder = options.ladder ?? LADDER;
@@ -510,6 +455,7 @@ async function collect(page, options) {
510
455
  const mainAction = mainActionFor(options.mainAction, options.route);
511
456
  const perViewport = [];
512
457
  const tested = [];
458
+ let screens;
513
459
  for (const stop of plan(options)) {
514
460
  await page.setViewportSize({ width: stop.width, height: stop.height });
515
461
  await settle(page, stop.ladder ? 150 : 60);
@@ -518,20 +464,36 @@ async function collect(page, options) {
518
464
  const findings = [];
519
465
  if (stop.ladder) findings.push(...await page.evaluate(measurePhoneInPage, { ...floors, only: stop.touch ? ["target", "field", "text"] : ["text"] }));
520
466
  findings.push(...await page.evaluate(measureViewportInPage, { groups: stop.groups, thresholds, mainAction, metaViewport: stop.width < 768 }));
467
+ if (stop.name === SHORT_PHONE.name) {
468
+ const height = await page.evaluate(() => document.documentElement.scrollHeight, void 0);
469
+ screens = Math.round(height / stop.height * 10) / 10;
470
+ const budget = screenBudgetFor(options.screens, options.route);
471
+ 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}` });
472
+ }
521
473
  perViewport.push({ viewport: stop.name, findings });
522
474
  tested.push(stop.name);
523
475
  }
524
- return { perViewport, tested };
476
+ return { perViewport, tested, ...screens !== void 0 ? { screens } : {} };
477
+ }
478
+ function mergeCollected(collected) {
479
+ const screens = collected.find((c) => c.screens !== void 0)?.screens;
480
+ return { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested), ...screens !== void 0 ? { screens } : {} };
525
481
  }
526
482
  function finish(route, collected, out) {
527
- const result = { route, viewports: collected.tested, findings: mergeViewportFindings(collected.perViewport, collected.tested), perViewport: collected.perViewport };
483
+ const result = {
484
+ route,
485
+ viewports: collected.tested,
486
+ ...collected.screens !== void 0 ? { screens: collected.screens } : {},
487
+ findings: mergeViewportFindings(collected.perViewport, collected.tested),
488
+ perViewport: collected.perViewport
489
+ };
528
490
  if (out !== false) writeMeasured(result, out ?? DEFAULT_MEASURED_DIR);
529
491
  return result;
530
492
  }
531
493
  function writeMeasured(result, dir) {
532
494
  mkdirSync(dir, { recursive: true });
533
- const { route, viewport, viewports, findings } = result;
534
- writeFileSync(join(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, findings }, null, 1)}
495
+ const { route, viewport, viewports, screens, findings } = result;
496
+ writeFileSync(join(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, ...screens !== void 0 ? { screens } : {}, findings }, null, 1)}
535
497
  `);
536
498
  }
537
499
  var DESKTOP = { viewport: { width: 1024, height: 768 }, deviceScaleFactor: 1 };
@@ -562,7 +524,7 @@ async function measureSite(browser, options) {
562
524
  result.errors.push({ route, error: failed });
563
525
  continue;
564
526
  }
565
- result.routes.push(finish(route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out));
527
+ result.routes.push(finish(route, mergeCollected(collected), options.out));
566
528
  }
567
529
  return result;
568
530
  }
@@ -596,8 +558,7 @@ function loadConfig(path, cwd) {
596
558
  }
597
559
  function loadBaseline(path) {
598
560
  if (!existsSync(path)) return EMPTY_BASELINE;
599
- const b = JSON.parse(readFileSync(path, "utf8"));
600
- return { ...b.note ? { note: b.note } : {}, static: b.static ?? [], measured: b.measured ?? [] };
561
+ return parseBaseline(JSON.parse(readFileSync(path, "utf8")), path);
601
562
  }
602
563
  async function runViewports(args, io) {
603
564
  const flags = parse(args);
@@ -629,6 +590,7 @@ ${USAGE}`);
629
590
  ...config.mainAction ? { mainAction: config.mainAction } : {},
630
591
  ...config.thresholds ? { thresholds: config.thresholds } : {},
631
592
  ...config.floors ? { floors: config.floors } : {},
593
+ ...config.screens ? { screens: checkScreenBudgets(config.screens, "viewports.json screens") } : {},
632
594
  sweep: flags.sweep ? config.sweep : false,
633
595
  ...captures ? {
634
596
  onViewport: async (page, viewport, route) => {
@@ -642,7 +604,7 @@ ${USAGE}`);
642
604
  const baseline = loadBaseline(baselinePath);
643
605
  const measuredRoutes = new Set(site.routes.map((r) => r.route));
644
606
  if (flags.update) {
645
- const next = writeBaseline([], site.routes, baseline.note);
607
+ const next = writeBaseline([], site.routes, baseline.note, baseline);
646
608
  next.static = baseline.static;
647
609
  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
610
  writeFileSync2(baselinePath, `${JSON.stringify(next, null, 2)}
@@ -653,7 +615,7 @@ ${USAGE}`);
653
615
  const judged = judgeMeasured(site.routes, { ...baseline, measured: baseline.measured.filter((m) => measuredRoutes.has(m.route)) });
654
616
  const failed = site.errors.length + judged.fresh.length + judged.stale.length > 0;
655
617
  if (flags.json) {
656
- io.out(toJson({ pass: !failed, routes: site.routes.map((r) => ({ route: r.route, viewports: r.viewports, findings: r.findings.length })), errors: site.errors, measured: judged }));
618
+ io.out(toJson({ pass: !failed, routes: site.routes.map((r) => ({ route: r.route, viewports: r.viewports, screens: r.screens ?? null, findings: r.findings.length })), errors: site.errors, measured: judged }));
657
619
  return failed ? 1 : 0;
658
620
  }
659
621
  const section = (title, lines) => lines.length ? `
@@ -661,13 +623,16 @@ ${title}
661
623
  ${lines.map((l) => ` ${l}`).join("\n")}` : "";
662
624
  const viewports = site.routes[0]?.viewports ?? [];
663
625
  const summary = `${site.routes.length} route${site.routes.length === 1 ? "" : "s"} at ${viewports.length} viewports${judged.known ? `, ${judged.known} known breach${judged.known === 1 ? "" : "es"} in the baseline` : ""}`;
626
+ const screens = formatScreens(site.routes, config.screens);
627
+ const screensLine = screens ? `
628
+ phone screens to the footer: ${screens}` : "";
664
629
  if (!failed) {
665
- io.out(`craft viewports: pass. ${summary}.`);
630
+ io.out(`craft viewports: pass. ${summary}.${screensLine}`);
666
631
  return 0;
667
632
  }
668
633
  io.err(
669
634
  [
670
- `craft viewports: fail. ${summary}.`,
635
+ `craft viewports: fail. ${summary}.${screensLine}`,
671
636
  section("Did not answer (not measured, so not passed):", site.errors.map((e) => `${e.route}: ${e.error}`)),
672
637
  section("Measured across the viewports, phone first:", judged.fresh),
673
638
  section("The baseline claims more than is there (good: lower it, so the fix stays fixed):", judged.stale)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/craft",
3
- "version": "0.20.5",
3
+ "version": "0.22.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
@@ -1,5 +1,5 @@
1
1
  {
2
- "catalogueVersion": "2026.10.3",
2
+ "catalogueVersion": "2026.10.4",
3
3
  "lists": {
4
4
  "aiWords": [
5
5
  "tapestry",
@@ -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
- };
@@ -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
- };