@domandigital/craft 0.13.0 → 0.15.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 (37) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/CHARACTER.md +272 -39
  3. package/COPY.md +5 -0
  4. package/README.md +19 -1
  5. package/art-direction.schema.json +428 -25
  6. package/dist/{audit-5TYDYRXV.js → audit-MTUC24XU.js} +3 -2
  7. package/dist/audit.cjs +674 -92
  8. package/dist/audit.d.cts +15 -1
  9. package/dist/audit.d.ts +15 -1
  10. package/dist/audit.js +674 -92
  11. package/dist/{chunk-IMB4V34S.js → chunk-2IQ5OXFR.js} +7 -7
  12. package/dist/{chunk-GMVXADJ6.js → chunk-LFNSB6W7.js} +711 -92
  13. package/dist/{chunk-3VTPI7BU.js → chunk-MUVFECFC.js} +74 -96
  14. package/dist/chunk-OQXTQQAU.js +27 -0
  15. package/dist/{chunk-ZLDA2SYB.js → chunk-OUQ25LY2.js} +47 -6
  16. package/dist/{chunk-K2BZN4GA.js → chunk-QU6Q2QZZ.js} +49 -14
  17. package/dist/chunk-QYBAVU3P.js +6 -0
  18. package/dist/{chunk-ESCVTKPN.js → chunk-RXXRXV3V.js} +9 -26
  19. package/dist/{chunk-CZDOMIVS.js → chunk-WUACK2HX.js} +2 -1
  20. package/dist/chunk-YDURAOYR.js +711 -0
  21. package/dist/cli-22TSWAX6.js +21 -0
  22. package/dist/cli-5ACKICBO.js +22 -0
  23. package/dist/{cli-DRKAMFAH.js → cli-7OZCYOQY.js} +89 -22
  24. package/dist/{cli-J5ECRCD4.js → cli-FCKHYAVZ.js} +40 -16
  25. package/dist/{cli-ILH7OO5G.js → cli-FLG3ZCAF.js} +6 -4
  26. package/dist/cli.js +3 -2
  27. package/dist/generate.js +6 -0
  28. package/dist/index.cjs +1618 -328
  29. package/dist/index.d.cts +619 -58
  30. package/dist/index.d.ts +619 -58
  31. package/dist/index.js +1555 -328
  32. package/dist/migrate-VIFAYIBM.js +9 -0
  33. package/package.json +1 -1
  34. package/word-lists.json +1 -1
  35. package/dist/chunk-ZZ5CKOJP.js +0 -295
  36. package/dist/cli-BKJMLD3H.js +0 -18
  37. package/dist/cli-N2IDXH7W.js +0 -20
package/dist/index.js CHANGED
@@ -490,9 +490,9 @@ function fluidClamp(minPx, maxPx, minViewport, maxViewport, rootPx = 16) {
490
490
  const interceptRem = round((minPx - slope * minViewport) / rootPx);
491
491
  const slopeVw = round(slope * 100);
492
492
  const preferred = interceptRem === 0 ? `${slopeVw}vw` : `${interceptRem}rem + ${slopeVw}vw`;
493
- const lower = Math.min(minRem, maxRem);
493
+ const lower2 = Math.min(minRem, maxRem);
494
494
  const upper = Math.max(minRem, maxRem);
495
- return `clamp(${lower}rem, ${preferred}, ${upper}rem)`;
495
+ return `clamp(${lower2}rem, ${preferred}, ${upper}rem)`;
496
496
  }
497
497
  function fluidType(options = {}) {
498
498
  const {
@@ -1286,7 +1286,7 @@ function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => fal
1286
1286
  };
1287
1287
  }
1288
1288
  var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/'/g, "['\u2019]?").replace(/ /g, "\\s+");
1289
- var wordList = (words2) => new RegExp(`\\b(?:${[...words2].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
1289
+ var wordList = (words3) => new RegExp(`\\b(?:${[...words3].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
1290
1290
  var quoted = (m) => `"${m}"`;
1291
1291
  var AI_WORDS = [
1292
1292
  "tapestry",
@@ -2124,8 +2124,8 @@ function documents(ctx) {
2124
2124
  const docs = [];
2125
2125
  for (const [path, blocks] of byPath) {
2126
2126
  const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
2127
- const words2 = countWords(text);
2128
- if (words2 >= MIN_WORDS) docs.push({ path, blocks, words: words2 });
2127
+ const words3 = countWords(text);
2128
+ if (words3 >= MIN_WORDS) docs.push({ path, blocks, words: words3 });
2129
2129
  }
2130
2130
  return docs;
2131
2131
  }
@@ -2250,14 +2250,14 @@ function contractionHits(ctx) {
2250
2250
  for (const doc2 of documents(ctx)) {
2251
2251
  if (doc2.words < 800) continue;
2252
2252
  const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
2253
- const count3 = (text.match(CONTRACTION) ?? []).length;
2254
- const rate = count3 * 1e3 / doc2.words;
2253
+ const count4 = (text.match(CONTRACTION) ?? []).length;
2254
+ const rate = count4 * 1e3 / doc2.words;
2255
2255
  if (rate < 1) {
2256
2256
  const first = sentences(doc2)[0];
2257
2257
  hits.push({
2258
2258
  path: doc2.path,
2259
2259
  offset: first?.offset ?? 0,
2260
- message: `${count3} contraction${count3 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
2260
+ message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
2261
2261
  });
2262
2262
  }
2263
2263
  }
@@ -2269,14 +2269,14 @@ function rhythmHits(ctx) {
2269
2269
  const list = sentences(doc2);
2270
2270
  if (list.length < 40) continue;
2271
2271
  const lengths = list.map((s) => countWords(s.text));
2272
- const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
2273
- const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
2274
- const cv = sd / mean;
2275
- if (cv < 0.42) {
2272
+ const mean2 = lengths.reduce((a, b) => a + b, 0) / lengths.length;
2273
+ const sd2 = Math.sqrt(lengths.reduce((a, b) => a + (b - mean2) ** 2, 0) / lengths.length);
2274
+ const cv2 = sd2 / mean2;
2275
+ if (cv2 < 0.42) {
2276
2276
  hits.push({
2277
2277
  path: doc2.path,
2278
2278
  offset: list[0].offset,
2279
- message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
2279
+ message: `${list.length} sentences averaging ${mean2.toFixed(1)} words with little variation (spread ${cv2.toFixed(2)}, reads metronomic below 0.42)`
2280
2280
  });
2281
2281
  }
2282
2282
  }
@@ -2290,19 +2290,19 @@ function repeatHits(ctx) {
2290
2290
  const whole = /* @__PURE__ */ new Map();
2291
2291
  const runs = /* @__PURE__ */ new Map();
2292
2292
  for (const s of sentences(doc2)) {
2293
- const words2 = normal(s.text);
2294
- if (words2.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
2293
+ const words3 = normal(s.text);
2294
+ if (words3.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
2295
2295
  if (/^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
2296
- const key = words2.join(" ");
2296
+ const key = words3.join(" ");
2297
2297
  const earlier = whole.get(key);
2298
2298
  if (earlier) {
2299
- hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words2.length} words)`, excerpt: s.text.slice(0, 120) });
2299
+ hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words3.length} words)`, excerpt: s.text.slice(0, 120) });
2300
2300
  continue;
2301
2301
  }
2302
2302
  whole.set(key, s);
2303
2303
  let reported = false;
2304
- for (let i = 0; i + RUN <= words2.length && !reported; i += 1) {
2305
- const run = words2.slice(i, i + RUN).join(" ");
2304
+ for (let i = 0; i + RUN <= words3.length && !reported; i += 1) {
2305
+ const run = words3.slice(i, i + RUN).join(" ");
2306
2306
  const seen = runs.get(run);
2307
2307
  if (seen && seen.key !== key) {
2308
2308
  hits.push({ path: doc2.path, offset: s.offset, message: `shares a ${RUN}-word run with an earlier sentence: "${run}"`, excerpt: s.text.slice(0, 120) });
@@ -2359,11 +2359,11 @@ function headingEchoHits(ctx) {
2359
2359
  const hits = [];
2360
2360
  for (const { file } of longFiles(ctx)) {
2361
2361
  for (const heading of headings(file)) {
2362
- const words2 = normal(heading.text);
2363
- if (words2.length < 4) continue;
2362
+ const words3 = normal(heading.text);
2363
+ if (words3.length < 4) continue;
2364
2364
  const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
2365
- const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words2.length);
2366
- if (opening.join(" ") === words2.join(" ")) {
2365
+ const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words3.length);
2366
+ if (opening.join(" ") === words3.join(" ")) {
2367
2367
  hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
2368
2368
  }
2369
2369
  }
@@ -2371,7 +2371,7 @@ function headingEchoHits(ctx) {
2371
2371
  return hits;
2372
2372
  }
2373
2373
  var FILLER_WORDS = "boiler van kitchen garden studio market street window roof tile paint brush ladder hedge lawn fence gate shop counter oven bread flour kettle chair table shelf lamp cable socket switch meter valve pipe tap sink drain gutter brick mortar timber plank screw bolt hinge lock key door frame glass mirror towel basin morning evening winter summer spring autumn monday tuesday friday weekend hour minute week month year fitted checked cleaned painted mended booked quoted delivered collected measured sanded sealed wired local quiet small large early late busy bright warm dry".split(" ");
2374
- function filler(words2, seed = 1, options = {}) {
2374
+ function filler(words3, seed = 1, options = {}) {
2375
2375
  const { contractions = true, uniform } = options;
2376
2376
  const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
2377
2377
  let state = seed;
@@ -2380,16 +2380,16 @@ function filler(words2, seed = 1, options = {}) {
2380
2380
  return state % n;
2381
2381
  };
2382
2382
  const out = [];
2383
- let count3 = 0;
2383
+ let count4 = 0;
2384
2384
  let i = 0;
2385
- while (count3 < words2) {
2385
+ while (count4 < words3) {
2386
2386
  const length = uniform ?? shape[i % shape.length];
2387
2387
  const s = [];
2388
2388
  for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
2389
2389
  if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
2390
2390
  s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
2391
2391
  out.push(`${s.join(" ")}.`);
2392
- count3 += s.length;
2392
+ count4 += s.length;
2393
2393
  i += 1;
2394
2394
  }
2395
2395
  const paragraphs = [];
@@ -3529,64 +3529,76 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
3529
3529
  }
3530
3530
  ];
3531
3531
 
3532
- // src/character/house.ts
3533
- var HOUSE = {
3534
- "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
3535
- emoji: { tier: "block", label: "emoji" },
3536
- "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
3537
- "no-x-no-y": {
3538
- tier: "block",
3539
- label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
3540
- },
3541
- "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
3542
- "ai-phrase": { tier: "block", label: "AI tell" },
3543
- "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
3544
- "plain-english": { tier: "block", label: 'use "properly explained"' },
3545
- buzzword: { tier: "block", label: "forbidden phrase" },
3546
- "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
3547
- // Evidence of a pasted chat reply, not a matter of style. Moved from review
3548
- // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
3549
- "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
3550
- "review-phrase": { tier: "review", label: "review tier" },
3551
- "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3552
- "stock-phrase": { tier: "review", label: "stock phrase" },
3553
- "hollow-imperative": { tier: "review", label: "hollow imperative" },
3554
- "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
3555
- "staccato-triplet": { tier: "review", label: "staccato triplet" },
3556
- "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
3557
- "ing-tail": { tier: "review", label: "empty -ing tail" },
3558
- "vague-attribution": { tier: "review", label: "unnamed source" },
3559
- "closing-summary": { tier: "review", label: "closing summary" },
3560
- "false-range": { tier: "review", label: "false range" },
3561
- "placeholder": { tier: "review", label: "unfilled placeholder" },
3562
- // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
3563
- // a proposal can honestly say "the attached research". Review until measured there.
3564
- "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
3565
- "question-reveal": { tier: "review", label: "staged reveal" },
3566
- "inline-label-list": { tier: "review", label: "bold-label bullets" },
3567
- "phrase-density": { tier: "review", label: "density" },
3568
- "aphorism-density": { tier: "review", label: "density" },
3569
- "contraction-scarcity": { tier: "review", label: "density" },
3570
- "sentence-rhythm": { tier: "review", label: "density" },
3571
- "repeated-sentence": { tier: "review", label: "repetition" },
3572
- "heading-shape": { tier: "review", label: "headings" },
3573
- "heading-echo": { tier: "review", label: "headings" },
3574
- // Banned only "when vague", per the rules. Too noisy for a sweep.
3575
- "vague-word": { tier: "explicit", label: "vague" }
3576
- };
3577
- function houseRule(id, name = id) {
3578
- return HOUSE[id] ?? { tier: "review", label: name };
3579
- }
3580
- var HOUSE_LOCKED = "the house blocking tier cannot be lowered by a repo. A genuine one-off takes copy-ok on its line; a rule that is wrong is changed in craft's house.ts and COPY.md";
3581
- function holdHouseBlocks(exceptions, severity) {
3582
- const blocks = (tell) => HOUSE[tell]?.tier === "block";
3583
- const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
3584
- return {
3585
- exceptions: exceptions.filter((e) => !blocks(e.tell)),
3586
- refused: exceptions.filter((e) => blocks(e.tell)),
3587
- severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
3588
- refusedSeverity
3532
+ // src/snapshot/types.ts
3533
+ var SNAPSHOT_VERSION = 2;
3534
+
3535
+ // src/snapshot/fixture.ts
3536
+ function makeSnapshot(patch = {}) {
3537
+ const base = {
3538
+ version: SNAPSHOT_VERSION,
3539
+ url: "https://example.test/",
3540
+ capturedAt: "2026-09-23T00:00:00.000Z",
3541
+ viewport: { width: 1440, height: 900 },
3542
+ pageHeight: 4200,
3543
+ ground: "rgb(255, 255, 255)",
3544
+ colours: {
3545
+ backgrounds: [
3546
+ { value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
3547
+ { value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
3548
+ ],
3549
+ text: [
3550
+ { value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
3551
+ { value: "rgb(31, 58, 46)", area: 0, chars: 300 }
3552
+ ]
3553
+ },
3554
+ fonts: [
3555
+ { family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
3556
+ { family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
3557
+ ],
3558
+ headings: [
3559
+ { level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
3560
+ { level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
3561
+ ],
3562
+ controls: [
3563
+ { kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
3564
+ { kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
3565
+ { kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
3566
+ { kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
3567
+ ],
3568
+ sections: [
3569
+ { top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
3570
+ { top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
3571
+ { top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
3572
+ { top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
3573
+ { top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
3574
+ ],
3575
+ effects: {
3576
+ glass: 0,
3577
+ gradientText: 0,
3578
+ gradients: [],
3579
+ glows: 0,
3580
+ gridBackgrounds: 0,
3581
+ marquees: 0,
3582
+ hairlineShadowCards: 0,
3583
+ eyebrowChip: false
3584
+ },
3585
+ motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
3586
+ metrics: {
3587
+ fontSizes: [14, 16, 20, 36, 64],
3588
+ fontWeights: [400, 500],
3589
+ radii: ["4px"],
3590
+ shadows: [],
3591
+ spacingOnScalePct: 96,
3592
+ bodyLineLengthCh: 68,
3593
+ bodyFontSizePx: 17
3594
+ }
3589
3595
  };
3596
+ const out = { ...base };
3597
+ for (const [key, value] of Object.entries(patch)) {
3598
+ const current = base[key];
3599
+ out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
3600
+ }
3601
+ return out;
3590
3602
  }
3591
3603
 
3592
3604
  // src/character/parse.ts
@@ -3759,15 +3771,15 @@ var blank = (s) => s.replace(/[^\n]/g, " ");
3759
3771
  function readsAsProse(value) {
3760
3772
  if (value.length < 12 || !/\s/.test(value)) return false;
3761
3773
  if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
3762
- const words2 = withoutMarkup(value);
3763
- if (looksLikeCode(words2) || /[<>]/.test(words2)) return false;
3764
- return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words2);
3774
+ const words3 = withoutMarkup(value);
3775
+ if (looksLikeCode(words3) || /[<>]/.test(words3)) return false;
3776
+ return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words3);
3765
3777
  }
3766
3778
  function withoutMarkup(value) {
3767
3779
  return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
3768
3780
  }
3769
- function looksLikeCode(words2) {
3770
- return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words2);
3781
+ function looksLikeCode(words3) {
3782
+ return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words3);
3771
3783
  }
3772
3784
  var UTILITY = /^(?:flex|grid|block|inline|hidden|contents|relative|absolute|fixed|sticky|static|rounded|border|shadow|underline|italic|uppercase|lowercase|capitalize|truncate|container|group|peer|transition|antialiased|prose|sr-only|outline|ring|grow|shrink|isolate|invisible|visible|collapse)$/;
3773
3785
  function looksLikeClasses(value) {
@@ -3920,76 +3932,664 @@ function extractStrings(file) {
3920
3932
  return [];
3921
3933
  }
3922
3934
 
3923
- // src/snapshot/types.ts
3924
- var SNAPSHOT_VERSION = 2;
3935
+ // src/character/facts.ts
3936
+ var MONTH = "(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)";
3937
+ var PATTERNS = [
3938
+ ["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
3939
+ ["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
3940
+ ["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
3941
+ ["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
3942
+ ["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
3943
+ ["date", new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH}\\b(?:\\s+\\d{4})?|\\b${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?\\b(?:,?\\s+\\d{4})?|\\b${MONTH}\\s+\\d{4}\\b|\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b|\\b(?:19|20)\\d{2}\\b`, "g")],
3944
+ ["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
3945
+ ["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
3946
+ ];
3947
+ var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
3948
+ var normalise = (kind, value) => {
3949
+ const v = value.trim();
3950
+ if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
3951
+ if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
3952
+ if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
3953
+ if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
3954
+ return v.replace(/\s+/g, " ").toLowerCase();
3955
+ };
3956
+ function visibleText(file) {
3957
+ const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
3958
+ return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
3959
+ }
3960
+ function protectedFacts(text) {
3961
+ const facts = [];
3962
+ let rest = text;
3963
+ const mask = (start, length) => {
3964
+ rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
3965
+ };
3966
+ for (const [kind, pattern] of PATTERNS) {
3967
+ for (const m of [...rest.matchAll(pattern)]) {
3968
+ const value = m[0].replace(/[.,;:]+$/, "");
3969
+ facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
3970
+ mask(m.index ?? 0, m[0].length);
3971
+ }
3972
+ }
3973
+ const lines = rest.split("\n");
3974
+ for (const line of lines) {
3975
+ if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
3976
+ for (const m of line.matchAll(NAME)) {
3977
+ const at = m.index ?? 0;
3978
+ const before = line.slice(0, at).trimEnd();
3979
+ const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
3980
+ const word = m[0];
3981
+ const acronym = /^[A-Z]{2,}$/.test(word);
3982
+ if (opensSentence && !acronym && !/\s/.test(word)) continue;
3983
+ if (word === "I") continue;
3984
+ facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
3985
+ }
3986
+ }
3987
+ return facts;
3988
+ }
3989
+ var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
3990
+ function compareFacts(before, after) {
3991
+ const a = unique(protectedFacts(before));
3992
+ const b = unique(protectedFacts(after));
3993
+ const inA = new Set(a.map((f4) => f4.key));
3994
+ const inB = new Set(b.map((f4) => f4.key));
3995
+ return {
3996
+ before: a.length,
3997
+ after: b.length,
3998
+ lost: a.filter((f4) => !inB.has(f4.key)),
3999
+ added: b.filter((f4) => !inA.has(f4.key))
4000
+ };
4001
+ }
4002
+ function formatComparison(result, beforePath, afterPath) {
4003
+ const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
4004
+ if (result.lost.length) {
4005
+ lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
4006
+ for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
4007
+ }
4008
+ if (result.added.length) {
4009
+ lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
4010
+ for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
4011
+ }
4012
+ if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
4013
+ return lines.join("\n");
4014
+ }
3925
4015
 
3926
- // src/snapshot/fixture.ts
3927
- function makeSnapshot(patch = {}) {
3928
- const base = {
3929
- version: SNAPSHOT_VERSION,
3930
- url: "https://example.test/",
3931
- capturedAt: "2026-09-23T00:00:00.000Z",
3932
- viewport: { width: 1440, height: 900 },
3933
- pageHeight: 4200,
3934
- ground: "rgb(255, 255, 255)",
3935
- colours: {
3936
- backgrounds: [
3937
- { value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
3938
- { value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
4016
+ // src/character/specificity.ts
4017
+ var TRADE_NOUNS = [
4018
+ // heating, plumbing, electrical
4019
+ "boiler",
4020
+ "radiator",
4021
+ "combi",
4022
+ "cylinder",
4023
+ "thermostat",
4024
+ "underfloor heating",
4025
+ "heat pump",
4026
+ "solar panel",
4027
+ "plumbing",
4028
+ "plumber",
4029
+ "leak",
4030
+ "toilet",
4031
+ "shower",
4032
+ "bathroom",
4033
+ "wet room",
4034
+ "drain",
4035
+ "fuse board",
4036
+ "fuseboard",
4037
+ "consumer unit",
4038
+ "rewire",
4039
+ "socket",
4040
+ "ev charger",
4041
+ "electrician",
4042
+ "gas engineer",
4043
+ "power flush",
4044
+ "landlord certificate",
4045
+ "eicr",
4046
+ // building and outside
4047
+ "extension",
4048
+ "loft conversion",
4049
+ "loft",
4050
+ "kitchen",
4051
+ "roof",
4052
+ "roofer",
4053
+ "gutter",
4054
+ "chimney",
4055
+ "driveway",
4056
+ "patio",
4057
+ "fence",
4058
+ "fencing",
4059
+ "decking",
4060
+ "window",
4061
+ "conservatory",
4062
+ "plastering",
4063
+ "plasterer",
4064
+ "render",
4065
+ "brickwork",
4066
+ "bricklayer",
4067
+ "tiling",
4068
+ "tiler",
4069
+ "flooring",
4070
+ "carpet",
4071
+ "joinery",
4072
+ "joiner",
4073
+ "carpenter",
4074
+ "staircase",
4075
+ "damp",
4076
+ "scaffolding",
4077
+ "garage conversion",
4078
+ "lawn",
4079
+ "hedge",
4080
+ "tree surgery",
4081
+ "stump",
4082
+ "landscaping",
4083
+ "gardener",
4084
+ "gate",
4085
+ "locksmith",
4086
+ "pest control",
4087
+ "wasp",
4088
+ "cleaning",
4089
+ "oven",
4090
+ "end of tenancy",
4091
+ // vehicles
4092
+ "mot",
4093
+ "tyre",
4094
+ "brake",
4095
+ "clutch",
4096
+ "cambelt",
4097
+ "exhaust",
4098
+ "gearbox",
4099
+ "valeting",
4100
+ // personal and health
4101
+ "haircut",
4102
+ "skin fade",
4103
+ "beard",
4104
+ "barber",
4105
+ "balayage",
4106
+ "nails",
4107
+ "lashes",
4108
+ "massage",
4109
+ "physio",
4110
+ "physiotherapy",
4111
+ "osteopath",
4112
+ "chiropractor",
4113
+ "dentist",
4114
+ "dental",
4115
+ "implant",
4116
+ "brace",
4117
+ "invisalign",
4118
+ "hygienist",
4119
+ "optician",
4120
+ "eye test",
4121
+ "vet",
4122
+ "grooming",
4123
+ "dog walking",
4124
+ "personal trainer",
4125
+ "yoga",
4126
+ "pilates",
4127
+ // professional
4128
+ "accountant",
4129
+ "tax return",
4130
+ "bookkeeping",
4131
+ "payroll",
4132
+ "vat",
4133
+ "self assessment",
4134
+ "solicitor",
4135
+ "conveyancing",
4136
+ "probate",
4137
+ "divorce",
4138
+ "lasting power of attorney",
4139
+ "mortgage",
4140
+ "surveyor",
4141
+ "architect",
4142
+ "planning permission",
4143
+ "building regulations",
4144
+ // web and digital, for the agency's own pages
4145
+ "website",
4146
+ "web design",
4147
+ "seo",
4148
+ "hosting",
4149
+ "google business profile",
4150
+ "online shop",
4151
+ "ecommerce",
4152
+ // food and hospitality
4153
+ "breakfast",
4154
+ "lunch",
4155
+ "dinner",
4156
+ "coffee",
4157
+ "bakery",
4158
+ "cake",
4159
+ "catering",
4160
+ "wedding",
4161
+ "venue"
4162
+ ];
4163
+ var STOCK_FIGURES = [
4164
+ /\b24\s?\/\s?7\b/gi,
4165
+ /\b24[\s-]hours?\b/gi,
4166
+ /\b1[01]0\s?%/g,
4167
+ /\b(?:5|five)[\s-]?star\b/gi,
4168
+ /#\s?1\b/g,
4169
+ /\bno\.?\s?1\b/gi,
4170
+ /\b365\s+days\b/gi
4171
+ ];
4172
+ var NOT_NAMES = new Set(
4173
+ "a an the and of for to in on at by with your you our we us it get free quote quotes call now today book booking contact learn more read see view explore discover start started home about services service quality trusted trust expert experts expertise professional professionals best solutions solution digital team welcome why choose how works local reliable affordable fast friendly premium leading excellence excellent results success growth business businesses customer customers client clients care support help new our story mission vision values innovation innovative modern simple easy smart better great amazing perfect ultimate transform elevate empower unlock experience experienced dedicated passionate committed tailored bespoke seamless peace mind can do all every everything more less here there this that what who where when need needs make difference satisfaction guaranteed".split(" ")
4174
+ );
4175
+ var BRIEF_STOP = new Set(
4176
+ "a an the and or of for to in on at by with from who that which what their they them its it is are was were be been being has have had do does did can will would should could may might must this these those there here as into over under business company small local family run based owned services service provides provide offering offers people customers clients home homes house houses area areas around near across nearby town city county village uk".split(" ")
4177
+ );
4178
+ var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
4179
+ var countWords2 = (text) => (text.match(WORD2) ?? []).length;
4180
+ var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4181
+ var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
4182
+ var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
4183
+ var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
4184
+ function caseless(line) {
4185
+ const words3 = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
4186
+ const letters = words3.filter((w) => w.length >= 2);
4187
+ if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
4188
+ const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
4189
+ if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
4190
+ return null;
4191
+ }
4192
+ var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
4193
+ function briefTerms(brief) {
4194
+ const terms = /* @__PURE__ */ new Set();
4195
+ for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
4196
+ const lower2 = w.toLowerCase();
4197
+ if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower2) && !NOT_NAMES.has(lower2)) terms.add(lower2);
4198
+ }
4199
+ for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
4200
+ for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
4201
+ return [...terms];
4202
+ }
4203
+ function specifics(text, brief) {
4204
+ const found = /* @__PURE__ */ new Map();
4205
+ const add = (kind, value) => {
4206
+ const key = value.toLowerCase().replace(/\s+/g, " ").trim();
4207
+ if (key && !found.has(key)) found.set(key, { kind, value, key });
4208
+ };
4209
+ for (const raw of text.split("\n")) {
4210
+ const line = maskStock(raw);
4211
+ if (!line.trim()) continue;
4212
+ const capitals = caseless(line);
4213
+ for (const f4 of protectedFacts(line)) {
4214
+ if (f4.kind === "name") {
4215
+ if (capitals === "shout") continue;
4216
+ if (capitals === "title") {
4217
+ for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
4218
+ continue;
4219
+ }
4220
+ const words3 = f4.value.split(/\s+/);
4221
+ const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
4222
+ const first = words3.findIndex(named);
4223
+ if (first === -1) continue;
4224
+ const last = words3.length - 1 - [...words3].reverse().findIndex(named);
4225
+ add("name", words3.slice(first, last + 1).join(" "));
4226
+ continue;
4227
+ }
4228
+ add(f4.kind, f4.value);
4229
+ }
4230
+ for (const [term, re] of TRADE_PATTERNS) {
4231
+ const m = re.exec(line);
4232
+ if (m) add("trade", term);
4233
+ }
4234
+ }
4235
+ if (brief) {
4236
+ for (const term of briefTerms(brief)) {
4237
+ if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
4238
+ if (termPattern(term).test(text)) add("brief", term);
4239
+ }
4240
+ }
4241
+ return [...found.values()];
4242
+ }
4243
+ function specificity(text, brief) {
4244
+ const list = specifics(text, brief);
4245
+ const words3 = countWords2(text);
4246
+ const trade = list.filter((s) => s.kind === "trade").length;
4247
+ const hard = list.length - trade;
4248
+ return {
4249
+ words: words3,
4250
+ specifics: list,
4251
+ hard,
4252
+ trade,
4253
+ perHundred: words3 ? Math.round(list.length / words3 * 1e3) / 10 : 0,
4254
+ generic: hard === 0 && trade < 2
4255
+ };
4256
+ }
4257
+ var NOT_SERVICE_ROLES = /* @__PURE__ */ new Set(["testimonials", "team", "faq", "contact", "logos", "stats", "cta-band", "footer-cta", "marquee"]);
4258
+ var SERVICE_ROLES = /* @__PURE__ */ new Set(["features", "process", "pricing", "cards"]);
4259
+ var SERVICE_LABEL = /\bservices?\b|\bwhat we (?:do|fix|offer|make|build)\b|\bhow we\b|\bour work\b|\bwe (?:fix|fit|install|repair|build|offer|make)\b/i;
4260
+ function sentencesOf(blocks) {
4261
+ const out = [];
4262
+ for (const block of blocks) {
4263
+ for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
4264
+ const s = m[0].trim();
4265
+ if (countWords2(s) >= 3) out.push(s);
4266
+ }
4267
+ }
4268
+ return [...new Set(out)];
4269
+ }
4270
+ function pageCopy(snapshot) {
4271
+ const withText2 = snapshot.sections.filter((s) => s.text);
4272
+ if (!snapshot.firstScreenText && withText2.length === 0) return null;
4273
+ const hero = [...snapshot.firstScreenText ?? [], ...snapshot.sections.filter((s) => s.kind === "hero").flatMap((s) => s.text ?? [])];
4274
+ const services = snapshot.sections.filter((s) => s.kind !== "hero" && !NOT_SERVICE_ROLES.has(s.role ?? s.kind)).filter((s) => SERVICE_ROLES.has(s.role ?? s.kind) || SERVICE_LABEL.test(s.label)).flatMap((s) => s.text ?? []);
4275
+ const all = [...snapshot.firstScreenText ?? [], ...withText2.flatMap((s) => s.text ?? [])];
4276
+ return { url: snapshot.url, hero: sentencesOf(hero), services: sentencesOf(services), all };
4277
+ }
4278
+ function side(page, other, brief) {
4279
+ const otherKeys = new Set(specifics(other.all.join("\n"), brief).map((s) => s.key));
4280
+ const sentences3 = [.../* @__PURE__ */ new Set([...page.hero, ...page.services])];
4281
+ const interchangeable = sentences3.filter((s) => !specifics(s, brief).some((x) => !otherKeys.has(x.key)));
4282
+ return {
4283
+ url: page.url,
4284
+ sentences: sentences3.length,
4285
+ interchangeable,
4286
+ share: sentences3.length ? Math.round(interchangeable.length / sentences3.length * 1e3) / 1e3 : null,
4287
+ perHundred: specificity(sentences3.join("\n"), brief).perHundred
4288
+ };
4289
+ }
4290
+ function compareCompetitor(ours, competitor, brief) {
4291
+ return { ours: side(ours, competitor, brief), competitor: side(competitor, ours, brief), brief: Boolean(brief) };
4292
+ }
4293
+ var pctOf = (share) => share === null ? "no sentences" : `${Math.round(share * 100)}%`;
4294
+ function formatCompetitor(result) {
4295
+ const row = (label, s) => ` ${label.padEnd(11)} ${s.interchangeable.length} of ${s.sentences} (${pctOf(s.share)}), ${s.perHundred} specifics per 100 words`;
4296
+ const lines = [
4297
+ `craft copy compare: ${result.ours.url} against ${result.competitor.url}`,
4298
+ " Hero and service sentences holding nothing the other page does not also say:",
4299
+ row("ours", result.ours),
4300
+ row("competitor", result.competitor)
4301
+ ];
4302
+ if (result.brief) lines.push(" The brief's own terms were recognised on both pages.");
4303
+ if (result.ours.interchangeable.length) {
4304
+ lines.push("", " Interchangeable on ours: add the job, the place, the price or the name only this business has");
4305
+ for (const s of result.ours.interchangeable.slice(0, 12)) lines.push(` "${s.length > 110 ? `${s.slice(0, 107)}...` : s}"`);
4306
+ if (result.ours.interchangeable.length > 12) lines.push(` and ${result.ours.interchangeable.length - 12} more`);
4307
+ } else {
4308
+ lines.push("", " Every hero and service sentence on ours holds something the competitor does not say.");
4309
+ }
4310
+ return lines.join("\n");
4311
+ }
4312
+
4313
+ // src/character/tells/specificity.ts
4314
+ var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
4315
+ var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
4316
+ var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
4317
+ function literalText(inner) {
4318
+ const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
4319
+ if (/[{}]/.test(t)) return null;
4320
+ return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
4321
+ }
4322
+ var HOME_FILE = /(?:^|\/)app\/(?:\([^/]+\)\/)*page\.(?:tsx|jsx|mdx)$|(?:^|\/)pages\/index\.(?:tsx|jsx|astro|vue|mdx)$|(?:^|\/)routes\/(?:\([^/]+\)\/)*\+page\.svelte$|^(?:(?:public|src|site|www)\/)?index\.html?$|(?:^|\/)[\w-]*(?:hero|home)[\w-]*\.(?:tsx|jsx|astro|vue|svelte|html?)$/i;
4323
+ var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
4324
+ var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
4325
+ var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
4326
+ function headlines(ctx) {
4327
+ const out = [];
4328
+ for (const file of ctx.files) {
4329
+ if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
4330
+ for (const m of file.text.matchAll(H1)) {
4331
+ const h1 = literalText(m[1]);
4332
+ if (!h1) continue;
4333
+ const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
4334
+ const p = NEXT_P.exec(after);
4335
+ const sub = p ? literalText(p[1]) : null;
4336
+ out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
4337
+ }
4338
+ }
4339
+ return out;
4340
+ }
4341
+ var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
4342
+ var STANDING_CLAIM = /(?<![\w#])(?:trusted|reliable|reputable|award[- ]winning|top[- ]rated|best[- ]rated|highly (?:rated|recommended|experienced|skilled|qualified)|fully (?:qualified|certified|accredited|licensed|insured)|(?:qualified|certified|accredited|approved|registered|experienced|expert) (?:engineers?|electricians?|plumbers?|builders?|technicians?|installers?|team|tradesmen|professionals?|staff)|(?:years|decades) of experience|(?:5|five)[- ]star (?:service|rated|reviews?)|(?:happy|satisfied) (?:customers|clients)|(?:customers|clients) (?:love|trust|recommend)|number one|no\.? ?1|#1)(?![\w])/i;
4343
+ var PROOF_NAMES = [
4344
+ "Gas Safe",
4345
+ "NICEIC",
4346
+ "NAPIT",
4347
+ "ELECSA",
4348
+ "Stroma",
4349
+ "TrustMark",
4350
+ "Which? Trusted Trader",
4351
+ "Checkatrade",
4352
+ "TrustATrader",
4353
+ "Rated People",
4354
+ "MyBuilder",
4355
+ "Federation of Master Builders",
4356
+ "FMB",
4357
+ "Guild of Master Craftsmen",
4358
+ "OFTEC",
4359
+ "HETAS",
4360
+ "CIPHE",
4361
+ "APHC",
4362
+ "CHAS",
4363
+ "SafeContractor",
4364
+ "Constructionline",
4365
+ "FENSA",
4366
+ "CERTASS",
4367
+ "MCS",
4368
+ "RECC",
4369
+ "BPEC",
4370
+ "City & Guilds",
4371
+ "NVQ",
4372
+ "CSCS",
4373
+ "Worcester Bosch",
4374
+ "Vaillant",
4375
+ "Viessmann",
4376
+ "BAFE",
4377
+ "NSI",
4378
+ "SSAIB",
4379
+ "BPCA",
4380
+ "RICS",
4381
+ "CIOB",
4382
+ "RIBA",
4383
+ "ICAEW",
4384
+ "ACCA",
4385
+ "CIMA",
4386
+ "SRA",
4387
+ "Law Society",
4388
+ "GDC",
4389
+ "GMC",
4390
+ "HCPC",
4391
+ "CQC",
4392
+ "Ofsted",
4393
+ "ISO 9001",
4394
+ "ISO 27001",
4395
+ "Cyber Essentials",
4396
+ "Companies House",
4397
+ "FCA",
4398
+ "Trustpilot",
4399
+ "Feefo",
4400
+ "Reviews.io",
4401
+ "Yell",
4402
+ "Houzz"
4403
+ ];
4404
+ var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4405
+ var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
4406
+ var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
4407
+ var PROOF_PATTERNS = [
4408
+ PROOF_NAME,
4409
+ /\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
4410
+ // A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
4411
+ /\b[1-5](?:\.\d)?\s?(?:\/\s?5|out of 5)\b|\brated\s+[1-5](?:\.\d)?\b|★{3,}|\b\d[\d,]*\+?\s+(?:verified\s+)?(?:reviews|ratings)\b/i,
4412
+ // A registration or licence number.
4413
+ /\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
4414
+ ];
4415
+ var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
4416
+ function sectionHasProof(s, section, index) {
4417
+ if ((section.role ?? section.kind) === "testimonials") return true;
4418
+ const text = section.text ?? [];
4419
+ if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
4420
+ return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
4421
+ }
4422
+ function unprovenClaims(s) {
4423
+ const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
4424
+ const out = [];
4425
+ s.sections.forEach((section, i) => {
4426
+ if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
4427
+ for (const sentence of sentencesOf(section.text)) {
4428
+ const m = STANDING_CLAIM.exec(sentence);
4429
+ if (m) out.push({ claim: m[0], sentence, section: section.label });
4430
+ }
4431
+ });
4432
+ return out;
4433
+ }
4434
+ var withText = (texts, patch = () => ({})) => {
4435
+ const base = makeSnapshot();
4436
+ return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
4437
+ };
4438
+ var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
4439
+ var SPECIFICITY_COPY_TELLS = [
4440
+ {
4441
+ id: "generic-hero-claim",
4442
+ name: "Headline that fits any business",
4443
+ generation: 1,
4444
+ severity: "warn",
4445
+ surface: "copy",
4446
+ why: "The first thing a visitor reads on the home page names no place, no person, no price and no job, so it would fit every competitor's site unchanged. A model writes it when it has nothing particular to say.",
4447
+ fix: 'Put what only this business can say on the first screen: the job, the town, a price, the name. "Boilers fixed the same day in Brackley" beats "Quality you can trust".',
4448
+ detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
4449
+ fixtures: {
4450
+ flag: [
4451
+ { path: "app/page.tsx", text: '<section>\n <h1 className="text-6xl">Quality you can trust</h1>\n <p>We deliver reliable solutions tailored to your needs.</p>\n</section>' },
4452
+ // Shouted and Title Case: its capitals are not names, and its figures are stock.
4453
+ { path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
3939
4454
  ],
3940
- text: [
3941
- { value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
3942
- { value: "rgb(31, 58, 46)", area: 0, chars: 300 }
4455
+ pass: [
4456
+ { path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
4457
+ // A stock headline rescued by the line under it.
4458
+ { path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
4459
+ // Two trade nouns say what the business does.
4460
+ { path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
4461
+ // The words come from data: nothing to read.
4462
+ { path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
4463
+ // Another page's headline is a label, not the site's claim.
4464
+ { path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
3943
4465
  ]
3944
4466
  },
3945
- fonts: [
3946
- { family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
3947
- { family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
3948
- ],
3949
- headings: [
3950
- { level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
3951
- { level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
3952
- ],
3953
- controls: [
3954
- { kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
3955
- { kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
3956
- { kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
3957
- { kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
3958
- ],
3959
- sections: [
3960
- { top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
3961
- { top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
3962
- { top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
3963
- { top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
3964
- { top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
3965
- ],
3966
- effects: {
3967
- glass: 0,
3968
- gradientText: 0,
3969
- gradients: [],
3970
- glows: 0,
3971
- gridBackgrounds: 0,
3972
- marquees: 0,
3973
- hairlineShadowCards: 0,
3974
- eyebrowChip: false
3975
- },
3976
- motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
3977
- metrics: {
3978
- fontSizes: [14, 16, 20, 36, 64],
3979
- fontWeights: [400, 500],
3980
- radii: ["4px"],
3981
- shadows: [],
3982
- spacingOnScalePct: 96,
3983
- bodyLineLengthCh: 68,
3984
- bodyFontSizePx: 17
4467
+ rendered: {
4468
+ detect: (s) => {
4469
+ if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
4470
+ const text = s.firstScreenText.join("\n");
4471
+ if (!text.trim() || !specificity(text).generic) return [];
4472
+ const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
4473
+ return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
4474
+ },
4475
+ fixtures: {
4476
+ flag: [
4477
+ makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
4478
+ makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
4479
+ ],
4480
+ pass: [
4481
+ makeSnapshot(),
4482
+ makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
4483
+ makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
4484
+ // A first screen that is a picture and nothing else is not judged on its words.
4485
+ makeSnapshot({ firstScreenText: [] }),
4486
+ // Nor is any page but the home page.
4487
+ makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
4488
+ ]
4489
+ }
3985
4490
  }
4491
+ }
4492
+ ];
4493
+ var SPECIFICITY_RENDERED_TELLS = [
4494
+ {
4495
+ id: "unproven-claim",
4496
+ name: "Claim with no proof next to it",
4497
+ generation: 1,
4498
+ severity: "warn",
4499
+ surface: "rendered",
4500
+ why: `"Trusted", "fully qualified" and "award-winning" ask the visitor to take the business's word for it. A page built with no reviews and no registration number makes the claim anyway, with nothing beside it.`,
4501
+ fix: "Put the proof where the claim is: the Gas Safe or NICEIC number, the named review with its town, the rating and where it was left. Or cut the claim.",
4502
+ rendered: {
4503
+ detect: (s) => {
4504
+ if (!s.sections.some((x) => x.text)) return [];
4505
+ const found = unprovenClaims(s);
4506
+ if (found.length === 0) return [];
4507
+ const first = found[0];
4508
+ const where = first.section ? `"${first.section}"` : "an unnamed section";
4509
+ return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
4510
+ },
4511
+ fixtures: {
4512
+ flag: [
4513
+ withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
4514
+ // Proof two sections away is not next to the claim.
4515
+ withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["Recent jobs"], ["\u201CThey turned up on time and fixed the boiler in an hour.\u201D", "Sam, Brackley"]], (i) => i === 3 ? { role: "testimonials" } : {})
4516
+ ],
4517
+ pass: [
4518
+ makeSnapshot(),
4519
+ withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
4520
+ withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
4521
+ withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
4522
+ {
4523
+ ...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
4524
+ images: [{ src: "https://example.test/img/gas-safe-register.svg", host: "example.test", width: 96, height: 96, top: 1200, role: "logo", alt: "Gas Safe Register", decorative: false, section: 1, background: false }]
4525
+ }
4526
+ ]
4527
+ }
4528
+ }
4529
+ }
4530
+ ];
4531
+
4532
+ // src/character/house.ts
4533
+ var HOUSE = {
4534
+ "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
4535
+ emoji: { tier: "block", label: "emoji" },
4536
+ "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
4537
+ "no-x-no-y": {
4538
+ tier: "block",
4539
+ label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
4540
+ },
4541
+ "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
4542
+ "ai-phrase": { tier: "block", label: "AI tell" },
4543
+ "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
4544
+ "plain-english": { tier: "block", label: 'use "properly explained"' },
4545
+ buzzword: { tier: "block", label: "forbidden phrase" },
4546
+ "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
4547
+ // Evidence of a pasted chat reply, not a matter of style. Moved from review
4548
+ // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
4549
+ "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
4550
+ "review-phrase": { tier: "review", label: "review tier" },
4551
+ "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
4552
+ "stock-phrase": { tier: "review", label: "stock phrase" },
4553
+ "hollow-imperative": { tier: "review", label: "hollow imperative" },
4554
+ "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
4555
+ "staccato-triplet": { tier: "review", label: "staccato triplet" },
4556
+ "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
4557
+ "ing-tail": { tier: "review", label: "empty -ing tail" },
4558
+ "vague-attribution": { tier: "review", label: "unnamed source" },
4559
+ "closing-summary": { tier: "review", label: "closing summary" },
4560
+ "false-range": { tier: "review", label: "false range" },
4561
+ "placeholder": { tier: "review", label: "unfilled placeholder" },
4562
+ // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
4563
+ // a proposal can honestly say "the attached research". Review until measured there.
4564
+ "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
4565
+ "question-reveal": { tier: "review", label: "staged reveal" },
4566
+ "inline-label-list": { tier: "review", label: "bold-label bullets" },
4567
+ "phrase-density": { tier: "review", label: "density" },
4568
+ "aphorism-density": { tier: "review", label: "density" },
4569
+ "contraction-scarcity": { tier: "review", label: "density" },
4570
+ "sentence-rhythm": { tier: "review", label: "density" },
4571
+ "repeated-sentence": { tier: "review", label: "repetition" },
4572
+ "heading-shape": { tier: "review", label: "headings" },
4573
+ "heading-echo": { tier: "review", label: "headings" },
4574
+ // A threshold on a count, not a named pattern: a person decides whether the
4575
+ // headline needed a particular. Review until its estate hits are read.
4576
+ "generic-hero-claim": { tier: "review", label: "headline that fits any business" },
4577
+ // Banned only "when vague", per the rules. Too noisy for a sweep.
4578
+ "vague-word": { tier: "explicit", label: "vague" }
4579
+ };
4580
+ function houseRule(id, name = id) {
4581
+ return HOUSE[id] ?? { tier: "review", label: name };
4582
+ }
4583
+ var HOUSE_LOCKED = "the house blocking tier cannot be lowered by a repo. A genuine one-off takes copy-ok on its line; a rule that is wrong is changed in craft's house.ts and COPY.md";
4584
+ function holdHouseBlocks(exceptions, severity) {
4585
+ const blocks = (tell) => HOUSE[tell]?.tier === "block";
4586
+ const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
4587
+ return {
4588
+ exceptions: exceptions.filter((e) => !blocks(e.tell)),
4589
+ refused: exceptions.filter((e) => blocks(e.tell)),
4590
+ severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
4591
+ refusedSeverity
3986
4592
  };
3987
- const out = { ...base };
3988
- for (const [key, value] of Object.entries(patch)) {
3989
- const current = base[key];
3990
- out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
3991
- }
3992
- return out;
3993
4593
  }
3994
4594
 
3995
4595
  // src/snapshot/fonts.ts
@@ -4109,7 +4709,7 @@ function blueToPurple(s) {
4109
4709
  }
4110
4710
  return hits;
4111
4711
  }
4112
- var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
4712
+ var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
4113
4713
  var RENDERED_PATHS = {
4114
4714
  "reflex-font": {
4115
4715
  detect: reflexFonts(REFLEX_FONTS_1),
@@ -4147,11 +4747,11 @@ var RENDERED_PATHS = {
4147
4747
  }
4148
4748
  },
4149
4749
  "gradient-text": {
4150
- detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
4750
+ detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
4151
4751
  fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
4152
4752
  },
4153
4753
  "glass-panel": {
4154
- detect: (s) => s.effects.glass > 0 ? [hit(s, `${count(s.effects.glass, "translucent blurred panel")}`)] : [],
4754
+ detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
4155
4755
  fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
4156
4756
  },
4157
4757
  "cream-palette": {
@@ -4229,7 +4829,7 @@ var RENDERED_PATHS = {
4229
4829
  detect: (s) => {
4230
4830
  const radial = s.effects.gradients.filter((g) => g.radial).length;
4231
4831
  const n = s.effects.glows + radial;
4232
- return n > 0 ? [hit(s, `${count(n, "soft glow")} behind the content`)] : [];
4832
+ return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
4233
4833
  },
4234
4834
  fixtures: {
4235
4835
  flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
@@ -4237,11 +4837,11 @@ var RENDERED_PATHS = {
4237
4837
  }
4238
4838
  },
4239
4839
  "grid-background": {
4240
- detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
4840
+ detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
4241
4841
  fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
4242
4842
  },
4243
4843
  marquee: {
4244
- detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count(s.effects.marquees, "endless scrolling strip")}`)] : [],
4844
+ detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
4245
4845
  fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
4246
4846
  },
4247
4847
  "thin-border-wide-shadow": {
@@ -4359,9 +4959,14 @@ function faceDistance(a, b) {
4359
4959
  if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
4360
4960
  return a.class === b.class ? 0.5 : 1;
4361
4961
  }
4962
+ var ACCENT_SCALE = 0.3;
4963
+ var GROUND_SCALE = 0.15;
4964
+ var SHARED_PART = 0.2;
4965
+ var ACCENT_SHARED = ACCENT_SCALE * SHARED_PART;
4966
+ var GROUND_SHARED = GROUND_SCALE * SHARED_PART;
4362
4967
  function fingerprintDistance(a, b) {
4363
- const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / 0.3) : a.accent || b.accent ? 1 : 0;
4364
- const ground = clamp(deltaEOk(a.ground, b.ground) / 0.15);
4968
+ const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / ACCENT_SCALE) : a.accent || b.accent ? 1 : 0;
4969
+ const ground = clamp(deltaEOk(a.ground, b.ground) / GROUND_SCALE);
4365
4970
  const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
4366
4971
  const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
4367
4972
  const motion = clamp(Math.abs(a.motion - b.motion));
@@ -4472,8 +5077,8 @@ function sharedChoices(target2, runs) {
4472
5077
  const out = [];
4473
5078
  for (const [part, values] of Object.entries(mine)) {
4474
5079
  for (const value of values) {
4475
- const count3 = all.filter((c) => c[part].includes(value)).length;
4476
- if (count3 / runs.length >= 0.25) out.push({ part, value, runs: count3, of: runs.length });
5080
+ const count4 = all.filter((c) => c[part].includes(value)).length;
5081
+ if (count4 / runs.length >= 0.25) out.push({ part, value, runs: count4, of: runs.length });
4477
5082
  }
4478
5083
  }
4479
5084
  return out.sort((a, b) => b.runs - a.runs);
@@ -4496,10 +5101,10 @@ var STOP = new Set(
4496
5101
  function grams(text) {
4497
5102
  const out = /* @__PURE__ */ new Set();
4498
5103
  for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
4499
- const words2 = clause.match(/[a-z][a-z'-]*/g) ?? [];
5104
+ const words3 = clause.match(/[a-z][a-z'-]*/g) ?? [];
4500
5105
  for (let n = 2; n <= 4; n += 1) {
4501
- for (let i = 0; i + n <= words2.length; i += 1) {
4502
- const g = words2.slice(i, i + n);
5106
+ for (let i = 0; i + n <= words3.length; i += 1) {
5107
+ const g = words3.slice(i, i + n);
4503
5108
  if (STOP.has(g[0]) || STOP.has(g[n - 1])) continue;
4504
5109
  if (g.filter((w) => !STOP.has(w)).length < 2) continue;
4505
5110
  out.add(g.join(" "));
@@ -4666,7 +5271,7 @@ function closestComponents(a, b, role) {
4666
5271
  }
4667
5272
  function estateComponentPairs(register, role) {
4668
5273
  const out = [];
4669
- const s = register.sites;
5274
+ const s = register.sites.filter((site) => site.fingerprint);
4670
5275
  for (let i = 0; i < s.length; i += 1) {
4671
5276
  for (let j = i + 1; j < s.length; j += 1) {
4672
5277
  const c = closestComponents(s[i].fingerprint, s[j].fingerprint, role);
@@ -4947,7 +5552,7 @@ var picture = (patch = {}) => ({
4947
5552
  var withImages = (images) => ({ ...makeSnapshot(), images });
4948
5553
  var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
4949
5554
  var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
4950
- var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
5555
+ var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
4951
5556
  var IMAGERY_TELLS = [
4952
5557
  {
4953
5558
  id: "stock-photo",
@@ -4962,7 +5567,7 @@ var IMAGERY_TELLS = [
4962
5567
  const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
4963
5568
  if (found.length === 0) return [];
4964
5569
  const names = [...new Set(found.map((x) => x.from))].join(", ");
4965
- return [hit3(s, `${count2(found.length, "picture")} from ${names}`, found[0].image.src)];
5570
+ return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
4966
5571
  },
4967
5572
  fixtures: {
4968
5573
  flag: [
@@ -4987,7 +5592,7 @@ var IMAGERY_TELLS = [
4987
5592
  const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
4988
5593
  if (found.length === 0) return [];
4989
5594
  const names = [...new Set(found.map((x) => x.from))].join(", ");
4990
- return [hit3(s, `${count2(found.length, "portrait")} from ${names}`, found[0].image.src)];
5595
+ return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
4991
5596
  },
4992
5597
  fixtures: {
4993
5598
  flag: [
@@ -5012,7 +5617,7 @@ var IMAGERY_TELLS = [
5012
5617
  if (found.length === 0) return [];
5013
5618
  const first = found[0].provenance;
5014
5619
  const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
5015
- return [hit3(s, `${count2(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
5620
+ return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
5016
5621
  },
5017
5622
  fixtures: {
5018
5623
  flag: [
@@ -5037,7 +5642,7 @@ var IMAGERY_TELLS = [
5037
5642
  if (!s.images || s.sections.length < 4) return [];
5038
5643
  if (s.images.some((i) => i.role === "photo")) return [];
5039
5644
  const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
5040
- return [hit3(s, `no photograph on the page${icons ? `: ${count2(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
5645
+ return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
5041
5646
  },
5042
5647
  fixtures: {
5043
5648
  flag: [
@@ -5052,11 +5657,13 @@ var IMAGERY_TELLS = [
5052
5657
  ];
5053
5658
 
5054
5659
  // src/character/check.ts
5055
- var CATALOGUE_VERSION = "2026.09.11";
5660
+ var CATALOGUE_VERSION = "2026.09.12";
5056
5661
  var CATALOGUE = [
5057
5662
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
5663
+ ...SPECIFICITY_COPY_TELLS,
5058
5664
  ...RENDERED_TELLS,
5059
- ...IMAGERY_TELLS
5665
+ ...IMAGERY_TELLS,
5666
+ ...SPECIFICITY_RENDERED_TELLS
5060
5667
  ];
5061
5668
  function tellById(id) {
5062
5669
  return CATALOGUE.find((t) => t.id === id);
@@ -5133,7 +5740,7 @@ function report(tells, files, run, options) {
5133
5740
  catalogueVersion: CATALOGUE_VERSION,
5134
5741
  findings,
5135
5742
  rejectedExceptions: rejected,
5136
- excepted: [...excepted].map(([e, count3]) => ({ tell: e.tell, because: e.because, count: count3 })),
5743
+ excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
5137
5744
  suppressed,
5138
5745
  summary: {
5139
5746
  files: files.length,
@@ -5174,7 +5781,7 @@ function runTell(tell, files) {
5174
5781
  }
5175
5782
  function catalogueTable() {
5176
5783
  const rows = CATALOGUE.map(
5177
- (t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
5784
+ (t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered && t.surface !== "rendered" ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
5178
5785
  );
5179
5786
  return [
5180
5787
  `Catalogue version \`${CATALOGUE_VERSION}\`, ${CATALOGUE.length} tells.`,
@@ -5239,87 +5846,6 @@ function formatReport(report2, title) {
5239
5846
  return lines.join("\n").replace(/^\n/, "");
5240
5847
  }
5241
5848
 
5242
- // src/character/facts.ts
5243
- var MONTH = "(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)";
5244
- var PATTERNS = [
5245
- ["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
5246
- ["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
5247
- ["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
5248
- ["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
5249
- ["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
5250
- ["date", new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH}\\b(?:\\s+\\d{4})?|\\b${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?\\b(?:,?\\s+\\d{4})?|\\b${MONTH}\\s+\\d{4}\\b|\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b|\\b(?:19|20)\\d{2}\\b`, "g")],
5251
- ["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
5252
- ["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
5253
- ];
5254
- var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
5255
- var normalise = (kind, value) => {
5256
- const v = value.trim();
5257
- if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
5258
- if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
5259
- if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
5260
- if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
5261
- return v.replace(/\s+/g, " ").toLowerCase();
5262
- };
5263
- function visibleText(file) {
5264
- const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
5265
- return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
5266
- }
5267
- function protectedFacts(text) {
5268
- const facts = [];
5269
- let rest = text;
5270
- const mask = (start, length) => {
5271
- rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
5272
- };
5273
- for (const [kind, pattern] of PATTERNS) {
5274
- for (const m of [...rest.matchAll(pattern)]) {
5275
- const value = m[0].replace(/[.,;:]+$/, "");
5276
- facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
5277
- mask(m.index ?? 0, m[0].length);
5278
- }
5279
- }
5280
- const lines = rest.split("\n");
5281
- for (const line of lines) {
5282
- if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
5283
- for (const m of line.matchAll(NAME)) {
5284
- const at = m.index ?? 0;
5285
- const before = line.slice(0, at).trimEnd();
5286
- const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
5287
- const word = m[0];
5288
- const acronym = /^[A-Z]{2,}$/.test(word);
5289
- if (opensSentence && !acronym && !/\s/.test(word)) continue;
5290
- if (word === "I") continue;
5291
- facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
5292
- }
5293
- }
5294
- return facts;
5295
- }
5296
- var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
5297
- function compareFacts(before, after) {
5298
- const a = unique(protectedFacts(before));
5299
- const b = unique(protectedFacts(after));
5300
- const inA = new Set(a.map((f4) => f4.key));
5301
- const inB = new Set(b.map((f4) => f4.key));
5302
- return {
5303
- before: a.length,
5304
- after: b.length,
5305
- lost: a.filter((f4) => !inB.has(f4.key)),
5306
- added: b.filter((f4) => !inA.has(f4.key))
5307
- };
5308
- }
5309
- function formatComparison(result, beforePath, afterPath) {
5310
- const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
5311
- if (result.lost.length) {
5312
- lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
5313
- for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
5314
- }
5315
- if (result.added.length) {
5316
- lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
5317
- for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
5318
- }
5319
- if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
5320
- return lines.join("\n");
5321
- }
5322
-
5323
5849
  // src/character/claims.ts
5324
5850
  var CLAIM_KINDS = /* @__PURE__ */ new Set(["money", "number", "date"]);
5325
5851
  var PROPER = "[A-Z][\\w&.\u2019'-]*(?:\\s+(?:of|and|&|the|for)?\\s*[A-Z][\\w&.\u2019'-]*)*";
@@ -5339,9 +5865,9 @@ var ATTRIBUTION = [
5339
5865
  ];
5340
5866
  var LINK2 = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)|\bhttps?:\/\/[^\s<>"')\]]+/g;
5341
5867
  function cleanName(raw) {
5342
- const words2 = raw.trim().replace(/[.,;:]+$/, "").split(/\s+/);
5343
- while (words2.length && NOT_A_SOURCE.test(words2[0])) words2.shift();
5344
- return words2.join(" ").replace(/(?:’s|'s)(?=\s|$).*$/, "").trim();
5868
+ const words3 = raw.trim().replace(/[.,;:]+$/, "").split(/\s+/);
5869
+ while (words3.length && NOT_A_SOURCE.test(words3[0])) words3.shift();
5870
+ return words3.join(" ").replace(/(?:’s|'s)(?=\s|$).*$/, "").trim();
5345
5871
  }
5346
5872
  var IMPERATIVE = /^(?:Add|Use|Check|Make|Get|Set|Include|Create|Update|List|Keep|Ask|Write|Track|Test|Claim|Build|Link|Submit|Verify|Review|Fix|Remove|Upload|Respond|Reply|Choose|Pick|Start|Run|Open|Install|Connect|Enable|Turn|Put|Give|Send|Book|Call|Read|See|Try|Compare|Aim|Target|Avoid|Post|Share|Mark|Tag)$/;
5347
5873
  var atStart = (sentence, index) => /^[\s*_"“”’(>#|-]*(?:\d{1,3}[.)]\s*)?[\s*_]*(?:[\w ]+:\**\s*)?$/.test(sentence.slice(0, index)) || /\|\s*\**$/.test(sentence.slice(0, index));
@@ -5434,6 +5960,207 @@ function formatClaims(report2, label = "craft copy claims") {
5434
5960
  return lines.join("\n");
5435
5961
  }
5436
5962
 
5963
+ // src/character/shape.ts
5964
+ var ABBREVIATIONS = /\b(?:e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|St|No|approx|incl|Ltd|Co|Inc|Jr|Sr)\./g;
5965
+ var DOT = "\0";
5966
+ function splitSentences(text) {
5967
+ const guarded = text.replace(/\s+/g, " ").replace(ABBREVIATIONS, (m) => m.replace(/\./g, DOT)).replace(/(\d)\.(\d)/g, `$1${DOT}$2`);
5968
+ return guarded.split(/(?<=[.!?]["'’”)]*)\s+(?=["'‘“(]?[A-Z0-9£$€])/).map((s) => s.split(DOT).join(".").trim()).filter((s) => words(s).length > 0);
5969
+ }
5970
+ var WORD3 = /[A-Za-z0-9£$€][A-Za-z0-9£$€%'’-]*/g;
5971
+ function words(text) {
5972
+ return text.match(WORD3) ?? [];
5973
+ }
5974
+ var lower = (w) => w.toLowerCase().replace(/’/g, "'");
5975
+ var FUNCTION_WORDS = new Set(
5976
+ "a an the this that these those my your our their his her its we you they i he she it me us them and or but nor so yet for if when while where whereas because although though unless once since until as than of to in on at by with from into onto over under about after before between through during without within across is are was were be been being am do does did done have has had will would can could may might must shall should not no every each all any some more most many much few such own same other only just also very too there here what which who whom whose how why then which's it's you'll we'll you're we're they're don't won't can't isn't aren't".split(" ")
5977
+ );
5978
+ var ITEM = "[A-Za-z0-9\xA3$\u20AC'\u2019-]+(?: [A-Za-z0-9\xA3$\u20AC'\u2019-]+){0,5}";
5979
+ var TRICOLON = new RegExp(`(${ITEM}), (${ITEM}),? (and|or) (${ITEM})(?=[,.;:!?)]|$)`, "g");
5980
+ function tricolons(sentence) {
5981
+ const s = sentence.replace(/\s+/g, " ");
5982
+ const out = [];
5983
+ for (const m of s.matchAll(TRICOLON)) {
5984
+ const [, aSpan, b, , c] = m;
5985
+ const bWords = words(b);
5986
+ const cWords = words(c);
5987
+ if (bWords.length === 0 || cWords.length === 0 || cWords.length > 6) continue;
5988
+ const aWords = words(aSpan);
5989
+ const a = aWords.slice(-Math.max(1, bWords.length));
5990
+ const lead = lower(bWords[0]);
5991
+ const parallel = bWords.length >= 2 && FUNCTION_WORDS.has(lead) && (lower(cWords[0]) === lead || lower(a[0]) === lead);
5992
+ out.push({ text: m[0], parallel });
5993
+ }
5994
+ return out;
5995
+ }
5996
+ var STACKED = /,\s+(?:and|but|so|or)\s+(?:if|when|where|once|unless|provided)\b[^,.;!?]{1,60},/i;
5997
+ function stackedConditional(sentence) {
5998
+ return STACKED.test(sentence);
5999
+ }
6000
+ var SUBORDINATOR = /\b(?:if|when|which|because|so|while|where)\b/gi;
6001
+ function clauseDepth(sentence) {
6002
+ return (sentence.match(/,/g) ?? []).length + (sentence.match(SUBORDINATOR) ?? []).length;
6003
+ }
6004
+ var HEDGES = [
6005
+ "if it's a fit",
6006
+ "if it is a fit",
6007
+ "where needed",
6008
+ "if needed",
6009
+ "as needed",
6010
+ "where appropriate",
6011
+ "if appropriate",
6012
+ "as appropriate",
6013
+ "as required",
6014
+ "where required",
6015
+ "if required",
6016
+ "where possible",
6017
+ "if possible",
6018
+ "where relevant",
6019
+ "if relevant",
6020
+ "depending on",
6021
+ "can help",
6022
+ "tend to",
6023
+ "tends to",
6024
+ "may",
6025
+ "might",
6026
+ "could",
6027
+ "typically",
6028
+ "usually",
6029
+ "generally",
6030
+ "often",
6031
+ "potentially",
6032
+ "likely"
6033
+ ];
6034
+ var HEDGE = new RegExp(`\\b(?:${HEDGES.map((h) => h.replace(/'/g, "['\u2019]").replace(/ /g, "\\s+")).join("|")})\\b`, "gi");
6035
+ var CONDITIONAL_CLOSE = /\b(?:if|unless|where|provided|depending)\b/i;
6036
+ function hedgeCount(text) {
6037
+ return (text.match(HEDGE) ?? []).length;
6038
+ }
6039
+ function hedgedClose(sentences3) {
6040
+ const last = sentences3[sentences3.length - 1];
6041
+ if (!last) return false;
6042
+ HEDGE.lastIndex = 0;
6043
+ return HEDGE.test(last) || CONDITIONAL_CLOSE.test(last);
6044
+ }
6045
+ var NOMINAL = /^[a-z]{3,}(?:tion|sion|ment|ness|ity|ance|ence)s?$/;
6046
+ var NOT_NOMINAL = new Set(
6047
+ [
6048
+ "nation station ration lotion potion portion question moment cement comment element garment segment fragment apartment document ointment",
6049
+ "city pity entity quality sentence science audience silence balance finance distance instance entrance substance romance province"
6050
+ ].join(" ").split(" ").flatMap((w) => [w, `${w}s`])
6051
+ );
6052
+ function nominalisations(text) {
6053
+ return words(text).filter((w) => {
6054
+ const l = lower(w);
6055
+ return NOMINAL.test(l) && !NOT_NOMINAL.has(l);
6056
+ }).length;
6057
+ }
6058
+ var PRONOUN_OPENER = /^["'‘“(]?(?:you|your|we|our)\b/i;
6059
+ var TRANSITION_OPENER = /^["'‘“(]?(?:however|moreover|additionally|furthermore|thus|therefore|consequently|in addition|as a result|ultimately|overall|importantly|notably)\b/i;
6060
+ var IMPERSONAL_OPENER = /^["'‘“(]?(?:it is|it's|it’s|there is|there are|there's|there’s)\b/i;
6061
+ var NOT_PARTICIPLE = new Set(
6062
+ "thing nothing something anything everything bring string spring sting swing king ring sing wing during morning evening ceiling building meeting wedding pudding clothing lightning including".split(" ")
6063
+ );
6064
+ function participialClauses(sentence) {
6065
+ let n = 0;
6066
+ for (const m of sentence.matchAll(/,\s+([A-Za-z]{2,}ing)\b/g)) if (!NOT_PARTICIPLE.has(lower(m[1]))) n += 1;
6067
+ const open = sentence.match(/^["'‘“(]?([A-Z][a-z]{2,}ing)\b[^,]{0,60},/);
6068
+ if (open && !NOT_PARTICIPLE.has(lower(open[1]))) n += 1;
6069
+ return n;
6070
+ }
6071
+ function phrasalCoordinations(sentence) {
6072
+ let n = 0;
6073
+ for (const m of sentence.matchAll(/(^|[^,]\s)([A-Za-z][a-z'’-]{2,}) (?:and|or) ([A-Za-z][a-z'’-]{2,})\b/g)) {
6074
+ const a = lower(m[2]);
6075
+ const b = lower(m[3]);
6076
+ if (FUNCTION_WORDS.has(a) || FUNCTION_WORDS.has(b) || a.endsWith("ly") || b.endsWith("ly")) continue;
6077
+ n += 1;
6078
+ }
6079
+ return n;
6080
+ }
6081
+ function skeleton(sentence) {
6082
+ const out = [];
6083
+ for (const w of words(sentence)) {
6084
+ const l = lower(w);
6085
+ const token = FUNCTION_WORDS.has(l) ? l : "_";
6086
+ if (token === "_" && out[out.length - 1] === "_") continue;
6087
+ out.push(token);
6088
+ }
6089
+ return out;
6090
+ }
6091
+ function skeletonRepeat(sentences3) {
6092
+ const grams2 = /* @__PURE__ */ new Map();
6093
+ let total = 0;
6094
+ for (const s of sentences3) {
6095
+ const sk = skeleton(s);
6096
+ for (let i = 0; i + 4 <= sk.length; i += 1) {
6097
+ const g = sk.slice(i, i + 4).join(" ");
6098
+ grams2.set(g, (grams2.get(g) ?? 0) + 1);
6099
+ total += 1;
6100
+ }
6101
+ }
6102
+ if (total === 0) return 0;
6103
+ let repeated = 0;
6104
+ for (const count4 of grams2.values()) if (count4 > 1) repeated += count4;
6105
+ return repeated / total;
6106
+ }
6107
+ var mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
6108
+ var sd = (xs) => {
6109
+ const m = mean(xs);
6110
+ return Math.sqrt(xs.reduce((s, x) => s + (x - m) ** 2, 0) / xs.length);
6111
+ };
6112
+ function cv(xs, min) {
6113
+ if (xs.length < min) return null;
6114
+ const m = mean(xs);
6115
+ return m === 0 ? null : sd(xs) / m;
6116
+ }
6117
+ function shapeOf(text) {
6118
+ const ss = splitSentences(text);
6119
+ const wordCount = words(text).length;
6120
+ const per100 = (n) => wordCount === 0 ? 0 : 100 * n / wordCount;
6121
+ const share = (re) => ss.length === 0 ? 0 : ss.filter((s) => re.test(s)).length / ss.length;
6122
+ const tri = ss.flatMap(tricolons);
6123
+ const commas = (text.match(/,/g) ?? []).length;
6124
+ return {
6125
+ sentences: ss.length,
6126
+ words: wordCount,
6127
+ tricolons: tri.length,
6128
+ parallelTriads: tri.filter((t) => t.parallel).length,
6129
+ stackedConditionals: ss.filter(stackedConditional).length,
6130
+ clauseDepth: ss.length === 0 ? 0 : mean(ss.map(clauseDepth)),
6131
+ sentenceCv: cv(ss.map((s) => words(s).length), 3),
6132
+ hedgeRate: per100(hedgeCount(text)),
6133
+ hedgedClose: hedgedClose(ss),
6134
+ nominalisationRate: per100(nominalisations(text)),
6135
+ pronounOpenerShare: share(PRONOUN_OPENER),
6136
+ transitionOpenerShare: share(TRANSITION_OPENER),
6137
+ impersonalOpenerShare: share(IMPERSONAL_OPENER),
6138
+ commasPerSentence: ss.length === 0 ? 0 : commas / ss.length,
6139
+ commasPer100: per100(commas),
6140
+ participialRate: ss.length === 0 ? 0 : ss.reduce((s, x) => s + participialClauses(x), 0) / ss.length,
6141
+ coordinationRate: per100(ss.reduce((s, x) => s + phrasalCoordinations(x), 0)),
6142
+ skeletonRepeat: skeletonRepeat(ss)
6143
+ };
6144
+ }
6145
+ function shapeMoves(f4) {
6146
+ const out = [];
6147
+ if (f4.parallelTriads > 0) out.push("parallel triad");
6148
+ else if (f4.tricolons > 0) out.push("tricolon");
6149
+ if (f4.stackedConditionals > 0) out.push("stacked conditional");
6150
+ if (f4.hedgedClose) out.push("hedged close");
6151
+ if (f4.participialRate > 0) out.push("participial clause");
6152
+ return out;
6153
+ }
6154
+ function paragraphUniformity(paragraphs) {
6155
+ return cv(
6156
+ paragraphs.map((p) => words(p).length).filter((n) => n > 0),
6157
+ 4
6158
+ );
6159
+ }
6160
+ function isBlurb(f4) {
6161
+ return f4.sentences >= 2 && f4.sentences <= 8 && f4.words >= 20 && f4.words <= 160;
6162
+ }
6163
+
5437
6164
  // src/character/baseline.ts
5438
6165
  var BASELINE_VERSION = 1;
5439
6166
  function fnv1a(text) {
@@ -5729,7 +6456,9 @@ function visualMeasures(img, maxSamples = 1e6) {
5729
6456
  }
5730
6457
 
5731
6458
  // src/direction/types.ts
5732
- var DIRECTION_VERSION = 1;
6459
+ var DIRECTION_VERSION = 2;
6460
+ var DIRECTION_VERSIONS = [1, 2];
6461
+ var CUSTOMER_KINDS = ["review", "enquiry", "conversation", "search"];
5733
6462
  var SOURCE_KINDS = [
5734
6463
  "livery",
5735
6464
  "shopfront",
@@ -5743,9 +6472,413 @@ var SOURCE_KINDS = [
5743
6472
  "trade",
5744
6473
  "print",
5745
6474
  "photo",
5746
- "founder"
6475
+ "founder",
6476
+ ...CUSTOMER_KINDS,
6477
+ "reference"
5747
6478
  ];
5748
6479
  var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
6480
+ var CONSIDERATION = ["low", "considered", "high"];
6481
+ var PAGE_TYPES = ["home", "service", "about", "pricing", "booking", "contact", "article", "landing"];
6482
+ var HIERARCHY_FIELDS = ["primaryAction", "order", "lead", "journey"];
6483
+
6484
+ // src/direction/reason.ts
6485
+ var PROPOSED = "PROPOSED:";
6486
+ var PREFERENCE = /\b(?:client (?:likes|liked|wants|wanted|asked|chose|prefers?)|(?:we|they|i) (?:like|liked|love|loved|prefer)|on[- ]trend|trendy|popular|brand (?:colou?r|font|guidelines?) (?:is|are|says?))\b/i;
6487
+ var MOOD = /\b(?:modern|clean|fresh|professional|trustworthy|premium|elegant|sleek|timeless|minimal(?:ist)?|bold|vibrant|friendly|approachable|luxur(?:y|ious)|sophisticated|contemporary|stylish|warm|inviting|high[- ]end)\b/gi;
6488
+ var CONVENTION = new RegExp(
6489
+ [
6490
+ // authority
6491
+ String.raw`industry[- ]standard`,
6492
+ String.raw`best[- ]practices?`,
6493
+ String.raw`the norm\b`,
6494
+ String.raw`standard (?:layout|structure|practice|pattern|format)`,
6495
+ String.raw`tried[- ]and[- ]tested`,
6496
+ String.raw`proven (?:layout|structure|pattern|format)`,
6497
+ // the crowd
6498
+ String.raw`(?:most|other|typical|many|all|similar)\s+(?:\w+\s+){0,2}(?:web)?sites\b`,
6499
+ String.raw`competitors?(?:'|’)?s?\b`,
6500
+ String.raw`(?:like|as) (?:everyone|everybody|all the others|the rest)\b`,
6501
+ String.raw`commonly used`,
6502
+ String.raw`(?:what|how) (?:everyone|others|the industry) (?:does|do|uses?)`,
6503
+ // familiarity
6504
+ String.raw`familiar (?:layout|structure|pattern|format|order|shape|design)`,
6505
+ String.raw`recogni[sz]able (?:layout|structure|pattern|format|order)`,
6506
+ String.raw`conventional(?:ly)?\b`,
6507
+ String.raw`(?:visitors|users|people|customers|anyone) (?:are|is) used to`,
6508
+ String.raw`feels? (?:at home|comfortable|familiar)(?: with)?(?: the)? (?:layout|site|page|structure|design)?`,
6509
+ String.raw`intuitive(?:ly)?\b`,
6510
+ String.raw`as usual\b`,
6511
+ String.raw`what (?:visitors|users|people) expect\b`
6512
+ ].join("|"),
6513
+ "i"
6514
+ );
6515
+ var GENERIC = new Set(
6516
+ "customer customers visitor visitors client clients people person someone business businesses service services website site page pages want wants need needs help first before after know feel time work good right sure make find look looking trust reason reasons thing things just really very able also".split(" ")
6517
+ );
6518
+ var STOP2 = /* @__PURE__ */ new Set(["with", "that", "this", "from", "their", "they", "have", "been", "were", "which", "what", "when", "where", "there", "about", "into", "onto", "over", "same", "each", "every", "your", "them", "then", "than", "will", "would", "could", "should", "because", "so", "the", "and"]);
6519
+ var VALUE_WORDS = /* @__PURE__ */ new Set(["green", "blue", "navy", "black", "white", "cream", "grey", "gray", "gold", "yellow", "orange", "purple", "violet", "pink", "brown", "teal", "bottle", "dark", "light", "deep", "pale", "bright", "colour", "color", "font", "face", "serif", "sans", "type", "lettering"]);
6520
+ function contentWords(text) {
6521
+ return new Set(
6522
+ (text.toLowerCase().match(/[a-z][a-z'’-]{3,}/g) ?? []).map((w) => w.replace(/['’]s$/, "").replace(/['’]/g, "")).filter((w) => !STOP2.has(w) && !VALUE_WORDS.has(w) && !GENERIC.has(w))
6523
+ );
6524
+ }
6525
+ var stem = (w) => w.replace(/(?:ing|ers|er|ed|es|s)$/, "");
6526
+ function shares(a, b) {
6527
+ const bs = new Set([...b].map(stem));
6528
+ return [...a].filter((w) => bs.has(stem(w)));
6529
+ }
6530
+ function jobWords(job) {
6531
+ return contentWords(
6532
+ [
6533
+ job.statement?.verb,
6534
+ job.statement?.object,
6535
+ job.statement?.context,
6536
+ job.functional?.value,
6537
+ job.emotional?.value,
6538
+ job.social?.value,
6539
+ ...Array.isArray(job.objections) ? job.objections : [],
6540
+ ...Array.isArray(job.language) ? job.language : []
6541
+ ].filter((x) => typeof x === "string").join(" ")
6542
+ );
6543
+ }
6544
+ var isCustomerVoice = (s) => Boolean(s && CUSTOMER_KINDS.includes(s.kind));
6545
+ function checkReason(input) {
6546
+ const problems = [];
6547
+ const err = (field, message) => problems.push({ severity: "error", at: `${input.at}.${field}`, message });
6548
+ const because = typeof input.because === "string" ? input.because.trim() : "";
6549
+ const evidence = Array.isArray(input.evidence) ? input.evidence.filter((e) => typeof e === "string") : [];
6550
+ if (input.evidence !== void 0 && (!Array.isArray(input.evidence) || input.evidence.some((e) => typeof e !== "string"))) err("evidence", "evidence must be a list of source ids");
6551
+ const ids = new Map(input.sources.filter((s) => s && typeof s.id === "string").map((s) => [s.id, s]));
6552
+ if (because.split(/\s+/).filter(Boolean).length < 8) {
6553
+ err("because", "a reason is at least a sentence. Without one this is a default, however good it looks.");
6554
+ return problems;
6555
+ }
6556
+ if (because.startsWith(PROPOSED)) {
6557
+ err("because", "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
6558
+ return problems;
6559
+ }
6560
+ if (PREFERENCE.test(because)) err("because", "that is a preference, not a reason. Say what in the client's world this comes from.");
6561
+ const moods = because.match(MOOD) ?? [];
6562
+ if (moods.length >= 2) err("because", `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
6563
+ const convention = because.match(CONVENTION);
6564
+ if (convention) {
6565
+ err("because", `"${convention[0]}" is the category's default speaking, not a reason. Say what in this client's job or world it answers.`);
6566
+ }
6567
+ const needsEvidence = input.tie !== "job";
6568
+ if (needsEvidence && evidence.length === 0) err("evidence", "cite at least one source");
6569
+ for (const id of evidence) if (!ids.has(id)) err("evidence", `no source called "${id}"`);
6570
+ if (input.tie === "customer") {
6571
+ for (const id of evidence) {
6572
+ if (ids.has(id) && !isCustomerVoice(ids.get(id))) err("evidence", `"${id}" is a ${ids.get(id).kind}. The job is evidenced by the customer's own words: ${CUSTOMER_KINDS.join(", ")}.`);
6573
+ }
6574
+ }
6575
+ if (input.tie === "source") {
6576
+ for (const id of evidence) {
6577
+ if (isCustomerVoice(ids.get(id))) err("evidence", `"${id}" is customer voice. Token decisions need a physical source or an outside-category reference.`);
6578
+ }
6579
+ }
6580
+ if (problems.length) return problems;
6581
+ const words3 = contentWords(because);
6582
+ if (input.tie === "job") {
6583
+ if (!input.job) {
6584
+ err("because", "a structural reason is checked against the job, and the file has no job map yet");
6585
+ } else if (shares(words3, jobWords(input.job)).length === 0) {
6586
+ err("because", "the reason does not use this client's job (its statement, objections or the customer's words). A reason that would fit any client's site is a default.");
6587
+ }
6588
+ } else {
6589
+ const cited = evidence.map((id) => ids.get(id));
6590
+ const tied = cited.some((s) => words3.has(s.id) || shares(words3, contentWords(`${s.note} ${s.lettering ?? ""}`)).length > 0);
6591
+ if (!tied) err("because", `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
6592
+ }
6593
+ return problems;
6594
+ }
6595
+
6596
+ // src/direction/job.ts
6597
+ var JOB_VERBS = new Set(
6598
+ "find choose pick book get fix stop avoid know feel understand compare decide keep protect prepare plan sort arrange replace repair learn prove show recover reduce save spend hire check see buy sell move start finish handle manage meet reach return treat look end make give take bring hand leave trust rely feel prove explain secure settle solve restore improve change become stay pass sign agree ask call reassure justify confirm".split(" ")
6599
+ );
6600
+ var LABEL_WORDS = /* @__PURE__ */ new Set(["website", "websites", "site", "sites", "page", "pages", "homepage", "web", "online", "presence", "company", "business", "services", "service", "local", "best", "good", "an", "a", "the", "for", "uk"]);
6601
+ var NOUNISH = /(?:tion|sion|ment|ness|ity|site|page)$/;
6602
+ var words2 = (s) => s.toLowerCase().match(/[a-z][a-z'’-]*/g) ?? [];
6603
+ function isCategoryLabel(object, brief = "") {
6604
+ const trade = /* @__PURE__ */ new Set([...TRADE_NOUNS.flatMap((t) => t.split(" ")), ...words2(brief).filter((w) => w.length > 3)]);
6605
+ const ws = words2(object);
6606
+ return ws.length > 0 && ws.every((w) => LABEL_WORDS.has(w) || trade.has(w) || trade.has(w.replace(/s$/, "")));
6607
+ }
6608
+ var normalise2 = (s) => s.toLowerCase().replace(/[“”"‘’'.,!?;:()-]/g, " ").replace(/\s+/g, " ").trim();
6609
+ function validateJob(job, sources, ctx = {}) {
6610
+ const problems = [];
6611
+ const err = (at, message) => problems.push({ severity: "error", at: `job${at}`, message });
6612
+ const warn = (at, message) => problems.push({ severity: "warn", at: `job${at}`, message });
6613
+ if (!job || typeof job !== "object" || Array.isArray(job)) {
6614
+ err("", "no job map: say what the visitor is trying to get done, in their words, before deciding structure or look");
6615
+ return { problems, decided: false };
6616
+ }
6617
+ const j = job;
6618
+ const ids = new Map(sources.map((s) => [s.id, s]));
6619
+ const st = j.statement;
6620
+ if (!st || typeof st !== "object") err(".statement", "a job statement is verb, object and context");
6621
+ else {
6622
+ const verb = typeof st.verb === "string" ? st.verb.trim().toLowerCase() : "";
6623
+ if (!verb) err(".statement.verb", "what the visitor is trying to do, as a verb: find, choose, book, fix");
6624
+ else if (verb.split(/\s+/).length > 3 || NOUNISH.test(verb) || TRADE_NOUNS.includes(verb)) err(".statement.verb", `"${st.verb}" is not a verb. A job starts with what the visitor is trying to do.`);
6625
+ else if (!JOB_VERBS.has(verb.split(/\s+/)[0])) warn(".statement.verb", `"${st.verb}" is not a verb craft knows. Check it says what the visitor does, not what the site is.`);
6626
+ const object = typeof st.object === "string" ? st.object.trim() : "";
6627
+ if (words2(object).length < 3) err(".statement.object", "the object needs a few words: what they are trying to get, and what makes it hard");
6628
+ else if (isCategoryLabel(object, ctx.brief)) err(".statement.object", `"${object}" names the category, not the job. Say what the visitor needs from it: "an electrician they can trust with a dangerous fault".`);
6629
+ if (typeof st.context !== "string" || words2(st.context).length < 3) err(".statement.context", "the context: when, under what pressure, or what they are trying to avoid");
6630
+ }
6631
+ for (const part of ["functional", "emotional", "social"]) {
6632
+ const p = j[part];
6633
+ if (!p || typeof p !== "object") {
6634
+ err(`.${part}`, `the ${part} side of the job, with the customer-voice source that shows it`);
6635
+ continue;
6636
+ }
6637
+ if (typeof p.value !== "string" || words2(p.value).length < 6) err(`.${part}.value`, "a sentence: what they need, in the terms they would use");
6638
+ else problems.push(...checkReason({ at: `job.${part}`, because: p.value, evidence: p.evidence, sources, tie: "customer" }).map((x) => ({ ...x, at: x.at.replace(/\.because$/, ".value") })));
6639
+ }
6640
+ const c = j.consideration;
6641
+ if (!c || typeof c !== "object") err(".consideration", `how long and how carefully they decide: ${CONSIDERATION.join(", ")}`);
6642
+ else {
6643
+ if (!CONSIDERATION.includes(c.value)) err(".consideration.value", `one of ${CONSIDERATION.join(", ")}`);
6644
+ problems.push(...checkReason({ at: "job.consideration", because: c.because, evidence: c.evidence, sources, tie: "customer" }));
6645
+ }
6646
+ const objections = Array.isArray(j.objections) ? j.objections : [];
6647
+ if (objections.length === 0) err(".objections", "what stops them: the worries they bring to the page");
6648
+ objections.forEach((o, i) => {
6649
+ if (typeof o !== "string" || words2(o).length < 3) err(`.objections[${i}]`, "an objection is a few words: what they worry about");
6650
+ });
6651
+ const evidence = Array.isArray(j.evidence) ? j.evidence : [];
6652
+ const voice = evidence.map((id) => ids.get(id)).filter(isCustomerVoice);
6653
+ if (evidence.length === 0 || voice.length === 0) err(".evidence", "cite the reviews, enquiries or conversations the job comes from");
6654
+ for (const id of evidence) if (!ids.has(id)) err(".evidence", `no source called "${id}"`);
6655
+ const language = Array.isArray(j.language) ? j.language : [];
6656
+ if (language.length === 0) err(".language", "quote the customer: the words they use for the problem");
6657
+ const texts = voice.map((s) => normalise2(`${s.note} ${ctx.readSource?.(s) ?? ""}`));
6658
+ language.forEach((q, i) => {
6659
+ if (typeof q !== "string" || !q.trim()) return err(`.language[${i}]`, "an empty quote");
6660
+ const n = normalise2(q);
6661
+ if (!texts.some((t) => t.includes(n))) warn(`.language[${i}]`, `"${q}" is not in any cited source's note or file. Quote it from the source, or it may be invented.`);
6662
+ });
6663
+ return { problems, decided: problems.every((p) => p.severity !== "error") };
6664
+ }
6665
+
6666
+ // src/direction/hierarchy.ts
6667
+ var SECTION_ROLES = [
6668
+ "hero",
6669
+ "logos",
6670
+ "stats",
6671
+ "cards",
6672
+ "marquee",
6673
+ "text",
6674
+ "other",
6675
+ "cta-band",
6676
+ "footer-cta",
6677
+ "pricing",
6678
+ "testimonials",
6679
+ "faq",
6680
+ "process",
6681
+ "features",
6682
+ "team",
6683
+ "contact"
6684
+ ];
6685
+ var POSITIONS = /* @__PURE__ */ new Set([...SECTION_ROLES, "header", "sticky"]);
6686
+ function considerationTied(because, job) {
6687
+ const text = because.toLowerCase();
6688
+ const level = job.consideration?.value;
6689
+ if (level && new RegExp(`\\b${level}\\b`).test(text)) return true;
6690
+ const said = contentWords(because);
6691
+ return (Array.isArray(job.objections) ? job.objections : []).some((o) => typeof o === "string" && [...contentWords(o)].some((w) => said.has(w)));
6692
+ }
6693
+ function validateHierarchy(hierarchy, job, sources) {
6694
+ const problems = [];
6695
+ const err = (at, message) => problems.push({ severity: "error", at: `hierarchy${at}`, message });
6696
+ const pages = [];
6697
+ if (!hierarchy || typeof hierarchy !== "object" || Array.isArray(hierarchy)) {
6698
+ err("", "no hierarchy: decide what the home page leads with, where it asks, and the order, each from the job");
6699
+ return { problems, pages };
6700
+ }
6701
+ const h = hierarchy;
6702
+ for (const key of Object.keys(h)) if (!PAGE_TYPES.includes(key)) err(`.${key}`, `not a page type craft knows. Page types are: ${PAGE_TYPES.join(", ")}`);
6703
+ if (!h.home) err(".home", "no home page hierarchy: the home page is where most visitors start");
6704
+ for (const page of PAGE_TYPES) {
6705
+ const p = h[page];
6706
+ if (!p) continue;
6707
+ let decided = 0;
6708
+ const at = (f4) => `hierarchy.${page}.${f4}`;
6709
+ const reason = (where, because, evidence) => checkReason({ at: where, because, evidence, sources, tie: "job", job });
6710
+ const count4 = (found) => {
6711
+ problems.push(...found);
6712
+ if (!found.some((x) => x.severity === "error")) decided += 1;
6713
+ };
6714
+ if (!p.primaryAction) err(`.${page}.primaryAction`, "not decided yet");
6715
+ else {
6716
+ const a = p.primaryAction;
6717
+ const found = [];
6718
+ if (typeof a.value !== "string" || !a.value.trim()) found.push({ severity: "error", at: `${at("primaryAction")}.value`, message: "what the visitor is asked to do" });
6719
+ const positions = Array.isArray(a.positions) ? a.positions : [];
6720
+ if (positions.length === 0) found.push({ severity: "error", at: `${at("primaryAction")}.positions`, message: 'where the ask sits: one place, or several ("hero", "footer-cta")' });
6721
+ for (const pos of positions) if (!POSITIONS.has(pos)) found.push({ severity: "error", at: `${at("primaryAction")}.positions`, message: `"${pos}" is not a section role, "header" or "sticky"` });
6722
+ const r = reason(at("primaryAction"), a.because, a.evidence);
6723
+ found.push(...r);
6724
+ if (r.length === 0 && job && typeof a.because === "string" && !considerationTied(a.because, job)) {
6725
+ found.push({
6726
+ severity: "error",
6727
+ at: `${at("primaryAction")}.because`,
6728
+ message: `say how the job's consideration (${job.consideration?.value ?? "not set"}) or one of its objections leads to asking here. Placement evidence is mixed, so the reason is what decides it.`
6729
+ });
6730
+ }
6731
+ count4(found);
6732
+ }
6733
+ const order = Array.isArray(p.order) ? p.order : [];
6734
+ if (order.length < 2) err(`.${page}.order`, "the running order, two sections or more, each with why it sits there");
6735
+ else {
6736
+ const found = [];
6737
+ order.forEach((o, i) => {
6738
+ if (!o || !SECTION_ROLES.includes(o.role)) found.push({ severity: "error", at: `${at("order")}[${i}].role`, message: `a section role: ${SECTION_ROLES.join(", ")}` });
6739
+ found.push(...reason(`${at("order")}[${i}]`, o?.because));
6740
+ });
6741
+ count4(found);
6742
+ }
6743
+ if (!p.lead) err(`.${page}.lead`, "not decided yet");
6744
+ else {
6745
+ const found = [];
6746
+ if (typeof p.lead.value !== "string" || !p.lead.value.trim()) found.push({ severity: "error", at: `${at("lead")}.value`, message: "what the page leads with" });
6747
+ found.push(...reason(at("lead"), p.lead.because));
6748
+ count4(found);
6749
+ }
6750
+ const journey = Array.isArray(p.journey) ? p.journey : [];
6751
+ if (journey.length < 2) err(`.${page}.journey`, "the stages the visitor goes through, two or more, each with why");
6752
+ else {
6753
+ const found = [];
6754
+ journey.forEach((s, i) => {
6755
+ if (!s || typeof s.stage !== "string" || !s.stage.trim()) found.push({ severity: "error", at: `${at("journey")}[${i}].stage`, message: "name the stage" });
6756
+ found.push(...reason(`${at("journey")}[${i}]`, s?.because));
6757
+ });
6758
+ count4(found);
6759
+ }
6760
+ pages.push({ page, decided, total: HIERARCHY_FIELDS.length });
6761
+ }
6762
+ return { problems, pages };
6763
+ }
6764
+ function renderedOrder(snapshot) {
6765
+ const sections = snapshot.sections ?? [];
6766
+ if (sections.length === 0 || !sections.every((s) => s.role)) return null;
6767
+ return sections.map((s) => s.role).filter((r) => r !== "other");
6768
+ }
6769
+ function checkOrder(declared, page, snapshot, strict = false) {
6770
+ if (!declared || !Array.isArray(declared.order)) return [{ severity: strict ? "error" : "warn", at: `hierarchy.${page}`, message: "no declared hierarchy for this page: its running order cannot be checked" }];
6771
+ const rendered = renderedOrder(snapshot);
6772
+ if (!rendered) return [{ severity: "warn", at: `hierarchy.${page}.order`, message: "the snapshot has no section roles (version 1): take a fresh one to check the order" }];
6773
+ const said = declared.order.map((o) => o.role).filter((r) => r !== "other");
6774
+ if (said.join(",") === rendered.join(",")) return [];
6775
+ return [{ severity: strict ? "error" : "warn", at: `hierarchy.${page}.order`, message: `the page runs ${rendered.join(", ")}; the file says ${said.join(", ")}` }];
6776
+ }
6777
+
6778
+ // src/direction/research.ts
6779
+ function tradeOf(brief) {
6780
+ const text = brief.toLowerCase();
6781
+ return TRADE_NOUNS.filter((t) => new RegExp(`\\b${t.replace(/ /g, "\\s+")}s?\\b`).test(text));
6782
+ }
6783
+ function categoryMatchesBrief(category, brief) {
6784
+ const known = tradeOf(category);
6785
+ if (tradeOf(brief).some((t) => known.includes(t))) return true;
6786
+ const label = category.trim().replace(/^(?:(?:a|an|the|local|independent)\s+)+/i, "");
6787
+ if (!label) return false;
6788
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
6789
+ return new RegExp(`\\b${escaped}s?\\b`, "i").test(brief);
6790
+ }
6791
+ function researchPrompt(input) {
6792
+ const trade = tradeOf(input.brief);
6793
+ const category = trade.length ? trade.join(", ") : "this kind of business";
6794
+ const who = input.client ? `${input.client}: ${input.brief}` : input.brief;
6795
+ const lines = [
6796
+ "# Research brief: the customer's job",
6797
+ "",
6798
+ `The business: ${who}`,
6799
+ "",
6800
+ "Find out about the people who hire a business like this, not about the businesses. Answer each question with sources: reviews, forum threads, Q&A sites, complaints, search suggestions. Quote people's own words and link to where they said them.",
6801
+ "",
6802
+ "1. **The situation.** What has just happened, or is about to, when someone starts looking? What pressure are they under: time, money, safety, someone else waiting on them?",
6803
+ '2. **The job.** What are they trying to get done, in one sentence that starts with a verb? What would "done" look like to them?',
6804
+ "3. **Objections.** What do they worry about before getting in touch? What has gone wrong for them, or people they know, before?",
6805
+ "4. **Their words.** The phrases they actually use for the problem and for what they want. Quote them exactly.",
6806
+ "5. **Who else is involved.** Who do they have to explain or justify the choice to (a partner, a landlord, a tenant, a manager), and what would make them look good or bad to that person?",
6807
+ "6. **Consideration.** Do they decide in minutes, days or months? What do they compare before deciding?",
6808
+ "7. **Where they look.** What do they search for, and where do they ask?",
6809
+ "",
6810
+ "## Do not",
6811
+ "",
6812
+ `- Do not describe, list, rank or summarise what ${category} websites look like, or how they are laid out.`,
6813
+ '- Do not suggest colours, fonts, layouts, sections or visual "inspiration" from this category.',
6814
+ "- Do not summarise competitors' marketing. Their claims are the category average; the customer's words are the point.",
6815
+ "- Do not invent quotes. If you cannot find a source for something, say so."
6816
+ ];
6817
+ if (input.visual) {
6818
+ lines.push(
6819
+ "",
6820
+ "# Visual references, from outside the category",
6821
+ "",
6822
+ `Separately, and only from outside ${category}: find things this business already resembles or shares a world with. Print, signage, packaging, tools, vehicles, uniforms, places, materials, other trades and crafts. For each, say what it is, where it is from, and what about it could carry over.`,
6823
+ "",
6824
+ `Nothing from a ${category} website, and nothing from a web design gallery.`
6825
+ );
6826
+ }
6827
+ return `${lines.join("\n")}
6828
+ `;
6829
+ }
6830
+
6831
+ // src/estate/direction.ts
6832
+ function summariseDirection(d) {
6833
+ const c = d.choices ?? {};
6834
+ const home = d.hierarchy?.home;
6835
+ const s = {};
6836
+ if (typeof c.accent?.value === "string" && c.accent.value) s.accent = c.accent.value;
6837
+ if (typeof c.ground?.value === "string" && c.ground.value) s.ground = c.ground.value;
6838
+ if (typeof c.display?.value === "string" && c.display.value) s.display = normaliseFamily(c.display.value);
6839
+ if (typeof c.body?.value === "string" && c.body.value) s.body = normaliseFamily(c.body.value);
6840
+ if (typeof c.shape?.value === "string" && c.shape.value) s.shape = c.shape.value;
6841
+ if (Array.isArray(home?.order) && home.order.length) s.order = home.order.filter((o) => o && typeof o.role === "string").map((o) => o.role);
6842
+ if (typeof home?.primaryAction?.value === "string" && home.primaryAction.value) {
6843
+ s.primaryAction = home.primaryAction.value;
6844
+ s.positions = Array.isArray(home.primaryAction.positions) ? [...home.primaryAction.positions] : [];
6845
+ }
6846
+ return s;
6847
+ }
6848
+ var delta = (a, b) => {
6849
+ const x = a ? parseColour(a) : null;
6850
+ const y = b ? parseColour(b) : null;
6851
+ return x && y ? deltaEOk(x.oklch, y.oklch) : null;
6852
+ };
6853
+ var same = (a, b) => Boolean(a && b && a.trim().toLowerCase() === b.trim().toLowerCase());
6854
+ function compareDirections(a, b) {
6855
+ const shared = [];
6856
+ const accentDelta = delta(a.accent, b.accent);
6857
+ const groundDelta = delta(a.ground, b.ground);
6858
+ if (accentDelta !== null && accentDelta <= ACCENT_SHARED) shared.push(`accent (\u0394E ${accentDelta.toFixed(3)})`);
6859
+ if (groundDelta !== null && groundDelta <= GROUND_SHARED) shared.push(`ground (\u0394E ${groundDelta.toFixed(3)})`);
6860
+ if (a.display && b.display && same(normaliseFamily(a.display), normaliseFamily(b.display))) shared.push(`${normaliseFamily(a.display)} headline`);
6861
+ if (a.body && b.body && same(normaliseFamily(a.body), normaliseFamily(b.body))) shared.push(`${normaliseFamily(a.body)} body`);
6862
+ if (same(a.shape, b.shape)) shared.push(`shape "${a.shape}"`);
6863
+ let orderDistance = null;
6864
+ if (a.order?.length && b.order?.length) {
6865
+ orderDistance = levenshtein(a.order, b.order) / Math.max(a.order.length, b.order.length);
6866
+ if (orderDistance <= SHARED_PART) shared.push(orderDistance === 0 ? "the same section order" : "nearly the same section order");
6867
+ }
6868
+ if (same(a.primaryAction, b.primaryAction) && [...a.positions ?? []].sort().join(",") === [...b.positions ?? []].sort().join(",")) shared.push(`"${a.primaryAction}" asked in the same places`);
6869
+ const surface = shared.filter((s) => /^(?:accent|ground|shape)|headline$/.test(s)).length;
6870
+ const flagged = surface >= 2 || orderDistance !== null && orderDistance <= SHARED_PART;
6871
+ return { shared, accentDelta, groundDelta, orderDistance, flagged };
6872
+ }
6873
+ function compareToDeclared(target2, sites, exclude) {
6874
+ return sites.filter((s) => s.direction && s.id !== exclude).map((s) => ({ id: s.id, ...compareDirections(target2, s.direction) })).sort((x, y) => y.shared.length - x.shared.length || (x.accentDelta ?? 1) - (y.accentDelta ?? 1));
6875
+ }
6876
+ function formatDirectionMatches(label, matches) {
6877
+ const lines = [`${label}: declared directions in the estate (ranked, not judged: no sibling line yet)`];
6878
+ for (const m of matches) lines.push(` ${m.id.padEnd(20)} ${m.flagged ? "LOOK " : " "}${m.shared.length ? `shares ${m.shared.join(", ")}` : "nothing shared"}`);
6879
+ if (matches.length === 0) lines.push(" no site in the register has a declared direction yet: craft estate add --direction <file>");
6880
+ return lines.join("\n");
6881
+ }
5749
6882
 
5750
6883
  // src/direction/licences.ts
5751
6884
  var LICENCES = [
@@ -5859,13 +6992,6 @@ function licencesJson() {
5859
6992
  }
5860
6993
 
5861
6994
  // src/direction/validate.ts
5862
- var PREFERENCE = /\b(?:client (?:likes|liked|wants|wanted|asked|chose|prefers?)|(?:we|they|i) (?:like|liked|love|loved|prefer)|on[- ]trend|trendy|popular|what (?:everyone|competitors) (?:uses?|does)|brand (?:colou?r|font|guidelines?) (?:is|are|says?))\b/i;
5863
- var MOOD = /\b(?:modern|clean|fresh|professional|trustworthy|premium|elegant|sleek|timeless|minimal(?:ist)?|bold|vibrant|friendly|approachable|luxur(?:y|ious)|sophisticated|contemporary|stylish|warm|inviting|high[- ]end)\b/gi;
5864
- var VALUE_WORDS = /* @__PURE__ */ new Set(["green", "blue", "navy", "black", "white", "cream", "grey", "gray", "gold", "yellow", "orange", "purple", "violet", "pink", "brown", "teal", "bottle", "dark", "light", "deep", "pale", "bright", "colour", "color", "font", "face", "serif", "sans", "type", "lettering"]);
5865
- var STOP2 = /* @__PURE__ */ new Set(["with", "that", "this", "from", "their", "they", "have", "been", "were", "which", "what", "when", "where", "there", "about", "into", "onto", "over", "same", "each", "every"]);
5866
- function words(text) {
5867
- return new Set((text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).map((w) => w.replace(/'s$/, "")).filter((w) => !STOP2.has(w) && !VALUE_WORDS.has(w)));
5868
- }
5869
6995
  function tellFor(key, value) {
5870
6996
  if (key === "accent") {
5871
6997
  const c = parseColour(value);
@@ -5882,44 +7008,58 @@ function tellFor(key, value) {
5882
7008
  }
5883
7009
  return null;
5884
7010
  }
7011
+ var MIN_TOKENS = 5;
7012
+ var emptyLayers = () => ({ job: false, hierarchy: [], tokens: { decided: 0, total: CHOICE_KEYS.length }, complete: false });
5885
7013
  function validateDirection(input, ctx = {}) {
5886
7014
  const problems = [];
5887
7015
  const err = (at, message) => problems.push({ severity: "error", at, message });
5888
7016
  const warn = (at, message) => problems.push({ severity: "warn", at, message });
5889
7017
  if (!input || typeof input !== "object" || Array.isArray(input)) {
5890
7018
  err("", "art-direction.json must be a JSON object");
5891
- return { valid: false, problems, decided: 0 };
7019
+ return { valid: false, problems, decided: 0, layers: emptyLayers() };
5892
7020
  }
5893
7021
  const d = input;
5894
7022
  const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
5895
7023
  if (legacy) {
5896
7024
  warn("", "exceptions only: run craft direction init to record the choices and their reasons");
5897
- return { valid: true, problems, decided: 0 };
7025
+ return { valid: true, problems, decided: 0, layers: emptyLayers() };
5898
7026
  }
5899
- if (d.version !== DIRECTION_VERSION) err("version", `version must be ${DIRECTION_VERSION}`);
7027
+ if (!DIRECTION_VERSIONS.includes(d.version)) err("version", `version must be ${DIRECTION_VERSIONS.join(" or ")}`);
7028
+ else if (d.version === 1) warn("version", "version 1: the job map and the hierarchy are not recorded, so the site cannot be decided. Move to version 2 (craft direction init --out) and add them.");
5900
7029
  if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
5901
7030
  if (typeof d.brief !== "string" || d.brief.trim().split(/\s+/).length < 8) err("brief", "the brief needs a sentence: who, where, and what they do");
5902
- const sources = Array.isArray(d.sources) ? d.sources : [];
7031
+ const rawSources = Array.isArray(d.sources) ? d.sources : [];
7032
+ const sources = rawSources.filter((s) => Boolean(s && typeof s === "object" && !Array.isArray(s)));
5903
7033
  if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
5904
7034
  if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
5905
7035
  const ids = /* @__PURE__ */ new Set();
5906
- sources.forEach((s, i) => {
7036
+ rawSources.forEach((s, i) => {
5907
7037
  const at = `sources[${i}]`;
5908
- if (!s || typeof s !== "object") return err(at, "a source must be an object");
7038
+ if (!s || typeof s !== "object" || Array.isArray(s)) return err(at, "a source must be an object");
5909
7039
  if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
5910
7040
  else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
5911
7041
  else ids.add(s.id);
5912
7042
  if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
5913
7043
  if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
5914
- if (s.path && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
5915
- for (const [j, c] of (s.colours ?? []).entries()) if (!parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
7044
+ if (s.path !== void 0 && typeof s.path !== "string") err(`${at}.path`, "a path must be text");
7045
+ if (typeof s.path === "string" && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
7046
+ if (s.colours !== void 0 && !Array.isArray(s.colours)) err(`${at}.colours`, "colours must be a list");
7047
+ for (const [j, c] of (Array.isArray(s.colours) ? s.colours : []).entries()) if (typeof c !== "string" || !parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
7048
+ if (s.kind === "reference") {
7049
+ if (typeof s.category !== "string" || !s.category.trim()) err(`${at}.category`, "say where the reference comes from: print, a place, another trade");
7050
+ else if (/\b(?:web ?sites?|web design|competitors?|dribbble|behance|awwwards|themeforest)\b/i.test(s.category) || categoryMatchesBrief(s.category, typeof d.brief === "string" ? d.brief : "")) {
7051
+ err(`${at}.category`, `"${s.category}" is inside the client's category. A reference must come from outside it, or it hands back the category average.`);
7052
+ }
7053
+ if (typeof s.path === "string" && /^https?:/.test(s.path)) warn(`${at}.path`, "a web page as a reference: check it is not a site in the client's category");
7054
+ }
7055
+ if (/\bcompetitor(?:s|['’]s)?\b/i.test(`${s.note} ${s.category ?? ""}`)) err(at, "a source cannot be a competitor's website; use customer voice or a reference from outside the category");
5916
7056
  });
5917
7057
  const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
5918
7058
  if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
5919
7059
  const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
5920
7060
  let decided = 0;
5921
7061
  for (const key of Object.keys(choices)) {
5922
- if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Layout and navigation stay conventional.`);
7062
+ if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Record section order and the primary action in hierarchy.`);
5923
7063
  }
5924
7064
  for (const key of CHOICE_KEYS) {
5925
7065
  const c = choices[key];
@@ -5936,44 +7076,9 @@ function validateDirection(input, ctx = {}) {
5936
7076
  err(`${at}.value`, `"${c.value}" is not a colour`);
5937
7077
  ok = false;
5938
7078
  }
5939
- const because = typeof c.because === "string" ? c.because.trim() : "";
5940
- const evidence = Array.isArray(c.evidence) ? c.evidence : [];
5941
- if (because.split(/\s+/).filter(Boolean).length < 8) {
5942
- err(`${at}.because`, "a reason is at least a sentence. Without one this is a default, however good it looks.");
5943
- ok = false;
5944
- } else if (because.startsWith("PROPOSED:")) {
5945
- err(`${at}.because`, "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
5946
- ok = false;
5947
- } else {
5948
- if (PREFERENCE.test(because)) {
5949
- err(`${at}.because`, "that is a preference, not a reason. Say what in the client's world this comes from.");
5950
- ok = false;
5951
- }
5952
- const moods = because.match(MOOD) ?? [];
5953
- if (moods.length >= 2) {
5954
- err(`${at}.because`, `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
5955
- ok = false;
5956
- }
5957
- }
5958
- if (evidence.length === 0) {
5959
- err(`${at}.evidence`, "cite at least one source");
5960
- ok = false;
5961
- }
5962
- for (const id of evidence) {
5963
- if (!ids.has(id)) {
5964
- err(`${at}.evidence`, `no source called "${id}"`);
5965
- ok = false;
5966
- }
5967
- }
5968
- if (ok) {
5969
- const cited = sources.filter((s) => evidence.includes(s.id));
5970
- const reasonWords = words(because);
5971
- const tied = cited.some((s) => reasonWords.has(s.id) || [...words(`${s.note} ${s.lettering ?? ""}`)].some((w) => reasonWords.has(w)));
5972
- if (!tied) {
5973
- err(`${at}.because`, `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
5974
- ok = false;
5975
- }
5976
- }
7079
+ const reasons = checkReason({ at, because: c.because, evidence: c.evidence, sources, tie: "source" });
7080
+ problems.push(...reasons);
7081
+ if (reasons.length) ok = false;
5977
7082
  if (typeof c.value === "string") {
5978
7083
  const tell = tellFor(key, c.value);
5979
7084
  if (tell && !exceptions.some((e) => e.tell === tell)) {
@@ -6009,7 +7114,33 @@ function validateDirection(input, ctx = {}) {
6009
7114
  }
6010
7115
  if (ok) decided += 1;
6011
7116
  }
6012
- return { valid: problems.every((p) => p.severity !== "error"), problems, decided };
7117
+ let job = false;
7118
+ let hierarchy = [];
7119
+ if (d.version === 2) {
7120
+ const j = validateJob(d.job, sources, { brief: typeof d.brief === "string" ? d.brief : "", readSource: ctx.readSource });
7121
+ problems.push(...j.problems);
7122
+ job = j.decided && d.job !== void 0;
7123
+ const h = validateHierarchy(d.hierarchy, d.job, sources);
7124
+ problems.push(...h.problems);
7125
+ hierarchy = h.pages;
7126
+ if (ctx.snapshot) problems.push(...checkOrder(d.hierarchy?.[ctx.page ?? "home"], ctx.page ?? "home", ctx.snapshot, ctx.strict));
7127
+ }
7128
+ if (ctx.estate?.length) {
7129
+ for (const m of compareToDeclared(summariseDirection(d), ctx.estate, ctx.estateId)) {
7130
+ if (m.flagged) warn("", `close to ${m.id}'s declared direction: shares ${m.shared.join(", ")}. Decide which of these should differ before either site is built.`);
7131
+ }
7132
+ }
7133
+ if (ctx.strict) {
7134
+ for (const p of problems) if (p.severity === "warn" && /the page (?:does not show|sets|runs)/.test(p.message)) p.severity = "error";
7135
+ }
7136
+ const home = hierarchy.find((p) => p.page === "home");
7137
+ const complete = job && Boolean(home) && hierarchy.every((p) => p.decided === p.total) && decided >= MIN_TOKENS;
7138
+ return {
7139
+ valid: problems.every((p) => p.severity !== "error"),
7140
+ problems,
7141
+ decided,
7142
+ layers: { job, hierarchy, tokens: { decided, total: CHOICE_KEYS.length }, complete: complete && problems.every((p) => p.severity !== "error") }
7143
+ };
6013
7144
  }
6014
7145
 
6015
7146
  // src/direction/init.ts
@@ -6028,11 +7159,31 @@ function initDirection(input) {
6028
7159
  body: empty(fp.body.family),
6029
7160
  ...fp.roundness !== null ? { shape: empty(fp.roundness >= 0.45 ? "pill buttons" : fp.roundness <= 0.05 ? "square corners" : `corners at ${Math.round(fp.roundness * 100)}% of button height`) } : {}
6030
7161
  } : {};
7162
+ const part = () => ({ value: "", evidence: [] });
6031
7163
  return {
6032
7164
  $schema: "https://unpkg.com/@domandigital/craft/art-direction.schema.json",
6033
7165
  version: DIRECTION_VERSION,
6034
7166
  client: input.client ?? "",
6035
7167
  brief: input.brief ?? "",
7168
+ // Empty on purpose, like the reasons: the first validate lists the job and the structure as undecided.
7169
+ job: {
7170
+ statement: { verb: "", object: "", context: "" },
7171
+ functional: part(),
7172
+ emotional: part(),
7173
+ social: part(),
7174
+ consideration: { value: "low", because: "", evidence: [] },
7175
+ objections: [],
7176
+ language: [],
7177
+ evidence: []
7178
+ },
7179
+ hierarchy: {
7180
+ home: {
7181
+ primaryAction: { value: "", positions: [], because: "" },
7182
+ order: fp?.sections?.length ? fp.sections.map((s) => ({ role: s.role, because: "" })) : [],
7183
+ lead: { value: "", because: "" },
7184
+ journey: []
7185
+ }
7186
+ },
6036
7187
  sources: [],
6037
7188
  choices,
6038
7189
  ...input.exceptions?.length ? { exceptions: input.exceptions } : {}
@@ -6040,7 +7191,6 @@ function initDirection(input) {
6040
7191
  }
6041
7192
 
6042
7193
  // src/direction/propose.ts
6043
- var PROPOSED = "PROPOSED:";
6044
7194
  function srgbToLinear2(c) {
6045
7195
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
6046
7196
  }
@@ -6102,7 +7252,13 @@ function accentCandidates(input) {
6102
7252
  const parsed = parseColour(c);
6103
7253
  if (!parsed || !chromatic(parsed.oklch)) continue;
6104
7254
  const o = parsed.oklch;
6105
- const siblings = (input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 }));
7255
+ const siblings = [
7256
+ ...(input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 })),
7257
+ ...(input.declared ?? []).flatMap((e) => {
7258
+ const p = parseColour(e.accent);
7259
+ return p ? [{ id: `${e.id} (declared)`, d: deltaEOk(o, p.oklch) }] : [];
7260
+ })
7261
+ ];
6106
7262
  const nearest2 = siblings.sort((a, b) => a.d - b.d)[0];
6107
7263
  const penalty = isAiViolet(o) ? 1 : 0;
6108
7264
  out.push({
@@ -6169,7 +7325,7 @@ function addSite(register, site) {
6169
7325
  }
6170
7326
  function sharedParts(a, b, d = fingerprintDistance(a, b)) {
6171
7327
  const out = [];
6172
- const close = (k) => d.parts[k] <= 0.2;
7328
+ const close = (k) => d.parts[k] <= SHARED_PART;
6173
7329
  if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
6174
7330
  if (close("type")) {
6175
7331
  if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
@@ -6184,14 +7340,14 @@ function sharedParts(a, b, d = fingerprintDistance(a, b)) {
6184
7340
  }
6185
7341
  function compareToEstate(target2, register, options = {}) {
6186
7342
  const at = options.siblingAt ?? SIBLING_AT;
6187
- return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
7343
+ return register.sites.filter((s) => s.id !== options.exclude && s.fingerprint).map((s) => {
6188
7344
  const d = fingerprintDistance(target2, s.fingerprint);
6189
7345
  return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target2, s.fingerprint, d) };
6190
7346
  }).sort((a, b) => a.distance - b.distance);
6191
7347
  }
6192
7348
  function estatePairs(register, siblingAt = SIBLING_AT) {
6193
7349
  const out = [];
6194
- const s = register.sites;
7350
+ const s = register.sites.filter((site) => site.fingerprint);
6195
7351
  for (let i = 0; i < s.length; i += 1) {
6196
7352
  for (let j = i + 1; j < s.length; j += 1) {
6197
7353
  const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
@@ -6311,8 +7467,16 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
6311
7467
  }
6312
7468
  if (input.direction) {
6313
7469
  const d = input.direction;
6314
- const raised2 = !d.valid || d.decided < MIN_DECIDED;
6315
- signals.push({ id: "reasons", raised: raised2, detail: `${d.decided} of 7 choices decided with a reason${d.valid ? "" : ", art-direction.json has errors"}` });
7470
+ const layers = d.layers;
7471
+ const raised2 = !d.valid || d.decided < MIN_DECIDED || !layers.complete;
7472
+ const pages = layers.hierarchy.map((p) => `${p.page} ${p.decided} of ${p.total}`).join(", ");
7473
+ const detail = [
7474
+ `job ${layers.job ? "decided" : "not decided"}`,
7475
+ `hierarchy ${pages || "not decided"}`,
7476
+ `${d.decided} of 7 choices decided with a reason`
7477
+ ].join("; ");
7478
+ signals.push({ id: "reasons", raised: raised2, detail: `${detail}${d.valid ? "" : ", art-direction.json has errors"}` });
7479
+ if (!layers.job) actions.push({ area: "direction", what: "Write the job map: what the visitor is trying to get done, from reviews, enquiries and conversations. Run craft direction research for the questions.", why: "structure and look are decided from the job", priority: 1 });
6316
7480
  for (const p of d.problems) {
6317
7481
  const key = p.at.match(/^choices\.(\w+)/)?.[1];
6318
7482
  if (p.severity === "error" || p.message === "not decided yet") {
@@ -6330,11 +7494,11 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
6330
7494
  const order = ["direction", "type", "colour", "shape", "effects", "motion", "layout", "copy"];
6331
7495
  const merged = [];
6332
7496
  for (const a of actions) {
6333
- const same = a.choice && a.area !== "direction" && a.area !== "copy" ? merged.find((m) => m.choice === a.choice && m.area === a.area) : void 0;
6334
- if (same) {
7497
+ const same2 = a.choice && a.area !== "direction" && a.area !== "copy" ? merged.find((m) => m.choice === a.choice && m.area === a.area) : void 0;
7498
+ if (same2) {
6335
7499
  const fromTell = /\(\S+, gen \d\)/.test(a.why);
6336
- same.why = `${same.why}; ${fromTell ? a.why : `${a.what.replace(/\.$/, "")} (${a.why})`}`;
6337
- same.priority = Math.min(same.priority, a.priority);
7500
+ same2.why = `${same2.why}; ${fromTell ? a.why : `${a.what.replace(/\.$/, "")} (${a.why})`}`;
7501
+ same2.priority = Math.min(same2.priority, a.priority);
6338
7502
  } else merged.push({ ...a });
6339
7503
  }
6340
7504
  const unique2 = merged.filter((a, i) => merged.findIndex((b) => b.area === a.area && b.what === a.what) === i);
@@ -6411,6 +7575,8 @@ function retrofitPlan(report2, direction) {
6411
7575
  return lines.join("\n");
6412
7576
  }
6413
7577
  export {
7578
+ ACCENT_SCALE,
7579
+ ACCENT_SHARED,
6414
7580
  AI_PHRASES,
6415
7581
  AI_SOURCE_TYPES,
6416
7582
  AI_VIOLET,
@@ -6421,10 +7587,14 @@ export {
6421
7587
  CATALOGUE_VERSION,
6422
7588
  CHOICE_KEYS,
6423
7589
  COMPONENT_ROLES,
7590
+ CONSIDERATION,
7591
+ CONVENTION,
7592
+ CUSTOMER_KINDS,
6424
7593
  DEFAULT_IGNORE,
6425
7594
  DENSITIES,
6426
7595
  DENSITY_NAMES,
6427
7596
  DIRECTION_VERSION,
7597
+ DIRECTION_VERSIONS,
6428
7598
  DISTANCE_WEIGHTS,
6429
7599
  DURATION_MS,
6430
7600
  DURATION_S,
@@ -6434,6 +7604,12 @@ export {
6434
7604
  ESTATE_VERSION,
6435
7605
  EXIT_RATIO,
6436
7606
  FINGERPRINT_VERSION,
7607
+ FUNCTION_WORDS,
7608
+ GROUND_SCALE,
7609
+ GROUND_SHARED,
7610
+ HIERARCHY_FIELDS,
7611
+ HOME_FILE,
7612
+ HOME_URL,
6437
7613
  HOUSE,
6438
7614
  HOUSE_BUDGET,
6439
7615
  HOUSE_LOCKED,
@@ -6444,11 +7620,15 @@ export {
6444
7620
  LIGHTNESS_CURVE,
6445
7621
  MIN_DECIDED,
6446
7622
  MIN_RUNS,
7623
+ MOOD,
6447
7624
  NEGATIVE_REASSURANCE,
6448
7625
  NULL_VERSION,
7626
+ PAGE_TYPES,
6449
7627
  PILL_LIMIT,
6450
7628
  PLAINER_WORDS,
7629
+ PREFERENCE,
6451
7630
  PROMO_BADGE,
7631
+ PROOF_NAMES,
6452
7632
  PROPOSED,
6453
7633
  PROVENANCE_SCAN_BYTES,
6454
7634
  RAMP_STEPS,
@@ -6460,17 +7640,24 @@ export {
6460
7640
  REVEAL_LIMIT,
6461
7641
  REVIEW_PHRASES,
6462
7642
  SCALE,
7643
+ SECTION_ROLES,
6463
7644
  SHADCN_LIMIT,
6464
7645
  SHADCN_PRIMITIVES,
7646
+ SHARED_PART,
6465
7647
  SIBLING_AT,
6466
7648
  SNAPSHOT_VERSION,
6467
7649
  SOURCE_KINDS,
6468
7650
  SPACE_STEPS,
7651
+ SPECIFICITY_COPY_TELLS,
7652
+ SPECIFICITY_RENDERED_TELLS,
6469
7653
  SPRING,
7654
+ STANDING_CLAIM,
6470
7655
  STOCK_AVATAR_HOSTS,
7656
+ STOCK_FIGURES,
6471
7657
  STOCK_PHOTO_HOSTS,
6472
7658
  STOCK_PHRASES,
6473
7659
  TELL_HEAVY,
7660
+ TRADE_NOUNS,
6474
7661
  TYPE_STEPS,
6475
7662
  TYPICAL_AT,
6476
7663
  VAGUE_WORDS,
@@ -6481,22 +7668,32 @@ export {
6481
7668
  applyHouseGate,
6482
7669
  applySeverity,
6483
7670
  auditSnapshot,
7671
+ briefTerms,
6484
7672
  catalogueTable,
6485
7673
  characterReport,
6486
7674
  checkCopy,
7675
+ checkOrder,
6487
7676
  checkPair,
7677
+ checkReason,
6488
7678
  checkRestraint,
6489
7679
  choicesOf,
7680
+ clauseDepth,
6490
7681
  closestComponents,
7682
+ compareCompetitor,
7683
+ compareDirections,
6491
7684
  compareFacts,
7685
+ compareToDeclared,
6492
7686
  compareToEstate,
6493
7687
  componentDistance,
6494
7688
  componentShared,
6495
7689
  componentTypicalities,
6496
7690
  componentTypicality,
6497
7691
  componentsOf,
7692
+ contentWords,
7693
+ countWords2 as countWords,
6498
7694
  craftTokens,
6499
7695
  createBaseline,
7696
+ cv,
6500
7697
  deltaEOk,
6501
7698
  deltaEOkHex,
6502
7699
  densityCss,
@@ -6519,11 +7716,15 @@ export {
6519
7716
  fontClass,
6520
7717
  formatClaims,
6521
7718
  formatComparison,
7719
+ formatCompetitor,
7720
+ formatDirectionMatches,
6522
7721
  formatHex,
6523
7722
  formatReport,
6524
7723
  globToRegExp,
6525
7724
  groundName,
6526
7725
  harvest,
7726
+ hedgeCount,
7727
+ hedgedClose,
6527
7728
  hexToOklch,
6528
7729
  holdHouseBlocks,
6529
7730
  houseRule,
@@ -6534,49 +7735,75 @@ export {
6534
7735
  inSrgbGamut,
6535
7736
  initDirection,
6536
7737
  isAiViolet,
7738
+ isBlurb,
7739
+ isCategoryLabel,
6537
7740
  isCream,
7741
+ jobWords,
6538
7742
  layoutDistance,
6539
7743
  licencesJson,
6540
7744
  makeSnapshot,
6541
7745
  motionTokens,
6542
7746
  namedSources,
6543
7747
  nearest,
7748
+ nominalisations,
6544
7749
  normaliseFamily,
6545
7750
  nullPrompt,
6546
7751
  oklchToHex,
6547
7752
  oklchToRgb,
7753
+ pageCopy,
6548
7754
  paletteFromPixels,
7755
+ paragraphUniformity,
6549
7756
  parseBaseline,
6550
7757
  parseColour,
6551
7758
  parseCraftConfig,
6552
7759
  parseHex,
7760
+ participialClauses,
7761
+ phrasalCoordinations,
6553
7762
  propose,
6554
7763
  protectedFacts,
6555
7764
  ramp,
6556
7765
  rampFromAnchors,
6557
7766
  readSnapshot,
7767
+ renderedOrder,
7768
+ researchPrompt,
6558
7769
  retrofitPlan,
6559
7770
  runTell,
6560
7771
  scanProvenance,
6561
7772
  scanSource,
6562
7773
  sectionRhythm,
6563
7774
  semantic,
7775
+ sentencesOf,
6564
7776
  severityExceptions,
7777
+ shapeMoves,
6565
7778
  shapeName,
7779
+ shapeOf,
7780
+ words as shapeWords,
6566
7781
  sharedParts,
6567
7782
  shouldAnimate,
6568
7783
  siblingLine,
7784
+ skeleton,
7785
+ skeletonRepeat,
7786
+ specificity,
7787
+ specifics,
7788
+ splitSentences,
7789
+ stackedConditional,
6569
7790
  stockAvatarSource,
6570
7791
  stockSource,
7792
+ summariseDirection,
6571
7793
  tailwindV3Preset,
6572
7794
  tailwindV4Theme,
6573
7795
  tellById,
6574
7796
  toGamut,
6575
7797
  toJson,
6576
7798
  toSarif,
7799
+ tradeOf,
7800
+ tricolons,
6577
7801
  typeFeatureTokens,
6578
7802
  typicality,
7803
+ unprovenClaims,
6579
7804
  validateDirection,
7805
+ validateHierarchy,
7806
+ validateJob,
6580
7807
  visibleText,
6581
7808
  visualMeasures,
6582
7809
  wcagContrast,