@domandigital/craft 0.20.4 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CHARACTER.md +3 -1
  3. package/COPY.md +9 -3
  4. package/README.md +73 -3
  5. package/dist/{audit-BN2GSFKT.js → audit-LQ2TDISV.js} +3 -3
  6. package/dist/audit.cjs +104 -3
  7. package/dist/audit.js +104 -3
  8. package/dist/{chunk-NQAYRK2K.js → chunk-4WAIOZ4W.js} +14 -14
  9. package/dist/{chunk-YOZLPNJS.js → chunk-6DRBFDWT.js} +1 -1
  10. package/dist/chunk-BQ7H23QP.js +261 -0
  11. package/dist/{chunk-2276FJVN.js → chunk-BYXDX66D.js} +4 -4
  12. package/dist/{chunk-JGWOA4LI.js → chunk-DE2ZAV4G.js} +6 -2
  13. package/dist/{chunk-KQF44RI3.js → chunk-FNB3NP57.js} +5 -5
  14. package/dist/{chunk-S3L7TEJF.js → chunk-G47ZCBD5.js} +2 -2
  15. package/dist/{chunk-7DQDF3VL.js → chunk-ONA73THJ.js} +99 -2
  16. package/dist/{chunk-GBDPNNC2.js → chunk-STWH5EYU.js} +5 -5
  17. package/dist/{cli-QGJB6OUB.js → cli-5QI6NIZN.js} +6 -6
  18. package/dist/{cli-AK4EQHUF.js → cli-5VHX5EJF.js} +5 -5
  19. package/dist/{cli-ZXDGVDYU.js → cli-7QL26YPR.js} +1 -1
  20. package/dist/cli-EYHRJJAX.js +241 -0
  21. package/dist/{cli-AFKXDGUH.js → cli-PBCU7Z64.js} +4 -4
  22. package/dist/{cli-6AZVLNWL.js → cli-RHDNHL4U.js} +5 -5
  23. package/dist/{cli-OYVZOHHV.js → cli-XAWJWK7I.js} +6 -6
  24. package/dist/{cli-7HKVVL3P.js → cli-YSGPGQ7R.js} +9 -9
  25. package/dist/cli.js +3 -3
  26. package/dist/index.cjs +104 -3
  27. package/dist/index.d.cts +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +104 -3
  30. package/dist/phone.cjs +137 -11
  31. package/dist/phone.d.cts +86 -12
  32. package/dist/phone.d.ts +86 -12
  33. package/dist/phone.js +128 -11
  34. package/dist/{viewports-cli-WWWWEBKI.js → viewports-cli-XYYX4SKV.js} +12 -8
  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-6INEFJYN.js +0 -122
package/dist/phone.d.ts 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,6 +17,12 @@ 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
@@ -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 {
@@ -86,18 +97,30 @@ interface MeasuredRoute {
86
97
  interface Baseline {
87
98
  /** Why the baseline exists and which issue burns it down. Free text, for a person. */
88
99
  note?: string;
100
+ /**
101
+ * `reason` makes an entry a named exception: it may be added, or raised,
102
+ * after the baseline went in. Absent on the faults that were there first.
103
+ */
89
104
  static: {
90
105
  file: string;
91
106
  count: number;
92
107
  found?: string[];
108
+ reason?: string;
93
109
  }[];
94
110
  measured: {
95
111
  route: string;
96
112
  check: PhoneCheck;
97
113
  element: string;
98
114
  count: number;
115
+ reason?: string;
99
116
  }[];
100
117
  }
118
+ /** An entry is a named exception when its `reason` says something. */
119
+ declare const hasReason: (entry: {
120
+ reason?: unknown;
121
+ }) => boolean;
122
+ /** A baseline file, read. Old files (no `reason`, no `note`) read as before; a `reason` that is not text is an error. */
123
+ declare function parseBaseline(raw: unknown, where: string): Baseline;
101
124
  declare const EMPTY_BASELINE: Baseline;
102
125
  /** The reason in a `mobile-first-ok: <reason>` comment, "" for the marker with no reason, null for none. */
103
126
  declare function reasonOf(line: string | undefined): string | null;
@@ -106,7 +129,10 @@ declare function reasonOf(line: string | undefined): string | null;
106
129
  * `mobile-first-ok: <reason>` on the line or the line above, or the whole file
107
130
  * in `exceptions` with a reason. A marker with no reason is itself a finding.
108
131
  */
109
- declare function scanPhoneFirst(files: SourceFile[], exceptions?: FileException[]): {
132
+ declare function scanPhoneFirst(files: SourceFile[], exceptions?: FileException[], options?: {
133
+ /** True for a file in an app whose tailwindcss is v3: it is also scanned for `/NN` opacity on a `var()` colour. */
134
+ tailwind3?: (path: string) => boolean;
135
+ }): {
110
136
  findings: StaticFinding[];
111
137
  excused: number;
112
138
  };
@@ -128,7 +154,48 @@ interface Judgement {
128
154
  declare function judgeStatic(findings: StaticFinding[], baseline: Baseline): Judgement;
129
155
  declare function judgeMeasured(routes: MeasuredRoute[], baseline: Baseline): Judgement;
130
156
  /** The baseline that makes this run pass: what is there now, counted. */
131
- declare function writeBaseline(findings: StaticFinding[], routes: MeasuredRoute[], note?: string): Baseline;
157
+ declare function writeBaseline(findings: StaticFinding[], routes: MeasuredRoute[], note?: string, previous?: Baseline): Baseline;
158
+ /**
159
+ * Entries the baseline holds that the base branch's copy does not, or holds
160
+ * at a lower count, and that name no reason. The baseline lists the faults
161
+ * that were there when the gate arrived; a new breach is fixed where it is.
162
+ * An entry with a `reason` is a named exception and may be added or raised.
163
+ */
164
+ declare function judgeGrowth(current: Baseline, base: Baseline, baseName?: string): string[];
165
+ /** Whether the baseline holds anything a reason does not name: only then is the base branch's copy needed. */
166
+ declare const hasUnexcusedEntries: (b: Baseline) => boolean;
167
+ /**
168
+ * Page routes from the page files of a Next app directory, as paths relative
169
+ * to it (`portal/(shell)/invoices/[id]/page.tsx`). Route groups `(x)`, parallel
170
+ * slots `@x` and private folders `_x` are not part of the URL; an intercepting
171
+ * route `(.)x` is a second way into a page, not a page of its own.
172
+ */
173
+ declare function routesFromPages(files: string[]): string[];
174
+ /** Page routes from Next's `app-path-routes-manifest.json` (`{ "/portal/page": "/portal" }`). Route handlers and internal `_` routes are not pages. */
175
+ declare function routesFromManifest(manifest: unknown): string[];
176
+ interface ExcludedRoute {
177
+ route: string;
178
+ reason: string;
179
+ }
180
+ /** A route as the gate names it: no query, no hash, no trailing slash. */
181
+ declare const cleanRoute: (route: string) => string;
182
+ /** Whether a measured route (`/portal/projects/p1`, or the pattern itself) is a page route (`/portal/projects/[id]`). */
183
+ declare function routeMatches(pattern: string, route: string): boolean;
184
+ interface RouteJudgement {
185
+ /** Page routes nothing measured and no exclusion covers: the gate fails on these. */
186
+ missing: string[];
187
+ /** Problems with `excludeRoutes` itself: no reason, or names no page. */
188
+ problems: string[];
189
+ /** Pages measured or excluded with a reason. */
190
+ covered: number;
191
+ }
192
+ /**
193
+ * Every page route of the app is in the gate's route list. A measured route
194
+ * counts for the most specific page it matches, so `/portal/projects/new` does
195
+ * not cover `/portal/projects/[id]`. A page that only redirects measures
196
+ * nothing: it is excluded with that reason.
197
+ */
198
+ declare function judgeRoutes(pages: string[], measuredRoutes: string[], excluded?: ExcludedRoute[]): RouteJudgement;
132
199
 
133
200
  /**
134
201
  * What "every viewport" means (STANDARD.md section 10, DOM-787): the ladder
@@ -263,8 +330,13 @@ declare const PHONE: {
263
330
  readonly isMobile: true;
264
331
  readonly hasTouch: true;
265
332
  };
266
- /** The house floors at phone width. Not per-repo settings. */
267
- declare const PHONE_FLOORS: PhoneFloors;
333
+ /**
334
+ * The house floors at phone width. A site may raise `textPx`, or lower
335
+ * `fieldHeightPx` with a reason; they are not per-repo taste. The field height
336
+ * is portal standard rule 5 (48px inputs); until DOM-1053 the gate held fields
337
+ * to the 44px target floor only.
338
+ */
339
+ declare const PHONE_FLOORS: Required<PhoneFloors>;
268
340
  /** Where `measurePhone` writes and `craft phone-first --measured` reads, unless told otherwise. */
269
341
  declare const DEFAULT_MEASURED_DIR = "phone-first/measured";
270
342
  /** A route as a file name: `/portal/invoices` is `portal-invoices.json`, `/` is `home.json`. */
@@ -309,7 +381,7 @@ interface MeasureViewportsOptions {
309
381
  scope?: "all" | "phone" | "wide";
310
382
  /** Override house thresholds. */
311
383
  thresholds?: Partial<Thresholds>;
312
- /** Override the 12 / 16 / 44px floors. The defaults are the house standard; a site may set `textPx` higher. */
384
+ /** 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
385
  floors?: Partial<PhoneFloors>;
314
386
  /** The main action's selector: one for all routes, or per route with `*` as the fallback. Absent: not checked. */
315
387
  mainAction?: MainAction;
@@ -440,6 +512,8 @@ interface MeasurePhoneOptions {
440
512
  route: string;
441
513
  /** The folder to write `<route>.json` into. Default `phone-first/measured`, from the working directory. */
442
514
  out?: string;
515
+ /** Override the 12 / 16 / 44 / 48px floors (text, field text, target, field height). The defaults are the house standard. */
516
+ floors?: Partial<PhoneFloors>;
443
517
  }
444
518
  /**
445
519
  * Measure the page as it is now and write the result for `craft phone-first`.
@@ -448,4 +522,4 @@ interface MeasurePhoneOptions {
448
522
  */
449
523
  declare function measurePhone(page: MinimalPage, options: MeasurePhoneOptions): Promise<MeasuredRoute>;
450
524
 
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 };
525
+ 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 SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, cleanRoute, formatWidths, hasReason, hasUnexcusedEntries, judgeGrowth, judgeMeasured, judgeRoutes, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureRoute, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, parseBaseline, reasonOf, routeFile, routeMatches, routesFromManifest, routesFromPages, scanPhoneFirst, sweepWidths, writeBaseline };
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
- for (const { line, what } of found.sort((a, b) => a.line - b.line)) {
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 rank = new Map(plain.map((name, i) => [name, i]));
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) => rank.has(n)).sort((a, b) => rank.get(a) - rank.get(b))) {
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 && rank.get(name) === rank.get(last.at(-1)) + 1) last.push(name);
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) => !rank.has(n))].join(", ");
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-IJUZ7RV4.js";
6
+ } from "./chunk-BQ7H23QP.js";
6
7
  import {
7
8
  loadPlaywright
8
- } from "./chunk-YOZLPNJS.js";
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-JGWOA4LI.js";
14
- import "./chunk-7DQDF3VL.js";
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
- const b = JSON.parse(readFileSync(path, "utf8"));
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.20.4",
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "catalogueVersion": "2026.10.2",
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
- };