@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/index.js CHANGED
@@ -1740,6 +1740,33 @@ var OUTCOME_PROMISES = [
1740
1740
  /\byou(?:['’]ll| will)\s+(?:get|see|win|receive|start getting)\s+more\b/i,
1741
1741
  new RegExp(`\\b(?:will|we['\u2019]ll)\\s+(?:(?:bring|drive|generate|get)\\s+you|bring|drive|generate)\\s+more\\s+(?:[\\w-]+\\s+)?${SALES_RESULTS}\\b`, "i")
1742
1742
  ];
1743
+ var SENTENCE_START = String.raw`(?<=^|[\n>"“]|[.!?]\s+)`;
1744
+ var MATEY = [
1745
+ // "Alright Carlo,", "Oi Dave", "Alright, mate"
1746
+ /\b(?:Alright|Oi)(?:,\s*|\s+)(?:mate\b|[A-Z][a-z]+\b)/,
1747
+ // "mate" as a form of address, not "a mate who knows"
1748
+ /,\s*mate\b|\b(?:thanks|ta|cheers|alright|oi|fussed|sorry|worries|bother|hiya|hey)\s+mate\b/i,
1749
+ /\b(?:innit|chuffed|lovely jubbly)\b|\bbish,?\s+bash,?\s+bosh\b|\bbob['’]?s\s+your\s+uncle\b/i,
1750
+ /\bno\s+(?:worries|bother)\b|\bnot\s+fussed\b/i,
1751
+ // "Right then," opening a sentence, not "the right then left turn"
1752
+ new RegExp(`${SENTENCE_START}Right,?\\s+then\\b[,!.]`),
1753
+ // "we'll get you sorted", "all sorted", "Sorted!", not "sorted by date"
1754
+ /\b(?:all|get(?:ting)?\s+(?:it|that|this|you|them|everything)|got\s+(?:it|that|you)|you['’]re)\s+sorted\b(?!\s+(?:by|in|into|alphabetically)\b)/i,
1755
+ new RegExp(`${SENTENCE_START}Sorted[.!]`),
1756
+ // "proper" as an intensifier before an adjective, not "proper planning"
1757
+ /\bproper\s+(?:good|nice|quick|tidy|lovely|decent|chuffed|busy|mint|smart|happy|gutted|brilliant|buzzing)\b/i,
1758
+ /\bgive\s+(?:me|us)\s+a\s+(?:shout|bell|buzz|tinkle)\b/i,
1759
+ /\bwhen\s+you\s+(?:get|have|['’]ve\s+got)\s+a\s+(?:sec|min|mo)\b/i,
1760
+ /\bhave\s+a\s+(?:quick\s+)?(?:gander|squiz|nosey|nose(?:\s+(?:round|around))?)\b(?!\s+for\b)/i,
1761
+ /\bcheck\s+out\s+(?:our|my|this|these)\b/i
1762
+ ];
1763
+ var CLIPPED = [
1764
+ /\b(?:take|takes|took|taking|have|has|had|having)\s+(?:quick\s+)?look\s+(?:at|on|over|through|in)\b/i,
1765
+ /\bgive\s+(?:it|this|that|them)\s+(?:go|try|look|read|whirl|bash)\b(?![-\w])/i,
1766
+ new RegExp(`${SENTENCE_START}Quick\\s+one\\b(?=\\s*[:,.!?\u2014\u2013-]|\\s+(?:for you|before|on|about)\\b)`),
1767
+ // "Alright Carlo, built you a proper site": the "I" left out
1768
+ /(?<=^|[\n>"“]|[.!?—–-]\s*|,\s+)(?:[Bb]uilt|[Mm]ade|[Pp]ut together|[Kk]nocked up)\s+(?:you|your\s+\w+|the\s+\w+)\s+an?\b/
1769
+ ];
1743
1770
  var NEGATION = [
1744
1771
  // "It's not just a haircut, it's an experience."
1745
1772
  /\b(?:it['’]?s|it is|this is|this isn['’]t|we['’]re|we are|they['’]re)\s+(?:not|more than)\s+(?:just|only|merely|simply)?\s*[^.!?\n]{1,60}?[,;.—-]\s*(?:it['’]?s|it is|this is|we['’]re|we are|they['’]re)\b/i,
@@ -1976,6 +2003,40 @@ var COPY_TELLS = [
1976
2003
  ]
1977
2004
  }
1978
2005
  },
2006
+ {
2007
+ id: "matey-register",
2008
+ name: "Matey register",
2009
+ generation: 2,
2010
+ severity: "warn",
2011
+ surface: "copy",
2012
+ why: "'Alright Dave,', 'no worries, we'll get you sorted', 'Quick one:', 'have look at': a put-on, over-familiar British voice, with the small words clipped out. A model asked to sound British writes it; the UK is broad, and a client reads it as cringe and a little rude.",
2013
+ fix: "Write plain, complete British English. 'Alright Dave, built you a site' becomes 'Hi Dave, I have built you a site'; 'No worries, we'll get you sorted' becomes 'We can fit you in on Thursday'.",
2014
+ detect: phrases([...MATEY, ...CLIPPED], quoted),
2015
+ fixtures: {
2016
+ flag: [
2017
+ // From the portal's seeded prospect follow-ups, 2026-10-19.
2018
+ f("emails/followup.md", "Alright Dave, it's Dmitri. No pressure, have a look when you get a min."),
2019
+ f("emails/followup.md", "All good if you're not fussed mate, I'll leave it there."),
2020
+ f("emails/followup.md", "Carlo, built the shop a proper site."),
2021
+ f("content/contact.md", "No worries, we'll get you sorted."),
2022
+ f("emails/quote.md", "Take look on these photos and give it go."),
2023
+ f("emails/quote.md", "Quick one: is Thursday still good?"),
2024
+ f("emails/quote.md", "Right then, here is the plan."),
2025
+ f("content/reviews.md", "The fitters did a proper good job and we were chuffed.")
2026
+ ],
2027
+ pass: [
2028
+ f("content/home.md", "Proper planning saves a second visit."),
2029
+ f("content/certificates.md", "Download a sorted list of certificates, sorted by date."),
2030
+ f("content/directions.md", "At the junction, take the right then left turn."),
2031
+ // Chair and Blade's own approved line, and "mate" as a noun.
2032
+ f("content/home.md", "Every cut starts with a proper chat about what you actually want."),
2033
+ f("content/home.md", "It feels like getting a cut from a mate who knows what he is doing."),
2034
+ f("emails/quote.md", "Have a look at the quote and ring me if Thursday does not work."),
2035
+ f("content/contact.md", "Give us a ring on 01280 700123. We take a look at the fault before quoting."),
2036
+ f("content/home.md", "Built to last, and made for older houses.")
2037
+ ]
2038
+ }
2039
+ },
1979
2040
  {
1980
2041
  id: "hollow-imperative",
1981
2042
  name: "Hollow imperative",
@@ -2176,7 +2237,12 @@ var FALSE_RANGE = [
2176
2237
  ];
2177
2238
  var RESIDUE = [
2178
2239
  /\bas an AI(?:\s+language)?\s+(?:model|assistant)\b/i,
2179
- /(?:^|\n|>)\s*(?:Certainly|Sure|Absolutely|Of course|Great question)[!,.]\s+(?:Here(?:['’]s| is| are)|Below)\b/i,
2240
+ // At the start of a line, or after a sentence that ends in a full stop or
2241
+ // an exclamation mark: "I hope this email finds you well! Certainly, here
2242
+ // is the handover request." slipped through when only a line start counted
2243
+ // (found live, 2026-10-19). Not after a question mark: an FAQ answer may
2244
+ // honestly open "Do you work weekends? Absolutely, here are our hours."
2245
+ /(?<=^|[\n>]|[.!]\s+)\s*(?:Certainly|Sure|Absolutely|Of course|Great question)[!,.]\s+(?:Here(?:['’]s| is| are)|Below)\b/i,
2180
2246
  /\bhere(?:['’]s| is)\s+(?:a|an|the|your)\s+(?:revised|rewritten|updated|polished|refined|improved|draft|more concise)\s+(?:version|draft|copy)\b/i,
2181
2247
  /\blet me know if you(?:['’]d| would) like me to\b/i,
2182
2248
  /\b(?:my|the) (?:knowledge|training) cut-?off\b|\bas of my last (?:update|training)\b/i,
@@ -2192,6 +2258,13 @@ var RESIDUE = [
2192
2258
  /[\uE200-\uE2FF]*entity[\uE200-\uE2FF]*\["[a-z_]+"\s*,\s*"[^"\n]{1,80}"(?:\s*,\s*"[^"\n]{0,160}")*\][\uE200-\uE2FF]*/,
2193
2259
  /【\d+(?:[:†][^】]{0,40})?】/
2194
2260
  ];
2261
+ var STOCK_EMAIL = [
2262
+ /\bI (?:hope|trust) (?:this|my|the) (?:e-?mail|message|note|letter) finds you(?: all)? (?:well|in good health)\b/i,
2263
+ /\bI hope you(?:['’]re| are)(?: all)? (?:doing |keeping )?well\b/i,
2264
+ /\b(?:I )?hope you(?:['’]re| are) having an? (?:great|good|lovely|wonderful|fantastic) (?:day|week|weekend)\b/i,
2265
+ /\blet (?:me|us) know if you have any (?:further |other |more )?questions\b/i,
2266
+ /\b(?:do not|don['’]t) hesitate to (?:contact|reach out|get in touch|ask|call)\b/i
2267
+ ];
2195
2268
  var AI_UTM = /utm_source=(?:chatgpt\.com|chat\.openai\.com|openai|perplexity(?:\.ai)?|claude\.ai|copilot(?:\.microsoft\.com)?|gemini(?:\.google\.com)?)\b/gi;
2196
2269
  function residueHits(ctx) {
2197
2270
  const hits = phrases(RESIDUE, quoted, allText)(ctx);
@@ -2273,15 +2346,39 @@ var RESEARCH_TELLS = [
2273
2346
  f("content/home.md", "Prices start at \xA340 :contentReference[oaicite:2]{index=2} for a full set."),
2274
2347
  f("content/home.md", "Prices start at \xA340\u30104\u2020source\u3011 for a full set."),
2275
2348
  f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=chatgpt.com">Read the guide</a>;`),
2276
- f("content/home.md", "Here's a more concise version for the hero.")
2349
+ f("content/home.md", "Here's a more concise version for the hero."),
2350
+ // The client handover email that a clean copy-check let through, 2026-10-19.
2351
+ f("emails/handover.md", "Hi,\n\nI hope this email finds you well! Certainly, here is the handover request. Let me know if you have any questions.")
2277
2352
  ],
2278
2353
  pass: [
2279
2354
  f("content/home.md", "Certainly the busiest week of the year: book early."),
2355
+ f("content/faq.md", "Do you work weekends? Absolutely, here are our hours."),
2280
2356
  f("content/home.md", "Here's the price list for 2026."),
2281
2357
  f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=newsletter">Read the guide</a>;`)
2282
2358
  ]
2283
2359
  }
2284
2360
  },
2361
+ {
2362
+ id: "stock-email-line",
2363
+ name: "Stock email line",
2364
+ generation: 1,
2365
+ severity: "warn",
2366
+ surface: "copy",
2367
+ why: "'I hope this email finds you well', 'let me know if you have any questions', 'don't hesitate to contact us': the opener and closer of a form email. A client reads past them, and a model drafting an email writes them every time.",
2368
+ fix: "Cut the opener and start with the reason for writing. Replace the closer with the next step and who to ask: 'Ring me on 07700 900123 if the date does not work.'",
2369
+ detect: phrases(STOCK_EMAIL, quoted),
2370
+ fixtures: {
2371
+ flag: [
2372
+ f("emails/handover.md", "Hi,\n\nI hope this email finds you well! Certainly, here is the handover request. Let me know if you have any questions."),
2373
+ f("emails/quote.md", "Hope you're having a great week. The quote is attached."),
2374
+ f("content/contact.md", "Please don't hesitate to contact us about a rewire.")
2375
+ ],
2376
+ pass: [
2377
+ f("emails/handover.md", "Hi Sam,\n\nThe handover pack is attached: certificates, the board schedule and the warranty. Ring me on 07700 900123 if anything is missing."),
2378
+ f("content/contact.md", "If you have any questions, ring Jo on 01280 700123.")
2379
+ ]
2380
+ }
2381
+ },
2285
2382
  {
2286
2383
  id: "prompt-context",
2287
2384
  name: "Prompt context",
@@ -4891,6 +4988,10 @@ var HOUSE = {
4891
4988
  // New 2026-10-14 (founder): review first, like every new tell, until estate hits are read.
4892
4989
  "sales-tagline": { tier: "review", label: "sales tagline, say what you will actually do" },
4893
4990
  "outcome-promise": { tier: "review", label: "outcome promise, promise the work, not the result" },
4991
+ // New 2026-10-19: review first, like every new tell, until estate hits are read.
4992
+ "stock-email-line": { tier: "review", label: "stock email line, start with the reason for writing and end with the next step" },
4993
+ // New 2026-10-19 (founder): review first, like every new tell, until estate hits are read.
4994
+ "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
4894
4995
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
4895
4996
  "stock-phrase": { tier: "review", label: "stock phrase" },
4896
4997
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -6096,7 +6197,7 @@ var IMAGERY_TELLS = [
6096
6197
  ];
6097
6198
 
6098
6199
  // src/character/check.ts
6099
- var CATALOGUE_VERSION = "2026.10.2";
6200
+ var CATALOGUE_VERSION = "2026.10.4";
6100
6201
  var CATALOGUE = [
6101
6202
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
6102
6203
  ...SPECIFICITY_COPY_TELLS,
package/dist/phone.cjs CHANGED
@@ -33,8 +33,13 @@ __export(phone_exports, {
33
33
  WIDE_WIDTHS: () => WIDE_WIDTHS,
34
34
  bandOf: () => bandOf,
35
35
  checkExceptions: () => checkExceptions,
36
+ cleanRoute: () => cleanRoute,
36
37
  formatWidths: () => formatWidths,
38
+ hasReason: () => hasReason,
39
+ hasUnexcusedEntries: () => hasUnexcusedEntries,
40
+ judgeGrowth: () => judgeGrowth,
37
41
  judgeMeasured: () => judgeMeasured,
42
+ judgeRoutes: () => judgeRoutes,
38
43
  judgeStatic: () => judgeStatic,
39
44
  mainActionFor: () => mainActionFor,
40
45
  measurePhone: () => measurePhone,
@@ -45,8 +50,12 @@ __export(phone_exports, {
45
50
  measureViewports: () => measureViewports,
46
51
  mergeViewportFindings: () => mergeViewportFindings,
47
52
  normaliseElement: () => normaliseElement,
53
+ parseBaseline: () => parseBaseline,
48
54
  reasonOf: () => reasonOf,
49
55
  routeFile: () => routeFile,
56
+ routeMatches: () => routeMatches,
57
+ routesFromManifest: () => routesFromManifest,
58
+ routesFromPages: () => routesFromPages,
50
59
  scanPhoneFirst: () => scanPhoneFirst,
51
60
  sweepWidths: () => sweepWidths,
52
61
  writeBaseline: () => writeBaseline
@@ -62,13 +71,14 @@ var PHONE = {
62
71
  isMobile: true,
63
72
  hasTouch: true
64
73
  };
65
- var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
74
+ var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12, fieldHeightPx: 48 };
66
75
  var DEFAULT_MEASURED_DIR = "phone-first/measured";
67
76
  var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
68
77
 
69
78
  // src/phone/measure-in-page.ts
70
79
  function measurePhoneInPage(floors) {
71
80
  const out = [];
81
+ const fieldHeightPx = floors.fieldHeightPx ?? 48;
72
82
  const only = floors.only;
73
83
  const on = (c) => !only || only.includes(c);
74
84
  const vw = document.documentElement.clientWidth;
@@ -112,6 +122,9 @@ function measurePhoneInPage(floors) {
112
122
  if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
113
123
  const px = parseFloat(getComputedStyle(e).fontSize);
114
124
  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` });
125
+ const box = e.getBoundingClientRect();
126
+ const height = Math.round(box.height);
127
+ 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
128
  }
116
129
  for (const e of on("text") ? Array.from(document.body.querySelectorAll("*")) : []) {
117
130
  if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
@@ -419,17 +432,26 @@ function measureViewportInPage(arg) {
419
432
  }
420
433
 
421
434
  // src/phone/judge.ts
435
+ var hasReason = (entry) => typeof entry.reason === "string" && entry.reason.trim() !== "";
436
+ function parseBaseline(raw, where) {
437
+ const b = raw ?? {};
438
+ const entries = [...b.static ?? [], ...b.measured ?? []];
439
+ const bad = entries.find((e) => e.reason !== void 0 && typeof e.reason !== "string");
440
+ if (bad) throw new Error(`${where}: an entry has a "reason" that is not text: ${JSON.stringify(bad)}`);
441
+ return { ...b.note ? { note: b.note } : {}, static: b.static ?? [], measured: b.measured ?? [] };
442
+ }
422
443
  var EMPTY_BASELINE = { static: [], measured: [] };
423
444
  var MEDIA = /@(?:custom-)?media\b([^{;]*)/g;
424
445
  var CAPS_WIDTH = /\bmax-(?:device-)?width\s*:|\bwidth\s*<|>=?\s*(?:device-)?width\b/i;
425
446
  var VARIANT = /(?<![\w@-])max-(\[[^\]\s]+\]|[a-z0-9]+):/g;
426
447
  var NOT_VARIANTS = /* @__PURE__ */ new Set(["width", "height", "lines", "resolution", "zoom", "inline-size", "block-size"]);
427
448
  var OK = /mobile-first-ok\b:?(.*)/;
449
+ var VAR_OPACITY = /[^\s"'`]*var\([^)]*\)\]\/[0-9]+/g;
428
450
  function reasonOf(line) {
429
451
  const m = line?.match(OK);
430
452
  return m ? m[1].replace(/\*\/|-->|[{}]/g, "").trim() : null;
431
453
  }
432
- function scanPhoneFirst(files, exceptions = []) {
454
+ function scanPhoneFirst(files, exceptions = [], options = {}) {
433
455
  const excusedFiles = new Set(exceptions.filter((e) => e.reason?.trim()).map((e) => e.file));
434
456
  const findings = [];
435
457
  let excused = 0;
@@ -444,7 +466,18 @@ function scanPhoneFirst(files, exceptions = []) {
444
466
  for (const m of text.matchAll(VARIANT)) {
445
467
  if (!NOT_VARIANTS.has(m[1])) found.push({ line: lineOf(m.index), what: m[0] });
446
468
  }
447
- for (const { line, what } of found.sort((a, b) => a.line - b.line)) {
469
+ const silent = /* @__PURE__ */ new Set();
470
+ if (options.tailwind3?.(path)) {
471
+ for (const m of text.matchAll(VAR_OPACITY)) {
472
+ silent.add(found.push({ line: lineOf(m.index), what: `${m[0]} (Tailwind 3 emits no CSS for opacity on a var() colour)` }) - 1);
473
+ }
474
+ }
475
+ const order = found.map((f, i) => ({ ...f, silent: silent.has(i) })).sort((a, b) => a.line - b.line);
476
+ for (const { line, what, silent: cannotExcuse } of order) {
477
+ if (cannotExcuse) {
478
+ findings.push({ file: path, line, what });
479
+ continue;
480
+ }
448
481
  const here = reasonOf(lines[line - 1]);
449
482
  const above = reasonOf(lines[line - 2]);
450
483
  if (here || above) {
@@ -549,17 +582,101 @@ var LABEL = {
549
582
  tel: "phone number is not a link",
550
583
  linelength: "line too long"
551
584
  };
552
- function writeBaseline(findings, routes, note) {
585
+ function writeBaseline(findings, routes, note, previous) {
553
586
  const byFile = tally(findings, (f) => f.file);
554
587
  const measured = routes.flatMap(
555
588
  ({ 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
589
  );
590
+ const staticReasons = new Map((previous?.static ?? []).filter(hasReason).map((b) => [b.file, b.reason]));
591
+ const measuredReasons = new Map((previous?.measured ?? []).filter(hasReason).map((b) => [measuredKey(b.route, b.check, b.element), b.reason]));
557
592
  return {
558
593
  ...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))
594
+ 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)),
595
+ 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
596
  };
562
597
  }
598
+ function judgeGrowth(current, base, baseName = "main") {
599
+ const out = [];
600
+ const baseStatic = new Map(base.static.map((b) => [b.file, b.count]));
601
+ for (const b of current.static) {
602
+ const was = baseStatic.get(b.file) ?? 0;
603
+ 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`}`);
604
+ }
605
+ const baseMeasured = new Map(base.measured.map((b) => [measuredKey(b.route, b.check, b.element), b.count]));
606
+ for (const b of current.measured) {
607
+ const was = baseMeasured.get(measuredKey(b.route, b.check, b.element)) ?? 0;
608
+ 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})`}`);
609
+ }
610
+ return out;
611
+ }
612
+ var hasUnexcusedEntries = (b) => [...b.static, ...b.measured].some((e) => !hasReason(e));
613
+ var PAGE_FILE = /(?:^|\/)page\.(?:tsx|ts|jsx|js|mdx)$/;
614
+ var INTERCEPTING = /^\(\.{1,3}\)/;
615
+ function routesFromPages(files) {
616
+ const routes = /* @__PURE__ */ new Set();
617
+ for (const file of files) {
618
+ const rel = file.replace(/\\/g, "/");
619
+ if (!PAGE_FILE.test(rel)) continue;
620
+ const dirs = rel.split("/").slice(0, -1);
621
+ if (dirs.some((d) => INTERCEPTING.test(d) || d.startsWith("_"))) continue;
622
+ const url = dirs.filter((d) => !/^\(.*\)$/.test(d) && !d.startsWith("@"));
623
+ routes.add(`/${url.join("/")}`);
624
+ }
625
+ return [...routes].sort();
626
+ }
627
+ function routesFromManifest(manifest) {
628
+ const routes = /* @__PURE__ */ new Set();
629
+ for (const [key, route] of Object.entries(manifest ?? {})) {
630
+ if (!/(?:^|\/)page$/.test(key) || typeof route !== "string") continue;
631
+ if (route.split("/").some((d) => d.startsWith("_"))) continue;
632
+ routes.add(route);
633
+ }
634
+ return [...routes].sort();
635
+ }
636
+ var segmentsOf = (route) => route.split("/").filter(Boolean);
637
+ var rank = (seg) => seg.startsWith("[[...") ? 3 : seg.startsWith("[...") ? 2 : seg.startsWith("[") ? 1 : 0;
638
+ var cleanRoute = (route) => {
639
+ const path = route.replace(/[?#].*$/, "").replace(/\/+$/, "");
640
+ return path === "" ? "/" : path;
641
+ };
642
+ function routeMatches(pattern, route) {
643
+ if (pattern === route) return true;
644
+ const want = segmentsOf(pattern);
645
+ const have = segmentsOf(route);
646
+ for (let i = 0; i < want.length; i++) {
647
+ const seg = want[i];
648
+ if (seg.startsWith("[[...")) return true;
649
+ if (seg.startsWith("[...")) return have.length > i;
650
+ if (have[i] === void 0) return false;
651
+ if (!seg.startsWith("[") && seg !== have[i]) return false;
652
+ }
653
+ return want.length === have.length;
654
+ }
655
+ var moreSpecific = (a, b) => {
656
+ const x = segmentsOf(a).map(rank);
657
+ const y = segmentsOf(b).map(rank);
658
+ for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] < y[i];
659
+ return x.length > y.length;
660
+ };
661
+ var excludes = (entry, page) => entry.endsWith("/*") ? page === entry.slice(0, -2) || page.startsWith(entry.slice(0, -1)) : entry === page;
662
+ function judgeRoutes(pages, measuredRoutes, excluded = []) {
663
+ const problems = [];
664
+ excluded.forEach((e, i) => {
665
+ const at = `excludeRoutes[${i}]`;
666
+ const x = e;
667
+ if (typeof x?.route !== "string" || !x.route.startsWith("/")) problems.push(`${at}: names no route (start it with /)`);
668
+ 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.`);
669
+ 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.`);
670
+ });
671
+ const named = excluded.filter((e) => typeof e?.route === "string" && typeof e.reason === "string" && e.reason.trim());
672
+ const covered = /* @__PURE__ */ new Set();
673
+ for (const route of measuredRoutes.map(cleanRoute)) {
674
+ const hit = pages.filter((p) => routeMatches(p, route)).sort((a, b) => moreSpecific(a, b) ? -1 : moreSpecific(b, a) ? 1 : 0)[0];
675
+ if (hit) covered.add(hit);
676
+ }
677
+ for (const page of pages) if (named.some((e) => excludes(e.route, page))) covered.add(page);
678
+ return { missing: pages.filter((p) => !covered.has(p)), problems, covered: covered.size };
679
+ }
563
680
 
564
681
  // src/phone/viewports.ts
565
682
  var BANDS = [
@@ -603,16 +720,16 @@ var DEFAULT_THRESHOLDS = {
603
720
  var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
604
721
  function formatWidths(failing, tested) {
605
722
  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]));
723
+ const rank2 = new Map(plain.map((name, i) => [name, i]));
607
724
  const names = [...new Set(failing)];
608
725
  const runs = [];
609
- for (const name of names.filter((n) => rank.has(n)).sort((a, b) => rank.get(a) - rank.get(b))) {
726
+ for (const name of names.filter((n) => rank2.has(n)).sort((a, b) => rank2.get(a) - rank2.get(b))) {
610
727
  const last = runs.at(-1);
611
- if (last && rank.get(name) === rank.get(last.at(-1)) + 1) last.push(name);
728
+ if (last && rank2.get(name) === rank2.get(last.at(-1)) + 1) last.push(name);
612
729
  else runs.push([name]);
613
730
  }
614
731
  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(", ");
732
+ return [...out, ...names.filter((n) => !rank2.has(n))].join(", ");
616
733
  }
617
734
  var keyOf = (f) => `${f.check}\0${f.element.replace(/("[^]*)$/, (t) => t.replace(/\d+/g, "#"))}`;
618
735
  function mergeViewportFindings(perViewport, tested) {
@@ -777,7 +894,7 @@ async function measureSite(browser, options) {
777
894
  async function measurePhone(page, options) {
778
895
  if (!options.route.startsWith("/")) throw new Error(`measurePhone: route "${options.route}" should start with /`);
779
896
  await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
780
- const findings = await page.evaluate(measurePhoneInPage, PHONE_FLOORS);
897
+ const findings = await page.evaluate(measurePhoneInPage, { ...PHONE_FLOORS, ...options.floors ?? {} });
781
898
  const viewport = page.viewportSize?.() ?? void 0;
782
899
  const result = { route: options.route, ...viewport ? { viewport } : {}, findings };
783
900
  const dir = options.out ?? DEFAULT_MEASURED_DIR;
@@ -801,8 +918,13 @@ async function measurePhone(page, options) {
801
918
  WIDE_WIDTHS,
802
919
  bandOf,
803
920
  checkExceptions,
921
+ cleanRoute,
804
922
  formatWidths,
923
+ hasReason,
924
+ hasUnexcusedEntries,
925
+ judgeGrowth,
805
926
  judgeMeasured,
927
+ judgeRoutes,
806
928
  judgeStatic,
807
929
  mainActionFor,
808
930
  measurePhone,
@@ -813,8 +935,12 @@ async function measurePhone(page, options) {
813
935
  measureViewports,
814
936
  mergeViewportFindings,
815
937
  normaliseElement,
938
+ parseBaseline,
816
939
  reasonOf,
817
940
  routeFile,
941
+ routeMatches,
942
+ routesFromManifest,
943
+ routesFromPages,
818
944
  scanPhoneFirst,
819
945
  sweepWidths,
820
946
  writeBaseline
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,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 };