@domandigital/craft 0.21.0 → 0.22.1

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 +12 -0
  2. package/CHARACTER.md +134 -3
  3. package/COPY.md +10 -3
  4. package/PRINCIPLES.md +9 -0
  5. package/README.md +20 -1
  6. package/design-review.schema.json +156 -0
  7. package/dist/{audit-LQ2TDISV.js → audit-CDMZ6JHT.js} +3 -3
  8. package/dist/audit.cjs +258 -10
  9. package/dist/audit.js +258 -10
  10. package/dist/{chunk-6DRBFDWT.js → chunk-D6TAYXOS.js} +1 -1
  11. package/dist/{chunk-G47ZCBD5.js → chunk-IAU4ZAIU.js} +2 -2
  12. package/dist/{chunk-STWH5EYU.js → chunk-ICEG4S56.js} +5 -5
  13. package/dist/{chunk-BYXDX66D.js → chunk-JT2NLOQI.js} +4 -4
  14. package/dist/{chunk-FNB3NP57.js → chunk-KH56TEFK.js} +5 -5
  15. package/dist/{chunk-DE2ZAV4G.js → chunk-MLYHSPF7.js} +166 -3
  16. package/dist/{chunk-ONA73THJ.js → chunk-NV6FKMID.js} +93 -8
  17. package/dist/{chunk-4WAIOZ4W.js → chunk-WTR77PEM.js} +14 -14
  18. package/dist/{chunk-BQ7H23QP.js → chunk-XSKLVEI6.js} +109 -2
  19. package/dist/{cli-YSGPGQ7R.js → cli-223CUNU3.js} +9 -9
  20. package/dist/{cli-XAWJWK7I.js → cli-3QV4FKM4.js} +6 -6
  21. package/dist/{cli-PBCU7Z64.js → cli-4PXDNTZX.js} +4 -4
  22. package/dist/{cli-7QL26YPR.js → cli-6SMNHOCJ.js} +1 -1
  23. package/dist/{cli-5VHX5EJF.js → cli-7PPDBN6N.js} +5 -5
  24. package/dist/{cli-5QI6NIZN.js → cli-CQUORKMC.js} +6 -6
  25. package/dist/{cli-EYHRJJAX.js → cli-FKWJF4V4.js} +8 -5
  26. package/dist/{cli-RHDNHL4U.js → cli-IQ5U76AZ.js} +5 -5
  27. package/dist/cli.js +3 -3
  28. package/dist/index.cjs +261 -10
  29. package/dist/index.d.cts +49 -3
  30. package/dist/index.d.ts +49 -3
  31. package/dist/index.js +260 -10
  32. package/dist/phone.cjs +57 -7
  33. package/dist/phone.d.cts +28 -2
  34. package/dist/phone.d.ts +28 -2
  35. package/dist/phone.js +54 -7
  36. package/dist/{viewports-cli-XYYX4SKV.js → viewports-cli-H6UIUMBC.js} +43 -82
  37. package/package.json +1 -1
  38. package/word-lists.json +11 -2
@@ -9,13 +9,13 @@ import {
9
9
  runHarvest,
10
10
  runNull,
11
11
  serveDir
12
- } from "./chunk-FNB3NP57.js";
12
+ } from "./chunk-KH56TEFK.js";
13
13
  import "./chunk-OQXTQQAU.js";
14
- import "./chunk-4WAIOZ4W.js";
14
+ import "./chunk-WTR77PEM.js";
15
15
  import "./chunk-QIEDBL4J.js";
16
16
  import "./chunk-WUACK2HX.js";
17
- import "./chunk-DE2ZAV4G.js";
18
- import "./chunk-ONA73THJ.js";
17
+ import "./chunk-MLYHSPF7.js";
18
+ import "./chunk-NV6FKMID.js";
19
19
  import "./chunk-QYBAVU3P.js";
20
20
  export {
21
21
  describeTypicality,
@@ -9,7 +9,7 @@ import {
9
9
  STOCK_PHRASES,
10
10
  countWords,
11
11
  specificity
12
- } from "./chunk-ONA73THJ.js";
12
+ } from "./chunk-NV6FKMID.js";
13
13
 
14
14
  // src/register/cli.ts
15
15
  import { existsSync, readFileSync } from "fs";
@@ -4,11 +4,11 @@ import {
4
4
  import {
5
5
  expandNullTargets,
6
6
  loadNull
7
- } from "./chunk-FNB3NP57.js";
7
+ } from "./chunk-KH56TEFK.js";
8
8
  import "./chunk-OQXTQQAU.js";
9
9
  import {
10
10
  parseFlags
11
- } from "./chunk-4WAIOZ4W.js";
11
+ } from "./chunk-WTR77PEM.js";
12
12
  import {
13
13
  toJson
14
14
  } from "./chunk-QIEDBL4J.js";
@@ -21,8 +21,8 @@ import {
21
21
  fingerprint,
22
22
  scanSource,
23
23
  typicality
24
- } from "./chunk-DE2ZAV4G.js";
25
- import "./chunk-ONA73THJ.js";
24
+ } from "./chunk-MLYHSPF7.js";
25
+ import "./chunk-NV6FKMID.js";
26
26
  import {
27
27
  SNAPSHOT_VERSION
28
28
  } from "./chunk-QYBAVU3P.js";
@@ -188,7 +188,7 @@ async function runCalibrate(args, io) {
188
188
  if (toTake.length) {
189
189
  io.err(`craft calibrate: taking ${toTake.length} snapshot${toTake.length === 1 ? "" : "s"}`);
190
190
  try {
191
- const { snapshotUrls } = await import("./audit-LQ2TDISV.js");
191
+ const { snapshotUrls } = await import("./audit-CDMZ6JHT.js");
192
192
  const results = await snapshotUrls(toTake.map((t) => t.url));
193
193
  results.forEach((r, i) => {
194
194
  const { entry, keep } = toTake[i];
@@ -4,15 +4,15 @@ import {
4
4
  formatPairs,
5
5
  loadEstate,
6
6
  runEstate
7
- } from "./chunk-STWH5EYU.js";
8
- import "./chunk-G47ZCBD5.js";
9
- import "./chunk-FNB3NP57.js";
7
+ } from "./chunk-ICEG4S56.js";
8
+ import "./chunk-IAU4ZAIU.js";
9
+ import "./chunk-KH56TEFK.js";
10
10
  import "./chunk-OQXTQQAU.js";
11
- import "./chunk-4WAIOZ4W.js";
11
+ import "./chunk-WTR77PEM.js";
12
12
  import "./chunk-QIEDBL4J.js";
13
13
  import "./chunk-WUACK2HX.js";
14
- import "./chunk-DE2ZAV4G.js";
15
- import "./chunk-ONA73THJ.js";
14
+ import "./chunk-MLYHSPF7.js";
15
+ import "./chunk-NV6FKMID.js";
16
16
  import "./chunk-QYBAVU3P.js";
17
17
  export {
18
18
  formatComponentPairs,
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  EMPTY_BASELINE,
3
3
  checkExceptions,
4
+ formatScreens,
4
5
  hasUnexcusedEntries,
5
6
  judgeGrowth,
6
7
  judgeMeasured,
@@ -11,16 +12,16 @@ import {
11
12
  routesFromPages,
12
13
  scanPhoneFirst,
13
14
  writeBaseline
14
- } from "./chunk-BQ7H23QP.js";
15
+ } from "./chunk-XSKLVEI6.js";
15
16
  import {
16
17
  SOURCE_FILE,
17
18
  readPaths
18
- } from "./chunk-4WAIOZ4W.js";
19
+ } from "./chunk-WTR77PEM.js";
19
20
  import {
20
21
  toJson
21
22
  } from "./chunk-QIEDBL4J.js";
22
- import "./chunk-DE2ZAV4G.js";
23
- import "./chunk-ONA73THJ.js";
23
+ import "./chunk-MLYHSPF7.js";
24
+ import "./chunk-NV6FKMID.js";
24
25
  import "./chunk-QYBAVU3P.js";
25
26
 
26
27
  // src/phone/cli.ts
@@ -213,7 +214,9 @@ ${lines.map((l) => ` ${l}`).join("\n")}` : "";
213
214
  covered && app.routes.length ? `${app.routes.length} page route${app.routes.length === 1 ? "" : "s"} covered` : null
214
215
  ].filter(Boolean).join(", ");
215
216
  if (!failed) {
216
- io.out(`craft phone-first: pass. ${summary}.${[baseNote, routeNote].filter(Boolean).map((n) => `
217
+ const screens = routes ? formatScreens(routes) : "";
218
+ io.out(`craft phone-first: pass. ${summary}.${screens ? `
219
+ phone screens to the footer: ${screens}` : ""}${[baseNote, routeNote].filter(Boolean).map((n) => `
217
220
  note: ${n}`).join("")}`);
218
221
  return 0;
219
222
  }
@@ -19,10 +19,10 @@ import {
19
19
  checkReason,
20
20
  researchPrompt,
21
21
  validateDirection
22
- } from "./chunk-G47ZCBD5.js";
22
+ } from "./chunk-IAU4ZAIU.js";
23
23
  import {
24
24
  parseFlags
25
- } from "./chunk-4WAIOZ4W.js";
25
+ } from "./chunk-WTR77PEM.js";
26
26
  import {
27
27
  toJson
28
28
  } from "./chunk-QIEDBL4J.js";
@@ -38,8 +38,8 @@ import {
38
38
  isCream,
39
39
  oklchToRgb,
40
40
  parseColour
41
- } from "./chunk-DE2ZAV4G.js";
42
- import "./chunk-ONA73THJ.js";
41
+ } from "./chunk-MLYHSPF7.js";
42
+ import "./chunk-NV6FKMID.js";
43
43
  import "./chunk-QYBAVU3P.js";
44
44
 
45
45
  // src/direction/cli.ts
@@ -484,7 +484,7 @@ var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
484
484
  async function snapshotFrom(target, cwd) {
485
485
  if (!target) return void 0;
486
486
  if (/^https?:\/\//.test(target)) {
487
- const { snapshotUrl } = await import("./audit-LQ2TDISV.js");
487
+ const { snapshotUrl } = await import("./audit-CDMZ6JHT.js");
488
488
  return snapshotUrl(target);
489
489
  }
490
490
  return readSnapshot(readJson(resolve(cwd, target)), target);
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  run
4
- } from "./chunk-4WAIOZ4W.js";
4
+ } from "./chunk-WTR77PEM.js";
5
5
  import "./chunk-QIEDBL4J.js";
6
- import "./chunk-DE2ZAV4G.js";
7
- import "./chunk-ONA73THJ.js";
6
+ import "./chunk-MLYHSPF7.js";
7
+ import "./chunk-NV6FKMID.js";
8
8
  import "./chunk-QYBAVU3P.js";
9
9
 
10
10
  // src/cli.ts
package/dist/index.cjs CHANGED
@@ -108,6 +108,7 @@ __export(index_exports, {
108
108
  REVEAL_LIMIT: () => REVEAL_LIMIT,
109
109
  REVIEW_BASES: () => REVIEW_BASES,
110
110
  REVIEW_PHRASES: () => REVIEW_PHRASES,
111
+ REVIEW_VERDICTS: () => REVIEW_VERDICTS,
111
112
  SAMPLES_PER_SEGMENT: () => SAMPLES_PER_SEGMENT,
112
113
  SCALE: () => SCALE,
113
114
  SECTION_ROLES: () => SECTION_ROLES,
@@ -2029,7 +2030,17 @@ var REVIEW_PHRASES = [
2029
2030
  "by far the most",
2030
2031
  "hands down the",
2031
2032
  "arguably the best",
2032
- "bar none"
2033
+ "bar none",
2034
+ // "plain" as a self-description, the way "clear" is: say what the reader gets.
2035
+ "plain answer",
2036
+ "plain answers",
2037
+ "plain recommendation",
2038
+ "plain explanation",
2039
+ "plain explanations",
2040
+ "plain advice",
2041
+ "plain guidance",
2042
+ "plain-speaking",
2043
+ "plain speaking"
2033
2044
  ];
2034
2045
  var SALES_TAGLINES = [
2035
2046
  /\bwhere it (?:really |truly )?(?:counts|matters)(?: most)?\b/i,
@@ -2085,6 +2096,34 @@ var CLIPPED = [
2085
2096
  // "Alright Carlo, built you a proper site": the "I" left out
2086
2097
  /(?<=^|[\n>"“]|[.!?—–-]\s*|,\s+)(?:[Bb]uilt|[Mm]ade|[Pp]ut together|[Kk]nocked up)\s+(?:you|your\s+\w+|the\s+\w+)\s+an?\b/
2087
2098
  ];
2099
+ var PLAIN_FAMILY = [
2100
+ /\b(?:plain english|explained plainly|plainly explained)\b/i,
2101
+ /\bplainly\b(?!\s+marked\b)/i,
2102
+ /\bin\s+plain\s+(?:terms|language|words|speak)\b/i
2103
+ ];
2104
+ var CONFESSION = [
2105
+ // "What this is and is not:", "what we are and aren't"
2106
+ /\bwhat\s+(?:this|it|we|our\s+\w+)\s+(?:is|are)\s+and\s+(?:is\s+not|isn['’]t|are\s+not|aren['’]t)\b/i,
2107
+ // "What we are not", a label on its own; not "what we are not responsible for"
2108
+ /(?<!\band\s)\bwhat\s+we(?:\s+are|['’]re)\s+not\b(?!\s+(?:responsible|liable|able|allowed|sure)\b)/i,
2109
+ /\bwhat\s+we\s+(?:don['’]t|do\s+not)\s+do\b/i,
2110
+ // "We have not yet built and run", "we haven't yet worked with"
2111
+ /\bwe(?:\s+have|['’]ve)\s+not\s+yet\s+(?:built|run|done|worked|delivered|shipped|launched)\b/i,
2112
+ /\bwe\s+haven['’]t\s+(?:yet\s+)?(?:built|run|done|worked\s+with|delivered)\b/i,
2113
+ /\bwhat\s+we\s+have\s+and\s+(?:haven['’]t|have\s+not)\s+(?:done|built|run)\b/i,
2114
+ // "Our clinic experience so far is one practice"
2115
+ /\bour\s+(?:[\w-]+\s+){0,3}experience\s+so\s+far\s+(?:is|has\s+been|covers)\b/i,
2116
+ /\bso\s+far\s+(?:is|has\s+been)\s+(?:one|a\s+single|just\s+one)\b/i,
2117
+ /\bwhere\s+our\s+experience\s+(?:applies|ends|stops|runs\s+out)\b/i,
2118
+ /\bwhat\s+we\s+(?:would|['’]d|will|still)\s+need\s+to\s+learn\b/i,
2119
+ // "what we would and wouldn't take on", "what we won't take on"
2120
+ /\b(?:would|will)\s+and\s+(?:wouldn['’]t|would\s+not|won['’]t|will\s+not)\s+take\s+on\b/i,
2121
+ /\bwhat\s+we\s+(?:won['’]t|wouldn['’]t|would\s+not|will\s+not)\s+take\s+on\b/i,
2122
+ // "Who we don't work with", "who this is not for", "we're not for everyone"
2123
+ /\bwho\s+we\s+(?:don['’]t|do\s+not|won['’]t|will\s+not)\s+(?:work\s+with|serve|help|take\s+on)\b/i,
2124
+ /\bwho\s+(?:this|it|we)\s+(?:is|are|['’]re)\s+not\s+for\b|\bwho\s+(?:this|it)\s+isn['’]t\s+for\b/i,
2125
+ /\b(?:we|this)\s+(?:are|is|['’]re|['’]s)\s+not\s+(?:for\s+everyone|the\s+right\s+fit\s+for)\b/i
2126
+ ];
2088
2127
  var NEGATION = [
2089
2128
  // "It's not just a haircut, it's an experience."
2090
2129
  /\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,
@@ -2214,12 +2253,25 @@ var COPY_TELLS = [
2214
2253
  generation: 2,
2215
2254
  severity: "warn",
2216
2255
  surface: "copy",
2217
- why: "Told to avoid jargon, a model announces that it is avoiding jargon. 'Explained plainly' swaps a synonym and keeps the tell.",
2218
- fix: "Use the house replacement, 'properly explained', or drop the claim and let the copy show it.",
2219
- detect: phrases([/\b(?:plain english|explained plainly|plainly explained)\b/i], quoted),
2256
+ why: "Told to avoid jargon, a model announces that it is avoiding jargon: 'in plain English', 'stated plainly', 'we'll tell you plainly', 'in plain terms'. The founder bans the whole family (20 October 2026); a reader takes it as filler.",
2257
+ fix: "Drop the adverb and say the thing: 'Our prices, stated plainly' becomes 'Our prices'. Where the claim matters, the house replacement for 'plain English' is 'properly explained'.",
2258
+ detect: phrases(PLAIN_FAMILY, quoted),
2220
2259
  fixtures: {
2221
- flag: [f("content/home.md", "Your options, in plain English.")],
2222
- pass: [f("content/home.md", "Your options, properly explained.")]
2260
+ flag: [
2261
+ f("content/home.md", "Your options, in plain English."),
2262
+ // Eunice Ordor's draft lines, Pastel 986559 #12 and #13 (her diagnosis taken, her wording not).
2263
+ f("content/clinics.md", "Tell us what's happening and we'll say plainly what we would take on."),
2264
+ f("content/pricing.md", "DD's payment terms, plainly stated."),
2265
+ f("content/clinics.md", "We explain our findings in plain terms."),
2266
+ f("content/clinics.md", "We share findings in plain language.")
2267
+ ],
2268
+ pass: [
2269
+ f("content/home.md", "Your options, properly explained."),
2270
+ f("content/pricing.md", "Our payment terms: 30% when you sign, the rest at launch."),
2271
+ // The Apache and zlib licence wording, copied into a repo as it is.
2272
+ f("LICENSE.txt", "Altered source versions must be plainly marked as such."),
2273
+ f("content/menu.md", "Plain or seeded bagels, and plain-language allergen notes.")
2274
+ ]
2223
2275
  }
2224
2276
  },
2225
2277
  {
@@ -2270,11 +2322,11 @@ var COPY_TELLS = [
2270
2322
  generation: 1,
2271
2323
  severity: "warn",
2272
2324
  surface: "copy",
2273
- why: "Truly, genuinely, clear, ultimately, 'the single most', 'bar none': normal English once, and a tell when they stack. The house rules keep them for review, never blocking.",
2325
+ why: "Truly, genuinely, clear, ultimately, 'a plain recommendation', 'the single most', 'bar none': normal English once, and a tell when they stack. The house rules keep them for review, never blocking.",
2274
2326
  fix: "Keep the one that lands hardest and cut the rest. 'What is genuinely excellent' reads better as 'what is excellent'.",
2275
2327
  detect: phrases([new RegExp(`(?:^|\\b)(?:${[...REVIEW_PHRASES].sort((a, b) => b.length - a.length).map((p) => escape(p).replace(/^(\w)/, "\\b$1").replace(/(\w)$/, "$1\\b")).join("|")})`, "i")], quoted),
2276
2328
  fixtures: {
2277
- flag: [f("content/home.md", "We are truly proud of it."), f("content/home.md", "You leave with a clear next step.")],
2329
+ flag: [f("content/home.md", "We are truly proud of it."), f("content/home.md", "You leave with a clear next step."), f("content/process.md", "You get a plain recommendation and someone who knows your systems.")],
2278
2330
  pass: [f("content/home.md", "We have fitted 400 boilers since 2009."), f("content/home.md", "Keep a safe clearance around the meter.")]
2279
2331
  }
2280
2332
  },
@@ -2355,6 +2407,40 @@ var COPY_TELLS = [
2355
2407
  ]
2356
2408
  }
2357
2409
  },
2410
+ {
2411
+ id: "confession-line",
2412
+ name: "Confession line",
2413
+ generation: 2,
2414
+ severity: "warn",
2415
+ surface: "copy",
2416
+ why: "'What this is and is not', 'we have not yet built', 'our experience so far is one practice', 'what we would and wouldn't take on': a model scopes a business by confessing its limits. It reads as small print, and it turns a buyer away before they make contact.",
2417
+ fix: "Scope by being precise about what is true, and leave the door open: 'Built and run by us for Sensphere, a practice whose clinicians work across several sites.' Keep contractual facts (eligibility, who runs what) and say them as what the buyer gets.",
2418
+ detect: phrases(CONFESSION, quoted),
2419
+ fixtures: {
2420
+ flag: [
2421
+ // Each one from the DD site, as Eunice Ordor flagged it (Pastel 986559 #8, #12, #13).
2422
+ f("content/clinics.md", "What this is and is not: a practice portal we built and run for one practice."),
2423
+ f("content/clinics.md", "We have not yet built and run the digital systems of a practice with several clinicians."),
2424
+ f("content/home.md", "Including the client portal we run for Sensphere. Our clinic experience so far is one practice."),
2425
+ f("content/clinics.md", "Tell us what's happening and we'll say what we would and wouldn't take on."),
2426
+ f("content/clinics.md", "We will say what carries over from this work and what we would need to learn first."),
2427
+ f("content/clinics.md", "We will tell you where our experience applies directly and where it is close."),
2428
+ f("content/about.md", "What we are not"),
2429
+ f("content/about.md", "Who we don't work with: anyone who needs a site this week."),
2430
+ f("content/mobile.md", "Mobile is our newest line, so we tell you what we have and haven't done.")
2431
+ ],
2432
+ pass: [
2433
+ f("content/clinics.md", "Built and run by us for Sensphere, a growing practice whose clinicians work remotely."),
2434
+ f("content/home.md", "The client portal we run for Sensphere."),
2435
+ // Contractual facts, said as they are.
2436
+ f("content/pricing.md", "Pay monthly is open to UK residents aged 18 and over, subject to status."),
2437
+ f("content/pricing.md", "Your monthly payments do not buy the site; the buy-out price does."),
2438
+ f("content/terms.md", "What we are responsible for, and what we are not responsible for, is in clause 4."),
2439
+ f("content/insurance.md", "Ask what your policy does and does not cover."),
2440
+ f("emails/deposit.md", "We have not yet received your deposit, so the date is still open.")
2441
+ ]
2442
+ }
2443
+ },
2358
2444
  {
2359
2445
  id: "hollow-imperative",
2360
2446
  name: "Hollow imperative",
@@ -3621,6 +3707,69 @@ function eyebrowChipHits(ctx) {
3621
3707
  }
3622
3708
  return hits;
3623
3709
  }
3710
+ var HERO_OPEN = /<(section|header|div|main|article|Section|Container)\b[^>]*?\b(?:class|className|id)\s*=\s*(?:"|'|\{\s*[`"']?)[^"'`>]*?(?<![a-z])hero(?![a-z])[^>]*>/gi;
3711
+ function closeOf(text, tag, from) {
3712
+ const re = new RegExp(`<(/?)${tag}\\b[^>]*?(/?)>`, "g");
3713
+ re.lastIndex = from;
3714
+ let depth = 0;
3715
+ for (let m = re.exec(text); m; m = re.exec(text)) {
3716
+ if (m[2] === "/") continue;
3717
+ depth += m[1] === "/" ? -1 : 1;
3718
+ if (depth === 0) return m.index + m[0].length;
3719
+ if (m.index - from > 8e3) break;
3720
+ }
3721
+ return Math.min(text.length, from + 4e3);
3722
+ }
3723
+ function heroSpans(path, text) {
3724
+ if (/(?:^|[\\/])[\w-]*hero[\w-]*\.(?:astro|tsx|jsx|vue|svelte)$/i.test(path)) return [[0, text.length]];
3725
+ const spans = [];
3726
+ for (const m of text.matchAll(HERO_OPEN)) {
3727
+ const at = m.index ?? 0;
3728
+ if (spans.some(([a, b]) => at >= a && at < b)) continue;
3729
+ spans.push([at, closeOf(text, m[1], at)]);
3730
+ }
3731
+ if (spans.length > 0) return spans;
3732
+ const h1 = text.search(/<h1\b/);
3733
+ if (h1 === -1) return [];
3734
+ const rest = text.slice(h1);
3735
+ const end = rest.search(/<\/(?:section|header)>|<section\b/);
3736
+ return [[h1, h1 + Math.min(end === -1 ? rest.length : end, 1500)]];
3737
+ }
3738
+ var CONTROL = /<(a|button|Button|ButtonLink|LinkButton|Link|Cta|CTA|CallToAction)\b([^>]*)>([\s\S]{0,300}?)<\/\1>/g;
3739
+ var BUTTON_COMPONENT = /^(?:Button|ButtonLink|LinkButton|Cta|CTA|CallToAction)$/;
3740
+ var BUTTON_CLASS = /(?<![a-z])(?:btn|button|cta)(?![a-z])/i;
3741
+ var TOGGLE = /\baria-(?:expanded|controls|haspopup)\b|\btype\s*=\s*["']submit["']/;
3742
+ var NOT_A_LANDING = /(?:^|[\\/])(?:error|global-error|not-found|404|500)\.\w+$|[\\/](?:admin|portal|dashboard|account|\(qa\)|\(app\)|qa)[\\/]/i;
3743
+ function buttonLabel(attrs, inner) {
3744
+ const text = inner.replace(/<[^>]*>/g, " ").replace(/\{[^}]*\}/g, " ").replace(/\s+/g, " ").trim();
3745
+ if (text && !/[{}()=]/.test(text)) return text.slice(0, 40);
3746
+ return attrs.match(/\bhref\s*=\s*["'{`$]*\{?([^"'}`\s]+)/)?.[1] ?? "a button";
3747
+ }
3748
+ function heroCtaPairHits(ctx) {
3749
+ const hits = [];
3750
+ for (const file of ctx.files) {
3751
+ if (file.kind !== "markup" || NOT_A_LANDING.test(file.path)) continue;
3752
+ const text = file.text.replace(/\\"/g, ' "');
3753
+ for (const [start, end] of heroSpans(file.path, text)) {
3754
+ const span = text.slice(start, end).replace(/<(nav|form)\b[\s\S]*?<\/\1>/gi, (s) => " ".repeat(s.length));
3755
+ const buttons = [];
3756
+ for (const m of span.matchAll(CONTROL)) {
3757
+ const [, tag, attrs, inner] = m;
3758
+ if (TOGGLE.test(attrs)) continue;
3759
+ const cls = attrs.match(/\b(?:class|className|variant)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/g)?.join(" ") ?? "";
3760
+ const tw = /\bpx-\d/.test(cls) && /\bpy-\d/.test(cls) && /\b(?:bg-|border\b|border-|rounded)/.test(cls);
3761
+ if (BUTTON_COMPONENT.test(tag) || BUTTON_CLASS.test(cls) || tw || /\brole\s*=\s*["']button["']/.test(attrs)) {
3762
+ buttons.push({ label: buttonLabel(attrs, inner), offset: start + (m.index ?? 0) });
3763
+ }
3764
+ }
3765
+ if (buttons.length >= 2) {
3766
+ const names = buttons.slice(0, 3).map((b) => `"${b.label}"`).join(", ");
3767
+ hits.push({ path: file.path, offset: buttons[1].offset, message: `${buttons.length} buttons side by side in the hero: ${names}` });
3768
+ }
3769
+ }
3770
+ }
3771
+ return hits;
3772
+ }
3624
3773
  function iconTiles(file) {
3625
3774
  return file.classUnits.filter((unit) => {
3626
3775
  const sized = has(unit, /^size-(?:8|9|10|11|12|14)$/) || (find(unit, /^h-(?:8|9|10|11|12|14)$/) ?? "x").slice(2) === (find(unit, /^w-(?:8|9|10|11|12|14)$/) ?? "y").slice(2);
@@ -4071,6 +4220,75 @@ const inter = Inter({ subsets: ["latin"] });`),
4071
4220
  <span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
4072
4221
  }
4073
4222
  },
4223
+ {
4224
+ id: "hero-cta-pair",
4225
+ name: "Two buttons in the hero",
4226
+ generation: 1,
4227
+ severity: "warn",
4228
+ surface: "source",
4229
+ why: "A filled button beside an outline or ghost one ('Get a free quote' / 'See our plans') is in 140 of 140 heroes Claude builds for a brief. Two equal asks make the visitor choose before they know anything, and say nobody decided what the page is for.",
4230
+ fix: "Keep one button, the ask the page exists for. Put the second route in the copy as a text link ('or see the work first'), or lower down where it answers a question.",
4231
+ detect: heroCtaPairHits,
4232
+ fixtures: {
4233
+ flag: [
4234
+ // Eunice Ordor's #5 and #36 (Pastel 986559): the DD site's own hero and process close.
4235
+ f3(
4236
+ "src/components/Hero.astro",
4237
+ `<section class="hero">
4238
+ <h1>We run the systems behind your website</h1>
4239
+ <a href="/contact" class="btn btn-primary">Get in touch</a>
4240
+ <a href="/work" class="btn btn-ghost">See our work</a>
4241
+ </section>`
4242
+ ),
4243
+ // The null build's hero, verbatim in shape (calibration/null/rmp/pages/05.html).
4244
+ f3(
4245
+ "index.html",
4246
+ `<nav><a class="btn" href="#contact">Quote</a></nav>
4247
+ <section id="hero" class="hero">
4248
+ <h1>Electricians in Uxbridge</h1>
4249
+ <a href="#contact" class="btn btn-primary">Book a Free Quote</a>
4250
+ <a href="tel:+441895555210" class="btn btn-outline">Call 01895 555 210</a>
4251
+ </section>`
4252
+ ),
4253
+ f3(
4254
+ "app/page.tsx",
4255
+ `<section className="relative py-24">
4256
+ <h1 className="text-5xl">Nails in Brackley</h1>
4257
+ <div className="flex gap-4">
4258
+ <Button asChild><a href="/book">Book now</a></Button>
4259
+ <Button variant="outline">View treatments</Button>
4260
+ </div>
4261
+ </section>`
4262
+ )
4263
+ ],
4264
+ pass: [
4265
+ // One button, and the second route as a text link in the sentence.
4266
+ f3(
4267
+ "src/components/Hero.astro",
4268
+ `<section class="hero">
4269
+ <h1>We run the systems behind your website</h1>
4270
+ <p>Tell us what's going on, or <a href="/work">see the work first</a>.</p>
4271
+ <a href="/contact" class="btn btn-primary">Get in touch</a>
4272
+ </section>`
4273
+ ),
4274
+ // A button in the nav and a search form are not the hero's ask.
4275
+ f3(
4276
+ "index.html",
4277
+ `<header class="hero">
4278
+ <nav><a class="btn" href="/book">Book</a></nav>
4279
+ <h1>Barbers in Manchester</h1>
4280
+ <a class="btn" href="/book">Book a cut</a>
4281
+ <form><button class="btn" type="submit">Search</button></form>
4282
+ </header>`
4283
+ ),
4284
+ f3("app/page.tsx", `<section className="py-24">
4285
+ <h1 className="text-5xl">Nails in Brackley</h1>
4286
+ <a className="rounded bg-ink px-5 py-3" href="/book">Book now</a>
4287
+ </section>
4288
+ <section><a className="rounded bg-ink px-5 py-3" href="/prices">Prices</a></section>`)
4289
+ ]
4290
+ }
4291
+ },
4074
4292
  {
4075
4293
  id: "icon-tile-stack",
4076
4294
  name: "Icon in a tinted tile",
@@ -5296,7 +5514,9 @@ var HOUSE = {
5296
5514
  "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
5297
5515
  "ai-phrase": { tier: "block", label: "AI tell" },
5298
5516
  "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
5299
- "plain-english": { tier: "block", label: 'use "properly explained"' },
5517
+ // Widened 2026-10-20 (founder) to bare "plainly" and "in plain terms/language/words";
5518
+ // estate hits read first (DOM-1058), nearly all the stock "stated plainly".
5519
+ "plain-english": { tier: "block", label: 'the plain-English family. Drop the adverb and say the thing, or use "properly explained"' },
5300
5520
  buzzword: { tier: "block", label: "forbidden phrase" },
5301
5521
  "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
5302
5522
  // Evidence of a pasted chat reply, not a matter of style. Moved from review
@@ -5310,6 +5530,8 @@ var HOUSE = {
5310
5530
  "stock-email-line": { tier: "review", label: "stock email line, start with the reason for writing and end with the next step" },
5311
5531
  // New 2026-10-19 (founder): review first, like every new tell, until estate hits are read.
5312
5532
  "matey-register": { tier: "review", label: "matey register, write plain and complete British English" },
5533
+ // New 2026-10-20 (founder, from Eunice Ordor's review): review first, like every new tell.
5534
+ "confession-line": { tier: "review", label: "confession line, scope the work by saying precisely what is true" },
5313
5535
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
5314
5536
  "stock-phrase": { tier: "review", label: "stock phrase" },
5315
5537
  "hollow-imperative": { tier: "review", label: "hollow imperative" },
@@ -5546,6 +5768,33 @@ var RENDERED_PATHS = {
5546
5768
  detect: (s) => s.effects.eyebrowChip ? [hit(s, "a small bordered pill sits above the headline")] : [],
5547
5769
  fixtures: { flag: [makeSnapshot({ effects: { eyebrowChip: true } })], pass: [makeSnapshot()] }
5548
5770
  },
5771
+ "hero-cta-pair": {
5772
+ // Version 2 geometry counts buttons and button-styled links per section. A
5773
+ // header painted inside the hero's box adds its own button, which the
5774
+ // source path can tell apart and this one cannot; a ghost button with no
5775
+ // fill or border is not counted, so this path under-reports.
5776
+ detect: (s) => {
5777
+ const first = s.sections[0];
5778
+ const controls = first?.geometry?.controls ?? 0;
5779
+ return first && (first.role ?? first.kind) === "hero" && controls >= 2 ? [hit(s, `${controls} buttons in the hero`, first.label)] : [];
5780
+ },
5781
+ fixtures: {
5782
+ flag: [
5783
+ {
5784
+ ...makeSnapshot(),
5785
+ sections: makeSnapshot().sections.map((x, i) => i === 0 ? { ...x, role: "hero", geometry: { centredShare: 0.2, mirrorSymmetry: 0.6, whitespaceRatio: 0.6, contentWidthRatio: 0.8, background: "rgb(255, 255, 255)", controls: 2 } } : x)
5786
+ }
5787
+ ],
5788
+ pass: [
5789
+ makeSnapshot(),
5790
+ // One button, with the second route as a text link: one control.
5791
+ {
5792
+ ...makeSnapshot(),
5793
+ sections: makeSnapshot().sections.map((x, i) => i === 0 ? { ...x, role: "hero", geometry: { centredShare: 0.2, mirrorSymmetry: 0.6, whitespaceRatio: 0.6, contentWidthRatio: 0.8, background: "rgb(255, 255, 255)", controls: 1 } } : x)
5794
+ }
5795
+ ]
5796
+ }
5797
+ },
5549
5798
  "icon-tile-grid": {
5550
5799
  detect: (s) => s.sections.filter((x) => x.cards >= 3 && x.iconCards >= 3).map((x) => hit(s, `${x.iconCards} icon cards in a row`, x.label)),
5551
5800
  fixtures: {
@@ -6515,7 +6764,7 @@ var IMAGERY_TELLS = [
6515
6764
  ];
6516
6765
 
6517
6766
  // src/character/check.ts
6518
- var CATALOGUE_VERSION = "2026.10.4";
6767
+ var CATALOGUE_VERSION = "2026.10.5";
6519
6768
  var CATALOGUE = [
6520
6769
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
6521
6770
  ...SPECIFICITY_COPY_TELLS,
@@ -8694,6 +8943,7 @@ var INSPECTED_KINDS = ["screenshot", "rendered", "source", "measurement", "conte
8694
8943
  var REPORTED_KINDS = ["audit", "stakeholder", "customer"];
8695
8944
  var EVIDENCE_KINDS = [...INSPECTED_KINDS, ...REPORTED_KINDS];
8696
8945
  var CHANGE_CLASSES = ["confirmed-correction", "design-hypothesis", "optional-refinement", "no-change"];
8946
+ var REVIEW_VERDICTS = ["praise", "take", "take-reworded", "partly", "decline", "founder-decision", "known"];
8697
8947
  var CHANGE_STEPS = ["copy", "relocate", "group", "imagery", "trust-placement", "adjust-component", "remove-redundancy", "restructure", "new-component"];
8698
8948
  var ADDITIONS = ["carousel", "javascript", "dependency", "animation", "testimonial", "cta", "duplicate-content"];
8699
8949
 
@@ -10458,6 +10708,7 @@ function retrofitPlan(report2, direction) {
10458
10708
  REVEAL_LIMIT,
10459
10709
  REVIEW_BASES,
10460
10710
  REVIEW_PHRASES,
10711
+ REVIEW_VERDICTS,
10461
10712
  SAMPLES_PER_SEGMENT,
10462
10713
  SCALE,
10463
10714
  SECTION_ROLES,