@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.cjs CHANGED
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ ACCENT_SCALE: () => ACCENT_SCALE,
24
+ ACCENT_SHARED: () => ACCENT_SHARED,
23
25
  AI_PHRASES: () => AI_PHRASES,
24
26
  AI_SOURCE_TYPES: () => AI_SOURCE_TYPES,
25
27
  AI_VIOLET: () => AI_VIOLET,
@@ -30,10 +32,14 @@ __export(index_exports, {
30
32
  CATALOGUE_VERSION: () => CATALOGUE_VERSION,
31
33
  CHOICE_KEYS: () => CHOICE_KEYS,
32
34
  COMPONENT_ROLES: () => COMPONENT_ROLES,
35
+ CONSIDERATION: () => CONSIDERATION,
36
+ CONVENTION: () => CONVENTION,
37
+ CUSTOMER_KINDS: () => CUSTOMER_KINDS,
33
38
  DEFAULT_IGNORE: () => DEFAULT_IGNORE,
34
39
  DENSITIES: () => DENSITIES,
35
40
  DENSITY_NAMES: () => DENSITY_NAMES,
36
41
  DIRECTION_VERSION: () => DIRECTION_VERSION,
42
+ DIRECTION_VERSIONS: () => DIRECTION_VERSIONS,
37
43
  DISTANCE_WEIGHTS: () => DISTANCE_WEIGHTS,
38
44
  DURATION_MS: () => DURATION_MS,
39
45
  DURATION_S: () => DURATION_S,
@@ -43,6 +49,12 @@ __export(index_exports, {
43
49
  ESTATE_VERSION: () => ESTATE_VERSION,
44
50
  EXIT_RATIO: () => EXIT_RATIO,
45
51
  FINGERPRINT_VERSION: () => FINGERPRINT_VERSION,
52
+ FUNCTION_WORDS: () => FUNCTION_WORDS,
53
+ GROUND_SCALE: () => GROUND_SCALE,
54
+ GROUND_SHARED: () => GROUND_SHARED,
55
+ HIERARCHY_FIELDS: () => HIERARCHY_FIELDS,
56
+ HOME_FILE: () => HOME_FILE,
57
+ HOME_URL: () => HOME_URL,
46
58
  HOUSE: () => HOUSE,
47
59
  HOUSE_BUDGET: () => HOUSE_BUDGET,
48
60
  HOUSE_LOCKED: () => HOUSE_LOCKED,
@@ -53,11 +65,15 @@ __export(index_exports, {
53
65
  LIGHTNESS_CURVE: () => LIGHTNESS_CURVE,
54
66
  MIN_DECIDED: () => MIN_DECIDED,
55
67
  MIN_RUNS: () => MIN_RUNS,
68
+ MOOD: () => MOOD,
56
69
  NEGATIVE_REASSURANCE: () => NEGATIVE_REASSURANCE,
57
70
  NULL_VERSION: () => NULL_VERSION,
71
+ PAGE_TYPES: () => PAGE_TYPES,
58
72
  PILL_LIMIT: () => PILL_LIMIT,
59
73
  PLAINER_WORDS: () => PLAINER_WORDS,
74
+ PREFERENCE: () => PREFERENCE,
60
75
  PROMO_BADGE: () => PROMO_BADGE,
76
+ PROOF_NAMES: () => PROOF_NAMES,
61
77
  PROPOSED: () => PROPOSED,
62
78
  PROVENANCE_SCAN_BYTES: () => PROVENANCE_SCAN_BYTES,
63
79
  RAMP_STEPS: () => RAMP_STEPS,
@@ -69,17 +85,24 @@ __export(index_exports, {
69
85
  REVEAL_LIMIT: () => REVEAL_LIMIT,
70
86
  REVIEW_PHRASES: () => REVIEW_PHRASES,
71
87
  SCALE: () => SCALE,
88
+ SECTION_ROLES: () => SECTION_ROLES,
72
89
  SHADCN_LIMIT: () => SHADCN_LIMIT,
73
90
  SHADCN_PRIMITIVES: () => SHADCN_PRIMITIVES,
91
+ SHARED_PART: () => SHARED_PART,
74
92
  SIBLING_AT: () => SIBLING_AT,
75
93
  SNAPSHOT_VERSION: () => SNAPSHOT_VERSION,
76
94
  SOURCE_KINDS: () => SOURCE_KINDS,
77
95
  SPACE_STEPS: () => SPACE_STEPS,
96
+ SPECIFICITY_COPY_TELLS: () => SPECIFICITY_COPY_TELLS,
97
+ SPECIFICITY_RENDERED_TELLS: () => SPECIFICITY_RENDERED_TELLS,
78
98
  SPRING: () => SPRING,
99
+ STANDING_CLAIM: () => STANDING_CLAIM,
79
100
  STOCK_AVATAR_HOSTS: () => STOCK_AVATAR_HOSTS,
101
+ STOCK_FIGURES: () => STOCK_FIGURES,
80
102
  STOCK_PHOTO_HOSTS: () => STOCK_PHOTO_HOSTS,
81
103
  STOCK_PHRASES: () => STOCK_PHRASES,
82
104
  TELL_HEAVY: () => TELL_HEAVY,
105
+ TRADE_NOUNS: () => TRADE_NOUNS,
83
106
  TYPE_STEPS: () => TYPE_STEPS,
84
107
  TYPICAL_AT: () => TYPICAL_AT,
85
108
  VAGUE_WORDS: () => VAGUE_WORDS,
@@ -90,22 +113,32 @@ __export(index_exports, {
90
113
  applyHouseGate: () => applyHouseGate,
91
114
  applySeverity: () => applySeverity,
92
115
  auditSnapshot: () => auditSnapshot,
116
+ briefTerms: () => briefTerms,
93
117
  catalogueTable: () => catalogueTable,
94
118
  characterReport: () => characterReport,
95
119
  checkCopy: () => checkCopy,
120
+ checkOrder: () => checkOrder,
96
121
  checkPair: () => checkPair,
122
+ checkReason: () => checkReason,
97
123
  checkRestraint: () => checkRestraint,
98
124
  choicesOf: () => choicesOf,
125
+ clauseDepth: () => clauseDepth,
99
126
  closestComponents: () => closestComponents,
127
+ compareCompetitor: () => compareCompetitor,
128
+ compareDirections: () => compareDirections,
100
129
  compareFacts: () => compareFacts,
130
+ compareToDeclared: () => compareToDeclared,
101
131
  compareToEstate: () => compareToEstate,
102
132
  componentDistance: () => componentDistance,
103
133
  componentShared: () => componentShared,
104
134
  componentTypicalities: () => componentTypicalities,
105
135
  componentTypicality: () => componentTypicality,
106
136
  componentsOf: () => componentsOf,
137
+ contentWords: () => contentWords,
138
+ countWords: () => countWords2,
107
139
  craftTokens: () => craftTokens,
108
140
  createBaseline: () => createBaseline,
141
+ cv: () => cv,
109
142
  deltaEOk: () => deltaEOk,
110
143
  deltaEOkHex: () => deltaEOkHex,
111
144
  densityCss: () => densityCss,
@@ -128,11 +161,15 @@ __export(index_exports, {
128
161
  fontClass: () => fontClass,
129
162
  formatClaims: () => formatClaims,
130
163
  formatComparison: () => formatComparison,
164
+ formatCompetitor: () => formatCompetitor,
165
+ formatDirectionMatches: () => formatDirectionMatches,
131
166
  formatHex: () => formatHex,
132
167
  formatReport: () => formatReport,
133
168
  globToRegExp: () => globToRegExp,
134
169
  groundName: () => groundName,
135
170
  harvest: () => harvest,
171
+ hedgeCount: () => hedgeCount,
172
+ hedgedClose: () => hedgedClose,
136
173
  hexToOklch: () => hexToOklch,
137
174
  holdHouseBlocks: () => holdHouseBlocks,
138
175
  houseRule: () => houseRule,
@@ -143,49 +180,75 @@ __export(index_exports, {
143
180
  inSrgbGamut: () => inSrgbGamut,
144
181
  initDirection: () => initDirection,
145
182
  isAiViolet: () => isAiViolet,
183
+ isBlurb: () => isBlurb,
184
+ isCategoryLabel: () => isCategoryLabel,
146
185
  isCream: () => isCream,
186
+ jobWords: () => jobWords,
147
187
  layoutDistance: () => layoutDistance,
148
188
  licencesJson: () => licencesJson,
149
189
  makeSnapshot: () => makeSnapshot,
150
190
  motionTokens: () => motionTokens,
151
191
  namedSources: () => namedSources,
152
192
  nearest: () => nearest,
193
+ nominalisations: () => nominalisations,
153
194
  normaliseFamily: () => normaliseFamily,
154
195
  nullPrompt: () => nullPrompt,
155
196
  oklchToHex: () => oklchToHex,
156
197
  oklchToRgb: () => oklchToRgb,
198
+ pageCopy: () => pageCopy,
157
199
  paletteFromPixels: () => paletteFromPixels,
200
+ paragraphUniformity: () => paragraphUniformity,
158
201
  parseBaseline: () => parseBaseline,
159
202
  parseColour: () => parseColour,
160
203
  parseCraftConfig: () => parseCraftConfig,
161
204
  parseHex: () => parseHex,
205
+ participialClauses: () => participialClauses,
206
+ phrasalCoordinations: () => phrasalCoordinations,
162
207
  propose: () => propose,
163
208
  protectedFacts: () => protectedFacts,
164
209
  ramp: () => ramp,
165
210
  rampFromAnchors: () => rampFromAnchors,
166
211
  readSnapshot: () => readSnapshot,
212
+ renderedOrder: () => renderedOrder,
213
+ researchPrompt: () => researchPrompt,
167
214
  retrofitPlan: () => retrofitPlan,
168
215
  runTell: () => runTell,
169
216
  scanProvenance: () => scanProvenance,
170
217
  scanSource: () => scanSource,
171
218
  sectionRhythm: () => sectionRhythm,
172
219
  semantic: () => semantic,
220
+ sentencesOf: () => sentencesOf,
173
221
  severityExceptions: () => severityExceptions,
222
+ shapeMoves: () => shapeMoves,
174
223
  shapeName: () => shapeName,
224
+ shapeOf: () => shapeOf,
225
+ shapeWords: () => words,
175
226
  sharedParts: () => sharedParts,
176
227
  shouldAnimate: () => shouldAnimate,
177
228
  siblingLine: () => siblingLine,
229
+ skeleton: () => skeleton,
230
+ skeletonRepeat: () => skeletonRepeat,
231
+ specificity: () => specificity,
232
+ specifics: () => specifics,
233
+ splitSentences: () => splitSentences,
234
+ stackedConditional: () => stackedConditional,
178
235
  stockAvatarSource: () => stockAvatarSource,
179
236
  stockSource: () => stockSource,
237
+ summariseDirection: () => summariseDirection,
180
238
  tailwindV3Preset: () => tailwindV3Preset,
181
239
  tailwindV4Theme: () => tailwindV4Theme,
182
240
  tellById: () => tellById,
183
241
  toGamut: () => toGamut,
184
242
  toJson: () => toJson,
185
243
  toSarif: () => toSarif,
244
+ tradeOf: () => tradeOf,
245
+ tricolons: () => tricolons,
186
246
  typeFeatureTokens: () => typeFeatureTokens,
187
247
  typicality: () => typicality,
248
+ unprovenClaims: () => unprovenClaims,
188
249
  validateDirection: () => validateDirection,
250
+ validateHierarchy: () => validateHierarchy,
251
+ validateJob: () => validateJob,
189
252
  visibleText: () => visibleText,
190
253
  visualMeasures: () => visualMeasures,
191
254
  wcagContrast: () => wcagContrast,
@@ -685,9 +748,9 @@ function fluidClamp(minPx, maxPx, minViewport, maxViewport, rootPx = 16) {
685
748
  const interceptRem = round((minPx - slope * minViewport) / rootPx);
686
749
  const slopeVw = round(slope * 100);
687
750
  const preferred = interceptRem === 0 ? `${slopeVw}vw` : `${interceptRem}rem + ${slopeVw}vw`;
688
- const lower = Math.min(minRem, maxRem);
751
+ const lower2 = Math.min(minRem, maxRem);
689
752
  const upper = Math.max(minRem, maxRem);
690
- return `clamp(${lower}rem, ${preferred}, ${upper}rem)`;
753
+ return `clamp(${lower2}rem, ${preferred}, ${upper}rem)`;
691
754
  }
692
755
  function fluidType(options = {}) {
693
756
  const {
@@ -1481,7 +1544,7 @@ function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => fal
1481
1544
  };
1482
1545
  }
1483
1546
  var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/'/g, "['\u2019]?").replace(/ /g, "\\s+");
1484
- var wordList = (words2) => new RegExp(`\\b(?:${[...words2].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
1547
+ var wordList = (words3) => new RegExp(`\\b(?:${[...words3].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
1485
1548
  var quoted = (m) => `"${m}"`;
1486
1549
  var AI_WORDS = [
1487
1550
  "tapestry",
@@ -2319,8 +2382,8 @@ function documents(ctx) {
2319
2382
  const docs = [];
2320
2383
  for (const [path, blocks] of byPath) {
2321
2384
  const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
2322
- const words2 = countWords(text);
2323
- if (words2 >= MIN_WORDS) docs.push({ path, blocks, words: words2 });
2385
+ const words3 = countWords(text);
2386
+ if (words3 >= MIN_WORDS) docs.push({ path, blocks, words: words3 });
2324
2387
  }
2325
2388
  return docs;
2326
2389
  }
@@ -2445,14 +2508,14 @@ function contractionHits(ctx) {
2445
2508
  for (const doc2 of documents(ctx)) {
2446
2509
  if (doc2.words < 800) continue;
2447
2510
  const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
2448
- const count3 = (text.match(CONTRACTION) ?? []).length;
2449
- const rate = count3 * 1e3 / doc2.words;
2511
+ const count4 = (text.match(CONTRACTION) ?? []).length;
2512
+ const rate = count4 * 1e3 / doc2.words;
2450
2513
  if (rate < 1) {
2451
2514
  const first = sentences(doc2)[0];
2452
2515
  hits.push({
2453
2516
  path: doc2.path,
2454
2517
  offset: first?.offset ?? 0,
2455
- message: `${count3} contraction${count3 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
2518
+ message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
2456
2519
  });
2457
2520
  }
2458
2521
  }
@@ -2464,14 +2527,14 @@ function rhythmHits(ctx) {
2464
2527
  const list = sentences(doc2);
2465
2528
  if (list.length < 40) continue;
2466
2529
  const lengths = list.map((s) => countWords(s.text));
2467
- const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
2468
- const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
2469
- const cv = sd / mean;
2470
- if (cv < 0.42) {
2530
+ const mean2 = lengths.reduce((a, b) => a + b, 0) / lengths.length;
2531
+ const sd2 = Math.sqrt(lengths.reduce((a, b) => a + (b - mean2) ** 2, 0) / lengths.length);
2532
+ const cv2 = sd2 / mean2;
2533
+ if (cv2 < 0.42) {
2471
2534
  hits.push({
2472
2535
  path: doc2.path,
2473
2536
  offset: list[0].offset,
2474
- message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
2537
+ message: `${list.length} sentences averaging ${mean2.toFixed(1)} words with little variation (spread ${cv2.toFixed(2)}, reads metronomic below 0.42)`
2475
2538
  });
2476
2539
  }
2477
2540
  }
@@ -2485,19 +2548,19 @@ function repeatHits(ctx) {
2485
2548
  const whole = /* @__PURE__ */ new Map();
2486
2549
  const runs = /* @__PURE__ */ new Map();
2487
2550
  for (const s of sentences(doc2)) {
2488
- const words2 = normal(s.text);
2489
- if (words2.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
2551
+ const words3 = normal(s.text);
2552
+ if (words3.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
2490
2553
  if (/^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
2491
- const key = words2.join(" ");
2554
+ const key = words3.join(" ");
2492
2555
  const earlier = whole.get(key);
2493
2556
  if (earlier) {
2494
- hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words2.length} words)`, excerpt: s.text.slice(0, 120) });
2557
+ hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words3.length} words)`, excerpt: s.text.slice(0, 120) });
2495
2558
  continue;
2496
2559
  }
2497
2560
  whole.set(key, s);
2498
2561
  let reported = false;
2499
- for (let i = 0; i + RUN <= words2.length && !reported; i += 1) {
2500
- const run = words2.slice(i, i + RUN).join(" ");
2562
+ for (let i = 0; i + RUN <= words3.length && !reported; i += 1) {
2563
+ const run = words3.slice(i, i + RUN).join(" ");
2501
2564
  const seen = runs.get(run);
2502
2565
  if (seen && seen.key !== key) {
2503
2566
  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) });
@@ -2554,11 +2617,11 @@ function headingEchoHits(ctx) {
2554
2617
  const hits = [];
2555
2618
  for (const { file } of longFiles(ctx)) {
2556
2619
  for (const heading of headings(file)) {
2557
- const words2 = normal(heading.text);
2558
- if (words2.length < 4) continue;
2620
+ const words3 = normal(heading.text);
2621
+ if (words3.length < 4) continue;
2559
2622
  const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
2560
- const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words2.length);
2561
- if (opening.join(" ") === words2.join(" ")) {
2623
+ const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words3.length);
2624
+ if (opening.join(" ") === words3.join(" ")) {
2562
2625
  hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
2563
2626
  }
2564
2627
  }
@@ -2566,7 +2629,7 @@ function headingEchoHits(ctx) {
2566
2629
  return hits;
2567
2630
  }
2568
2631
  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(" ");
2569
- function filler(words2, seed = 1, options = {}) {
2632
+ function filler(words3, seed = 1, options = {}) {
2570
2633
  const { contractions = true, uniform } = options;
2571
2634
  const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
2572
2635
  let state = seed;
@@ -2575,16 +2638,16 @@ function filler(words2, seed = 1, options = {}) {
2575
2638
  return state % n;
2576
2639
  };
2577
2640
  const out = [];
2578
- let count3 = 0;
2641
+ let count4 = 0;
2579
2642
  let i = 0;
2580
- while (count3 < words2) {
2643
+ while (count4 < words3) {
2581
2644
  const length = uniform ?? shape[i % shape.length];
2582
2645
  const s = [];
2583
2646
  for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
2584
2647
  if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
2585
2648
  s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
2586
2649
  out.push(`${s.join(" ")}.`);
2587
- count3 += s.length;
2650
+ count4 += s.length;
2588
2651
  i += 1;
2589
2652
  }
2590
2653
  const paragraphs = [];
@@ -3724,64 +3787,76 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
3724
3787
  }
3725
3788
  ];
3726
3789
 
3727
- // src/character/house.ts
3728
- var HOUSE = {
3729
- "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
3730
- emoji: { tier: "block", label: "emoji" },
3731
- "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
3732
- "no-x-no-y": {
3733
- tier: "block",
3734
- label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
3735
- },
3736
- "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
3737
- "ai-phrase": { tier: "block", label: "AI tell" },
3738
- "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
3739
- "plain-english": { tier: "block", label: 'use "properly explained"' },
3740
- buzzword: { tier: "block", label: "forbidden phrase" },
3741
- "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
3742
- // Evidence of a pasted chat reply, not a matter of style. Moved from review
3743
- // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
3744
- "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
3745
- "review-phrase": { tier: "review", label: "review tier" },
3746
- "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3747
- "stock-phrase": { tier: "review", label: "stock phrase" },
3748
- "hollow-imperative": { tier: "review", label: "hollow imperative" },
3749
- "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
3750
- "staccato-triplet": { tier: "review", label: "staccato triplet" },
3751
- "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
3752
- "ing-tail": { tier: "review", label: "empty -ing tail" },
3753
- "vague-attribution": { tier: "review", label: "unnamed source" },
3754
- "closing-summary": { tier: "review", label: "closing summary" },
3755
- "false-range": { tier: "review", label: "false range" },
3756
- "placeholder": { tier: "review", label: "unfilled placeholder" },
3757
- // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
3758
- // a proposal can honestly say "the attached research". Review until measured there.
3759
- "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
3760
- "question-reveal": { tier: "review", label: "staged reveal" },
3761
- "inline-label-list": { tier: "review", label: "bold-label bullets" },
3762
- "phrase-density": { tier: "review", label: "density" },
3763
- "aphorism-density": { tier: "review", label: "density" },
3764
- "contraction-scarcity": { tier: "review", label: "density" },
3765
- "sentence-rhythm": { tier: "review", label: "density" },
3766
- "repeated-sentence": { tier: "review", label: "repetition" },
3767
- "heading-shape": { tier: "review", label: "headings" },
3768
- "heading-echo": { tier: "review", label: "headings" },
3769
- // Banned only "when vague", per the rules. Too noisy for a sweep.
3770
- "vague-word": { tier: "explicit", label: "vague" }
3771
- };
3772
- function houseRule(id, name = id) {
3773
- return HOUSE[id] ?? { tier: "review", label: name };
3774
- }
3775
- 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";
3776
- function holdHouseBlocks(exceptions, severity) {
3777
- const blocks = (tell) => HOUSE[tell]?.tier === "block";
3778
- const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
3779
- return {
3780
- exceptions: exceptions.filter((e) => !blocks(e.tell)),
3781
- refused: exceptions.filter((e) => blocks(e.tell)),
3782
- severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
3783
- refusedSeverity
3790
+ // src/snapshot/types.ts
3791
+ var SNAPSHOT_VERSION = 2;
3792
+
3793
+ // src/snapshot/fixture.ts
3794
+ function makeSnapshot(patch = {}) {
3795
+ const base = {
3796
+ version: SNAPSHOT_VERSION,
3797
+ url: "https://example.test/",
3798
+ capturedAt: "2026-09-23T00:00:00.000Z",
3799
+ viewport: { width: 1440, height: 900 },
3800
+ pageHeight: 4200,
3801
+ ground: "rgb(255, 255, 255)",
3802
+ colours: {
3803
+ backgrounds: [
3804
+ { value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
3805
+ { value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
3806
+ ],
3807
+ text: [
3808
+ { value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
3809
+ { value: "rgb(31, 58, 46)", area: 0, chars: 300 }
3810
+ ]
3811
+ },
3812
+ fonts: [
3813
+ { family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
3814
+ { family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
3815
+ ],
3816
+ headings: [
3817
+ { level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
3818
+ { level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
3819
+ ],
3820
+ controls: [
3821
+ { kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
3822
+ { kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
3823
+ { kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
3824
+ { kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
3825
+ ],
3826
+ sections: [
3827
+ { top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
3828
+ { top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
3829
+ { top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
3830
+ { top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
3831
+ { top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
3832
+ ],
3833
+ effects: {
3834
+ glass: 0,
3835
+ gradientText: 0,
3836
+ gradients: [],
3837
+ glows: 0,
3838
+ gridBackgrounds: 0,
3839
+ marquees: 0,
3840
+ hairlineShadowCards: 0,
3841
+ eyebrowChip: false
3842
+ },
3843
+ motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
3844
+ metrics: {
3845
+ fontSizes: [14, 16, 20, 36, 64],
3846
+ fontWeights: [400, 500],
3847
+ radii: ["4px"],
3848
+ shadows: [],
3849
+ spacingOnScalePct: 96,
3850
+ bodyLineLengthCh: 68,
3851
+ bodyFontSizePx: 17
3852
+ }
3784
3853
  };
3854
+ const out = { ...base };
3855
+ for (const [key, value] of Object.entries(patch)) {
3856
+ const current = base[key];
3857
+ out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
3858
+ }
3859
+ return out;
3785
3860
  }
3786
3861
 
3787
3862
  // src/character/parse.ts
@@ -3954,15 +4029,15 @@ var blank = (s) => s.replace(/[^\n]/g, " ");
3954
4029
  function readsAsProse(value) {
3955
4030
  if (value.length < 12 || !/\s/.test(value)) return false;
3956
4031
  if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
3957
- const words2 = withoutMarkup(value);
3958
- if (looksLikeCode(words2) || /[<>]/.test(words2)) return false;
3959
- return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words2);
4032
+ const words3 = withoutMarkup(value);
4033
+ if (looksLikeCode(words3) || /[<>]/.test(words3)) return false;
4034
+ return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words3);
3960
4035
  }
3961
4036
  function withoutMarkup(value) {
3962
4037
  return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
3963
4038
  }
3964
- function looksLikeCode(words2) {
3965
- return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words2);
4039
+ function looksLikeCode(words3) {
4040
+ return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words3);
3966
4041
  }
3967
4042
  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)$/;
3968
4043
  function looksLikeClasses(value) {
@@ -4115,76 +4190,664 @@ function extractStrings(file) {
4115
4190
  return [];
4116
4191
  }
4117
4192
 
4118
- // src/snapshot/types.ts
4119
- var SNAPSHOT_VERSION = 2;
4193
+ // src/character/facts.ts
4194
+ 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)?)";
4195
+ var PATTERNS = [
4196
+ ["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
4197
+ ["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
4198
+ ["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
4199
+ ["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
4200
+ ["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
4201
+ ["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")],
4202
+ ["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
4203
+ ["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
4204
+ ];
4205
+ var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
4206
+ var normalise = (kind, value) => {
4207
+ const v = value.trim();
4208
+ if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
4209
+ if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
4210
+ if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
4211
+ if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
4212
+ return v.replace(/\s+/g, " ").toLowerCase();
4213
+ };
4214
+ function visibleText(file) {
4215
+ const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
4216
+ return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
4217
+ }
4218
+ function protectedFacts(text) {
4219
+ const facts = [];
4220
+ let rest = text;
4221
+ const mask = (start, length) => {
4222
+ rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
4223
+ };
4224
+ for (const [kind, pattern] of PATTERNS) {
4225
+ for (const m of [...rest.matchAll(pattern)]) {
4226
+ const value = m[0].replace(/[.,;:]+$/, "");
4227
+ facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
4228
+ mask(m.index ?? 0, m[0].length);
4229
+ }
4230
+ }
4231
+ const lines = rest.split("\n");
4232
+ for (const line of lines) {
4233
+ if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
4234
+ for (const m of line.matchAll(NAME)) {
4235
+ const at = m.index ?? 0;
4236
+ const before = line.slice(0, at).trimEnd();
4237
+ const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
4238
+ const word = m[0];
4239
+ const acronym = /^[A-Z]{2,}$/.test(word);
4240
+ if (opensSentence && !acronym && !/\s/.test(word)) continue;
4241
+ if (word === "I") continue;
4242
+ facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
4243
+ }
4244
+ }
4245
+ return facts;
4246
+ }
4247
+ var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
4248
+ function compareFacts(before, after) {
4249
+ const a = unique(protectedFacts(before));
4250
+ const b = unique(protectedFacts(after));
4251
+ const inA = new Set(a.map((f4) => f4.key));
4252
+ const inB = new Set(b.map((f4) => f4.key));
4253
+ return {
4254
+ before: a.length,
4255
+ after: b.length,
4256
+ lost: a.filter((f4) => !inB.has(f4.key)),
4257
+ added: b.filter((f4) => !inA.has(f4.key))
4258
+ };
4259
+ }
4260
+ function formatComparison(result, beforePath, afterPath) {
4261
+ const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
4262
+ if (result.lost.length) {
4263
+ lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
4264
+ for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
4265
+ }
4266
+ if (result.added.length) {
4267
+ lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
4268
+ for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
4269
+ }
4270
+ if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
4271
+ return lines.join("\n");
4272
+ }
4120
4273
 
4121
- // src/snapshot/fixture.ts
4122
- function makeSnapshot(patch = {}) {
4123
- const base = {
4124
- version: SNAPSHOT_VERSION,
4125
- url: "https://example.test/",
4126
- capturedAt: "2026-09-23T00:00:00.000Z",
4127
- viewport: { width: 1440, height: 900 },
4128
- pageHeight: 4200,
4129
- ground: "rgb(255, 255, 255)",
4130
- colours: {
4131
- backgrounds: [
4132
- { value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
4133
- { value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
4274
+ // src/character/specificity.ts
4275
+ var TRADE_NOUNS = [
4276
+ // heating, plumbing, electrical
4277
+ "boiler",
4278
+ "radiator",
4279
+ "combi",
4280
+ "cylinder",
4281
+ "thermostat",
4282
+ "underfloor heating",
4283
+ "heat pump",
4284
+ "solar panel",
4285
+ "plumbing",
4286
+ "plumber",
4287
+ "leak",
4288
+ "toilet",
4289
+ "shower",
4290
+ "bathroom",
4291
+ "wet room",
4292
+ "drain",
4293
+ "fuse board",
4294
+ "fuseboard",
4295
+ "consumer unit",
4296
+ "rewire",
4297
+ "socket",
4298
+ "ev charger",
4299
+ "electrician",
4300
+ "gas engineer",
4301
+ "power flush",
4302
+ "landlord certificate",
4303
+ "eicr",
4304
+ // building and outside
4305
+ "extension",
4306
+ "loft conversion",
4307
+ "loft",
4308
+ "kitchen",
4309
+ "roof",
4310
+ "roofer",
4311
+ "gutter",
4312
+ "chimney",
4313
+ "driveway",
4314
+ "patio",
4315
+ "fence",
4316
+ "fencing",
4317
+ "decking",
4318
+ "window",
4319
+ "conservatory",
4320
+ "plastering",
4321
+ "plasterer",
4322
+ "render",
4323
+ "brickwork",
4324
+ "bricklayer",
4325
+ "tiling",
4326
+ "tiler",
4327
+ "flooring",
4328
+ "carpet",
4329
+ "joinery",
4330
+ "joiner",
4331
+ "carpenter",
4332
+ "staircase",
4333
+ "damp",
4334
+ "scaffolding",
4335
+ "garage conversion",
4336
+ "lawn",
4337
+ "hedge",
4338
+ "tree surgery",
4339
+ "stump",
4340
+ "landscaping",
4341
+ "gardener",
4342
+ "gate",
4343
+ "locksmith",
4344
+ "pest control",
4345
+ "wasp",
4346
+ "cleaning",
4347
+ "oven",
4348
+ "end of tenancy",
4349
+ // vehicles
4350
+ "mot",
4351
+ "tyre",
4352
+ "brake",
4353
+ "clutch",
4354
+ "cambelt",
4355
+ "exhaust",
4356
+ "gearbox",
4357
+ "valeting",
4358
+ // personal and health
4359
+ "haircut",
4360
+ "skin fade",
4361
+ "beard",
4362
+ "barber",
4363
+ "balayage",
4364
+ "nails",
4365
+ "lashes",
4366
+ "massage",
4367
+ "physio",
4368
+ "physiotherapy",
4369
+ "osteopath",
4370
+ "chiropractor",
4371
+ "dentist",
4372
+ "dental",
4373
+ "implant",
4374
+ "brace",
4375
+ "invisalign",
4376
+ "hygienist",
4377
+ "optician",
4378
+ "eye test",
4379
+ "vet",
4380
+ "grooming",
4381
+ "dog walking",
4382
+ "personal trainer",
4383
+ "yoga",
4384
+ "pilates",
4385
+ // professional
4386
+ "accountant",
4387
+ "tax return",
4388
+ "bookkeeping",
4389
+ "payroll",
4390
+ "vat",
4391
+ "self assessment",
4392
+ "solicitor",
4393
+ "conveyancing",
4394
+ "probate",
4395
+ "divorce",
4396
+ "lasting power of attorney",
4397
+ "mortgage",
4398
+ "surveyor",
4399
+ "architect",
4400
+ "planning permission",
4401
+ "building regulations",
4402
+ // web and digital, for the agency's own pages
4403
+ "website",
4404
+ "web design",
4405
+ "seo",
4406
+ "hosting",
4407
+ "google business profile",
4408
+ "online shop",
4409
+ "ecommerce",
4410
+ // food and hospitality
4411
+ "breakfast",
4412
+ "lunch",
4413
+ "dinner",
4414
+ "coffee",
4415
+ "bakery",
4416
+ "cake",
4417
+ "catering",
4418
+ "wedding",
4419
+ "venue"
4420
+ ];
4421
+ var STOCK_FIGURES = [
4422
+ /\b24\s?\/\s?7\b/gi,
4423
+ /\b24[\s-]hours?\b/gi,
4424
+ /\b1[01]0\s?%/g,
4425
+ /\b(?:5|five)[\s-]?star\b/gi,
4426
+ /#\s?1\b/g,
4427
+ /\bno\.?\s?1\b/gi,
4428
+ /\b365\s+days\b/gi
4429
+ ];
4430
+ var NOT_NAMES = new Set(
4431
+ "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(" ")
4432
+ );
4433
+ var BRIEF_STOP = new Set(
4434
+ "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(" ")
4435
+ );
4436
+ var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
4437
+ var countWords2 = (text) => (text.match(WORD2) ?? []).length;
4438
+ var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4439
+ var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
4440
+ var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
4441
+ var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
4442
+ function caseless(line) {
4443
+ const words3 = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
4444
+ const letters = words3.filter((w) => w.length >= 2);
4445
+ if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
4446
+ const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
4447
+ if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
4448
+ return null;
4449
+ }
4450
+ var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
4451
+ function briefTerms(brief) {
4452
+ const terms = /* @__PURE__ */ new Set();
4453
+ for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
4454
+ const lower2 = w.toLowerCase();
4455
+ if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower2) && !NOT_NAMES.has(lower2)) terms.add(lower2);
4456
+ }
4457
+ for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
4458
+ for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
4459
+ return [...terms];
4460
+ }
4461
+ function specifics(text, brief) {
4462
+ const found = /* @__PURE__ */ new Map();
4463
+ const add = (kind, value) => {
4464
+ const key = value.toLowerCase().replace(/\s+/g, " ").trim();
4465
+ if (key && !found.has(key)) found.set(key, { kind, value, key });
4466
+ };
4467
+ for (const raw of text.split("\n")) {
4468
+ const line = maskStock(raw);
4469
+ if (!line.trim()) continue;
4470
+ const capitals = caseless(line);
4471
+ for (const f4 of protectedFacts(line)) {
4472
+ if (f4.kind === "name") {
4473
+ if (capitals === "shout") continue;
4474
+ if (capitals === "title") {
4475
+ for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
4476
+ continue;
4477
+ }
4478
+ const words3 = f4.value.split(/\s+/);
4479
+ const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
4480
+ const first = words3.findIndex(named);
4481
+ if (first === -1) continue;
4482
+ const last = words3.length - 1 - [...words3].reverse().findIndex(named);
4483
+ add("name", words3.slice(first, last + 1).join(" "));
4484
+ continue;
4485
+ }
4486
+ add(f4.kind, f4.value);
4487
+ }
4488
+ for (const [term, re] of TRADE_PATTERNS) {
4489
+ const m = re.exec(line);
4490
+ if (m) add("trade", term);
4491
+ }
4492
+ }
4493
+ if (brief) {
4494
+ for (const term of briefTerms(brief)) {
4495
+ if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
4496
+ if (termPattern(term).test(text)) add("brief", term);
4497
+ }
4498
+ }
4499
+ return [...found.values()];
4500
+ }
4501
+ function specificity(text, brief) {
4502
+ const list = specifics(text, brief);
4503
+ const words3 = countWords2(text);
4504
+ const trade = list.filter((s) => s.kind === "trade").length;
4505
+ const hard = list.length - trade;
4506
+ return {
4507
+ words: words3,
4508
+ specifics: list,
4509
+ hard,
4510
+ trade,
4511
+ perHundred: words3 ? Math.round(list.length / words3 * 1e3) / 10 : 0,
4512
+ generic: hard === 0 && trade < 2
4513
+ };
4514
+ }
4515
+ var NOT_SERVICE_ROLES = /* @__PURE__ */ new Set(["testimonials", "team", "faq", "contact", "logos", "stats", "cta-band", "footer-cta", "marquee"]);
4516
+ var SERVICE_ROLES = /* @__PURE__ */ new Set(["features", "process", "pricing", "cards"]);
4517
+ 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;
4518
+ function sentencesOf(blocks) {
4519
+ const out = [];
4520
+ for (const block of blocks) {
4521
+ for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
4522
+ const s = m[0].trim();
4523
+ if (countWords2(s) >= 3) out.push(s);
4524
+ }
4525
+ }
4526
+ return [...new Set(out)];
4527
+ }
4528
+ function pageCopy(snapshot) {
4529
+ const withText2 = snapshot.sections.filter((s) => s.text);
4530
+ if (!snapshot.firstScreenText && withText2.length === 0) return null;
4531
+ const hero = [...snapshot.firstScreenText ?? [], ...snapshot.sections.filter((s) => s.kind === "hero").flatMap((s) => s.text ?? [])];
4532
+ 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 ?? []);
4533
+ const all = [...snapshot.firstScreenText ?? [], ...withText2.flatMap((s) => s.text ?? [])];
4534
+ return { url: snapshot.url, hero: sentencesOf(hero), services: sentencesOf(services), all };
4535
+ }
4536
+ function side(page, other, brief) {
4537
+ const otherKeys = new Set(specifics(other.all.join("\n"), brief).map((s) => s.key));
4538
+ const sentences3 = [.../* @__PURE__ */ new Set([...page.hero, ...page.services])];
4539
+ const interchangeable = sentences3.filter((s) => !specifics(s, brief).some((x) => !otherKeys.has(x.key)));
4540
+ return {
4541
+ url: page.url,
4542
+ sentences: sentences3.length,
4543
+ interchangeable,
4544
+ share: sentences3.length ? Math.round(interchangeable.length / sentences3.length * 1e3) / 1e3 : null,
4545
+ perHundred: specificity(sentences3.join("\n"), brief).perHundred
4546
+ };
4547
+ }
4548
+ function compareCompetitor(ours, competitor, brief) {
4549
+ return { ours: side(ours, competitor, brief), competitor: side(competitor, ours, brief), brief: Boolean(brief) };
4550
+ }
4551
+ var pctOf = (share) => share === null ? "no sentences" : `${Math.round(share * 100)}%`;
4552
+ function formatCompetitor(result) {
4553
+ const row = (label, s) => ` ${label.padEnd(11)} ${s.interchangeable.length} of ${s.sentences} (${pctOf(s.share)}), ${s.perHundred} specifics per 100 words`;
4554
+ const lines = [
4555
+ `craft copy compare: ${result.ours.url} against ${result.competitor.url}`,
4556
+ " Hero and service sentences holding nothing the other page does not also say:",
4557
+ row("ours", result.ours),
4558
+ row("competitor", result.competitor)
4559
+ ];
4560
+ if (result.brief) lines.push(" The brief's own terms were recognised on both pages.");
4561
+ if (result.ours.interchangeable.length) {
4562
+ lines.push("", " Interchangeable on ours: add the job, the place, the price or the name only this business has");
4563
+ for (const s of result.ours.interchangeable.slice(0, 12)) lines.push(` "${s.length > 110 ? `${s.slice(0, 107)}...` : s}"`);
4564
+ if (result.ours.interchangeable.length > 12) lines.push(` and ${result.ours.interchangeable.length - 12} more`);
4565
+ } else {
4566
+ lines.push("", " Every hero and service sentence on ours holds something the competitor does not say.");
4567
+ }
4568
+ return lines.join("\n");
4569
+ }
4570
+
4571
+ // src/character/tells/specificity.ts
4572
+ var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
4573
+ var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
4574
+ var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
4575
+ function literalText(inner) {
4576
+ const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
4577
+ if (/[{}]/.test(t)) return null;
4578
+ return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
4579
+ }
4580
+ 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;
4581
+ var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
4582
+ var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
4583
+ var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
4584
+ function headlines(ctx) {
4585
+ const out = [];
4586
+ for (const file of ctx.files) {
4587
+ if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
4588
+ for (const m of file.text.matchAll(H1)) {
4589
+ const h1 = literalText(m[1]);
4590
+ if (!h1) continue;
4591
+ const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
4592
+ const p = NEXT_P.exec(after);
4593
+ const sub = p ? literalText(p[1]) : null;
4594
+ out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
4595
+ }
4596
+ }
4597
+ return out;
4598
+ }
4599
+ var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
4600
+ 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;
4601
+ var PROOF_NAMES = [
4602
+ "Gas Safe",
4603
+ "NICEIC",
4604
+ "NAPIT",
4605
+ "ELECSA",
4606
+ "Stroma",
4607
+ "TrustMark",
4608
+ "Which? Trusted Trader",
4609
+ "Checkatrade",
4610
+ "TrustATrader",
4611
+ "Rated People",
4612
+ "MyBuilder",
4613
+ "Federation of Master Builders",
4614
+ "FMB",
4615
+ "Guild of Master Craftsmen",
4616
+ "OFTEC",
4617
+ "HETAS",
4618
+ "CIPHE",
4619
+ "APHC",
4620
+ "CHAS",
4621
+ "SafeContractor",
4622
+ "Constructionline",
4623
+ "FENSA",
4624
+ "CERTASS",
4625
+ "MCS",
4626
+ "RECC",
4627
+ "BPEC",
4628
+ "City & Guilds",
4629
+ "NVQ",
4630
+ "CSCS",
4631
+ "Worcester Bosch",
4632
+ "Vaillant",
4633
+ "Viessmann",
4634
+ "BAFE",
4635
+ "NSI",
4636
+ "SSAIB",
4637
+ "BPCA",
4638
+ "RICS",
4639
+ "CIOB",
4640
+ "RIBA",
4641
+ "ICAEW",
4642
+ "ACCA",
4643
+ "CIMA",
4644
+ "SRA",
4645
+ "Law Society",
4646
+ "GDC",
4647
+ "GMC",
4648
+ "HCPC",
4649
+ "CQC",
4650
+ "Ofsted",
4651
+ "ISO 9001",
4652
+ "ISO 27001",
4653
+ "Cyber Essentials",
4654
+ "Companies House",
4655
+ "FCA",
4656
+ "Trustpilot",
4657
+ "Feefo",
4658
+ "Reviews.io",
4659
+ "Yell",
4660
+ "Houzz"
4661
+ ];
4662
+ var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4663
+ var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
4664
+ var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
4665
+ var PROOF_PATTERNS = [
4666
+ PROOF_NAME,
4667
+ /\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
4668
+ // A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
4669
+ /\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,
4670
+ // A registration or licence number.
4671
+ /\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
4672
+ ];
4673
+ var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
4674
+ function sectionHasProof(s, section, index) {
4675
+ if ((section.role ?? section.kind) === "testimonials") return true;
4676
+ const text = section.text ?? [];
4677
+ if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
4678
+ return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
4679
+ }
4680
+ function unprovenClaims(s) {
4681
+ const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
4682
+ const out = [];
4683
+ s.sections.forEach((section, i) => {
4684
+ if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
4685
+ for (const sentence of sentencesOf(section.text)) {
4686
+ const m = STANDING_CLAIM.exec(sentence);
4687
+ if (m) out.push({ claim: m[0], sentence, section: section.label });
4688
+ }
4689
+ });
4690
+ return out;
4691
+ }
4692
+ var withText = (texts, patch = () => ({})) => {
4693
+ const base = makeSnapshot();
4694
+ return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
4695
+ };
4696
+ var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
4697
+ var SPECIFICITY_COPY_TELLS = [
4698
+ {
4699
+ id: "generic-hero-claim",
4700
+ name: "Headline that fits any business",
4701
+ generation: 1,
4702
+ severity: "warn",
4703
+ surface: "copy",
4704
+ 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.",
4705
+ 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".',
4706
+ detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
4707
+ fixtures: {
4708
+ flag: [
4709
+ { 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>' },
4710
+ // Shouted and Title Case: its capitals are not names, and its figures are stock.
4711
+ { path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
4134
4712
  ],
4135
- text: [
4136
- { value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
4137
- { value: "rgb(31, 58, 46)", area: 0, chars: 300 }
4713
+ pass: [
4714
+ { path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
4715
+ // A stock headline rescued by the line under it.
4716
+ { path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
4717
+ // Two trade nouns say what the business does.
4718
+ { path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
4719
+ // The words come from data: nothing to read.
4720
+ { path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
4721
+ // Another page's headline is a label, not the site's claim.
4722
+ { path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
4138
4723
  ]
4139
4724
  },
4140
- fonts: [
4141
- { family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
4142
- { family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
4143
- ],
4144
- headings: [
4145
- { level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
4146
- { level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
4147
- ],
4148
- controls: [
4149
- { kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
4150
- { kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
4151
- { kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
4152
- { kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
4153
- ],
4154
- sections: [
4155
- { top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
4156
- { top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
4157
- { top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
4158
- { top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
4159
- { top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
4160
- ],
4161
- effects: {
4162
- glass: 0,
4163
- gradientText: 0,
4164
- gradients: [],
4165
- glows: 0,
4166
- gridBackgrounds: 0,
4167
- marquees: 0,
4168
- hairlineShadowCards: 0,
4169
- eyebrowChip: false
4170
- },
4171
- motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
4172
- metrics: {
4173
- fontSizes: [14, 16, 20, 36, 64],
4174
- fontWeights: [400, 500],
4175
- radii: ["4px"],
4176
- shadows: [],
4177
- spacingOnScalePct: 96,
4178
- bodyLineLengthCh: 68,
4179
- bodyFontSizePx: 17
4725
+ rendered: {
4726
+ detect: (s) => {
4727
+ if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
4728
+ const text = s.firstScreenText.join("\n");
4729
+ if (!text.trim() || !specificity(text).generic) return [];
4730
+ const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
4731
+ return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
4732
+ },
4733
+ fixtures: {
4734
+ flag: [
4735
+ makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
4736
+ makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
4737
+ ],
4738
+ pass: [
4739
+ makeSnapshot(),
4740
+ makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
4741
+ makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
4742
+ // A first screen that is a picture and nothing else is not judged on its words.
4743
+ makeSnapshot({ firstScreenText: [] }),
4744
+ // Nor is any page but the home page.
4745
+ makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
4746
+ ]
4747
+ }
4180
4748
  }
4749
+ }
4750
+ ];
4751
+ var SPECIFICITY_RENDERED_TELLS = [
4752
+ {
4753
+ id: "unproven-claim",
4754
+ name: "Claim with no proof next to it",
4755
+ generation: 1,
4756
+ severity: "warn",
4757
+ surface: "rendered",
4758
+ 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.`,
4759
+ 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.",
4760
+ rendered: {
4761
+ detect: (s) => {
4762
+ if (!s.sections.some((x) => x.text)) return [];
4763
+ const found = unprovenClaims(s);
4764
+ if (found.length === 0) return [];
4765
+ const first = found[0];
4766
+ const where = first.section ? `"${first.section}"` : "an unnamed section";
4767
+ return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
4768
+ },
4769
+ fixtures: {
4770
+ flag: [
4771
+ withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
4772
+ // Proof two sections away is not next to the claim.
4773
+ 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" } : {})
4774
+ ],
4775
+ pass: [
4776
+ makeSnapshot(),
4777
+ withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
4778
+ withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
4779
+ withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
4780
+ {
4781
+ ...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
4782
+ 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 }]
4783
+ }
4784
+ ]
4785
+ }
4786
+ }
4787
+ }
4788
+ ];
4789
+
4790
+ // src/character/house.ts
4791
+ var HOUSE = {
4792
+ "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
4793
+ emoji: { tier: "block", label: "emoji" },
4794
+ "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
4795
+ "no-x-no-y": {
4796
+ tier: "block",
4797
+ label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
4798
+ },
4799
+ "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
4800
+ "ai-phrase": { tier: "block", label: "AI tell" },
4801
+ "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
4802
+ "plain-english": { tier: "block", label: 'use "properly explained"' },
4803
+ buzzword: { tier: "block", label: "forbidden phrase" },
4804
+ "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
4805
+ // Evidence of a pasted chat reply, not a matter of style. Moved from review
4806
+ // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
4807
+ "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
4808
+ "review-phrase": { tier: "review", label: "review tier" },
4809
+ "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
4810
+ "stock-phrase": { tier: "review", label: "stock phrase" },
4811
+ "hollow-imperative": { tier: "review", label: "hollow imperative" },
4812
+ "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
4813
+ "staccato-triplet": { tier: "review", label: "staccato triplet" },
4814
+ "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
4815
+ "ing-tail": { tier: "review", label: "empty -ing tail" },
4816
+ "vague-attribution": { tier: "review", label: "unnamed source" },
4817
+ "closing-summary": { tier: "review", label: "closing summary" },
4818
+ "false-range": { tier: "review", label: "false range" },
4819
+ "placeholder": { tier: "review", label: "unfilled placeholder" },
4820
+ // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
4821
+ // a proposal can honestly say "the attached research". Review until measured there.
4822
+ "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
4823
+ "question-reveal": { tier: "review", label: "staged reveal" },
4824
+ "inline-label-list": { tier: "review", label: "bold-label bullets" },
4825
+ "phrase-density": { tier: "review", label: "density" },
4826
+ "aphorism-density": { tier: "review", label: "density" },
4827
+ "contraction-scarcity": { tier: "review", label: "density" },
4828
+ "sentence-rhythm": { tier: "review", label: "density" },
4829
+ "repeated-sentence": { tier: "review", label: "repetition" },
4830
+ "heading-shape": { tier: "review", label: "headings" },
4831
+ "heading-echo": { tier: "review", label: "headings" },
4832
+ // A threshold on a count, not a named pattern: a person decides whether the
4833
+ // headline needed a particular. Review until its estate hits are read.
4834
+ "generic-hero-claim": { tier: "review", label: "headline that fits any business" },
4835
+ // Banned only "when vague", per the rules. Too noisy for a sweep.
4836
+ "vague-word": { tier: "explicit", label: "vague" }
4837
+ };
4838
+ function houseRule(id, name = id) {
4839
+ return HOUSE[id] ?? { tier: "review", label: name };
4840
+ }
4841
+ 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";
4842
+ function holdHouseBlocks(exceptions, severity) {
4843
+ const blocks = (tell) => HOUSE[tell]?.tier === "block";
4844
+ const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
4845
+ return {
4846
+ exceptions: exceptions.filter((e) => !blocks(e.tell)),
4847
+ refused: exceptions.filter((e) => blocks(e.tell)),
4848
+ severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
4849
+ refusedSeverity
4181
4850
  };
4182
- const out = { ...base };
4183
- for (const [key, value] of Object.entries(patch)) {
4184
- const current = base[key];
4185
- out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
4186
- }
4187
- return out;
4188
4851
  }
4189
4852
 
4190
4853
  // src/snapshot/fonts.ts
@@ -4304,7 +4967,7 @@ function blueToPurple(s) {
4304
4967
  }
4305
4968
  return hits;
4306
4969
  }
4307
- var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
4970
+ var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
4308
4971
  var RENDERED_PATHS = {
4309
4972
  "reflex-font": {
4310
4973
  detect: reflexFonts(REFLEX_FONTS_1),
@@ -4342,11 +5005,11 @@ var RENDERED_PATHS = {
4342
5005
  }
4343
5006
  },
4344
5007
  "gradient-text": {
4345
- detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
5008
+ detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
4346
5009
  fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
4347
5010
  },
4348
5011
  "glass-panel": {
4349
- detect: (s) => s.effects.glass > 0 ? [hit(s, `${count(s.effects.glass, "translucent blurred panel")}`)] : [],
5012
+ detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
4350
5013
  fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
4351
5014
  },
4352
5015
  "cream-palette": {
@@ -4424,7 +5087,7 @@ var RENDERED_PATHS = {
4424
5087
  detect: (s) => {
4425
5088
  const radial = s.effects.gradients.filter((g) => g.radial).length;
4426
5089
  const n = s.effects.glows + radial;
4427
- return n > 0 ? [hit(s, `${count(n, "soft glow")} behind the content`)] : [];
5090
+ return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
4428
5091
  },
4429
5092
  fixtures: {
4430
5093
  flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
@@ -4432,11 +5095,11 @@ var RENDERED_PATHS = {
4432
5095
  }
4433
5096
  },
4434
5097
  "grid-background": {
4435
- detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
5098
+ detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
4436
5099
  fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
4437
5100
  },
4438
5101
  marquee: {
4439
- detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count(s.effects.marquees, "endless scrolling strip")}`)] : [],
5102
+ detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
4440
5103
  fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
4441
5104
  },
4442
5105
  "thin-border-wide-shadow": {
@@ -4554,9 +5217,14 @@ function faceDistance(a, b) {
4554
5217
  if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
4555
5218
  return a.class === b.class ? 0.5 : 1;
4556
5219
  }
5220
+ var ACCENT_SCALE = 0.3;
5221
+ var GROUND_SCALE = 0.15;
5222
+ var SHARED_PART = 0.2;
5223
+ var ACCENT_SHARED = ACCENT_SCALE * SHARED_PART;
5224
+ var GROUND_SHARED = GROUND_SCALE * SHARED_PART;
4557
5225
  function fingerprintDistance(a, b) {
4558
- const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / 0.3) : a.accent || b.accent ? 1 : 0;
4559
- const ground = clamp(deltaEOk(a.ground, b.ground) / 0.15);
5226
+ const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / ACCENT_SCALE) : a.accent || b.accent ? 1 : 0;
5227
+ const ground = clamp(deltaEOk(a.ground, b.ground) / GROUND_SCALE);
4560
5228
  const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
4561
5229
  const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
4562
5230
  const motion = clamp(Math.abs(a.motion - b.motion));
@@ -4667,8 +5335,8 @@ function sharedChoices(target2, runs) {
4667
5335
  const out = [];
4668
5336
  for (const [part, values] of Object.entries(mine)) {
4669
5337
  for (const value of values) {
4670
- const count3 = all.filter((c) => c[part].includes(value)).length;
4671
- if (count3 / runs.length >= 0.25) out.push({ part, value, runs: count3, of: runs.length });
5338
+ const count4 = all.filter((c) => c[part].includes(value)).length;
5339
+ if (count4 / runs.length >= 0.25) out.push({ part, value, runs: count4, of: runs.length });
4672
5340
  }
4673
5341
  }
4674
5342
  return out.sort((a, b) => b.runs - a.runs);
@@ -4691,10 +5359,10 @@ var STOP = new Set(
4691
5359
  function grams(text) {
4692
5360
  const out = /* @__PURE__ */ new Set();
4693
5361
  for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
4694
- const words2 = clause.match(/[a-z][a-z'-]*/g) ?? [];
5362
+ const words3 = clause.match(/[a-z][a-z'-]*/g) ?? [];
4695
5363
  for (let n = 2; n <= 4; n += 1) {
4696
- for (let i = 0; i + n <= words2.length; i += 1) {
4697
- const g = words2.slice(i, i + n);
5364
+ for (let i = 0; i + n <= words3.length; i += 1) {
5365
+ const g = words3.slice(i, i + n);
4698
5366
  if (STOP.has(g[0]) || STOP.has(g[n - 1])) continue;
4699
5367
  if (g.filter((w) => !STOP.has(w)).length < 2) continue;
4700
5368
  out.add(g.join(" "));
@@ -4861,7 +5529,7 @@ function closestComponents(a, b, role) {
4861
5529
  }
4862
5530
  function estateComponentPairs(register, role) {
4863
5531
  const out = [];
4864
- const s = register.sites;
5532
+ const s = register.sites.filter((site) => site.fingerprint);
4865
5533
  for (let i = 0; i < s.length; i += 1) {
4866
5534
  for (let j = i + 1; j < s.length; j += 1) {
4867
5535
  const c = closestComponents(s[i].fingerprint, s[j].fingerprint, role);
@@ -5142,7 +5810,7 @@ var picture = (patch = {}) => ({
5142
5810
  var withImages = (images) => ({ ...makeSnapshot(), images });
5143
5811
  var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
5144
5812
  var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
5145
- var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
5813
+ var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
5146
5814
  var IMAGERY_TELLS = [
5147
5815
  {
5148
5816
  id: "stock-photo",
@@ -5157,7 +5825,7 @@ var IMAGERY_TELLS = [
5157
5825
  const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
5158
5826
  if (found.length === 0) return [];
5159
5827
  const names = [...new Set(found.map((x) => x.from))].join(", ");
5160
- return [hit3(s, `${count2(found.length, "picture")} from ${names}`, found[0].image.src)];
5828
+ return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
5161
5829
  },
5162
5830
  fixtures: {
5163
5831
  flag: [
@@ -5182,7 +5850,7 @@ var IMAGERY_TELLS = [
5182
5850
  const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
5183
5851
  if (found.length === 0) return [];
5184
5852
  const names = [...new Set(found.map((x) => x.from))].join(", ");
5185
- return [hit3(s, `${count2(found.length, "portrait")} from ${names}`, found[0].image.src)];
5853
+ return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
5186
5854
  },
5187
5855
  fixtures: {
5188
5856
  flag: [
@@ -5207,7 +5875,7 @@ var IMAGERY_TELLS = [
5207
5875
  if (found.length === 0) return [];
5208
5876
  const first = found[0].provenance;
5209
5877
  const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
5210
- return [hit3(s, `${count2(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
5878
+ return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
5211
5879
  },
5212
5880
  fixtures: {
5213
5881
  flag: [
@@ -5232,7 +5900,7 @@ var IMAGERY_TELLS = [
5232
5900
  if (!s.images || s.sections.length < 4) return [];
5233
5901
  if (s.images.some((i) => i.role === "photo")) return [];
5234
5902
  const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
5235
- return [hit3(s, `no photograph on the page${icons ? `: ${count2(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
5903
+ return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
5236
5904
  },
5237
5905
  fixtures: {
5238
5906
  flag: [
@@ -5247,11 +5915,13 @@ var IMAGERY_TELLS = [
5247
5915
  ];
5248
5916
 
5249
5917
  // src/character/check.ts
5250
- var CATALOGUE_VERSION = "2026.09.11";
5918
+ var CATALOGUE_VERSION = "2026.09.12";
5251
5919
  var CATALOGUE = [
5252
5920
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
5921
+ ...SPECIFICITY_COPY_TELLS,
5253
5922
  ...RENDERED_TELLS,
5254
- ...IMAGERY_TELLS
5923
+ ...IMAGERY_TELLS,
5924
+ ...SPECIFICITY_RENDERED_TELLS
5255
5925
  ];
5256
5926
  function tellById(id) {
5257
5927
  return CATALOGUE.find((t) => t.id === id);
@@ -5328,7 +5998,7 @@ function report(tells, files, run, options) {
5328
5998
  catalogueVersion: CATALOGUE_VERSION,
5329
5999
  findings,
5330
6000
  rejectedExceptions: rejected,
5331
- excepted: [...excepted].map(([e, count3]) => ({ tell: e.tell, because: e.because, count: count3 })),
6001
+ excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
5332
6002
  suppressed,
5333
6003
  summary: {
5334
6004
  files: files.length,
@@ -5369,7 +6039,7 @@ function runTell(tell, files) {
5369
6039
  }
5370
6040
  function catalogueTable() {
5371
6041
  const rows = CATALOGUE.map(
5372
- (t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
6042
+ (t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered && t.surface !== "rendered" ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
5373
6043
  );
5374
6044
  return [
5375
6045
  `Catalogue version \`${CATALOGUE_VERSION}\`, ${CATALOGUE.length} tells.`,
@@ -5434,87 +6104,6 @@ function formatReport(report2, title) {
5434
6104
  return lines.join("\n").replace(/^\n/, "");
5435
6105
  }
5436
6106
 
5437
- // src/character/facts.ts
5438
- 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)?)";
5439
- var PATTERNS = [
5440
- ["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
5441
- ["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
5442
- ["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
5443
- ["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
5444
- ["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
5445
- ["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")],
5446
- ["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
5447
- ["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
5448
- ];
5449
- var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
5450
- var normalise = (kind, value) => {
5451
- const v = value.trim();
5452
- if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
5453
- if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
5454
- if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
5455
- if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
5456
- return v.replace(/\s+/g, " ").toLowerCase();
5457
- };
5458
- function visibleText(file) {
5459
- const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
5460
- return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
5461
- }
5462
- function protectedFacts(text) {
5463
- const facts = [];
5464
- let rest = text;
5465
- const mask = (start, length) => {
5466
- rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
5467
- };
5468
- for (const [kind, pattern] of PATTERNS) {
5469
- for (const m of [...rest.matchAll(pattern)]) {
5470
- const value = m[0].replace(/[.,;:]+$/, "");
5471
- facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
5472
- mask(m.index ?? 0, m[0].length);
5473
- }
5474
- }
5475
- const lines = rest.split("\n");
5476
- for (const line of lines) {
5477
- if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
5478
- for (const m of line.matchAll(NAME)) {
5479
- const at = m.index ?? 0;
5480
- const before = line.slice(0, at).trimEnd();
5481
- const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
5482
- const word = m[0];
5483
- const acronym = /^[A-Z]{2,}$/.test(word);
5484
- if (opensSentence && !acronym && !/\s/.test(word)) continue;
5485
- if (word === "I") continue;
5486
- facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
5487
- }
5488
- }
5489
- return facts;
5490
- }
5491
- var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
5492
- function compareFacts(before, after) {
5493
- const a = unique(protectedFacts(before));
5494
- const b = unique(protectedFacts(after));
5495
- const inA = new Set(a.map((f4) => f4.key));
5496
- const inB = new Set(b.map((f4) => f4.key));
5497
- return {
5498
- before: a.length,
5499
- after: b.length,
5500
- lost: a.filter((f4) => !inB.has(f4.key)),
5501
- added: b.filter((f4) => !inA.has(f4.key))
5502
- };
5503
- }
5504
- function formatComparison(result, beforePath, afterPath) {
5505
- const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
5506
- if (result.lost.length) {
5507
- lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
5508
- for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
5509
- }
5510
- if (result.added.length) {
5511
- lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
5512
- for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
5513
- }
5514
- if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
5515
- return lines.join("\n");
5516
- }
5517
-
5518
6107
  // src/character/claims.ts
5519
6108
  var CLAIM_KINDS = /* @__PURE__ */ new Set(["money", "number", "date"]);
5520
6109
  var PROPER = "[A-Z][\\w&.\u2019'-]*(?:\\s+(?:of|and|&|the|for)?\\s*[A-Z][\\w&.\u2019'-]*)*";
@@ -5534,9 +6123,9 @@ var ATTRIBUTION = [
5534
6123
  ];
5535
6124
  var LINK2 = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)|\bhttps?:\/\/[^\s<>"')\]]+/g;
5536
6125
  function cleanName(raw) {
5537
- const words2 = raw.trim().replace(/[.,;:]+$/, "").split(/\s+/);
5538
- while (words2.length && NOT_A_SOURCE.test(words2[0])) words2.shift();
5539
- return words2.join(" ").replace(/(?:’s|'s)(?=\s|$).*$/, "").trim();
6126
+ const words3 = raw.trim().replace(/[.,;:]+$/, "").split(/\s+/);
6127
+ while (words3.length && NOT_A_SOURCE.test(words3[0])) words3.shift();
6128
+ return words3.join(" ").replace(/(?:’s|'s)(?=\s|$).*$/, "").trim();
5540
6129
  }
5541
6130
  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)$/;
5542
6131
  var atStart = (sentence, index) => /^[\s*_"“”’(>#|-]*(?:\d{1,3}[.)]\s*)?[\s*_]*(?:[\w ]+:\**\s*)?$/.test(sentence.slice(0, index)) || /\|\s*\**$/.test(sentence.slice(0, index));
@@ -5629,6 +6218,207 @@ function formatClaims(report2, label = "craft copy claims") {
5629
6218
  return lines.join("\n");
5630
6219
  }
5631
6220
 
6221
+ // src/character/shape.ts
6222
+ var ABBREVIATIONS = /\b(?:e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|St|No|approx|incl|Ltd|Co|Inc|Jr|Sr)\./g;
6223
+ var DOT = "\0";
6224
+ function splitSentences(text) {
6225
+ const guarded = text.replace(/\s+/g, " ").replace(ABBREVIATIONS, (m) => m.replace(/\./g, DOT)).replace(/(\d)\.(\d)/g, `$1${DOT}$2`);
6226
+ return guarded.split(/(?<=[.!?]["'’”)]*)\s+(?=["'‘“(]?[A-Z0-9£$€])/).map((s) => s.split(DOT).join(".").trim()).filter((s) => words(s).length > 0);
6227
+ }
6228
+ var WORD3 = /[A-Za-z0-9£$€][A-Za-z0-9£$€%'’-]*/g;
6229
+ function words(text) {
6230
+ return text.match(WORD3) ?? [];
6231
+ }
6232
+ var lower = (w) => w.toLowerCase().replace(/’/g, "'");
6233
+ var FUNCTION_WORDS = new Set(
6234
+ "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(" ")
6235
+ );
6236
+ var ITEM = "[A-Za-z0-9\xA3$\u20AC'\u2019-]+(?: [A-Za-z0-9\xA3$\u20AC'\u2019-]+){0,5}";
6237
+ var TRICOLON = new RegExp(`(${ITEM}), (${ITEM}),? (and|or) (${ITEM})(?=[,.;:!?)]|$)`, "g");
6238
+ function tricolons(sentence) {
6239
+ const s = sentence.replace(/\s+/g, " ");
6240
+ const out = [];
6241
+ for (const m of s.matchAll(TRICOLON)) {
6242
+ const [, aSpan, b, , c] = m;
6243
+ const bWords = words(b);
6244
+ const cWords = words(c);
6245
+ if (bWords.length === 0 || cWords.length === 0 || cWords.length > 6) continue;
6246
+ const aWords = words(aSpan);
6247
+ const a = aWords.slice(-Math.max(1, bWords.length));
6248
+ const lead = lower(bWords[0]);
6249
+ const parallel = bWords.length >= 2 && FUNCTION_WORDS.has(lead) && (lower(cWords[0]) === lead || lower(a[0]) === lead);
6250
+ out.push({ text: m[0], parallel });
6251
+ }
6252
+ return out;
6253
+ }
6254
+ var STACKED = /,\s+(?:and|but|so|or)\s+(?:if|when|where|once|unless|provided)\b[^,.;!?]{1,60},/i;
6255
+ function stackedConditional(sentence) {
6256
+ return STACKED.test(sentence);
6257
+ }
6258
+ var SUBORDINATOR = /\b(?:if|when|which|because|so|while|where)\b/gi;
6259
+ function clauseDepth(sentence) {
6260
+ return (sentence.match(/,/g) ?? []).length + (sentence.match(SUBORDINATOR) ?? []).length;
6261
+ }
6262
+ var HEDGES = [
6263
+ "if it's a fit",
6264
+ "if it is a fit",
6265
+ "where needed",
6266
+ "if needed",
6267
+ "as needed",
6268
+ "where appropriate",
6269
+ "if appropriate",
6270
+ "as appropriate",
6271
+ "as required",
6272
+ "where required",
6273
+ "if required",
6274
+ "where possible",
6275
+ "if possible",
6276
+ "where relevant",
6277
+ "if relevant",
6278
+ "depending on",
6279
+ "can help",
6280
+ "tend to",
6281
+ "tends to",
6282
+ "may",
6283
+ "might",
6284
+ "could",
6285
+ "typically",
6286
+ "usually",
6287
+ "generally",
6288
+ "often",
6289
+ "potentially",
6290
+ "likely"
6291
+ ];
6292
+ var HEDGE = new RegExp(`\\b(?:${HEDGES.map((h) => h.replace(/'/g, "['\u2019]").replace(/ /g, "\\s+")).join("|")})\\b`, "gi");
6293
+ var CONDITIONAL_CLOSE = /\b(?:if|unless|where|provided|depending)\b/i;
6294
+ function hedgeCount(text) {
6295
+ return (text.match(HEDGE) ?? []).length;
6296
+ }
6297
+ function hedgedClose(sentences3) {
6298
+ const last = sentences3[sentences3.length - 1];
6299
+ if (!last) return false;
6300
+ HEDGE.lastIndex = 0;
6301
+ return HEDGE.test(last) || CONDITIONAL_CLOSE.test(last);
6302
+ }
6303
+ var NOMINAL = /^[a-z]{3,}(?:tion|sion|ment|ness|ity|ance|ence)s?$/;
6304
+ var NOT_NOMINAL = new Set(
6305
+ [
6306
+ "nation station ration lotion potion portion question moment cement comment element garment segment fragment apartment document ointment",
6307
+ "city pity entity quality sentence science audience silence balance finance distance instance entrance substance romance province"
6308
+ ].join(" ").split(" ").flatMap((w) => [w, `${w}s`])
6309
+ );
6310
+ function nominalisations(text) {
6311
+ return words(text).filter((w) => {
6312
+ const l = lower(w);
6313
+ return NOMINAL.test(l) && !NOT_NOMINAL.has(l);
6314
+ }).length;
6315
+ }
6316
+ var PRONOUN_OPENER = /^["'‘“(]?(?:you|your|we|our)\b/i;
6317
+ var TRANSITION_OPENER = /^["'‘“(]?(?:however|moreover|additionally|furthermore|thus|therefore|consequently|in addition|as a result|ultimately|overall|importantly|notably)\b/i;
6318
+ var IMPERSONAL_OPENER = /^["'‘“(]?(?:it is|it's|it’s|there is|there are|there's|there’s)\b/i;
6319
+ var NOT_PARTICIPLE = new Set(
6320
+ "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(" ")
6321
+ );
6322
+ function participialClauses(sentence) {
6323
+ let n = 0;
6324
+ for (const m of sentence.matchAll(/,\s+([A-Za-z]{2,}ing)\b/g)) if (!NOT_PARTICIPLE.has(lower(m[1]))) n += 1;
6325
+ const open = sentence.match(/^["'‘“(]?([A-Z][a-z]{2,}ing)\b[^,]{0,60},/);
6326
+ if (open && !NOT_PARTICIPLE.has(lower(open[1]))) n += 1;
6327
+ return n;
6328
+ }
6329
+ function phrasalCoordinations(sentence) {
6330
+ let n = 0;
6331
+ for (const m of sentence.matchAll(/(^|[^,]\s)([A-Za-z][a-z'’-]{2,}) (?:and|or) ([A-Za-z][a-z'’-]{2,})\b/g)) {
6332
+ const a = lower(m[2]);
6333
+ const b = lower(m[3]);
6334
+ if (FUNCTION_WORDS.has(a) || FUNCTION_WORDS.has(b) || a.endsWith("ly") || b.endsWith("ly")) continue;
6335
+ n += 1;
6336
+ }
6337
+ return n;
6338
+ }
6339
+ function skeleton(sentence) {
6340
+ const out = [];
6341
+ for (const w of words(sentence)) {
6342
+ const l = lower(w);
6343
+ const token = FUNCTION_WORDS.has(l) ? l : "_";
6344
+ if (token === "_" && out[out.length - 1] === "_") continue;
6345
+ out.push(token);
6346
+ }
6347
+ return out;
6348
+ }
6349
+ function skeletonRepeat(sentences3) {
6350
+ const grams2 = /* @__PURE__ */ new Map();
6351
+ let total = 0;
6352
+ for (const s of sentences3) {
6353
+ const sk = skeleton(s);
6354
+ for (let i = 0; i + 4 <= sk.length; i += 1) {
6355
+ const g = sk.slice(i, i + 4).join(" ");
6356
+ grams2.set(g, (grams2.get(g) ?? 0) + 1);
6357
+ total += 1;
6358
+ }
6359
+ }
6360
+ if (total === 0) return 0;
6361
+ let repeated = 0;
6362
+ for (const count4 of grams2.values()) if (count4 > 1) repeated += count4;
6363
+ return repeated / total;
6364
+ }
6365
+ var mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
6366
+ var sd = (xs) => {
6367
+ const m = mean(xs);
6368
+ return Math.sqrt(xs.reduce((s, x) => s + (x - m) ** 2, 0) / xs.length);
6369
+ };
6370
+ function cv(xs, min) {
6371
+ if (xs.length < min) return null;
6372
+ const m = mean(xs);
6373
+ return m === 0 ? null : sd(xs) / m;
6374
+ }
6375
+ function shapeOf(text) {
6376
+ const ss = splitSentences(text);
6377
+ const wordCount = words(text).length;
6378
+ const per100 = (n) => wordCount === 0 ? 0 : 100 * n / wordCount;
6379
+ const share = (re) => ss.length === 0 ? 0 : ss.filter((s) => re.test(s)).length / ss.length;
6380
+ const tri = ss.flatMap(tricolons);
6381
+ const commas = (text.match(/,/g) ?? []).length;
6382
+ return {
6383
+ sentences: ss.length,
6384
+ words: wordCount,
6385
+ tricolons: tri.length,
6386
+ parallelTriads: tri.filter((t) => t.parallel).length,
6387
+ stackedConditionals: ss.filter(stackedConditional).length,
6388
+ clauseDepth: ss.length === 0 ? 0 : mean(ss.map(clauseDepth)),
6389
+ sentenceCv: cv(ss.map((s) => words(s).length), 3),
6390
+ hedgeRate: per100(hedgeCount(text)),
6391
+ hedgedClose: hedgedClose(ss),
6392
+ nominalisationRate: per100(nominalisations(text)),
6393
+ pronounOpenerShare: share(PRONOUN_OPENER),
6394
+ transitionOpenerShare: share(TRANSITION_OPENER),
6395
+ impersonalOpenerShare: share(IMPERSONAL_OPENER),
6396
+ commasPerSentence: ss.length === 0 ? 0 : commas / ss.length,
6397
+ commasPer100: per100(commas),
6398
+ participialRate: ss.length === 0 ? 0 : ss.reduce((s, x) => s + participialClauses(x), 0) / ss.length,
6399
+ coordinationRate: per100(ss.reduce((s, x) => s + phrasalCoordinations(x), 0)),
6400
+ skeletonRepeat: skeletonRepeat(ss)
6401
+ };
6402
+ }
6403
+ function shapeMoves(f4) {
6404
+ const out = [];
6405
+ if (f4.parallelTriads > 0) out.push("parallel triad");
6406
+ else if (f4.tricolons > 0) out.push("tricolon");
6407
+ if (f4.stackedConditionals > 0) out.push("stacked conditional");
6408
+ if (f4.hedgedClose) out.push("hedged close");
6409
+ if (f4.participialRate > 0) out.push("participial clause");
6410
+ return out;
6411
+ }
6412
+ function paragraphUniformity(paragraphs) {
6413
+ return cv(
6414
+ paragraphs.map((p) => words(p).length).filter((n) => n > 0),
6415
+ 4
6416
+ );
6417
+ }
6418
+ function isBlurb(f4) {
6419
+ return f4.sentences >= 2 && f4.sentences <= 8 && f4.words >= 20 && f4.words <= 160;
6420
+ }
6421
+
5632
6422
  // src/character/baseline.ts
5633
6423
  var BASELINE_VERSION = 1;
5634
6424
  function fnv1a(text) {
@@ -5924,7 +6714,9 @@ function visualMeasures(img, maxSamples = 1e6) {
5924
6714
  }
5925
6715
 
5926
6716
  // src/direction/types.ts
5927
- var DIRECTION_VERSION = 1;
6717
+ var DIRECTION_VERSION = 2;
6718
+ var DIRECTION_VERSIONS = [1, 2];
6719
+ var CUSTOMER_KINDS = ["review", "enquiry", "conversation", "search"];
5928
6720
  var SOURCE_KINDS = [
5929
6721
  "livery",
5930
6722
  "shopfront",
@@ -5938,9 +6730,413 @@ var SOURCE_KINDS = [
5938
6730
  "trade",
5939
6731
  "print",
5940
6732
  "photo",
5941
- "founder"
6733
+ "founder",
6734
+ ...CUSTOMER_KINDS,
6735
+ "reference"
5942
6736
  ];
5943
6737
  var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
6738
+ var CONSIDERATION = ["low", "considered", "high"];
6739
+ var PAGE_TYPES = ["home", "service", "about", "pricing", "booking", "contact", "article", "landing"];
6740
+ var HIERARCHY_FIELDS = ["primaryAction", "order", "lead", "journey"];
6741
+
6742
+ // src/direction/reason.ts
6743
+ var PROPOSED = "PROPOSED:";
6744
+ 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;
6745
+ 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;
6746
+ var CONVENTION = new RegExp(
6747
+ [
6748
+ // authority
6749
+ String.raw`industry[- ]standard`,
6750
+ String.raw`best[- ]practices?`,
6751
+ String.raw`the norm\b`,
6752
+ String.raw`standard (?:layout|structure|practice|pattern|format)`,
6753
+ String.raw`tried[- ]and[- ]tested`,
6754
+ String.raw`proven (?:layout|structure|pattern|format)`,
6755
+ // the crowd
6756
+ String.raw`(?:most|other|typical|many|all|similar)\s+(?:\w+\s+){0,2}(?:web)?sites\b`,
6757
+ String.raw`competitors?(?:'|’)?s?\b`,
6758
+ String.raw`(?:like|as) (?:everyone|everybody|all the others|the rest)\b`,
6759
+ String.raw`commonly used`,
6760
+ String.raw`(?:what|how) (?:everyone|others|the industry) (?:does|do|uses?)`,
6761
+ // familiarity
6762
+ String.raw`familiar (?:layout|structure|pattern|format|order|shape|design)`,
6763
+ String.raw`recogni[sz]able (?:layout|structure|pattern|format|order)`,
6764
+ String.raw`conventional(?:ly)?\b`,
6765
+ String.raw`(?:visitors|users|people|customers|anyone) (?:are|is) used to`,
6766
+ String.raw`feels? (?:at home|comfortable|familiar)(?: with)?(?: the)? (?:layout|site|page|structure|design)?`,
6767
+ String.raw`intuitive(?:ly)?\b`,
6768
+ String.raw`as usual\b`,
6769
+ String.raw`what (?:visitors|users|people) expect\b`
6770
+ ].join("|"),
6771
+ "i"
6772
+ );
6773
+ var GENERIC = new Set(
6774
+ "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(" ")
6775
+ );
6776
+ 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"]);
6777
+ 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"]);
6778
+ function contentWords(text) {
6779
+ return new Set(
6780
+ (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))
6781
+ );
6782
+ }
6783
+ var stem = (w) => w.replace(/(?:ing|ers|er|ed|es|s)$/, "");
6784
+ function shares(a, b) {
6785
+ const bs = new Set([...b].map(stem));
6786
+ return [...a].filter((w) => bs.has(stem(w)));
6787
+ }
6788
+ function jobWords(job) {
6789
+ return contentWords(
6790
+ [
6791
+ job.statement?.verb,
6792
+ job.statement?.object,
6793
+ job.statement?.context,
6794
+ job.functional?.value,
6795
+ job.emotional?.value,
6796
+ job.social?.value,
6797
+ ...Array.isArray(job.objections) ? job.objections : [],
6798
+ ...Array.isArray(job.language) ? job.language : []
6799
+ ].filter((x) => typeof x === "string").join(" ")
6800
+ );
6801
+ }
6802
+ var isCustomerVoice = (s) => Boolean(s && CUSTOMER_KINDS.includes(s.kind));
6803
+ function checkReason(input) {
6804
+ const problems = [];
6805
+ const err = (field, message) => problems.push({ severity: "error", at: `${input.at}.${field}`, message });
6806
+ const because = typeof input.because === "string" ? input.because.trim() : "";
6807
+ const evidence = Array.isArray(input.evidence) ? input.evidence.filter((e) => typeof e === "string") : [];
6808
+ 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");
6809
+ const ids = new Map(input.sources.filter((s) => s && typeof s.id === "string").map((s) => [s.id, s]));
6810
+ if (because.split(/\s+/).filter(Boolean).length < 8) {
6811
+ err("because", "a reason is at least a sentence. Without one this is a default, however good it looks.");
6812
+ return problems;
6813
+ }
6814
+ if (because.startsWith(PROPOSED)) {
6815
+ err("because", "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
6816
+ return problems;
6817
+ }
6818
+ if (PREFERENCE.test(because)) err("because", "that is a preference, not a reason. Say what in the client's world this comes from.");
6819
+ const moods = because.match(MOOD) ?? [];
6820
+ if (moods.length >= 2) err("because", `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
6821
+ const convention = because.match(CONVENTION);
6822
+ if (convention) {
6823
+ err("because", `"${convention[0]}" is the category's default speaking, not a reason. Say what in this client's job or world it answers.`);
6824
+ }
6825
+ const needsEvidence = input.tie !== "job";
6826
+ if (needsEvidence && evidence.length === 0) err("evidence", "cite at least one source");
6827
+ for (const id of evidence) if (!ids.has(id)) err("evidence", `no source called "${id}"`);
6828
+ if (input.tie === "customer") {
6829
+ for (const id of evidence) {
6830
+ 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(", ")}.`);
6831
+ }
6832
+ }
6833
+ if (input.tie === "source") {
6834
+ for (const id of evidence) {
6835
+ if (isCustomerVoice(ids.get(id))) err("evidence", `"${id}" is customer voice. Token decisions need a physical source or an outside-category reference.`);
6836
+ }
6837
+ }
6838
+ if (problems.length) return problems;
6839
+ const words3 = contentWords(because);
6840
+ if (input.tie === "job") {
6841
+ if (!input.job) {
6842
+ err("because", "a structural reason is checked against the job, and the file has no job map yet");
6843
+ } else if (shares(words3, jobWords(input.job)).length === 0) {
6844
+ 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.");
6845
+ }
6846
+ } else {
6847
+ const cited = evidence.map((id) => ids.get(id));
6848
+ const tied = cited.some((s) => words3.has(s.id) || shares(words3, contentWords(`${s.note} ${s.lettering ?? ""}`)).length > 0);
6849
+ if (!tied) err("because", `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
6850
+ }
6851
+ return problems;
6852
+ }
6853
+
6854
+ // src/direction/job.ts
6855
+ var JOB_VERBS = new Set(
6856
+ "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(" ")
6857
+ );
6858
+ 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"]);
6859
+ var NOUNISH = /(?:tion|sion|ment|ness|ity|site|page)$/;
6860
+ var words2 = (s) => s.toLowerCase().match(/[a-z][a-z'’-]*/g) ?? [];
6861
+ function isCategoryLabel(object, brief = "") {
6862
+ const trade = /* @__PURE__ */ new Set([...TRADE_NOUNS.flatMap((t) => t.split(" ")), ...words2(brief).filter((w) => w.length > 3)]);
6863
+ const ws = words2(object);
6864
+ return ws.length > 0 && ws.every((w) => LABEL_WORDS.has(w) || trade.has(w) || trade.has(w.replace(/s$/, "")));
6865
+ }
6866
+ var normalise2 = (s) => s.toLowerCase().replace(/[“”"‘’'.,!?;:()-]/g, " ").replace(/\s+/g, " ").trim();
6867
+ function validateJob(job, sources, ctx = {}) {
6868
+ const problems = [];
6869
+ const err = (at, message) => problems.push({ severity: "error", at: `job${at}`, message });
6870
+ const warn = (at, message) => problems.push({ severity: "warn", at: `job${at}`, message });
6871
+ if (!job || typeof job !== "object" || Array.isArray(job)) {
6872
+ err("", "no job map: say what the visitor is trying to get done, in their words, before deciding structure or look");
6873
+ return { problems, decided: false };
6874
+ }
6875
+ const j = job;
6876
+ const ids = new Map(sources.map((s) => [s.id, s]));
6877
+ const st = j.statement;
6878
+ if (!st || typeof st !== "object") err(".statement", "a job statement is verb, object and context");
6879
+ else {
6880
+ const verb = typeof st.verb === "string" ? st.verb.trim().toLowerCase() : "";
6881
+ if (!verb) err(".statement.verb", "what the visitor is trying to do, as a verb: find, choose, book, fix");
6882
+ 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.`);
6883
+ 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.`);
6884
+ const object = typeof st.object === "string" ? st.object.trim() : "";
6885
+ 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");
6886
+ 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".`);
6887
+ 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");
6888
+ }
6889
+ for (const part of ["functional", "emotional", "social"]) {
6890
+ const p = j[part];
6891
+ if (!p || typeof p !== "object") {
6892
+ err(`.${part}`, `the ${part} side of the job, with the customer-voice source that shows it`);
6893
+ continue;
6894
+ }
6895
+ if (typeof p.value !== "string" || words2(p.value).length < 6) err(`.${part}.value`, "a sentence: what they need, in the terms they would use");
6896
+ 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") })));
6897
+ }
6898
+ const c = j.consideration;
6899
+ if (!c || typeof c !== "object") err(".consideration", `how long and how carefully they decide: ${CONSIDERATION.join(", ")}`);
6900
+ else {
6901
+ if (!CONSIDERATION.includes(c.value)) err(".consideration.value", `one of ${CONSIDERATION.join(", ")}`);
6902
+ problems.push(...checkReason({ at: "job.consideration", because: c.because, evidence: c.evidence, sources, tie: "customer" }));
6903
+ }
6904
+ const objections = Array.isArray(j.objections) ? j.objections : [];
6905
+ if (objections.length === 0) err(".objections", "what stops them: the worries they bring to the page");
6906
+ objections.forEach((o, i) => {
6907
+ if (typeof o !== "string" || words2(o).length < 3) err(`.objections[${i}]`, "an objection is a few words: what they worry about");
6908
+ });
6909
+ const evidence = Array.isArray(j.evidence) ? j.evidence : [];
6910
+ const voice = evidence.map((id) => ids.get(id)).filter(isCustomerVoice);
6911
+ if (evidence.length === 0 || voice.length === 0) err(".evidence", "cite the reviews, enquiries or conversations the job comes from");
6912
+ for (const id of evidence) if (!ids.has(id)) err(".evidence", `no source called "${id}"`);
6913
+ const language = Array.isArray(j.language) ? j.language : [];
6914
+ if (language.length === 0) err(".language", "quote the customer: the words they use for the problem");
6915
+ const texts = voice.map((s) => normalise2(`${s.note} ${ctx.readSource?.(s) ?? ""}`));
6916
+ language.forEach((q, i) => {
6917
+ if (typeof q !== "string" || !q.trim()) return err(`.language[${i}]`, "an empty quote");
6918
+ const n = normalise2(q);
6919
+ 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.`);
6920
+ });
6921
+ return { problems, decided: problems.every((p) => p.severity !== "error") };
6922
+ }
6923
+
6924
+ // src/direction/hierarchy.ts
6925
+ var SECTION_ROLES = [
6926
+ "hero",
6927
+ "logos",
6928
+ "stats",
6929
+ "cards",
6930
+ "marquee",
6931
+ "text",
6932
+ "other",
6933
+ "cta-band",
6934
+ "footer-cta",
6935
+ "pricing",
6936
+ "testimonials",
6937
+ "faq",
6938
+ "process",
6939
+ "features",
6940
+ "team",
6941
+ "contact"
6942
+ ];
6943
+ var POSITIONS = /* @__PURE__ */ new Set([...SECTION_ROLES, "header", "sticky"]);
6944
+ function considerationTied(because, job) {
6945
+ const text = because.toLowerCase();
6946
+ const level = job.consideration?.value;
6947
+ if (level && new RegExp(`\\b${level}\\b`).test(text)) return true;
6948
+ const said = contentWords(because);
6949
+ return (Array.isArray(job.objections) ? job.objections : []).some((o) => typeof o === "string" && [...contentWords(o)].some((w) => said.has(w)));
6950
+ }
6951
+ function validateHierarchy(hierarchy, job, sources) {
6952
+ const problems = [];
6953
+ const err = (at, message) => problems.push({ severity: "error", at: `hierarchy${at}`, message });
6954
+ const pages = [];
6955
+ if (!hierarchy || typeof hierarchy !== "object" || Array.isArray(hierarchy)) {
6956
+ err("", "no hierarchy: decide what the home page leads with, where it asks, and the order, each from the job");
6957
+ return { problems, pages };
6958
+ }
6959
+ const h = hierarchy;
6960
+ 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(", ")}`);
6961
+ if (!h.home) err(".home", "no home page hierarchy: the home page is where most visitors start");
6962
+ for (const page of PAGE_TYPES) {
6963
+ const p = h[page];
6964
+ if (!p) continue;
6965
+ let decided = 0;
6966
+ const at = (f4) => `hierarchy.${page}.${f4}`;
6967
+ const reason = (where, because, evidence) => checkReason({ at: where, because, evidence, sources, tie: "job", job });
6968
+ const count4 = (found) => {
6969
+ problems.push(...found);
6970
+ if (!found.some((x) => x.severity === "error")) decided += 1;
6971
+ };
6972
+ if (!p.primaryAction) err(`.${page}.primaryAction`, "not decided yet");
6973
+ else {
6974
+ const a = p.primaryAction;
6975
+ const found = [];
6976
+ if (typeof a.value !== "string" || !a.value.trim()) found.push({ severity: "error", at: `${at("primaryAction")}.value`, message: "what the visitor is asked to do" });
6977
+ const positions = Array.isArray(a.positions) ? a.positions : [];
6978
+ if (positions.length === 0) found.push({ severity: "error", at: `${at("primaryAction")}.positions`, message: 'where the ask sits: one place, or several ("hero", "footer-cta")' });
6979
+ 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"` });
6980
+ const r = reason(at("primaryAction"), a.because, a.evidence);
6981
+ found.push(...r);
6982
+ if (r.length === 0 && job && typeof a.because === "string" && !considerationTied(a.because, job)) {
6983
+ found.push({
6984
+ severity: "error",
6985
+ at: `${at("primaryAction")}.because`,
6986
+ 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.`
6987
+ });
6988
+ }
6989
+ count4(found);
6990
+ }
6991
+ const order = Array.isArray(p.order) ? p.order : [];
6992
+ if (order.length < 2) err(`.${page}.order`, "the running order, two sections or more, each with why it sits there");
6993
+ else {
6994
+ const found = [];
6995
+ order.forEach((o, i) => {
6996
+ if (!o || !SECTION_ROLES.includes(o.role)) found.push({ severity: "error", at: `${at("order")}[${i}].role`, message: `a section role: ${SECTION_ROLES.join(", ")}` });
6997
+ found.push(...reason(`${at("order")}[${i}]`, o?.because));
6998
+ });
6999
+ count4(found);
7000
+ }
7001
+ if (!p.lead) err(`.${page}.lead`, "not decided yet");
7002
+ else {
7003
+ const found = [];
7004
+ if (typeof p.lead.value !== "string" || !p.lead.value.trim()) found.push({ severity: "error", at: `${at("lead")}.value`, message: "what the page leads with" });
7005
+ found.push(...reason(at("lead"), p.lead.because));
7006
+ count4(found);
7007
+ }
7008
+ const journey = Array.isArray(p.journey) ? p.journey : [];
7009
+ if (journey.length < 2) err(`.${page}.journey`, "the stages the visitor goes through, two or more, each with why");
7010
+ else {
7011
+ const found = [];
7012
+ journey.forEach((s, i) => {
7013
+ if (!s || typeof s.stage !== "string" || !s.stage.trim()) found.push({ severity: "error", at: `${at("journey")}[${i}].stage`, message: "name the stage" });
7014
+ found.push(...reason(`${at("journey")}[${i}]`, s?.because));
7015
+ });
7016
+ count4(found);
7017
+ }
7018
+ pages.push({ page, decided, total: HIERARCHY_FIELDS.length });
7019
+ }
7020
+ return { problems, pages };
7021
+ }
7022
+ function renderedOrder(snapshot) {
7023
+ const sections = snapshot.sections ?? [];
7024
+ if (sections.length === 0 || !sections.every((s) => s.role)) return null;
7025
+ return sections.map((s) => s.role).filter((r) => r !== "other");
7026
+ }
7027
+ function checkOrder(declared, page, snapshot, strict = false) {
7028
+ 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" }];
7029
+ const rendered = renderedOrder(snapshot);
7030
+ 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" }];
7031
+ const said = declared.order.map((o) => o.role).filter((r) => r !== "other");
7032
+ if (said.join(",") === rendered.join(",")) return [];
7033
+ return [{ severity: strict ? "error" : "warn", at: `hierarchy.${page}.order`, message: `the page runs ${rendered.join(", ")}; the file says ${said.join(", ")}` }];
7034
+ }
7035
+
7036
+ // src/direction/research.ts
7037
+ function tradeOf(brief) {
7038
+ const text = brief.toLowerCase();
7039
+ return TRADE_NOUNS.filter((t) => new RegExp(`\\b${t.replace(/ /g, "\\s+")}s?\\b`).test(text));
7040
+ }
7041
+ function categoryMatchesBrief(category, brief) {
7042
+ const known = tradeOf(category);
7043
+ if (tradeOf(brief).some((t) => known.includes(t))) return true;
7044
+ const label = category.trim().replace(/^(?:(?:a|an|the|local|independent)\s+)+/i, "");
7045
+ if (!label) return false;
7046
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
7047
+ return new RegExp(`\\b${escaped}s?\\b`, "i").test(brief);
7048
+ }
7049
+ function researchPrompt(input) {
7050
+ const trade = tradeOf(input.brief);
7051
+ const category = trade.length ? trade.join(", ") : "this kind of business";
7052
+ const who = input.client ? `${input.client}: ${input.brief}` : input.brief;
7053
+ const lines = [
7054
+ "# Research brief: the customer's job",
7055
+ "",
7056
+ `The business: ${who}`,
7057
+ "",
7058
+ "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.",
7059
+ "",
7060
+ "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?",
7061
+ '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?',
7062
+ "3. **Objections.** What do they worry about before getting in touch? What has gone wrong for them, or people they know, before?",
7063
+ "4. **Their words.** The phrases they actually use for the problem and for what they want. Quote them exactly.",
7064
+ "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?",
7065
+ "6. **Consideration.** Do they decide in minutes, days or months? What do they compare before deciding?",
7066
+ "7. **Where they look.** What do they search for, and where do they ask?",
7067
+ "",
7068
+ "## Do not",
7069
+ "",
7070
+ `- Do not describe, list, rank or summarise what ${category} websites look like, or how they are laid out.`,
7071
+ '- Do not suggest colours, fonts, layouts, sections or visual "inspiration" from this category.',
7072
+ "- Do not summarise competitors' marketing. Their claims are the category average; the customer's words are the point.",
7073
+ "- Do not invent quotes. If you cannot find a source for something, say so."
7074
+ ];
7075
+ if (input.visual) {
7076
+ lines.push(
7077
+ "",
7078
+ "# Visual references, from outside the category",
7079
+ "",
7080
+ `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.`,
7081
+ "",
7082
+ `Nothing from a ${category} website, and nothing from a web design gallery.`
7083
+ );
7084
+ }
7085
+ return `${lines.join("\n")}
7086
+ `;
7087
+ }
7088
+
7089
+ // src/estate/direction.ts
7090
+ function summariseDirection(d) {
7091
+ const c = d.choices ?? {};
7092
+ const home = d.hierarchy?.home;
7093
+ const s = {};
7094
+ if (typeof c.accent?.value === "string" && c.accent.value) s.accent = c.accent.value;
7095
+ if (typeof c.ground?.value === "string" && c.ground.value) s.ground = c.ground.value;
7096
+ if (typeof c.display?.value === "string" && c.display.value) s.display = normaliseFamily(c.display.value);
7097
+ if (typeof c.body?.value === "string" && c.body.value) s.body = normaliseFamily(c.body.value);
7098
+ if (typeof c.shape?.value === "string" && c.shape.value) s.shape = c.shape.value;
7099
+ if (Array.isArray(home?.order) && home.order.length) s.order = home.order.filter((o) => o && typeof o.role === "string").map((o) => o.role);
7100
+ if (typeof home?.primaryAction?.value === "string" && home.primaryAction.value) {
7101
+ s.primaryAction = home.primaryAction.value;
7102
+ s.positions = Array.isArray(home.primaryAction.positions) ? [...home.primaryAction.positions] : [];
7103
+ }
7104
+ return s;
7105
+ }
7106
+ var delta = (a, b) => {
7107
+ const x = a ? parseColour(a) : null;
7108
+ const y = b ? parseColour(b) : null;
7109
+ return x && y ? deltaEOk(x.oklch, y.oklch) : null;
7110
+ };
7111
+ var same = (a, b) => Boolean(a && b && a.trim().toLowerCase() === b.trim().toLowerCase());
7112
+ function compareDirections(a, b) {
7113
+ const shared = [];
7114
+ const accentDelta = delta(a.accent, b.accent);
7115
+ const groundDelta = delta(a.ground, b.ground);
7116
+ if (accentDelta !== null && accentDelta <= ACCENT_SHARED) shared.push(`accent (\u0394E ${accentDelta.toFixed(3)})`);
7117
+ if (groundDelta !== null && groundDelta <= GROUND_SHARED) shared.push(`ground (\u0394E ${groundDelta.toFixed(3)})`);
7118
+ if (a.display && b.display && same(normaliseFamily(a.display), normaliseFamily(b.display))) shared.push(`${normaliseFamily(a.display)} headline`);
7119
+ if (a.body && b.body && same(normaliseFamily(a.body), normaliseFamily(b.body))) shared.push(`${normaliseFamily(a.body)} body`);
7120
+ if (same(a.shape, b.shape)) shared.push(`shape "${a.shape}"`);
7121
+ let orderDistance = null;
7122
+ if (a.order?.length && b.order?.length) {
7123
+ orderDistance = levenshtein(a.order, b.order) / Math.max(a.order.length, b.order.length);
7124
+ if (orderDistance <= SHARED_PART) shared.push(orderDistance === 0 ? "the same section order" : "nearly the same section order");
7125
+ }
7126
+ if (same(a.primaryAction, b.primaryAction) && [...a.positions ?? []].sort().join(",") === [...b.positions ?? []].sort().join(",")) shared.push(`"${a.primaryAction}" asked in the same places`);
7127
+ const surface = shared.filter((s) => /^(?:accent|ground|shape)|headline$/.test(s)).length;
7128
+ const flagged = surface >= 2 || orderDistance !== null && orderDistance <= SHARED_PART;
7129
+ return { shared, accentDelta, groundDelta, orderDistance, flagged };
7130
+ }
7131
+ function compareToDeclared(target2, sites, exclude) {
7132
+ 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));
7133
+ }
7134
+ function formatDirectionMatches(label, matches) {
7135
+ const lines = [`${label}: declared directions in the estate (ranked, not judged: no sibling line yet)`];
7136
+ for (const m of matches) lines.push(` ${m.id.padEnd(20)} ${m.flagged ? "LOOK " : " "}${m.shared.length ? `shares ${m.shared.join(", ")}` : "nothing shared"}`);
7137
+ if (matches.length === 0) lines.push(" no site in the register has a declared direction yet: craft estate add --direction <file>");
7138
+ return lines.join("\n");
7139
+ }
5944
7140
 
5945
7141
  // src/direction/licences.ts
5946
7142
  var LICENCES = [
@@ -6054,13 +7250,6 @@ function licencesJson() {
6054
7250
  }
6055
7251
 
6056
7252
  // src/direction/validate.ts
6057
- 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;
6058
- 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;
6059
- 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"]);
6060
- 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"]);
6061
- function words(text) {
6062
- 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)));
6063
- }
6064
7253
  function tellFor(key, value) {
6065
7254
  if (key === "accent") {
6066
7255
  const c = parseColour(value);
@@ -6077,44 +7266,58 @@ function tellFor(key, value) {
6077
7266
  }
6078
7267
  return null;
6079
7268
  }
7269
+ var MIN_TOKENS = 5;
7270
+ var emptyLayers = () => ({ job: false, hierarchy: [], tokens: { decided: 0, total: CHOICE_KEYS.length }, complete: false });
6080
7271
  function validateDirection(input, ctx = {}) {
6081
7272
  const problems = [];
6082
7273
  const err = (at, message) => problems.push({ severity: "error", at, message });
6083
7274
  const warn = (at, message) => problems.push({ severity: "warn", at, message });
6084
7275
  if (!input || typeof input !== "object" || Array.isArray(input)) {
6085
7276
  err("", "art-direction.json must be a JSON object");
6086
- return { valid: false, problems, decided: 0 };
7277
+ return { valid: false, problems, decided: 0, layers: emptyLayers() };
6087
7278
  }
6088
7279
  const d = input;
6089
7280
  const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
6090
7281
  if (legacy) {
6091
7282
  warn("", "exceptions only: run craft direction init to record the choices and their reasons");
6092
- return { valid: true, problems, decided: 0 };
7283
+ return { valid: true, problems, decided: 0, layers: emptyLayers() };
6093
7284
  }
6094
- if (d.version !== DIRECTION_VERSION) err("version", `version must be ${DIRECTION_VERSION}`);
7285
+ if (!DIRECTION_VERSIONS.includes(d.version)) err("version", `version must be ${DIRECTION_VERSIONS.join(" or ")}`);
7286
+ 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.");
6095
7287
  if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
6096
7288
  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");
6097
- const sources = Array.isArray(d.sources) ? d.sources : [];
7289
+ const rawSources = Array.isArray(d.sources) ? d.sources : [];
7290
+ const sources = rawSources.filter((s) => Boolean(s && typeof s === "object" && !Array.isArray(s)));
6098
7291
  if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
6099
7292
  if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
6100
7293
  const ids = /* @__PURE__ */ new Set();
6101
- sources.forEach((s, i) => {
7294
+ rawSources.forEach((s, i) => {
6102
7295
  const at = `sources[${i}]`;
6103
- if (!s || typeof s !== "object") return err(at, "a source must be an object");
7296
+ if (!s || typeof s !== "object" || Array.isArray(s)) return err(at, "a source must be an object");
6104
7297
  if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
6105
7298
  else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
6106
7299
  else ids.add(s.id);
6107
7300
  if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
6108
7301
  if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
6109
- if (s.path && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
6110
- for (const [j, c] of (s.colours ?? []).entries()) if (!parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
7302
+ if (s.path !== void 0 && typeof s.path !== "string") err(`${at}.path`, "a path must be text");
7303
+ if (typeof s.path === "string" && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
7304
+ if (s.colours !== void 0 && !Array.isArray(s.colours)) err(`${at}.colours`, "colours must be a list");
7305
+ 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`);
7306
+ if (s.kind === "reference") {
7307
+ if (typeof s.category !== "string" || !s.category.trim()) err(`${at}.category`, "say where the reference comes from: print, a place, another trade");
7308
+ 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 : "")) {
7309
+ 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.`);
7310
+ }
7311
+ 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");
7312
+ }
7313
+ 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");
6111
7314
  });
6112
7315
  const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
6113
7316
  if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
6114
7317
  const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
6115
7318
  let decided = 0;
6116
7319
  for (const key of Object.keys(choices)) {
6117
- if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Layout and navigation stay conventional.`);
7320
+ 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.`);
6118
7321
  }
6119
7322
  for (const key of CHOICE_KEYS) {
6120
7323
  const c = choices[key];
@@ -6131,44 +7334,9 @@ function validateDirection(input, ctx = {}) {
6131
7334
  err(`${at}.value`, `"${c.value}" is not a colour`);
6132
7335
  ok = false;
6133
7336
  }
6134
- const because = typeof c.because === "string" ? c.because.trim() : "";
6135
- const evidence = Array.isArray(c.evidence) ? c.evidence : [];
6136
- if (because.split(/\s+/).filter(Boolean).length < 8) {
6137
- err(`${at}.because`, "a reason is at least a sentence. Without one this is a default, however good it looks.");
6138
- ok = false;
6139
- } else if (because.startsWith("PROPOSED:")) {
6140
- err(`${at}.because`, "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
6141
- ok = false;
6142
- } else {
6143
- if (PREFERENCE.test(because)) {
6144
- err(`${at}.because`, "that is a preference, not a reason. Say what in the client's world this comes from.");
6145
- ok = false;
6146
- }
6147
- const moods = because.match(MOOD) ?? [];
6148
- if (moods.length >= 2) {
6149
- err(`${at}.because`, `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
6150
- ok = false;
6151
- }
6152
- }
6153
- if (evidence.length === 0) {
6154
- err(`${at}.evidence`, "cite at least one source");
6155
- ok = false;
6156
- }
6157
- for (const id of evidence) {
6158
- if (!ids.has(id)) {
6159
- err(`${at}.evidence`, `no source called "${id}"`);
6160
- ok = false;
6161
- }
6162
- }
6163
- if (ok) {
6164
- const cited = sources.filter((s) => evidence.includes(s.id));
6165
- const reasonWords = words(because);
6166
- const tied = cited.some((s) => reasonWords.has(s.id) || [...words(`${s.note} ${s.lettering ?? ""}`)].some((w) => reasonWords.has(w)));
6167
- if (!tied) {
6168
- err(`${at}.because`, `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
6169
- ok = false;
6170
- }
6171
- }
7337
+ const reasons = checkReason({ at, because: c.because, evidence: c.evidence, sources, tie: "source" });
7338
+ problems.push(...reasons);
7339
+ if (reasons.length) ok = false;
6172
7340
  if (typeof c.value === "string") {
6173
7341
  const tell = tellFor(key, c.value);
6174
7342
  if (tell && !exceptions.some((e) => e.tell === tell)) {
@@ -6204,7 +7372,33 @@ function validateDirection(input, ctx = {}) {
6204
7372
  }
6205
7373
  if (ok) decided += 1;
6206
7374
  }
6207
- return { valid: problems.every((p) => p.severity !== "error"), problems, decided };
7375
+ let job = false;
7376
+ let hierarchy = [];
7377
+ if (d.version === 2) {
7378
+ const j = validateJob(d.job, sources, { brief: typeof d.brief === "string" ? d.brief : "", readSource: ctx.readSource });
7379
+ problems.push(...j.problems);
7380
+ job = j.decided && d.job !== void 0;
7381
+ const h = validateHierarchy(d.hierarchy, d.job, sources);
7382
+ problems.push(...h.problems);
7383
+ hierarchy = h.pages;
7384
+ if (ctx.snapshot) problems.push(...checkOrder(d.hierarchy?.[ctx.page ?? "home"], ctx.page ?? "home", ctx.snapshot, ctx.strict));
7385
+ }
7386
+ if (ctx.estate?.length) {
7387
+ for (const m of compareToDeclared(summariseDirection(d), ctx.estate, ctx.estateId)) {
7388
+ 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.`);
7389
+ }
7390
+ }
7391
+ if (ctx.strict) {
7392
+ for (const p of problems) if (p.severity === "warn" && /the page (?:does not show|sets|runs)/.test(p.message)) p.severity = "error";
7393
+ }
7394
+ const home = hierarchy.find((p) => p.page === "home");
7395
+ const complete = job && Boolean(home) && hierarchy.every((p) => p.decided === p.total) && decided >= MIN_TOKENS;
7396
+ return {
7397
+ valid: problems.every((p) => p.severity !== "error"),
7398
+ problems,
7399
+ decided,
7400
+ layers: { job, hierarchy, tokens: { decided, total: CHOICE_KEYS.length }, complete: complete && problems.every((p) => p.severity !== "error") }
7401
+ };
6208
7402
  }
6209
7403
 
6210
7404
  // src/direction/init.ts
@@ -6223,11 +7417,31 @@ function initDirection(input) {
6223
7417
  body: empty(fp.body.family),
6224
7418
  ...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`) } : {}
6225
7419
  } : {};
7420
+ const part = () => ({ value: "", evidence: [] });
6226
7421
  return {
6227
7422
  $schema: "https://unpkg.com/@domandigital/craft/art-direction.schema.json",
6228
7423
  version: DIRECTION_VERSION,
6229
7424
  client: input.client ?? "",
6230
7425
  brief: input.brief ?? "",
7426
+ // Empty on purpose, like the reasons: the first validate lists the job and the structure as undecided.
7427
+ job: {
7428
+ statement: { verb: "", object: "", context: "" },
7429
+ functional: part(),
7430
+ emotional: part(),
7431
+ social: part(),
7432
+ consideration: { value: "low", because: "", evidence: [] },
7433
+ objections: [],
7434
+ language: [],
7435
+ evidence: []
7436
+ },
7437
+ hierarchy: {
7438
+ home: {
7439
+ primaryAction: { value: "", positions: [], because: "" },
7440
+ order: fp?.sections?.length ? fp.sections.map((s) => ({ role: s.role, because: "" })) : [],
7441
+ lead: { value: "", because: "" },
7442
+ journey: []
7443
+ }
7444
+ },
6231
7445
  sources: [],
6232
7446
  choices,
6233
7447
  ...input.exceptions?.length ? { exceptions: input.exceptions } : {}
@@ -6235,7 +7449,6 @@ function initDirection(input) {
6235
7449
  }
6236
7450
 
6237
7451
  // src/direction/propose.ts
6238
- var PROPOSED = "PROPOSED:";
6239
7452
  function srgbToLinear2(c) {
6240
7453
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
6241
7454
  }
@@ -6297,7 +7510,13 @@ function accentCandidates(input) {
6297
7510
  const parsed = parseColour(c);
6298
7511
  if (!parsed || !chromatic(parsed.oklch)) continue;
6299
7512
  const o = parsed.oklch;
6300
- const siblings = (input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 }));
7513
+ const siblings = [
7514
+ ...(input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 })),
7515
+ ...(input.declared ?? []).flatMap((e) => {
7516
+ const p = parseColour(e.accent);
7517
+ return p ? [{ id: `${e.id} (declared)`, d: deltaEOk(o, p.oklch) }] : [];
7518
+ })
7519
+ ];
6301
7520
  const nearest2 = siblings.sort((a, b) => a.d - b.d)[0];
6302
7521
  const penalty = isAiViolet(o) ? 1 : 0;
6303
7522
  out.push({
@@ -6364,7 +7583,7 @@ function addSite(register, site) {
6364
7583
  }
6365
7584
  function sharedParts(a, b, d = fingerprintDistance(a, b)) {
6366
7585
  const out = [];
6367
- const close = (k) => d.parts[k] <= 0.2;
7586
+ const close = (k) => d.parts[k] <= SHARED_PART;
6368
7587
  if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
6369
7588
  if (close("type")) {
6370
7589
  if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
@@ -6379,14 +7598,14 @@ function sharedParts(a, b, d = fingerprintDistance(a, b)) {
6379
7598
  }
6380
7599
  function compareToEstate(target2, register, options = {}) {
6381
7600
  const at = options.siblingAt ?? SIBLING_AT;
6382
- return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
7601
+ return register.sites.filter((s) => s.id !== options.exclude && s.fingerprint).map((s) => {
6383
7602
  const d = fingerprintDistance(target2, s.fingerprint);
6384
7603
  return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target2, s.fingerprint, d) };
6385
7604
  }).sort((a, b) => a.distance - b.distance);
6386
7605
  }
6387
7606
  function estatePairs(register, siblingAt = SIBLING_AT) {
6388
7607
  const out = [];
6389
- const s = register.sites;
7608
+ const s = register.sites.filter((site) => site.fingerprint);
6390
7609
  for (let i = 0; i < s.length; i += 1) {
6391
7610
  for (let j = i + 1; j < s.length; j += 1) {
6392
7611
  const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
@@ -6506,8 +7725,16 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
6506
7725
  }
6507
7726
  if (input.direction) {
6508
7727
  const d = input.direction;
6509
- const raised2 = !d.valid || d.decided < MIN_DECIDED;
6510
- signals.push({ id: "reasons", raised: raised2, detail: `${d.decided} of 7 choices decided with a reason${d.valid ? "" : ", art-direction.json has errors"}` });
7728
+ const layers = d.layers;
7729
+ const raised2 = !d.valid || d.decided < MIN_DECIDED || !layers.complete;
7730
+ const pages = layers.hierarchy.map((p) => `${p.page} ${p.decided} of ${p.total}`).join(", ");
7731
+ const detail = [
7732
+ `job ${layers.job ? "decided" : "not decided"}`,
7733
+ `hierarchy ${pages || "not decided"}`,
7734
+ `${d.decided} of 7 choices decided with a reason`
7735
+ ].join("; ");
7736
+ signals.push({ id: "reasons", raised: raised2, detail: `${detail}${d.valid ? "" : ", art-direction.json has errors"}` });
7737
+ 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 });
6511
7738
  for (const p of d.problems) {
6512
7739
  const key = p.at.match(/^choices\.(\w+)/)?.[1];
6513
7740
  if (p.severity === "error" || p.message === "not decided yet") {
@@ -6525,11 +7752,11 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
6525
7752
  const order = ["direction", "type", "colour", "shape", "effects", "motion", "layout", "copy"];
6526
7753
  const merged = [];
6527
7754
  for (const a of actions) {
6528
- const same = a.choice && a.area !== "direction" && a.area !== "copy" ? merged.find((m) => m.choice === a.choice && m.area === a.area) : void 0;
6529
- if (same) {
7755
+ const same2 = a.choice && a.area !== "direction" && a.area !== "copy" ? merged.find((m) => m.choice === a.choice && m.area === a.area) : void 0;
7756
+ if (same2) {
6530
7757
  const fromTell = /\(\S+, gen \d\)/.test(a.why);
6531
- same.why = `${same.why}; ${fromTell ? a.why : `${a.what.replace(/\.$/, "")} (${a.why})`}`;
6532
- same.priority = Math.min(same.priority, a.priority);
7758
+ same2.why = `${same2.why}; ${fromTell ? a.why : `${a.what.replace(/\.$/, "")} (${a.why})`}`;
7759
+ same2.priority = Math.min(same2.priority, a.priority);
6533
7760
  } else merged.push({ ...a });
6534
7761
  }
6535
7762
  const unique2 = merged.filter((a, i) => merged.findIndex((b) => b.area === a.area && b.what === a.what) === i);
@@ -6607,6 +7834,8 @@ function retrofitPlan(report2, direction) {
6607
7834
  }
6608
7835
  // Annotate the CommonJS export names for ESM import in node:
6609
7836
  0 && (module.exports = {
7837
+ ACCENT_SCALE,
7838
+ ACCENT_SHARED,
6610
7839
  AI_PHRASES,
6611
7840
  AI_SOURCE_TYPES,
6612
7841
  AI_VIOLET,
@@ -6617,10 +7846,14 @@ function retrofitPlan(report2, direction) {
6617
7846
  CATALOGUE_VERSION,
6618
7847
  CHOICE_KEYS,
6619
7848
  COMPONENT_ROLES,
7849
+ CONSIDERATION,
7850
+ CONVENTION,
7851
+ CUSTOMER_KINDS,
6620
7852
  DEFAULT_IGNORE,
6621
7853
  DENSITIES,
6622
7854
  DENSITY_NAMES,
6623
7855
  DIRECTION_VERSION,
7856
+ DIRECTION_VERSIONS,
6624
7857
  DISTANCE_WEIGHTS,
6625
7858
  DURATION_MS,
6626
7859
  DURATION_S,
@@ -6630,6 +7863,12 @@ function retrofitPlan(report2, direction) {
6630
7863
  ESTATE_VERSION,
6631
7864
  EXIT_RATIO,
6632
7865
  FINGERPRINT_VERSION,
7866
+ FUNCTION_WORDS,
7867
+ GROUND_SCALE,
7868
+ GROUND_SHARED,
7869
+ HIERARCHY_FIELDS,
7870
+ HOME_FILE,
7871
+ HOME_URL,
6633
7872
  HOUSE,
6634
7873
  HOUSE_BUDGET,
6635
7874
  HOUSE_LOCKED,
@@ -6640,11 +7879,15 @@ function retrofitPlan(report2, direction) {
6640
7879
  LIGHTNESS_CURVE,
6641
7880
  MIN_DECIDED,
6642
7881
  MIN_RUNS,
7882
+ MOOD,
6643
7883
  NEGATIVE_REASSURANCE,
6644
7884
  NULL_VERSION,
7885
+ PAGE_TYPES,
6645
7886
  PILL_LIMIT,
6646
7887
  PLAINER_WORDS,
7888
+ PREFERENCE,
6647
7889
  PROMO_BADGE,
7890
+ PROOF_NAMES,
6648
7891
  PROPOSED,
6649
7892
  PROVENANCE_SCAN_BYTES,
6650
7893
  RAMP_STEPS,
@@ -6656,17 +7899,24 @@ function retrofitPlan(report2, direction) {
6656
7899
  REVEAL_LIMIT,
6657
7900
  REVIEW_PHRASES,
6658
7901
  SCALE,
7902
+ SECTION_ROLES,
6659
7903
  SHADCN_LIMIT,
6660
7904
  SHADCN_PRIMITIVES,
7905
+ SHARED_PART,
6661
7906
  SIBLING_AT,
6662
7907
  SNAPSHOT_VERSION,
6663
7908
  SOURCE_KINDS,
6664
7909
  SPACE_STEPS,
7910
+ SPECIFICITY_COPY_TELLS,
7911
+ SPECIFICITY_RENDERED_TELLS,
6665
7912
  SPRING,
7913
+ STANDING_CLAIM,
6666
7914
  STOCK_AVATAR_HOSTS,
7915
+ STOCK_FIGURES,
6667
7916
  STOCK_PHOTO_HOSTS,
6668
7917
  STOCK_PHRASES,
6669
7918
  TELL_HEAVY,
7919
+ TRADE_NOUNS,
6670
7920
  TYPE_STEPS,
6671
7921
  TYPICAL_AT,
6672
7922
  VAGUE_WORDS,
@@ -6677,22 +7927,32 @@ function retrofitPlan(report2, direction) {
6677
7927
  applyHouseGate,
6678
7928
  applySeverity,
6679
7929
  auditSnapshot,
7930
+ briefTerms,
6680
7931
  catalogueTable,
6681
7932
  characterReport,
6682
7933
  checkCopy,
7934
+ checkOrder,
6683
7935
  checkPair,
7936
+ checkReason,
6684
7937
  checkRestraint,
6685
7938
  choicesOf,
7939
+ clauseDepth,
6686
7940
  closestComponents,
7941
+ compareCompetitor,
7942
+ compareDirections,
6687
7943
  compareFacts,
7944
+ compareToDeclared,
6688
7945
  compareToEstate,
6689
7946
  componentDistance,
6690
7947
  componentShared,
6691
7948
  componentTypicalities,
6692
7949
  componentTypicality,
6693
7950
  componentsOf,
7951
+ contentWords,
7952
+ countWords,
6694
7953
  craftTokens,
6695
7954
  createBaseline,
7955
+ cv,
6696
7956
  deltaEOk,
6697
7957
  deltaEOkHex,
6698
7958
  densityCss,
@@ -6715,11 +7975,15 @@ function retrofitPlan(report2, direction) {
6715
7975
  fontClass,
6716
7976
  formatClaims,
6717
7977
  formatComparison,
7978
+ formatCompetitor,
7979
+ formatDirectionMatches,
6718
7980
  formatHex,
6719
7981
  formatReport,
6720
7982
  globToRegExp,
6721
7983
  groundName,
6722
7984
  harvest,
7985
+ hedgeCount,
7986
+ hedgedClose,
6723
7987
  hexToOklch,
6724
7988
  holdHouseBlocks,
6725
7989
  houseRule,
@@ -6730,49 +7994,75 @@ function retrofitPlan(report2, direction) {
6730
7994
  inSrgbGamut,
6731
7995
  initDirection,
6732
7996
  isAiViolet,
7997
+ isBlurb,
7998
+ isCategoryLabel,
6733
7999
  isCream,
8000
+ jobWords,
6734
8001
  layoutDistance,
6735
8002
  licencesJson,
6736
8003
  makeSnapshot,
6737
8004
  motionTokens,
6738
8005
  namedSources,
6739
8006
  nearest,
8007
+ nominalisations,
6740
8008
  normaliseFamily,
6741
8009
  nullPrompt,
6742
8010
  oklchToHex,
6743
8011
  oklchToRgb,
8012
+ pageCopy,
6744
8013
  paletteFromPixels,
8014
+ paragraphUniformity,
6745
8015
  parseBaseline,
6746
8016
  parseColour,
6747
8017
  parseCraftConfig,
6748
8018
  parseHex,
8019
+ participialClauses,
8020
+ phrasalCoordinations,
6749
8021
  propose,
6750
8022
  protectedFacts,
6751
8023
  ramp,
6752
8024
  rampFromAnchors,
6753
8025
  readSnapshot,
8026
+ renderedOrder,
8027
+ researchPrompt,
6754
8028
  retrofitPlan,
6755
8029
  runTell,
6756
8030
  scanProvenance,
6757
8031
  scanSource,
6758
8032
  sectionRhythm,
6759
8033
  semantic,
8034
+ sentencesOf,
6760
8035
  severityExceptions,
8036
+ shapeMoves,
6761
8037
  shapeName,
8038
+ shapeOf,
8039
+ shapeWords,
6762
8040
  sharedParts,
6763
8041
  shouldAnimate,
6764
8042
  siblingLine,
8043
+ skeleton,
8044
+ skeletonRepeat,
8045
+ specificity,
8046
+ specifics,
8047
+ splitSentences,
8048
+ stackedConditional,
6765
8049
  stockAvatarSource,
6766
8050
  stockSource,
8051
+ summariseDirection,
6767
8052
  tailwindV3Preset,
6768
8053
  tailwindV4Theme,
6769
8054
  tellById,
6770
8055
  toGamut,
6771
8056
  toJson,
6772
8057
  toSarif,
8058
+ tradeOf,
8059
+ tricolons,
6773
8060
  typeFeatureTokens,
6774
8061
  typicality,
8062
+ unprovenClaims,
6775
8063
  validateDirection,
8064
+ validateHierarchy,
8065
+ validateJob,
6776
8066
  visibleText,
6777
8067
  visualMeasures,
6778
8068
  wcagContrast,