@domandigital/craft 0.14.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.
package/dist/index.js CHANGED
@@ -490,9 +490,9 @@ function fluidClamp(minPx, maxPx, minViewport, maxViewport, rootPx = 16) {
490
490
  const interceptRem = round((minPx - slope * minViewport) / rootPx);
491
491
  const slopeVw = round(slope * 100);
492
492
  const preferred = interceptRem === 0 ? `${slopeVw}vw` : `${interceptRem}rem + ${slopeVw}vw`;
493
- const lower = Math.min(minRem, maxRem);
493
+ const lower2 = Math.min(minRem, maxRem);
494
494
  const upper = Math.max(minRem, maxRem);
495
- return `clamp(${lower}rem, ${preferred}, ${upper}rem)`;
495
+ return `clamp(${lower2}rem, ${preferred}, ${upper}rem)`;
496
496
  }
497
497
  function fluidType(options = {}) {
498
498
  const {
@@ -1286,7 +1286,7 @@ function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => fal
1286
1286
  };
1287
1287
  }
1288
1288
  var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/'/g, "['\u2019]?").replace(/ /g, "\\s+");
1289
- var wordList = (words2) => new RegExp(`\\b(?:${[...words2].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
1289
+ var wordList = (words3) => new RegExp(`\\b(?:${[...words3].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
1290
1290
  var quoted = (m) => `"${m}"`;
1291
1291
  var AI_WORDS = [
1292
1292
  "tapestry",
@@ -2124,8 +2124,8 @@ function documents(ctx) {
2124
2124
  const docs = [];
2125
2125
  for (const [path, blocks] of byPath) {
2126
2126
  const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
2127
- const words2 = countWords(text);
2128
- if (words2 >= MIN_WORDS) docs.push({ path, blocks, words: words2 });
2127
+ const words3 = countWords(text);
2128
+ if (words3 >= MIN_WORDS) docs.push({ path, blocks, words: words3 });
2129
2129
  }
2130
2130
  return docs;
2131
2131
  }
@@ -2269,14 +2269,14 @@ function rhythmHits(ctx) {
2269
2269
  const list = sentences(doc2);
2270
2270
  if (list.length < 40) continue;
2271
2271
  const lengths = list.map((s) => countWords(s.text));
2272
- const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
2273
- const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
2274
- const cv = sd / mean;
2275
- if (cv < 0.42) {
2272
+ const mean2 = lengths.reduce((a, b) => a + b, 0) / lengths.length;
2273
+ const sd2 = Math.sqrt(lengths.reduce((a, b) => a + (b - mean2) ** 2, 0) / lengths.length);
2274
+ const cv2 = sd2 / mean2;
2275
+ if (cv2 < 0.42) {
2276
2276
  hits.push({
2277
2277
  path: doc2.path,
2278
2278
  offset: list[0].offset,
2279
- message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
2279
+ message: `${list.length} sentences averaging ${mean2.toFixed(1)} words with little variation (spread ${cv2.toFixed(2)}, reads metronomic below 0.42)`
2280
2280
  });
2281
2281
  }
2282
2282
  }
@@ -2290,19 +2290,19 @@ function repeatHits(ctx) {
2290
2290
  const whole = /* @__PURE__ */ new Map();
2291
2291
  const runs = /* @__PURE__ */ new Map();
2292
2292
  for (const s of sentences(doc2)) {
2293
- const words2 = normal(s.text);
2294
- if (words2.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
2293
+ const words3 = normal(s.text);
2294
+ if (words3.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
2295
2295
  if (/^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
2296
- const key = words2.join(" ");
2296
+ const key = words3.join(" ");
2297
2297
  const earlier = whole.get(key);
2298
2298
  if (earlier) {
2299
- hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words2.length} words)`, excerpt: s.text.slice(0, 120) });
2299
+ hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words3.length} words)`, excerpt: s.text.slice(0, 120) });
2300
2300
  continue;
2301
2301
  }
2302
2302
  whole.set(key, s);
2303
2303
  let reported = false;
2304
- for (let i = 0; i + RUN <= words2.length && !reported; i += 1) {
2305
- const run = words2.slice(i, i + RUN).join(" ");
2304
+ for (let i = 0; i + RUN <= words3.length && !reported; i += 1) {
2305
+ const run = words3.slice(i, i + RUN).join(" ");
2306
2306
  const seen = runs.get(run);
2307
2307
  if (seen && seen.key !== key) {
2308
2308
  hits.push({ path: doc2.path, offset: s.offset, message: `shares a ${RUN}-word run with an earlier sentence: "${run}"`, excerpt: s.text.slice(0, 120) });
@@ -2359,11 +2359,11 @@ function headingEchoHits(ctx) {
2359
2359
  const hits = [];
2360
2360
  for (const { file } of longFiles(ctx)) {
2361
2361
  for (const heading of headings(file)) {
2362
- const words2 = normal(heading.text);
2363
- if (words2.length < 4) continue;
2362
+ const words3 = normal(heading.text);
2363
+ if (words3.length < 4) continue;
2364
2364
  const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
2365
- const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words2.length);
2366
- if (opening.join(" ") === words2.join(" ")) {
2365
+ const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words3.length);
2366
+ if (opening.join(" ") === words3.join(" ")) {
2367
2367
  hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
2368
2368
  }
2369
2369
  }
@@ -2371,7 +2371,7 @@ function headingEchoHits(ctx) {
2371
2371
  return hits;
2372
2372
  }
2373
2373
  var FILLER_WORDS = "boiler van kitchen garden studio market street window roof tile paint brush ladder hedge lawn fence gate shop counter oven bread flour kettle chair table shelf lamp cable socket switch meter valve pipe tap sink drain gutter brick mortar timber plank screw bolt hinge lock key door frame glass mirror towel basin morning evening winter summer spring autumn monday tuesday friday weekend hour minute week month year fitted checked cleaned painted mended booked quoted delivered collected measured sanded sealed wired local quiet small large early late busy bright warm dry".split(" ");
2374
- function filler(words2, seed = 1, options = {}) {
2374
+ function filler(words3, seed = 1, options = {}) {
2375
2375
  const { contractions = true, uniform } = options;
2376
2376
  const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
2377
2377
  let state = seed;
@@ -2382,7 +2382,7 @@ function filler(words2, seed = 1, options = {}) {
2382
2382
  const out = [];
2383
2383
  let count4 = 0;
2384
2384
  let i = 0;
2385
- while (count4 < words2) {
2385
+ while (count4 < words3) {
2386
2386
  const length = uniform ?? shape[i % shape.length];
2387
2387
  const s = [];
2388
2388
  for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
@@ -3771,15 +3771,15 @@ var blank = (s) => s.replace(/[^\n]/g, " ");
3771
3771
  function readsAsProse(value) {
3772
3772
  if (value.length < 12 || !/\s/.test(value)) return false;
3773
3773
  if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
3774
- const words2 = withoutMarkup(value);
3775
- if (looksLikeCode(words2) || /[<>]/.test(words2)) return false;
3776
- return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words2);
3774
+ const words3 = withoutMarkup(value);
3775
+ if (looksLikeCode(words3) || /[<>]/.test(words3)) return false;
3776
+ return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words3);
3777
3777
  }
3778
3778
  function withoutMarkup(value) {
3779
3779
  return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
3780
3780
  }
3781
- function looksLikeCode(words2) {
3782
- return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words2);
3781
+ function looksLikeCode(words3) {
3782
+ return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words3);
3783
3783
  }
3784
3784
  var UTILITY = /^(?:flex|grid|block|inline|hidden|contents|relative|absolute|fixed|sticky|static|rounded|border|shadow|underline|italic|uppercase|lowercase|capitalize|truncate|container|group|peer|transition|antialiased|prose|sr-only|outline|ring|grow|shrink|isolate|invisible|visible|collapse)$/;
3785
3785
  function looksLikeClasses(value) {
@@ -4182,8 +4182,8 @@ var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/)
4182
4182
  var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
4183
4183
  var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
4184
4184
  function caseless(line) {
4185
- const words2 = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
4186
- const letters = words2.filter((w) => w.length >= 2);
4185
+ const words3 = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
4186
+ const letters = words3.filter((w) => w.length >= 2);
4187
4187
  if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
4188
4188
  const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
4189
4189
  if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
@@ -4193,8 +4193,8 @@ var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => "
4193
4193
  function briefTerms(brief) {
4194
4194
  const terms = /* @__PURE__ */ new Set();
4195
4195
  for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
4196
- const lower = w.toLowerCase();
4197
- if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower) && !NOT_NAMES.has(lower)) terms.add(lower);
4196
+ const lower2 = w.toLowerCase();
4197
+ if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower2) && !NOT_NAMES.has(lower2)) terms.add(lower2);
4198
4198
  }
4199
4199
  for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
4200
4200
  for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
@@ -4217,12 +4217,12 @@ function specifics(text, brief) {
4217
4217
  for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
4218
4218
  continue;
4219
4219
  }
4220
- const words2 = f4.value.split(/\s+/);
4220
+ const words3 = f4.value.split(/\s+/);
4221
4221
  const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
4222
- const first = words2.findIndex(named);
4222
+ const first = words3.findIndex(named);
4223
4223
  if (first === -1) continue;
4224
- const last = words2.length - 1 - [...words2].reverse().findIndex(named);
4225
- add("name", words2.slice(first, last + 1).join(" "));
4224
+ const last = words3.length - 1 - [...words3].reverse().findIndex(named);
4225
+ add("name", words3.slice(first, last + 1).join(" "));
4226
4226
  continue;
4227
4227
  }
4228
4228
  add(f4.kind, f4.value);
@@ -4242,15 +4242,15 @@ function specifics(text, brief) {
4242
4242
  }
4243
4243
  function specificity(text, brief) {
4244
4244
  const list = specifics(text, brief);
4245
- const words2 = countWords2(text);
4245
+ const words3 = countWords2(text);
4246
4246
  const trade = list.filter((s) => s.kind === "trade").length;
4247
4247
  const hard = list.length - trade;
4248
4248
  return {
4249
- words: words2,
4249
+ words: words3,
4250
4250
  specifics: list,
4251
4251
  hard,
4252
4252
  trade,
4253
- perHundred: words2 ? Math.round(list.length / words2 * 1e3) / 10 : 0,
4253
+ perHundred: words3 ? Math.round(list.length / words3 * 1e3) / 10 : 0,
4254
4254
  generic: hard === 0 && trade < 2
4255
4255
  };
4256
4256
  }
@@ -4959,9 +4959,14 @@ function faceDistance(a, b) {
4959
4959
  if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
4960
4960
  return a.class === b.class ? 0.5 : 1;
4961
4961
  }
4962
+ var ACCENT_SCALE = 0.3;
4963
+ var GROUND_SCALE = 0.15;
4964
+ var SHARED_PART = 0.2;
4965
+ var ACCENT_SHARED = ACCENT_SCALE * SHARED_PART;
4966
+ var GROUND_SHARED = GROUND_SCALE * SHARED_PART;
4962
4967
  function fingerprintDistance(a, b) {
4963
- const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / 0.3) : a.accent || b.accent ? 1 : 0;
4964
- const ground = clamp(deltaEOk(a.ground, b.ground) / 0.15);
4968
+ const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / ACCENT_SCALE) : a.accent || b.accent ? 1 : 0;
4969
+ const ground = clamp(deltaEOk(a.ground, b.ground) / GROUND_SCALE);
4965
4970
  const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
4966
4971
  const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
4967
4972
  const motion = clamp(Math.abs(a.motion - b.motion));
@@ -5096,10 +5101,10 @@ var STOP = new Set(
5096
5101
  function grams(text) {
5097
5102
  const out = /* @__PURE__ */ new Set();
5098
5103
  for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
5099
- const words2 = clause.match(/[a-z][a-z'-]*/g) ?? [];
5104
+ const words3 = clause.match(/[a-z][a-z'-]*/g) ?? [];
5100
5105
  for (let n = 2; n <= 4; n += 1) {
5101
- for (let i = 0; i + n <= words2.length; i += 1) {
5102
- const g = words2.slice(i, i + n);
5106
+ for (let i = 0; i + n <= words3.length; i += 1) {
5107
+ const g = words3.slice(i, i + n);
5103
5108
  if (STOP.has(g[0]) || STOP.has(g[n - 1])) continue;
5104
5109
  if (g.filter((w) => !STOP.has(w)).length < 2) continue;
5105
5110
  out.add(g.join(" "));
@@ -5266,7 +5271,7 @@ function closestComponents(a, b, role) {
5266
5271
  }
5267
5272
  function estateComponentPairs(register, role) {
5268
5273
  const out = [];
5269
- const s = register.sites;
5274
+ const s = register.sites.filter((site) => site.fingerprint);
5270
5275
  for (let i = 0; i < s.length; i += 1) {
5271
5276
  for (let j = i + 1; j < s.length; j += 1) {
5272
5277
  const c = closestComponents(s[i].fingerprint, s[j].fingerprint, role);
@@ -5860,9 +5865,9 @@ var ATTRIBUTION = [
5860
5865
  ];
5861
5866
  var LINK2 = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)|\bhttps?:\/\/[^\s<>"')\]]+/g;
5862
5867
  function cleanName(raw) {
5863
- const words2 = raw.trim().replace(/[.,;:]+$/, "").split(/\s+/);
5864
- while (words2.length && NOT_A_SOURCE.test(words2[0])) words2.shift();
5865
- return words2.join(" ").replace(/(?:’s|'s)(?=\s|$).*$/, "").trim();
5868
+ const words3 = raw.trim().replace(/[.,;:]+$/, "").split(/\s+/);
5869
+ while (words3.length && NOT_A_SOURCE.test(words3[0])) words3.shift();
5870
+ return words3.join(" ").replace(/(?:’s|'s)(?=\s|$).*$/, "").trim();
5866
5871
  }
5867
5872
  var IMPERATIVE = /^(?:Add|Use|Check|Make|Get|Set|Include|Create|Update|List|Keep|Ask|Write|Track|Test|Claim|Build|Link|Submit|Verify|Review|Fix|Remove|Upload|Respond|Reply|Choose|Pick|Start|Run|Open|Install|Connect|Enable|Turn|Put|Give|Send|Book|Call|Read|See|Try|Compare|Aim|Target|Avoid|Post|Share|Mark|Tag)$/;
5868
5873
  var atStart = (sentence, index) => /^[\s*_"“”’(>#|-]*(?:\d{1,3}[.)]\s*)?[\s*_]*(?:[\w ]+:\**\s*)?$/.test(sentence.slice(0, index)) || /\|\s*\**$/.test(sentence.slice(0, index));
@@ -5955,6 +5960,207 @@ function formatClaims(report2, label = "craft copy claims") {
5955
5960
  return lines.join("\n");
5956
5961
  }
5957
5962
 
5963
+ // src/character/shape.ts
5964
+ var ABBREVIATIONS = /\b(?:e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|St|No|approx|incl|Ltd|Co|Inc|Jr|Sr)\./g;
5965
+ var DOT = "\0";
5966
+ function splitSentences(text) {
5967
+ const guarded = text.replace(/\s+/g, " ").replace(ABBREVIATIONS, (m) => m.replace(/\./g, DOT)).replace(/(\d)\.(\d)/g, `$1${DOT}$2`);
5968
+ return guarded.split(/(?<=[.!?]["'’”)]*)\s+(?=["'‘“(]?[A-Z0-9£$€])/).map((s) => s.split(DOT).join(".").trim()).filter((s) => words(s).length > 0);
5969
+ }
5970
+ var WORD3 = /[A-Za-z0-9£$€][A-Za-z0-9£$€%'’-]*/g;
5971
+ function words(text) {
5972
+ return text.match(WORD3) ?? [];
5973
+ }
5974
+ var lower = (w) => w.toLowerCase().replace(/’/g, "'");
5975
+ var FUNCTION_WORDS = new Set(
5976
+ "a an the this that these those my your our their his her its we you they i he she it me us them and or but nor so yet for if when while where whereas because although though unless once since until as than of to in on at by with from into onto over under about after before between through during without within across is are was were be been being am do does did done have has had will would can could may might must shall should not no every each all any some more most many much few such own same other only just also very too there here what which who whom whose how why then which's it's you'll we'll you're we're they're don't won't can't isn't aren't".split(" ")
5977
+ );
5978
+ var ITEM = "[A-Za-z0-9\xA3$\u20AC'\u2019-]+(?: [A-Za-z0-9\xA3$\u20AC'\u2019-]+){0,5}";
5979
+ var TRICOLON = new RegExp(`(${ITEM}), (${ITEM}),? (and|or) (${ITEM})(?=[,.;:!?)]|$)`, "g");
5980
+ function tricolons(sentence) {
5981
+ const s = sentence.replace(/\s+/g, " ");
5982
+ const out = [];
5983
+ for (const m of s.matchAll(TRICOLON)) {
5984
+ const [, aSpan, b, , c] = m;
5985
+ const bWords = words(b);
5986
+ const cWords = words(c);
5987
+ if (bWords.length === 0 || cWords.length === 0 || cWords.length > 6) continue;
5988
+ const aWords = words(aSpan);
5989
+ const a = aWords.slice(-Math.max(1, bWords.length));
5990
+ const lead = lower(bWords[0]);
5991
+ const parallel = bWords.length >= 2 && FUNCTION_WORDS.has(lead) && (lower(cWords[0]) === lead || lower(a[0]) === lead);
5992
+ out.push({ text: m[0], parallel });
5993
+ }
5994
+ return out;
5995
+ }
5996
+ var STACKED = /,\s+(?:and|but|so|or)\s+(?:if|when|where|once|unless|provided)\b[^,.;!?]{1,60},/i;
5997
+ function stackedConditional(sentence) {
5998
+ return STACKED.test(sentence);
5999
+ }
6000
+ var SUBORDINATOR = /\b(?:if|when|which|because|so|while|where)\b/gi;
6001
+ function clauseDepth(sentence) {
6002
+ return (sentence.match(/,/g) ?? []).length + (sentence.match(SUBORDINATOR) ?? []).length;
6003
+ }
6004
+ var HEDGES = [
6005
+ "if it's a fit",
6006
+ "if it is a fit",
6007
+ "where needed",
6008
+ "if needed",
6009
+ "as needed",
6010
+ "where appropriate",
6011
+ "if appropriate",
6012
+ "as appropriate",
6013
+ "as required",
6014
+ "where required",
6015
+ "if required",
6016
+ "where possible",
6017
+ "if possible",
6018
+ "where relevant",
6019
+ "if relevant",
6020
+ "depending on",
6021
+ "can help",
6022
+ "tend to",
6023
+ "tends to",
6024
+ "may",
6025
+ "might",
6026
+ "could",
6027
+ "typically",
6028
+ "usually",
6029
+ "generally",
6030
+ "often",
6031
+ "potentially",
6032
+ "likely"
6033
+ ];
6034
+ var HEDGE = new RegExp(`\\b(?:${HEDGES.map((h) => h.replace(/'/g, "['\u2019]").replace(/ /g, "\\s+")).join("|")})\\b`, "gi");
6035
+ var CONDITIONAL_CLOSE = /\b(?:if|unless|where|provided|depending)\b/i;
6036
+ function hedgeCount(text) {
6037
+ return (text.match(HEDGE) ?? []).length;
6038
+ }
6039
+ function hedgedClose(sentences3) {
6040
+ const last = sentences3[sentences3.length - 1];
6041
+ if (!last) return false;
6042
+ HEDGE.lastIndex = 0;
6043
+ return HEDGE.test(last) || CONDITIONAL_CLOSE.test(last);
6044
+ }
6045
+ var NOMINAL = /^[a-z]{3,}(?:tion|sion|ment|ness|ity|ance|ence)s?$/;
6046
+ var NOT_NOMINAL = new Set(
6047
+ [
6048
+ "nation station ration lotion potion portion question moment cement comment element garment segment fragment apartment document ointment",
6049
+ "city pity entity quality sentence science audience silence balance finance distance instance entrance substance romance province"
6050
+ ].join(" ").split(" ").flatMap((w) => [w, `${w}s`])
6051
+ );
6052
+ function nominalisations(text) {
6053
+ return words(text).filter((w) => {
6054
+ const l = lower(w);
6055
+ return NOMINAL.test(l) && !NOT_NOMINAL.has(l);
6056
+ }).length;
6057
+ }
6058
+ var PRONOUN_OPENER = /^["'‘“(]?(?:you|your|we|our)\b/i;
6059
+ var TRANSITION_OPENER = /^["'‘“(]?(?:however|moreover|additionally|furthermore|thus|therefore|consequently|in addition|as a result|ultimately|overall|importantly|notably)\b/i;
6060
+ var IMPERSONAL_OPENER = /^["'‘“(]?(?:it is|it's|it’s|there is|there are|there's|there’s)\b/i;
6061
+ var NOT_PARTICIPLE = new Set(
6062
+ "thing nothing something anything everything bring string spring sting swing king ring sing wing during morning evening ceiling building meeting wedding pudding clothing lightning including".split(" ")
6063
+ );
6064
+ function participialClauses(sentence) {
6065
+ let n = 0;
6066
+ for (const m of sentence.matchAll(/,\s+([A-Za-z]{2,}ing)\b/g)) if (!NOT_PARTICIPLE.has(lower(m[1]))) n += 1;
6067
+ const open = sentence.match(/^["'‘“(]?([A-Z][a-z]{2,}ing)\b[^,]{0,60},/);
6068
+ if (open && !NOT_PARTICIPLE.has(lower(open[1]))) n += 1;
6069
+ return n;
6070
+ }
6071
+ function phrasalCoordinations(sentence) {
6072
+ let n = 0;
6073
+ for (const m of sentence.matchAll(/(^|[^,]\s)([A-Za-z][a-z'’-]{2,}) (?:and|or) ([A-Za-z][a-z'’-]{2,})\b/g)) {
6074
+ const a = lower(m[2]);
6075
+ const b = lower(m[3]);
6076
+ if (FUNCTION_WORDS.has(a) || FUNCTION_WORDS.has(b) || a.endsWith("ly") || b.endsWith("ly")) continue;
6077
+ n += 1;
6078
+ }
6079
+ return n;
6080
+ }
6081
+ function skeleton(sentence) {
6082
+ const out = [];
6083
+ for (const w of words(sentence)) {
6084
+ const l = lower(w);
6085
+ const token = FUNCTION_WORDS.has(l) ? l : "_";
6086
+ if (token === "_" && out[out.length - 1] === "_") continue;
6087
+ out.push(token);
6088
+ }
6089
+ return out;
6090
+ }
6091
+ function skeletonRepeat(sentences3) {
6092
+ const grams2 = /* @__PURE__ */ new Map();
6093
+ let total = 0;
6094
+ for (const s of sentences3) {
6095
+ const sk = skeleton(s);
6096
+ for (let i = 0; i + 4 <= sk.length; i += 1) {
6097
+ const g = sk.slice(i, i + 4).join(" ");
6098
+ grams2.set(g, (grams2.get(g) ?? 0) + 1);
6099
+ total += 1;
6100
+ }
6101
+ }
6102
+ if (total === 0) return 0;
6103
+ let repeated = 0;
6104
+ for (const count4 of grams2.values()) if (count4 > 1) repeated += count4;
6105
+ return repeated / total;
6106
+ }
6107
+ var mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
6108
+ var sd = (xs) => {
6109
+ const m = mean(xs);
6110
+ return Math.sqrt(xs.reduce((s, x) => s + (x - m) ** 2, 0) / xs.length);
6111
+ };
6112
+ function cv(xs, min) {
6113
+ if (xs.length < min) return null;
6114
+ const m = mean(xs);
6115
+ return m === 0 ? null : sd(xs) / m;
6116
+ }
6117
+ function shapeOf(text) {
6118
+ const ss = splitSentences(text);
6119
+ const wordCount = words(text).length;
6120
+ const per100 = (n) => wordCount === 0 ? 0 : 100 * n / wordCount;
6121
+ const share = (re) => ss.length === 0 ? 0 : ss.filter((s) => re.test(s)).length / ss.length;
6122
+ const tri = ss.flatMap(tricolons);
6123
+ const commas = (text.match(/,/g) ?? []).length;
6124
+ return {
6125
+ sentences: ss.length,
6126
+ words: wordCount,
6127
+ tricolons: tri.length,
6128
+ parallelTriads: tri.filter((t) => t.parallel).length,
6129
+ stackedConditionals: ss.filter(stackedConditional).length,
6130
+ clauseDepth: ss.length === 0 ? 0 : mean(ss.map(clauseDepth)),
6131
+ sentenceCv: cv(ss.map((s) => words(s).length), 3),
6132
+ hedgeRate: per100(hedgeCount(text)),
6133
+ hedgedClose: hedgedClose(ss),
6134
+ nominalisationRate: per100(nominalisations(text)),
6135
+ pronounOpenerShare: share(PRONOUN_OPENER),
6136
+ transitionOpenerShare: share(TRANSITION_OPENER),
6137
+ impersonalOpenerShare: share(IMPERSONAL_OPENER),
6138
+ commasPerSentence: ss.length === 0 ? 0 : commas / ss.length,
6139
+ commasPer100: per100(commas),
6140
+ participialRate: ss.length === 0 ? 0 : ss.reduce((s, x) => s + participialClauses(x), 0) / ss.length,
6141
+ coordinationRate: per100(ss.reduce((s, x) => s + phrasalCoordinations(x), 0)),
6142
+ skeletonRepeat: skeletonRepeat(ss)
6143
+ };
6144
+ }
6145
+ function shapeMoves(f4) {
6146
+ const out = [];
6147
+ if (f4.parallelTriads > 0) out.push("parallel triad");
6148
+ else if (f4.tricolons > 0) out.push("tricolon");
6149
+ if (f4.stackedConditionals > 0) out.push("stacked conditional");
6150
+ if (f4.hedgedClose) out.push("hedged close");
6151
+ if (f4.participialRate > 0) out.push("participial clause");
6152
+ return out;
6153
+ }
6154
+ function paragraphUniformity(paragraphs) {
6155
+ return cv(
6156
+ paragraphs.map((p) => words(p).length).filter((n) => n > 0),
6157
+ 4
6158
+ );
6159
+ }
6160
+ function isBlurb(f4) {
6161
+ return f4.sentences >= 2 && f4.sentences <= 8 && f4.words >= 20 && f4.words <= 160;
6162
+ }
6163
+
5958
6164
  // src/character/baseline.ts
5959
6165
  var BASELINE_VERSION = 1;
5960
6166
  function fnv1a(text) {
@@ -6250,7 +6456,9 @@ function visualMeasures(img, maxSamples = 1e6) {
6250
6456
  }
6251
6457
 
6252
6458
  // src/direction/types.ts
6253
- var DIRECTION_VERSION = 1;
6459
+ var DIRECTION_VERSION = 2;
6460
+ var DIRECTION_VERSIONS = [1, 2];
6461
+ var CUSTOMER_KINDS = ["review", "enquiry", "conversation", "search"];
6254
6462
  var SOURCE_KINDS = [
6255
6463
  "livery",
6256
6464
  "shopfront",
@@ -6264,9 +6472,413 @@ var SOURCE_KINDS = [
6264
6472
  "trade",
6265
6473
  "print",
6266
6474
  "photo",
6267
- "founder"
6475
+ "founder",
6476
+ ...CUSTOMER_KINDS,
6477
+ "reference"
6268
6478
  ];
6269
6479
  var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
6480
+ var CONSIDERATION = ["low", "considered", "high"];
6481
+ var PAGE_TYPES = ["home", "service", "about", "pricing", "booking", "contact", "article", "landing"];
6482
+ var HIERARCHY_FIELDS = ["primaryAction", "order", "lead", "journey"];
6483
+
6484
+ // src/direction/reason.ts
6485
+ var PROPOSED = "PROPOSED:";
6486
+ var PREFERENCE = /\b(?:client (?:likes|liked|wants|wanted|asked|chose|prefers?)|(?:we|they|i) (?:like|liked|love|loved|prefer)|on[- ]trend|trendy|popular|brand (?:colou?r|font|guidelines?) (?:is|are|says?))\b/i;
6487
+ var MOOD = /\b(?:modern|clean|fresh|professional|trustworthy|premium|elegant|sleek|timeless|minimal(?:ist)?|bold|vibrant|friendly|approachable|luxur(?:y|ious)|sophisticated|contemporary|stylish|warm|inviting|high[- ]end)\b/gi;
6488
+ var CONVENTION = new RegExp(
6489
+ [
6490
+ // authority
6491
+ String.raw`industry[- ]standard`,
6492
+ String.raw`best[- ]practices?`,
6493
+ String.raw`the norm\b`,
6494
+ String.raw`standard (?:layout|structure|practice|pattern|format)`,
6495
+ String.raw`tried[- ]and[- ]tested`,
6496
+ String.raw`proven (?:layout|structure|pattern|format)`,
6497
+ // the crowd
6498
+ String.raw`(?:most|other|typical|many|all|similar)\s+(?:\w+\s+){0,2}(?:web)?sites\b`,
6499
+ String.raw`competitors?(?:'|’)?s?\b`,
6500
+ String.raw`(?:like|as) (?:everyone|everybody|all the others|the rest)\b`,
6501
+ String.raw`commonly used`,
6502
+ String.raw`(?:what|how) (?:everyone|others|the industry) (?:does|do|uses?)`,
6503
+ // familiarity
6504
+ String.raw`familiar (?:layout|structure|pattern|format|order|shape|design)`,
6505
+ String.raw`recogni[sz]able (?:layout|structure|pattern|format|order)`,
6506
+ String.raw`conventional(?:ly)?\b`,
6507
+ String.raw`(?:visitors|users|people|customers|anyone) (?:are|is) used to`,
6508
+ String.raw`feels? (?:at home|comfortable|familiar)(?: with)?(?: the)? (?:layout|site|page|structure|design)?`,
6509
+ String.raw`intuitive(?:ly)?\b`,
6510
+ String.raw`as usual\b`,
6511
+ String.raw`what (?:visitors|users|people) expect\b`
6512
+ ].join("|"),
6513
+ "i"
6514
+ );
6515
+ var GENERIC = new Set(
6516
+ "customer customers visitor visitors client clients people person someone business businesses service services website site page pages want wants need needs help first before after know feel time work good right sure make find look looking trust reason reasons thing things just really very able also".split(" ")
6517
+ );
6518
+ var STOP2 = /* @__PURE__ */ new Set(["with", "that", "this", "from", "their", "they", "have", "been", "were", "which", "what", "when", "where", "there", "about", "into", "onto", "over", "same", "each", "every", "your", "them", "then", "than", "will", "would", "could", "should", "because", "so", "the", "and"]);
6519
+ var VALUE_WORDS = /* @__PURE__ */ new Set(["green", "blue", "navy", "black", "white", "cream", "grey", "gray", "gold", "yellow", "orange", "purple", "violet", "pink", "brown", "teal", "bottle", "dark", "light", "deep", "pale", "bright", "colour", "color", "font", "face", "serif", "sans", "type", "lettering"]);
6520
+ function contentWords(text) {
6521
+ return new Set(
6522
+ (text.toLowerCase().match(/[a-z][a-z'’-]{3,}/g) ?? []).map((w) => w.replace(/['’]s$/, "").replace(/['’]/g, "")).filter((w) => !STOP2.has(w) && !VALUE_WORDS.has(w) && !GENERIC.has(w))
6523
+ );
6524
+ }
6525
+ var stem = (w) => w.replace(/(?:ing|ers|er|ed|es|s)$/, "");
6526
+ function shares(a, b) {
6527
+ const bs = new Set([...b].map(stem));
6528
+ return [...a].filter((w) => bs.has(stem(w)));
6529
+ }
6530
+ function jobWords(job) {
6531
+ return contentWords(
6532
+ [
6533
+ job.statement?.verb,
6534
+ job.statement?.object,
6535
+ job.statement?.context,
6536
+ job.functional?.value,
6537
+ job.emotional?.value,
6538
+ job.social?.value,
6539
+ ...Array.isArray(job.objections) ? job.objections : [],
6540
+ ...Array.isArray(job.language) ? job.language : []
6541
+ ].filter((x) => typeof x === "string").join(" ")
6542
+ );
6543
+ }
6544
+ var isCustomerVoice = (s) => Boolean(s && CUSTOMER_KINDS.includes(s.kind));
6545
+ function checkReason(input) {
6546
+ const problems = [];
6547
+ const err = (field, message) => problems.push({ severity: "error", at: `${input.at}.${field}`, message });
6548
+ const because = typeof input.because === "string" ? input.because.trim() : "";
6549
+ const evidence = Array.isArray(input.evidence) ? input.evidence.filter((e) => typeof e === "string") : [];
6550
+ if (input.evidence !== void 0 && (!Array.isArray(input.evidence) || input.evidence.some((e) => typeof e !== "string"))) err("evidence", "evidence must be a list of source ids");
6551
+ const ids = new Map(input.sources.filter((s) => s && typeof s.id === "string").map((s) => [s.id, s]));
6552
+ if (because.split(/\s+/).filter(Boolean).length < 8) {
6553
+ err("because", "a reason is at least a sentence. Without one this is a default, however good it looks.");
6554
+ return problems;
6555
+ }
6556
+ if (because.startsWith(PROPOSED)) {
6557
+ err("because", "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
6558
+ return problems;
6559
+ }
6560
+ if (PREFERENCE.test(because)) err("because", "that is a preference, not a reason. Say what in the client's world this comes from.");
6561
+ const moods = because.match(MOOD) ?? [];
6562
+ if (moods.length >= 2) err("because", `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
6563
+ const convention = because.match(CONVENTION);
6564
+ if (convention) {
6565
+ err("because", `"${convention[0]}" is the category's default speaking, not a reason. Say what in this client's job or world it answers.`);
6566
+ }
6567
+ const needsEvidence = input.tie !== "job";
6568
+ if (needsEvidence && evidence.length === 0) err("evidence", "cite at least one source");
6569
+ for (const id of evidence) if (!ids.has(id)) err("evidence", `no source called "${id}"`);
6570
+ if (input.tie === "customer") {
6571
+ for (const id of evidence) {
6572
+ if (ids.has(id) && !isCustomerVoice(ids.get(id))) err("evidence", `"${id}" is a ${ids.get(id).kind}. The job is evidenced by the customer's own words: ${CUSTOMER_KINDS.join(", ")}.`);
6573
+ }
6574
+ }
6575
+ if (input.tie === "source") {
6576
+ for (const id of evidence) {
6577
+ if (isCustomerVoice(ids.get(id))) err("evidence", `"${id}" is customer voice. Token decisions need a physical source or an outside-category reference.`);
6578
+ }
6579
+ }
6580
+ if (problems.length) return problems;
6581
+ const words3 = contentWords(because);
6582
+ if (input.tie === "job") {
6583
+ if (!input.job) {
6584
+ err("because", "a structural reason is checked against the job, and the file has no job map yet");
6585
+ } else if (shares(words3, jobWords(input.job)).length === 0) {
6586
+ err("because", "the reason does not use this client's job (its statement, objections or the customer's words). A reason that would fit any client's site is a default.");
6587
+ }
6588
+ } else {
6589
+ const cited = evidence.map((id) => ids.get(id));
6590
+ const tied = cited.some((s) => words3.has(s.id) || shares(words3, contentWords(`${s.note} ${s.lettering ?? ""}`)).length > 0);
6591
+ if (!tied) err("because", `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
6592
+ }
6593
+ return problems;
6594
+ }
6595
+
6596
+ // src/direction/job.ts
6597
+ var JOB_VERBS = new Set(
6598
+ "find choose pick book get fix stop avoid know feel understand compare decide keep protect prepare plan sort arrange replace repair learn prove show recover reduce save spend hire check see buy sell move start finish handle manage meet reach return treat look end make give take bring hand leave trust rely feel prove explain secure settle solve restore improve change become stay pass sign agree ask call reassure justify confirm".split(" ")
6599
+ );
6600
+ var LABEL_WORDS = /* @__PURE__ */ new Set(["website", "websites", "site", "sites", "page", "pages", "homepage", "web", "online", "presence", "company", "business", "services", "service", "local", "best", "good", "an", "a", "the", "for", "uk"]);
6601
+ var NOUNISH = /(?:tion|sion|ment|ness|ity|site|page)$/;
6602
+ var words2 = (s) => s.toLowerCase().match(/[a-z][a-z'’-]*/g) ?? [];
6603
+ function isCategoryLabel(object, brief = "") {
6604
+ const trade = /* @__PURE__ */ new Set([...TRADE_NOUNS.flatMap((t) => t.split(" ")), ...words2(brief).filter((w) => w.length > 3)]);
6605
+ const ws = words2(object);
6606
+ return ws.length > 0 && ws.every((w) => LABEL_WORDS.has(w) || trade.has(w) || trade.has(w.replace(/s$/, "")));
6607
+ }
6608
+ var normalise2 = (s) => s.toLowerCase().replace(/[“”"‘’'.,!?;:()-]/g, " ").replace(/\s+/g, " ").trim();
6609
+ function validateJob(job, sources, ctx = {}) {
6610
+ const problems = [];
6611
+ const err = (at, message) => problems.push({ severity: "error", at: `job${at}`, message });
6612
+ const warn = (at, message) => problems.push({ severity: "warn", at: `job${at}`, message });
6613
+ if (!job || typeof job !== "object" || Array.isArray(job)) {
6614
+ err("", "no job map: say what the visitor is trying to get done, in their words, before deciding structure or look");
6615
+ return { problems, decided: false };
6616
+ }
6617
+ const j = job;
6618
+ const ids = new Map(sources.map((s) => [s.id, s]));
6619
+ const st = j.statement;
6620
+ if (!st || typeof st !== "object") err(".statement", "a job statement is verb, object and context");
6621
+ else {
6622
+ const verb = typeof st.verb === "string" ? st.verb.trim().toLowerCase() : "";
6623
+ if (!verb) err(".statement.verb", "what the visitor is trying to do, as a verb: find, choose, book, fix");
6624
+ else if (verb.split(/\s+/).length > 3 || NOUNISH.test(verb) || TRADE_NOUNS.includes(verb)) err(".statement.verb", `"${st.verb}" is not a verb. A job starts with what the visitor is trying to do.`);
6625
+ else if (!JOB_VERBS.has(verb.split(/\s+/)[0])) warn(".statement.verb", `"${st.verb}" is not a verb craft knows. Check it says what the visitor does, not what the site is.`);
6626
+ const object = typeof st.object === "string" ? st.object.trim() : "";
6627
+ if (words2(object).length < 3) err(".statement.object", "the object needs a few words: what they are trying to get, and what makes it hard");
6628
+ else if (isCategoryLabel(object, ctx.brief)) err(".statement.object", `"${object}" names the category, not the job. Say what the visitor needs from it: "an electrician they can trust with a dangerous fault".`);
6629
+ if (typeof st.context !== "string" || words2(st.context).length < 3) err(".statement.context", "the context: when, under what pressure, or what they are trying to avoid");
6630
+ }
6631
+ for (const part of ["functional", "emotional", "social"]) {
6632
+ const p = j[part];
6633
+ if (!p || typeof p !== "object") {
6634
+ err(`.${part}`, `the ${part} side of the job, with the customer-voice source that shows it`);
6635
+ continue;
6636
+ }
6637
+ if (typeof p.value !== "string" || words2(p.value).length < 6) err(`.${part}.value`, "a sentence: what they need, in the terms they would use");
6638
+ else problems.push(...checkReason({ at: `job.${part}`, because: p.value, evidence: p.evidence, sources, tie: "customer" }).map((x) => ({ ...x, at: x.at.replace(/\.because$/, ".value") })));
6639
+ }
6640
+ const c = j.consideration;
6641
+ if (!c || typeof c !== "object") err(".consideration", `how long and how carefully they decide: ${CONSIDERATION.join(", ")}`);
6642
+ else {
6643
+ if (!CONSIDERATION.includes(c.value)) err(".consideration.value", `one of ${CONSIDERATION.join(", ")}`);
6644
+ problems.push(...checkReason({ at: "job.consideration", because: c.because, evidence: c.evidence, sources, tie: "customer" }));
6645
+ }
6646
+ const objections = Array.isArray(j.objections) ? j.objections : [];
6647
+ if (objections.length === 0) err(".objections", "what stops them: the worries they bring to the page");
6648
+ objections.forEach((o, i) => {
6649
+ if (typeof o !== "string" || words2(o).length < 3) err(`.objections[${i}]`, "an objection is a few words: what they worry about");
6650
+ });
6651
+ const evidence = Array.isArray(j.evidence) ? j.evidence : [];
6652
+ const voice = evidence.map((id) => ids.get(id)).filter(isCustomerVoice);
6653
+ if (evidence.length === 0 || voice.length === 0) err(".evidence", "cite the reviews, enquiries or conversations the job comes from");
6654
+ for (const id of evidence) if (!ids.has(id)) err(".evidence", `no source called "${id}"`);
6655
+ const language = Array.isArray(j.language) ? j.language : [];
6656
+ if (language.length === 0) err(".language", "quote the customer: the words they use for the problem");
6657
+ const texts = voice.map((s) => normalise2(`${s.note} ${ctx.readSource?.(s) ?? ""}`));
6658
+ language.forEach((q, i) => {
6659
+ if (typeof q !== "string" || !q.trim()) return err(`.language[${i}]`, "an empty quote");
6660
+ const n = normalise2(q);
6661
+ if (!texts.some((t) => t.includes(n))) warn(`.language[${i}]`, `"${q}" is not in any cited source's note or file. Quote it from the source, or it may be invented.`);
6662
+ });
6663
+ return { problems, decided: problems.every((p) => p.severity !== "error") };
6664
+ }
6665
+
6666
+ // src/direction/hierarchy.ts
6667
+ var SECTION_ROLES = [
6668
+ "hero",
6669
+ "logos",
6670
+ "stats",
6671
+ "cards",
6672
+ "marquee",
6673
+ "text",
6674
+ "other",
6675
+ "cta-band",
6676
+ "footer-cta",
6677
+ "pricing",
6678
+ "testimonials",
6679
+ "faq",
6680
+ "process",
6681
+ "features",
6682
+ "team",
6683
+ "contact"
6684
+ ];
6685
+ var POSITIONS = /* @__PURE__ */ new Set([...SECTION_ROLES, "header", "sticky"]);
6686
+ function considerationTied(because, job) {
6687
+ const text = because.toLowerCase();
6688
+ const level = job.consideration?.value;
6689
+ if (level && new RegExp(`\\b${level}\\b`).test(text)) return true;
6690
+ const said = contentWords(because);
6691
+ return (Array.isArray(job.objections) ? job.objections : []).some((o) => typeof o === "string" && [...contentWords(o)].some((w) => said.has(w)));
6692
+ }
6693
+ function validateHierarchy(hierarchy, job, sources) {
6694
+ const problems = [];
6695
+ const err = (at, message) => problems.push({ severity: "error", at: `hierarchy${at}`, message });
6696
+ const pages = [];
6697
+ if (!hierarchy || typeof hierarchy !== "object" || Array.isArray(hierarchy)) {
6698
+ err("", "no hierarchy: decide what the home page leads with, where it asks, and the order, each from the job");
6699
+ return { problems, pages };
6700
+ }
6701
+ const h = hierarchy;
6702
+ for (const key of Object.keys(h)) if (!PAGE_TYPES.includes(key)) err(`.${key}`, `not a page type craft knows. Page types are: ${PAGE_TYPES.join(", ")}`);
6703
+ if (!h.home) err(".home", "no home page hierarchy: the home page is where most visitors start");
6704
+ for (const page of PAGE_TYPES) {
6705
+ const p = h[page];
6706
+ if (!p) continue;
6707
+ let decided = 0;
6708
+ const at = (f4) => `hierarchy.${page}.${f4}`;
6709
+ const reason = (where, because, evidence) => checkReason({ at: where, because, evidence, sources, tie: "job", job });
6710
+ const count4 = (found) => {
6711
+ problems.push(...found);
6712
+ if (!found.some((x) => x.severity === "error")) decided += 1;
6713
+ };
6714
+ if (!p.primaryAction) err(`.${page}.primaryAction`, "not decided yet");
6715
+ else {
6716
+ const a = p.primaryAction;
6717
+ const found = [];
6718
+ if (typeof a.value !== "string" || !a.value.trim()) found.push({ severity: "error", at: `${at("primaryAction")}.value`, message: "what the visitor is asked to do" });
6719
+ const positions = Array.isArray(a.positions) ? a.positions : [];
6720
+ if (positions.length === 0) found.push({ severity: "error", at: `${at("primaryAction")}.positions`, message: 'where the ask sits: one place, or several ("hero", "footer-cta")' });
6721
+ for (const pos of positions) if (!POSITIONS.has(pos)) found.push({ severity: "error", at: `${at("primaryAction")}.positions`, message: `"${pos}" is not a section role, "header" or "sticky"` });
6722
+ const r = reason(at("primaryAction"), a.because, a.evidence);
6723
+ found.push(...r);
6724
+ if (r.length === 0 && job && typeof a.because === "string" && !considerationTied(a.because, job)) {
6725
+ found.push({
6726
+ severity: "error",
6727
+ at: `${at("primaryAction")}.because`,
6728
+ message: `say how the job's consideration (${job.consideration?.value ?? "not set"}) or one of its objections leads to asking here. Placement evidence is mixed, so the reason is what decides it.`
6729
+ });
6730
+ }
6731
+ count4(found);
6732
+ }
6733
+ const order = Array.isArray(p.order) ? p.order : [];
6734
+ if (order.length < 2) err(`.${page}.order`, "the running order, two sections or more, each with why it sits there");
6735
+ else {
6736
+ const found = [];
6737
+ order.forEach((o, i) => {
6738
+ if (!o || !SECTION_ROLES.includes(o.role)) found.push({ severity: "error", at: `${at("order")}[${i}].role`, message: `a section role: ${SECTION_ROLES.join(", ")}` });
6739
+ found.push(...reason(`${at("order")}[${i}]`, o?.because));
6740
+ });
6741
+ count4(found);
6742
+ }
6743
+ if (!p.lead) err(`.${page}.lead`, "not decided yet");
6744
+ else {
6745
+ const found = [];
6746
+ if (typeof p.lead.value !== "string" || !p.lead.value.trim()) found.push({ severity: "error", at: `${at("lead")}.value`, message: "what the page leads with" });
6747
+ found.push(...reason(at("lead"), p.lead.because));
6748
+ count4(found);
6749
+ }
6750
+ const journey = Array.isArray(p.journey) ? p.journey : [];
6751
+ if (journey.length < 2) err(`.${page}.journey`, "the stages the visitor goes through, two or more, each with why");
6752
+ else {
6753
+ const found = [];
6754
+ journey.forEach((s, i) => {
6755
+ if (!s || typeof s.stage !== "string" || !s.stage.trim()) found.push({ severity: "error", at: `${at("journey")}[${i}].stage`, message: "name the stage" });
6756
+ found.push(...reason(`${at("journey")}[${i}]`, s?.because));
6757
+ });
6758
+ count4(found);
6759
+ }
6760
+ pages.push({ page, decided, total: HIERARCHY_FIELDS.length });
6761
+ }
6762
+ return { problems, pages };
6763
+ }
6764
+ function renderedOrder(snapshot) {
6765
+ const sections = snapshot.sections ?? [];
6766
+ if (sections.length === 0 || !sections.every((s) => s.role)) return null;
6767
+ return sections.map((s) => s.role).filter((r) => r !== "other");
6768
+ }
6769
+ function checkOrder(declared, page, snapshot, strict = false) {
6770
+ if (!declared || !Array.isArray(declared.order)) return [{ severity: strict ? "error" : "warn", at: `hierarchy.${page}`, message: "no declared hierarchy for this page: its running order cannot be checked" }];
6771
+ const rendered = renderedOrder(snapshot);
6772
+ if (!rendered) return [{ severity: "warn", at: `hierarchy.${page}.order`, message: "the snapshot has no section roles (version 1): take a fresh one to check the order" }];
6773
+ const said = declared.order.map((o) => o.role).filter((r) => r !== "other");
6774
+ if (said.join(",") === rendered.join(",")) return [];
6775
+ return [{ severity: strict ? "error" : "warn", at: `hierarchy.${page}.order`, message: `the page runs ${rendered.join(", ")}; the file says ${said.join(", ")}` }];
6776
+ }
6777
+
6778
+ // src/direction/research.ts
6779
+ function tradeOf(brief) {
6780
+ const text = brief.toLowerCase();
6781
+ return TRADE_NOUNS.filter((t) => new RegExp(`\\b${t.replace(/ /g, "\\s+")}s?\\b`).test(text));
6782
+ }
6783
+ function categoryMatchesBrief(category, brief) {
6784
+ const known = tradeOf(category);
6785
+ if (tradeOf(brief).some((t) => known.includes(t))) return true;
6786
+ const label = category.trim().replace(/^(?:(?:a|an|the|local|independent)\s+)+/i, "");
6787
+ if (!label) return false;
6788
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
6789
+ return new RegExp(`\\b${escaped}s?\\b`, "i").test(brief);
6790
+ }
6791
+ function researchPrompt(input) {
6792
+ const trade = tradeOf(input.brief);
6793
+ const category = trade.length ? trade.join(", ") : "this kind of business";
6794
+ const who = input.client ? `${input.client}: ${input.brief}` : input.brief;
6795
+ const lines = [
6796
+ "# Research brief: the customer's job",
6797
+ "",
6798
+ `The business: ${who}`,
6799
+ "",
6800
+ "Find out about the people who hire a business like this, not about the businesses. Answer each question with sources: reviews, forum threads, Q&A sites, complaints, search suggestions. Quote people's own words and link to where they said them.",
6801
+ "",
6802
+ "1. **The situation.** What has just happened, or is about to, when someone starts looking? What pressure are they under: time, money, safety, someone else waiting on them?",
6803
+ '2. **The job.** What are they trying to get done, in one sentence that starts with a verb? What would "done" look like to them?',
6804
+ "3. **Objections.** What do they worry about before getting in touch? What has gone wrong for them, or people they know, before?",
6805
+ "4. **Their words.** The phrases they actually use for the problem and for what they want. Quote them exactly.",
6806
+ "5. **Who else is involved.** Who do they have to explain or justify the choice to (a partner, a landlord, a tenant, a manager), and what would make them look good or bad to that person?",
6807
+ "6. **Consideration.** Do they decide in minutes, days or months? What do they compare before deciding?",
6808
+ "7. **Where they look.** What do they search for, and where do they ask?",
6809
+ "",
6810
+ "## Do not",
6811
+ "",
6812
+ `- Do not describe, list, rank or summarise what ${category} websites look like, or how they are laid out.`,
6813
+ '- Do not suggest colours, fonts, layouts, sections or visual "inspiration" from this category.',
6814
+ "- Do not summarise competitors' marketing. Their claims are the category average; the customer's words are the point.",
6815
+ "- Do not invent quotes. If you cannot find a source for something, say so."
6816
+ ];
6817
+ if (input.visual) {
6818
+ lines.push(
6819
+ "",
6820
+ "# Visual references, from outside the category",
6821
+ "",
6822
+ `Separately, and only from outside ${category}: find things this business already resembles or shares a world with. Print, signage, packaging, tools, vehicles, uniforms, places, materials, other trades and crafts. For each, say what it is, where it is from, and what about it could carry over.`,
6823
+ "",
6824
+ `Nothing from a ${category} website, and nothing from a web design gallery.`
6825
+ );
6826
+ }
6827
+ return `${lines.join("\n")}
6828
+ `;
6829
+ }
6830
+
6831
+ // src/estate/direction.ts
6832
+ function summariseDirection(d) {
6833
+ const c = d.choices ?? {};
6834
+ const home = d.hierarchy?.home;
6835
+ const s = {};
6836
+ if (typeof c.accent?.value === "string" && c.accent.value) s.accent = c.accent.value;
6837
+ if (typeof c.ground?.value === "string" && c.ground.value) s.ground = c.ground.value;
6838
+ if (typeof c.display?.value === "string" && c.display.value) s.display = normaliseFamily(c.display.value);
6839
+ if (typeof c.body?.value === "string" && c.body.value) s.body = normaliseFamily(c.body.value);
6840
+ if (typeof c.shape?.value === "string" && c.shape.value) s.shape = c.shape.value;
6841
+ if (Array.isArray(home?.order) && home.order.length) s.order = home.order.filter((o) => o && typeof o.role === "string").map((o) => o.role);
6842
+ if (typeof home?.primaryAction?.value === "string" && home.primaryAction.value) {
6843
+ s.primaryAction = home.primaryAction.value;
6844
+ s.positions = Array.isArray(home.primaryAction.positions) ? [...home.primaryAction.positions] : [];
6845
+ }
6846
+ return s;
6847
+ }
6848
+ var delta = (a, b) => {
6849
+ const x = a ? parseColour(a) : null;
6850
+ const y = b ? parseColour(b) : null;
6851
+ return x && y ? deltaEOk(x.oklch, y.oklch) : null;
6852
+ };
6853
+ var same = (a, b) => Boolean(a && b && a.trim().toLowerCase() === b.trim().toLowerCase());
6854
+ function compareDirections(a, b) {
6855
+ const shared = [];
6856
+ const accentDelta = delta(a.accent, b.accent);
6857
+ const groundDelta = delta(a.ground, b.ground);
6858
+ if (accentDelta !== null && accentDelta <= ACCENT_SHARED) shared.push(`accent (\u0394E ${accentDelta.toFixed(3)})`);
6859
+ if (groundDelta !== null && groundDelta <= GROUND_SHARED) shared.push(`ground (\u0394E ${groundDelta.toFixed(3)})`);
6860
+ if (a.display && b.display && same(normaliseFamily(a.display), normaliseFamily(b.display))) shared.push(`${normaliseFamily(a.display)} headline`);
6861
+ if (a.body && b.body && same(normaliseFamily(a.body), normaliseFamily(b.body))) shared.push(`${normaliseFamily(a.body)} body`);
6862
+ if (same(a.shape, b.shape)) shared.push(`shape "${a.shape}"`);
6863
+ let orderDistance = null;
6864
+ if (a.order?.length && b.order?.length) {
6865
+ orderDistance = levenshtein(a.order, b.order) / Math.max(a.order.length, b.order.length);
6866
+ if (orderDistance <= SHARED_PART) shared.push(orderDistance === 0 ? "the same section order" : "nearly the same section order");
6867
+ }
6868
+ if (same(a.primaryAction, b.primaryAction) && [...a.positions ?? []].sort().join(",") === [...b.positions ?? []].sort().join(",")) shared.push(`"${a.primaryAction}" asked in the same places`);
6869
+ const surface = shared.filter((s) => /^(?:accent|ground|shape)|headline$/.test(s)).length;
6870
+ const flagged = surface >= 2 || orderDistance !== null && orderDistance <= SHARED_PART;
6871
+ return { shared, accentDelta, groundDelta, orderDistance, flagged };
6872
+ }
6873
+ function compareToDeclared(target2, sites, exclude) {
6874
+ return sites.filter((s) => s.direction && s.id !== exclude).map((s) => ({ id: s.id, ...compareDirections(target2, s.direction) })).sort((x, y) => y.shared.length - x.shared.length || (x.accentDelta ?? 1) - (y.accentDelta ?? 1));
6875
+ }
6876
+ function formatDirectionMatches(label, matches) {
6877
+ const lines = [`${label}: declared directions in the estate (ranked, not judged: no sibling line yet)`];
6878
+ for (const m of matches) lines.push(` ${m.id.padEnd(20)} ${m.flagged ? "LOOK " : " "}${m.shared.length ? `shares ${m.shared.join(", ")}` : "nothing shared"}`);
6879
+ if (matches.length === 0) lines.push(" no site in the register has a declared direction yet: craft estate add --direction <file>");
6880
+ return lines.join("\n");
6881
+ }
6270
6882
 
6271
6883
  // src/direction/licences.ts
6272
6884
  var LICENCES = [
@@ -6380,13 +6992,6 @@ function licencesJson() {
6380
6992
  }
6381
6993
 
6382
6994
  // src/direction/validate.ts
6383
- 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;
6384
- 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;
6385
- 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"]);
6386
- 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"]);
6387
- function words(text) {
6388
- 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)));
6389
- }
6390
6995
  function tellFor(key, value) {
6391
6996
  if (key === "accent") {
6392
6997
  const c = parseColour(value);
@@ -6403,44 +7008,58 @@ function tellFor(key, value) {
6403
7008
  }
6404
7009
  return null;
6405
7010
  }
7011
+ var MIN_TOKENS = 5;
7012
+ var emptyLayers = () => ({ job: false, hierarchy: [], tokens: { decided: 0, total: CHOICE_KEYS.length }, complete: false });
6406
7013
  function validateDirection(input, ctx = {}) {
6407
7014
  const problems = [];
6408
7015
  const err = (at, message) => problems.push({ severity: "error", at, message });
6409
7016
  const warn = (at, message) => problems.push({ severity: "warn", at, message });
6410
7017
  if (!input || typeof input !== "object" || Array.isArray(input)) {
6411
7018
  err("", "art-direction.json must be a JSON object");
6412
- return { valid: false, problems, decided: 0 };
7019
+ return { valid: false, problems, decided: 0, layers: emptyLayers() };
6413
7020
  }
6414
7021
  const d = input;
6415
7022
  const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
6416
7023
  if (legacy) {
6417
7024
  warn("", "exceptions only: run craft direction init to record the choices and their reasons");
6418
- return { valid: true, problems, decided: 0 };
7025
+ return { valid: true, problems, decided: 0, layers: emptyLayers() };
6419
7026
  }
6420
- if (d.version !== DIRECTION_VERSION) err("version", `version must be ${DIRECTION_VERSION}`);
7027
+ if (!DIRECTION_VERSIONS.includes(d.version)) err("version", `version must be ${DIRECTION_VERSIONS.join(" or ")}`);
7028
+ else if (d.version === 1) warn("version", "version 1: the job map and the hierarchy are not recorded, so the site cannot be decided. Move to version 2 (craft direction init --out) and add them.");
6421
7029
  if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
6422
7030
  if (typeof d.brief !== "string" || d.brief.trim().split(/\s+/).length < 8) err("brief", "the brief needs a sentence: who, where, and what they do");
6423
- const sources = Array.isArray(d.sources) ? d.sources : [];
7031
+ const rawSources = Array.isArray(d.sources) ? d.sources : [];
7032
+ const sources = rawSources.filter((s) => Boolean(s && typeof s === "object" && !Array.isArray(s)));
6424
7033
  if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
6425
7034
  if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
6426
7035
  const ids = /* @__PURE__ */ new Set();
6427
- sources.forEach((s, i) => {
7036
+ rawSources.forEach((s, i) => {
6428
7037
  const at = `sources[${i}]`;
6429
- if (!s || typeof s !== "object") return err(at, "a source must be an object");
7038
+ if (!s || typeof s !== "object" || Array.isArray(s)) return err(at, "a source must be an object");
6430
7039
  if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
6431
7040
  else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
6432
7041
  else ids.add(s.id);
6433
7042
  if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
6434
7043
  if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
6435
- if (s.path && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
6436
- for (const [j, c] of (s.colours ?? []).entries()) if (!parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
7044
+ if (s.path !== void 0 && typeof s.path !== "string") err(`${at}.path`, "a path must be text");
7045
+ if (typeof s.path === "string" && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
7046
+ if (s.colours !== void 0 && !Array.isArray(s.colours)) err(`${at}.colours`, "colours must be a list");
7047
+ for (const [j, c] of (Array.isArray(s.colours) ? s.colours : []).entries()) if (typeof c !== "string" || !parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
7048
+ if (s.kind === "reference") {
7049
+ if (typeof s.category !== "string" || !s.category.trim()) err(`${at}.category`, "say where the reference comes from: print, a place, another trade");
7050
+ else if (/\b(?:web ?sites?|web design|competitors?|dribbble|behance|awwwards|themeforest)\b/i.test(s.category) || categoryMatchesBrief(s.category, typeof d.brief === "string" ? d.brief : "")) {
7051
+ err(`${at}.category`, `"${s.category}" is inside the client's category. A reference must come from outside it, or it hands back the category average.`);
7052
+ }
7053
+ if (typeof s.path === "string" && /^https?:/.test(s.path)) warn(`${at}.path`, "a web page as a reference: check it is not a site in the client's category");
7054
+ }
7055
+ if (/\bcompetitor(?:s|['’]s)?\b/i.test(`${s.note} ${s.category ?? ""}`)) err(at, "a source cannot be a competitor's website; use customer voice or a reference from outside the category");
6437
7056
  });
6438
7057
  const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
6439
7058
  if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
6440
7059
  const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
6441
7060
  let decided = 0;
6442
7061
  for (const key of Object.keys(choices)) {
6443
- if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Layout and navigation stay conventional.`);
7062
+ if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Record section order and the primary action in hierarchy.`);
6444
7063
  }
6445
7064
  for (const key of CHOICE_KEYS) {
6446
7065
  const c = choices[key];
@@ -6457,44 +7076,9 @@ function validateDirection(input, ctx = {}) {
6457
7076
  err(`${at}.value`, `"${c.value}" is not a colour`);
6458
7077
  ok = false;
6459
7078
  }
6460
- const because = typeof c.because === "string" ? c.because.trim() : "";
6461
- const evidence = Array.isArray(c.evidence) ? c.evidence : [];
6462
- if (because.split(/\s+/).filter(Boolean).length < 8) {
6463
- err(`${at}.because`, "a reason is at least a sentence. Without one this is a default, however good it looks.");
6464
- ok = false;
6465
- } else if (because.startsWith("PROPOSED:")) {
6466
- err(`${at}.because`, "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
6467
- ok = false;
6468
- } else {
6469
- if (PREFERENCE.test(because)) {
6470
- err(`${at}.because`, "that is a preference, not a reason. Say what in the client's world this comes from.");
6471
- ok = false;
6472
- }
6473
- const moods = because.match(MOOD) ?? [];
6474
- if (moods.length >= 2) {
6475
- err(`${at}.because`, `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
6476
- ok = false;
6477
- }
6478
- }
6479
- if (evidence.length === 0) {
6480
- err(`${at}.evidence`, "cite at least one source");
6481
- ok = false;
6482
- }
6483
- for (const id of evidence) {
6484
- if (!ids.has(id)) {
6485
- err(`${at}.evidence`, `no source called "${id}"`);
6486
- ok = false;
6487
- }
6488
- }
6489
- if (ok) {
6490
- const cited = sources.filter((s) => evidence.includes(s.id));
6491
- const reasonWords = words(because);
6492
- const tied = cited.some((s) => reasonWords.has(s.id) || [...words(`${s.note} ${s.lettering ?? ""}`)].some((w) => reasonWords.has(w)));
6493
- if (!tied) {
6494
- err(`${at}.because`, `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
6495
- ok = false;
6496
- }
6497
- }
7079
+ const reasons = checkReason({ at, because: c.because, evidence: c.evidence, sources, tie: "source" });
7080
+ problems.push(...reasons);
7081
+ if (reasons.length) ok = false;
6498
7082
  if (typeof c.value === "string") {
6499
7083
  const tell = tellFor(key, c.value);
6500
7084
  if (tell && !exceptions.some((e) => e.tell === tell)) {
@@ -6530,7 +7114,33 @@ function validateDirection(input, ctx = {}) {
6530
7114
  }
6531
7115
  if (ok) decided += 1;
6532
7116
  }
6533
- return { valid: problems.every((p) => p.severity !== "error"), problems, decided };
7117
+ let job = false;
7118
+ let hierarchy = [];
7119
+ if (d.version === 2) {
7120
+ const j = validateJob(d.job, sources, { brief: typeof d.brief === "string" ? d.brief : "", readSource: ctx.readSource });
7121
+ problems.push(...j.problems);
7122
+ job = j.decided && d.job !== void 0;
7123
+ const h = validateHierarchy(d.hierarchy, d.job, sources);
7124
+ problems.push(...h.problems);
7125
+ hierarchy = h.pages;
7126
+ if (ctx.snapshot) problems.push(...checkOrder(d.hierarchy?.[ctx.page ?? "home"], ctx.page ?? "home", ctx.snapshot, ctx.strict));
7127
+ }
7128
+ if (ctx.estate?.length) {
7129
+ for (const m of compareToDeclared(summariseDirection(d), ctx.estate, ctx.estateId)) {
7130
+ if (m.flagged) warn("", `close to ${m.id}'s declared direction: shares ${m.shared.join(", ")}. Decide which of these should differ before either site is built.`);
7131
+ }
7132
+ }
7133
+ if (ctx.strict) {
7134
+ for (const p of problems) if (p.severity === "warn" && /the page (?:does not show|sets|runs)/.test(p.message)) p.severity = "error";
7135
+ }
7136
+ const home = hierarchy.find((p) => p.page === "home");
7137
+ const complete = job && Boolean(home) && hierarchy.every((p) => p.decided === p.total) && decided >= MIN_TOKENS;
7138
+ return {
7139
+ valid: problems.every((p) => p.severity !== "error"),
7140
+ problems,
7141
+ decided,
7142
+ layers: { job, hierarchy, tokens: { decided, total: CHOICE_KEYS.length }, complete: complete && problems.every((p) => p.severity !== "error") }
7143
+ };
6534
7144
  }
6535
7145
 
6536
7146
  // src/direction/init.ts
@@ -6549,11 +7159,31 @@ function initDirection(input) {
6549
7159
  body: empty(fp.body.family),
6550
7160
  ...fp.roundness !== null ? { shape: empty(fp.roundness >= 0.45 ? "pill buttons" : fp.roundness <= 0.05 ? "square corners" : `corners at ${Math.round(fp.roundness * 100)}% of button height`) } : {}
6551
7161
  } : {};
7162
+ const part = () => ({ value: "", evidence: [] });
6552
7163
  return {
6553
7164
  $schema: "https://unpkg.com/@domandigital/craft/art-direction.schema.json",
6554
7165
  version: DIRECTION_VERSION,
6555
7166
  client: input.client ?? "",
6556
7167
  brief: input.brief ?? "",
7168
+ // Empty on purpose, like the reasons: the first validate lists the job and the structure as undecided.
7169
+ job: {
7170
+ statement: { verb: "", object: "", context: "" },
7171
+ functional: part(),
7172
+ emotional: part(),
7173
+ social: part(),
7174
+ consideration: { value: "low", because: "", evidence: [] },
7175
+ objections: [],
7176
+ language: [],
7177
+ evidence: []
7178
+ },
7179
+ hierarchy: {
7180
+ home: {
7181
+ primaryAction: { value: "", positions: [], because: "" },
7182
+ order: fp?.sections?.length ? fp.sections.map((s) => ({ role: s.role, because: "" })) : [],
7183
+ lead: { value: "", because: "" },
7184
+ journey: []
7185
+ }
7186
+ },
6557
7187
  sources: [],
6558
7188
  choices,
6559
7189
  ...input.exceptions?.length ? { exceptions: input.exceptions } : {}
@@ -6561,7 +7191,6 @@ function initDirection(input) {
6561
7191
  }
6562
7192
 
6563
7193
  // src/direction/propose.ts
6564
- var PROPOSED = "PROPOSED:";
6565
7194
  function srgbToLinear2(c) {
6566
7195
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
6567
7196
  }
@@ -6623,7 +7252,13 @@ function accentCandidates(input) {
6623
7252
  const parsed = parseColour(c);
6624
7253
  if (!parsed || !chromatic(parsed.oklch)) continue;
6625
7254
  const o = parsed.oklch;
6626
- const siblings = (input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 }));
7255
+ const siblings = [
7256
+ ...(input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 })),
7257
+ ...(input.declared ?? []).flatMap((e) => {
7258
+ const p = parseColour(e.accent);
7259
+ return p ? [{ id: `${e.id} (declared)`, d: deltaEOk(o, p.oklch) }] : [];
7260
+ })
7261
+ ];
6627
7262
  const nearest2 = siblings.sort((a, b) => a.d - b.d)[0];
6628
7263
  const penalty = isAiViolet(o) ? 1 : 0;
6629
7264
  out.push({
@@ -6690,7 +7325,7 @@ function addSite(register, site) {
6690
7325
  }
6691
7326
  function sharedParts(a, b, d = fingerprintDistance(a, b)) {
6692
7327
  const out = [];
6693
- const close = (k) => d.parts[k] <= 0.2;
7328
+ const close = (k) => d.parts[k] <= SHARED_PART;
6694
7329
  if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
6695
7330
  if (close("type")) {
6696
7331
  if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
@@ -6705,14 +7340,14 @@ function sharedParts(a, b, d = fingerprintDistance(a, b)) {
6705
7340
  }
6706
7341
  function compareToEstate(target2, register, options = {}) {
6707
7342
  const at = options.siblingAt ?? SIBLING_AT;
6708
- return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
7343
+ return register.sites.filter((s) => s.id !== options.exclude && s.fingerprint).map((s) => {
6709
7344
  const d = fingerprintDistance(target2, s.fingerprint);
6710
7345
  return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target2, s.fingerprint, d) };
6711
7346
  }).sort((a, b) => a.distance - b.distance);
6712
7347
  }
6713
7348
  function estatePairs(register, siblingAt = SIBLING_AT) {
6714
7349
  const out = [];
6715
- const s = register.sites;
7350
+ const s = register.sites.filter((site) => site.fingerprint);
6716
7351
  for (let i = 0; i < s.length; i += 1) {
6717
7352
  for (let j = i + 1; j < s.length; j += 1) {
6718
7353
  const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
@@ -6832,8 +7467,16 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
6832
7467
  }
6833
7468
  if (input.direction) {
6834
7469
  const d = input.direction;
6835
- const raised2 = !d.valid || d.decided < MIN_DECIDED;
6836
- signals.push({ id: "reasons", raised: raised2, detail: `${d.decided} of 7 choices decided with a reason${d.valid ? "" : ", art-direction.json has errors"}` });
7470
+ const layers = d.layers;
7471
+ const raised2 = !d.valid || d.decided < MIN_DECIDED || !layers.complete;
7472
+ const pages = layers.hierarchy.map((p) => `${p.page} ${p.decided} of ${p.total}`).join(", ");
7473
+ const detail = [
7474
+ `job ${layers.job ? "decided" : "not decided"}`,
7475
+ `hierarchy ${pages || "not decided"}`,
7476
+ `${d.decided} of 7 choices decided with a reason`
7477
+ ].join("; ");
7478
+ signals.push({ id: "reasons", raised: raised2, detail: `${detail}${d.valid ? "" : ", art-direction.json has errors"}` });
7479
+ if (!layers.job) actions.push({ area: "direction", what: "Write the job map: what the visitor is trying to get done, from reviews, enquiries and conversations. Run craft direction research for the questions.", why: "structure and look are decided from the job", priority: 1 });
6837
7480
  for (const p of d.problems) {
6838
7481
  const key = p.at.match(/^choices\.(\w+)/)?.[1];
6839
7482
  if (p.severity === "error" || p.message === "not decided yet") {
@@ -6851,11 +7494,11 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
6851
7494
  const order = ["direction", "type", "colour", "shape", "effects", "motion", "layout", "copy"];
6852
7495
  const merged = [];
6853
7496
  for (const a of actions) {
6854
- const same = a.choice && a.area !== "direction" && a.area !== "copy" ? merged.find((m) => m.choice === a.choice && m.area === a.area) : void 0;
6855
- if (same) {
7497
+ const same2 = a.choice && a.area !== "direction" && a.area !== "copy" ? merged.find((m) => m.choice === a.choice && m.area === a.area) : void 0;
7498
+ if (same2) {
6856
7499
  const fromTell = /\(\S+, gen \d\)/.test(a.why);
6857
- same.why = `${same.why}; ${fromTell ? a.why : `${a.what.replace(/\.$/, "")} (${a.why})`}`;
6858
- same.priority = Math.min(same.priority, a.priority);
7500
+ same2.why = `${same2.why}; ${fromTell ? a.why : `${a.what.replace(/\.$/, "")} (${a.why})`}`;
7501
+ same2.priority = Math.min(same2.priority, a.priority);
6859
7502
  } else merged.push({ ...a });
6860
7503
  }
6861
7504
  const unique2 = merged.filter((a, i) => merged.findIndex((b) => b.area === a.area && b.what === a.what) === i);
@@ -6932,6 +7575,8 @@ function retrofitPlan(report2, direction) {
6932
7575
  return lines.join("\n");
6933
7576
  }
6934
7577
  export {
7578
+ ACCENT_SCALE,
7579
+ ACCENT_SHARED,
6935
7580
  AI_PHRASES,
6936
7581
  AI_SOURCE_TYPES,
6937
7582
  AI_VIOLET,
@@ -6942,10 +7587,14 @@ export {
6942
7587
  CATALOGUE_VERSION,
6943
7588
  CHOICE_KEYS,
6944
7589
  COMPONENT_ROLES,
7590
+ CONSIDERATION,
7591
+ CONVENTION,
7592
+ CUSTOMER_KINDS,
6945
7593
  DEFAULT_IGNORE,
6946
7594
  DENSITIES,
6947
7595
  DENSITY_NAMES,
6948
7596
  DIRECTION_VERSION,
7597
+ DIRECTION_VERSIONS,
6949
7598
  DISTANCE_WEIGHTS,
6950
7599
  DURATION_MS,
6951
7600
  DURATION_S,
@@ -6955,6 +7604,10 @@ export {
6955
7604
  ESTATE_VERSION,
6956
7605
  EXIT_RATIO,
6957
7606
  FINGERPRINT_VERSION,
7607
+ FUNCTION_WORDS,
7608
+ GROUND_SCALE,
7609
+ GROUND_SHARED,
7610
+ HIERARCHY_FIELDS,
6958
7611
  HOME_FILE,
6959
7612
  HOME_URL,
6960
7613
  HOUSE,
@@ -6967,10 +7620,13 @@ export {
6967
7620
  LIGHTNESS_CURVE,
6968
7621
  MIN_DECIDED,
6969
7622
  MIN_RUNS,
7623
+ MOOD,
6970
7624
  NEGATIVE_REASSURANCE,
6971
7625
  NULL_VERSION,
7626
+ PAGE_TYPES,
6972
7627
  PILL_LIMIT,
6973
7628
  PLAINER_WORDS,
7629
+ PREFERENCE,
6974
7630
  PROMO_BADGE,
6975
7631
  PROOF_NAMES,
6976
7632
  PROPOSED,
@@ -6984,8 +7640,10 @@ export {
6984
7640
  REVEAL_LIMIT,
6985
7641
  REVIEW_PHRASES,
6986
7642
  SCALE,
7643
+ SECTION_ROLES,
6987
7644
  SHADCN_LIMIT,
6988
7645
  SHADCN_PRIMITIVES,
7646
+ SHARED_PART,
6989
7647
  SIBLING_AT,
6990
7648
  SNAPSHOT_VERSION,
6991
7649
  SOURCE_KINDS,
@@ -7014,21 +7672,28 @@ export {
7014
7672
  catalogueTable,
7015
7673
  characterReport,
7016
7674
  checkCopy,
7675
+ checkOrder,
7017
7676
  checkPair,
7677
+ checkReason,
7018
7678
  checkRestraint,
7019
7679
  choicesOf,
7680
+ clauseDepth,
7020
7681
  closestComponents,
7021
7682
  compareCompetitor,
7683
+ compareDirections,
7022
7684
  compareFacts,
7685
+ compareToDeclared,
7023
7686
  compareToEstate,
7024
7687
  componentDistance,
7025
7688
  componentShared,
7026
7689
  componentTypicalities,
7027
7690
  componentTypicality,
7028
7691
  componentsOf,
7692
+ contentWords,
7029
7693
  countWords2 as countWords,
7030
7694
  craftTokens,
7031
7695
  createBaseline,
7696
+ cv,
7032
7697
  deltaEOk,
7033
7698
  deltaEOkHex,
7034
7699
  densityCss,
@@ -7052,11 +7717,14 @@ export {
7052
7717
  formatClaims,
7053
7718
  formatComparison,
7054
7719
  formatCompetitor,
7720
+ formatDirectionMatches,
7055
7721
  formatHex,
7056
7722
  formatReport,
7057
7723
  globToRegExp,
7058
7724
  groundName,
7059
7725
  harvest,
7726
+ hedgeCount,
7727
+ hedgedClose,
7060
7728
  hexToOklch,
7061
7729
  holdHouseBlocks,
7062
7730
  houseRule,
@@ -7067,28 +7735,37 @@ export {
7067
7735
  inSrgbGamut,
7068
7736
  initDirection,
7069
7737
  isAiViolet,
7738
+ isBlurb,
7739
+ isCategoryLabel,
7070
7740
  isCream,
7741
+ jobWords,
7071
7742
  layoutDistance,
7072
7743
  licencesJson,
7073
7744
  makeSnapshot,
7074
7745
  motionTokens,
7075
7746
  namedSources,
7076
7747
  nearest,
7748
+ nominalisations,
7077
7749
  normaliseFamily,
7078
7750
  nullPrompt,
7079
7751
  oklchToHex,
7080
7752
  oklchToRgb,
7081
7753
  pageCopy,
7082
7754
  paletteFromPixels,
7755
+ paragraphUniformity,
7083
7756
  parseBaseline,
7084
7757
  parseColour,
7085
7758
  parseCraftConfig,
7086
7759
  parseHex,
7760
+ participialClauses,
7761
+ phrasalCoordinations,
7087
7762
  propose,
7088
7763
  protectedFacts,
7089
7764
  ramp,
7090
7765
  rampFromAnchors,
7091
7766
  readSnapshot,
7767
+ renderedOrder,
7768
+ researchPrompt,
7092
7769
  retrofitPlan,
7093
7770
  runTell,
7094
7771
  scanProvenance,
@@ -7097,24 +7774,36 @@ export {
7097
7774
  semantic,
7098
7775
  sentencesOf,
7099
7776
  severityExceptions,
7777
+ shapeMoves,
7100
7778
  shapeName,
7779
+ shapeOf,
7780
+ words as shapeWords,
7101
7781
  sharedParts,
7102
7782
  shouldAnimate,
7103
7783
  siblingLine,
7784
+ skeleton,
7785
+ skeletonRepeat,
7104
7786
  specificity,
7105
7787
  specifics,
7788
+ splitSentences,
7789
+ stackedConditional,
7106
7790
  stockAvatarSource,
7107
7791
  stockSource,
7792
+ summariseDirection,
7108
7793
  tailwindV3Preset,
7109
7794
  tailwindV4Theme,
7110
7795
  tellById,
7111
7796
  toGamut,
7112
7797
  toJson,
7113
7798
  toSarif,
7799
+ tradeOf,
7800
+ tricolons,
7114
7801
  typeFeatureTokens,
7115
7802
  typicality,
7116
7803
  unprovenClaims,
7117
7804
  validateDirection,
7805
+ validateHierarchy,
7806
+ validateJob,
7118
7807
  visibleText,
7119
7808
  visualMeasures,
7120
7809
  wcagContrast,