@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
@@ -0,0 +1,711 @@
1
+ import {
2
+ ACCENT_SHARED,
3
+ GROUND_SHARED,
4
+ REFLEX_FONTS_1,
5
+ REFLEX_FONTS_2,
6
+ SHARED_PART,
7
+ TRADE_NOUNS,
8
+ deltaEOk,
9
+ isAiViolet,
10
+ isCream,
11
+ levenshtein,
12
+ normaliseFamily,
13
+ parseColour
14
+ } from "./chunk-LFNSB6W7.js";
15
+
16
+ // src/direction/types.ts
17
+ var DIRECTION_VERSION = 2;
18
+ var DIRECTION_VERSIONS = [1, 2];
19
+ var CUSTOMER_KINDS = ["review", "enquiry", "conversation", "search"];
20
+ var SOURCE_KINDS = [
21
+ "livery",
22
+ "shopfront",
23
+ "signage",
24
+ "packaging",
25
+ "product",
26
+ "uniform",
27
+ "interior",
28
+ "place",
29
+ "material",
30
+ "trade",
31
+ "print",
32
+ "photo",
33
+ "founder",
34
+ ...CUSTOMER_KINDS,
35
+ "reference"
36
+ ];
37
+ var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
38
+ var CONSIDERATION = ["low", "considered", "high"];
39
+ var PAGE_TYPES = ["home", "service", "about", "pricing", "booking", "contact", "article", "landing"];
40
+ var HIERARCHY_FIELDS = ["primaryAction", "order", "lead", "journey"];
41
+
42
+ // src/direction/research.ts
43
+ function tradeOf(brief) {
44
+ const text = brief.toLowerCase();
45
+ return TRADE_NOUNS.filter((t) => new RegExp(`\\b${t.replace(/ /g, "\\s+")}s?\\b`).test(text));
46
+ }
47
+ function categoryMatchesBrief(category, brief) {
48
+ const known = tradeOf(category);
49
+ if (tradeOf(brief).some((t) => known.includes(t))) return true;
50
+ const label = category.trim().replace(/^(?:(?:a|an|the|local|independent)\s+)+/i, "");
51
+ if (!label) return false;
52
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
53
+ return new RegExp(`\\b${escaped}s?\\b`, "i").test(brief);
54
+ }
55
+ function researchPrompt(input) {
56
+ const trade = tradeOf(input.brief);
57
+ const category = trade.length ? trade.join(", ") : "this kind of business";
58
+ const who = input.client ? `${input.client}: ${input.brief}` : input.brief;
59
+ const lines = [
60
+ "# Research brief: the customer's job",
61
+ "",
62
+ `The business: ${who}`,
63
+ "",
64
+ "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.",
65
+ "",
66
+ "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?",
67
+ '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?',
68
+ "3. **Objections.** What do they worry about before getting in touch? What has gone wrong for them, or people they know, before?",
69
+ "4. **Their words.** The phrases they actually use for the problem and for what they want. Quote them exactly.",
70
+ "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?",
71
+ "6. **Consideration.** Do they decide in minutes, days or months? What do they compare before deciding?",
72
+ "7. **Where they look.** What do they search for, and where do they ask?",
73
+ "",
74
+ "## Do not",
75
+ "",
76
+ `- Do not describe, list, rank or summarise what ${category} websites look like, or how they are laid out.`,
77
+ '- Do not suggest colours, fonts, layouts, sections or visual "inspiration" from this category.',
78
+ "- Do not summarise competitors' marketing. Their claims are the category average; the customer's words are the point.",
79
+ "- Do not invent quotes. If you cannot find a source for something, say so."
80
+ ];
81
+ if (input.visual) {
82
+ lines.push(
83
+ "",
84
+ "# Visual references, from outside the category",
85
+ "",
86
+ `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.`,
87
+ "",
88
+ `Nothing from a ${category} website, and nothing from a web design gallery.`
89
+ );
90
+ }
91
+ return `${lines.join("\n")}
92
+ `;
93
+ }
94
+
95
+ // src/estate/direction.ts
96
+ function summariseDirection(d) {
97
+ const c = d.choices ?? {};
98
+ const home = d.hierarchy?.home;
99
+ const s = {};
100
+ if (typeof c.accent?.value === "string" && c.accent.value) s.accent = c.accent.value;
101
+ if (typeof c.ground?.value === "string" && c.ground.value) s.ground = c.ground.value;
102
+ if (typeof c.display?.value === "string" && c.display.value) s.display = normaliseFamily(c.display.value);
103
+ if (typeof c.body?.value === "string" && c.body.value) s.body = normaliseFamily(c.body.value);
104
+ if (typeof c.shape?.value === "string" && c.shape.value) s.shape = c.shape.value;
105
+ if (Array.isArray(home?.order) && home.order.length) s.order = home.order.filter((o) => o && typeof o.role === "string").map((o) => o.role);
106
+ if (typeof home?.primaryAction?.value === "string" && home.primaryAction.value) {
107
+ s.primaryAction = home.primaryAction.value;
108
+ s.positions = Array.isArray(home.primaryAction.positions) ? [...home.primaryAction.positions] : [];
109
+ }
110
+ return s;
111
+ }
112
+ var delta = (a, b) => {
113
+ const x = a ? parseColour(a) : null;
114
+ const y = b ? parseColour(b) : null;
115
+ return x && y ? deltaEOk(x.oklch, y.oklch) : null;
116
+ };
117
+ var same = (a, b) => Boolean(a && b && a.trim().toLowerCase() === b.trim().toLowerCase());
118
+ function compareDirections(a, b) {
119
+ const shared = [];
120
+ const accentDelta = delta(a.accent, b.accent);
121
+ const groundDelta = delta(a.ground, b.ground);
122
+ if (accentDelta !== null && accentDelta <= ACCENT_SHARED) shared.push(`accent (\u0394E ${accentDelta.toFixed(3)})`);
123
+ if (groundDelta !== null && groundDelta <= GROUND_SHARED) shared.push(`ground (\u0394E ${groundDelta.toFixed(3)})`);
124
+ if (a.display && b.display && same(normaliseFamily(a.display), normaliseFamily(b.display))) shared.push(`${normaliseFamily(a.display)} headline`);
125
+ if (a.body && b.body && same(normaliseFamily(a.body), normaliseFamily(b.body))) shared.push(`${normaliseFamily(a.body)} body`);
126
+ if (same(a.shape, b.shape)) shared.push(`shape "${a.shape}"`);
127
+ let orderDistance = null;
128
+ if (a.order?.length && b.order?.length) {
129
+ orderDistance = levenshtein(a.order, b.order) / Math.max(a.order.length, b.order.length);
130
+ if (orderDistance <= SHARED_PART) shared.push(orderDistance === 0 ? "the same section order" : "nearly the same section order");
131
+ }
132
+ if (same(a.primaryAction, b.primaryAction) && [...a.positions ?? []].sort().join(",") === [...b.positions ?? []].sort().join(",")) shared.push(`"${a.primaryAction}" asked in the same places`);
133
+ const surface = shared.filter((s) => /^(?:accent|ground|shape)|headline$/.test(s)).length;
134
+ const flagged = surface >= 2 || orderDistance !== null && orderDistance <= SHARED_PART;
135
+ return { shared, accentDelta, groundDelta, orderDistance, flagged };
136
+ }
137
+ function compareToDeclared(target, sites, exclude) {
138
+ return sites.filter((s) => s.direction && s.id !== exclude).map((s) => ({ id: s.id, ...compareDirections(target, s.direction) })).sort((x, y) => y.shared.length - x.shared.length || (x.accentDelta ?? 1) - (y.accentDelta ?? 1));
139
+ }
140
+ function formatDirectionMatches(label, matches) {
141
+ const lines = [`${label}: declared directions in the estate (ranked, not judged: no sibling line yet)`];
142
+ for (const m of matches) lines.push(` ${m.id.padEnd(20)} ${m.flagged ? "LOOK " : " "}${m.shared.length ? `shares ${m.shared.join(", ")}` : "nothing shared"}`);
143
+ if (matches.length === 0) lines.push(" no site in the register has a declared direction yet: craft estate add --direction <file>");
144
+ return lines.join("\n");
145
+ }
146
+
147
+ // src/direction/reason.ts
148
+ var PROPOSED = "PROPOSED:";
149
+ 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;
150
+ 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;
151
+ var CONVENTION = new RegExp(
152
+ [
153
+ // authority
154
+ String.raw`industry[- ]standard`,
155
+ String.raw`best[- ]practices?`,
156
+ String.raw`the norm\b`,
157
+ String.raw`standard (?:layout|structure|practice|pattern|format)`,
158
+ String.raw`tried[- ]and[- ]tested`,
159
+ String.raw`proven (?:layout|structure|pattern|format)`,
160
+ // the crowd
161
+ String.raw`(?:most|other|typical|many|all|similar)\s+(?:\w+\s+){0,2}(?:web)?sites\b`,
162
+ String.raw`competitors?(?:'|’)?s?\b`,
163
+ String.raw`(?:like|as) (?:everyone|everybody|all the others|the rest)\b`,
164
+ String.raw`commonly used`,
165
+ String.raw`(?:what|how) (?:everyone|others|the industry) (?:does|do|uses?)`,
166
+ // familiarity
167
+ String.raw`familiar (?:layout|structure|pattern|format|order|shape|design)`,
168
+ String.raw`recogni[sz]able (?:layout|structure|pattern|format|order)`,
169
+ String.raw`conventional(?:ly)?\b`,
170
+ String.raw`(?:visitors|users|people|customers|anyone) (?:are|is) used to`,
171
+ String.raw`feels? (?:at home|comfortable|familiar)(?: with)?(?: the)? (?:layout|site|page|structure|design)?`,
172
+ String.raw`intuitive(?:ly)?\b`,
173
+ String.raw`as usual\b`,
174
+ String.raw`what (?:visitors|users|people) expect\b`
175
+ ].join("|"),
176
+ "i"
177
+ );
178
+ var GENERIC = new Set(
179
+ "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(" ")
180
+ );
181
+ var STOP = /* @__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"]);
182
+ 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"]);
183
+ function contentWords(text) {
184
+ return new Set(
185
+ (text.toLowerCase().match(/[a-z][a-z'’-]{3,}/g) ?? []).map((w) => w.replace(/['’]s$/, "").replace(/['’]/g, "")).filter((w) => !STOP.has(w) && !VALUE_WORDS.has(w) && !GENERIC.has(w))
186
+ );
187
+ }
188
+ var stem = (w) => w.replace(/(?:ing|ers|er|ed|es|s)$/, "");
189
+ function shares(a, b) {
190
+ const bs = new Set([...b].map(stem));
191
+ return [...a].filter((w) => bs.has(stem(w)));
192
+ }
193
+ function jobWords(job) {
194
+ return contentWords(
195
+ [
196
+ job.statement?.verb,
197
+ job.statement?.object,
198
+ job.statement?.context,
199
+ job.functional?.value,
200
+ job.emotional?.value,
201
+ job.social?.value,
202
+ ...Array.isArray(job.objections) ? job.objections : [],
203
+ ...Array.isArray(job.language) ? job.language : []
204
+ ].filter((x) => typeof x === "string").join(" ")
205
+ );
206
+ }
207
+ var isCustomerVoice = (s) => Boolean(s && CUSTOMER_KINDS.includes(s.kind));
208
+ function checkReason(input) {
209
+ const problems = [];
210
+ const err = (field, message) => problems.push({ severity: "error", at: `${input.at}.${field}`, message });
211
+ const because = typeof input.because === "string" ? input.because.trim() : "";
212
+ const evidence = Array.isArray(input.evidence) ? input.evidence.filter((e) => typeof e === "string") : [];
213
+ 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");
214
+ const ids = new Map(input.sources.filter((s) => s && typeof s.id === "string").map((s) => [s.id, s]));
215
+ if (because.split(/\s+/).filter(Boolean).length < 8) {
216
+ err("because", "a reason is at least a sentence. Without one this is a default, however good it looks.");
217
+ return problems;
218
+ }
219
+ if (because.startsWith(PROPOSED)) {
220
+ err("because", "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
221
+ return problems;
222
+ }
223
+ if (PREFERENCE.test(because)) err("because", "that is a preference, not a reason. Say what in the client's world this comes from.");
224
+ const moods = because.match(MOOD) ?? [];
225
+ if (moods.length >= 2) err("because", `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
226
+ const convention = because.match(CONVENTION);
227
+ if (convention) {
228
+ err("because", `"${convention[0]}" is the category's default speaking, not a reason. Say what in this client's job or world it answers.`);
229
+ }
230
+ const needsEvidence = input.tie !== "job";
231
+ if (needsEvidence && evidence.length === 0) err("evidence", "cite at least one source");
232
+ for (const id of evidence) if (!ids.has(id)) err("evidence", `no source called "${id}"`);
233
+ if (input.tie === "customer") {
234
+ for (const id of evidence) {
235
+ 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(", ")}.`);
236
+ }
237
+ }
238
+ if (input.tie === "source") {
239
+ for (const id of evidence) {
240
+ if (isCustomerVoice(ids.get(id))) err("evidence", `"${id}" is customer voice. Token decisions need a physical source or an outside-category reference.`);
241
+ }
242
+ }
243
+ if (problems.length) return problems;
244
+ const words2 = contentWords(because);
245
+ if (input.tie === "job") {
246
+ if (!input.job) {
247
+ err("because", "a structural reason is checked against the job, and the file has no job map yet");
248
+ } else if (shares(words2, jobWords(input.job)).length === 0) {
249
+ 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.");
250
+ }
251
+ } else {
252
+ const cited = evidence.map((id) => ids.get(id));
253
+ const tied = cited.some((s) => words2.has(s.id) || shares(words2, contentWords(`${s.note} ${s.lettering ?? ""}`)).length > 0);
254
+ if (!tied) err("because", `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
255
+ }
256
+ return problems;
257
+ }
258
+
259
+ // src/direction/hierarchy.ts
260
+ var SECTION_ROLES = [
261
+ "hero",
262
+ "logos",
263
+ "stats",
264
+ "cards",
265
+ "marquee",
266
+ "text",
267
+ "other",
268
+ "cta-band",
269
+ "footer-cta",
270
+ "pricing",
271
+ "testimonials",
272
+ "faq",
273
+ "process",
274
+ "features",
275
+ "team",
276
+ "contact"
277
+ ];
278
+ var POSITIONS = /* @__PURE__ */ new Set([...SECTION_ROLES, "header", "sticky"]);
279
+ function considerationTied(because, job) {
280
+ const text = because.toLowerCase();
281
+ const level = job.consideration?.value;
282
+ if (level && new RegExp(`\\b${level}\\b`).test(text)) return true;
283
+ const said = contentWords(because);
284
+ return (Array.isArray(job.objections) ? job.objections : []).some((o) => typeof o === "string" && [...contentWords(o)].some((w) => said.has(w)));
285
+ }
286
+ function validateHierarchy(hierarchy, job, sources) {
287
+ const problems = [];
288
+ const err = (at, message) => problems.push({ severity: "error", at: `hierarchy${at}`, message });
289
+ const pages = [];
290
+ if (!hierarchy || typeof hierarchy !== "object" || Array.isArray(hierarchy)) {
291
+ err("", "no hierarchy: decide what the home page leads with, where it asks, and the order, each from the job");
292
+ return { problems, pages };
293
+ }
294
+ const h = hierarchy;
295
+ 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(", ")}`);
296
+ if (!h.home) err(".home", "no home page hierarchy: the home page is where most visitors start");
297
+ for (const page of PAGE_TYPES) {
298
+ const p = h[page];
299
+ if (!p) continue;
300
+ let decided = 0;
301
+ const at = (f) => `hierarchy.${page}.${f}`;
302
+ const reason = (where, because, evidence) => checkReason({ at: where, because, evidence, sources, tie: "job", job });
303
+ const count = (found) => {
304
+ problems.push(...found);
305
+ if (!found.some((x) => x.severity === "error")) decided += 1;
306
+ };
307
+ if (!p.primaryAction) err(`.${page}.primaryAction`, "not decided yet");
308
+ else {
309
+ const a = p.primaryAction;
310
+ const found = [];
311
+ if (typeof a.value !== "string" || !a.value.trim()) found.push({ severity: "error", at: `${at("primaryAction")}.value`, message: "what the visitor is asked to do" });
312
+ const positions = Array.isArray(a.positions) ? a.positions : [];
313
+ if (positions.length === 0) found.push({ severity: "error", at: `${at("primaryAction")}.positions`, message: 'where the ask sits: one place, or several ("hero", "footer-cta")' });
314
+ 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"` });
315
+ const r = reason(at("primaryAction"), a.because, a.evidence);
316
+ found.push(...r);
317
+ if (r.length === 0 && job && typeof a.because === "string" && !considerationTied(a.because, job)) {
318
+ found.push({
319
+ severity: "error",
320
+ at: `${at("primaryAction")}.because`,
321
+ 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.`
322
+ });
323
+ }
324
+ count(found);
325
+ }
326
+ const order = Array.isArray(p.order) ? p.order : [];
327
+ if (order.length < 2) err(`.${page}.order`, "the running order, two sections or more, each with why it sits there");
328
+ else {
329
+ const found = [];
330
+ order.forEach((o, i) => {
331
+ if (!o || !SECTION_ROLES.includes(o.role)) found.push({ severity: "error", at: `${at("order")}[${i}].role`, message: `a section role: ${SECTION_ROLES.join(", ")}` });
332
+ found.push(...reason(`${at("order")}[${i}]`, o?.because));
333
+ });
334
+ count(found);
335
+ }
336
+ if (!p.lead) err(`.${page}.lead`, "not decided yet");
337
+ else {
338
+ const found = [];
339
+ if (typeof p.lead.value !== "string" || !p.lead.value.trim()) found.push({ severity: "error", at: `${at("lead")}.value`, message: "what the page leads with" });
340
+ found.push(...reason(at("lead"), p.lead.because));
341
+ count(found);
342
+ }
343
+ const journey = Array.isArray(p.journey) ? p.journey : [];
344
+ if (journey.length < 2) err(`.${page}.journey`, "the stages the visitor goes through, two or more, each with why");
345
+ else {
346
+ const found = [];
347
+ journey.forEach((s, i) => {
348
+ if (!s || typeof s.stage !== "string" || !s.stage.trim()) found.push({ severity: "error", at: `${at("journey")}[${i}].stage`, message: "name the stage" });
349
+ found.push(...reason(`${at("journey")}[${i}]`, s?.because));
350
+ });
351
+ count(found);
352
+ }
353
+ pages.push({ page, decided, total: HIERARCHY_FIELDS.length });
354
+ }
355
+ return { problems, pages };
356
+ }
357
+ function renderedOrder(snapshot) {
358
+ const sections = snapshot.sections ?? [];
359
+ if (sections.length === 0 || !sections.every((s) => s.role)) return null;
360
+ return sections.map((s) => s.role).filter((r) => r !== "other");
361
+ }
362
+ function checkOrder(declared, page, snapshot, strict = false) {
363
+ 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" }];
364
+ const rendered = renderedOrder(snapshot);
365
+ 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" }];
366
+ const said = declared.order.map((o) => o.role).filter((r) => r !== "other");
367
+ if (said.join(",") === rendered.join(",")) return [];
368
+ return [{ severity: strict ? "error" : "warn", at: `hierarchy.${page}.order`, message: `the page runs ${rendered.join(", ")}; the file says ${said.join(", ")}` }];
369
+ }
370
+
371
+ // src/direction/job.ts
372
+ var JOB_VERBS = new Set(
373
+ "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(" ")
374
+ );
375
+ 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"]);
376
+ var NOUNISH = /(?:tion|sion|ment|ness|ity|site|page)$/;
377
+ var words = (s) => s.toLowerCase().match(/[a-z][a-z'’-]*/g) ?? [];
378
+ function isCategoryLabel(object, brief = "") {
379
+ const trade = /* @__PURE__ */ new Set([...TRADE_NOUNS.flatMap((t) => t.split(" ")), ...words(brief).filter((w) => w.length > 3)]);
380
+ const ws = words(object);
381
+ return ws.length > 0 && ws.every((w) => LABEL_WORDS.has(w) || trade.has(w) || trade.has(w.replace(/s$/, "")));
382
+ }
383
+ var normalise = (s) => s.toLowerCase().replace(/[“”"‘’'.,!?;:()-]/g, " ").replace(/\s+/g, " ").trim();
384
+ function validateJob(job, sources, ctx = {}) {
385
+ const problems = [];
386
+ const err = (at, message) => problems.push({ severity: "error", at: `job${at}`, message });
387
+ const warn = (at, message) => problems.push({ severity: "warn", at: `job${at}`, message });
388
+ if (!job || typeof job !== "object" || Array.isArray(job)) {
389
+ err("", "no job map: say what the visitor is trying to get done, in their words, before deciding structure or look");
390
+ return { problems, decided: false };
391
+ }
392
+ const j = job;
393
+ const ids = new Map(sources.map((s) => [s.id, s]));
394
+ const st = j.statement;
395
+ if (!st || typeof st !== "object") err(".statement", "a job statement is verb, object and context");
396
+ else {
397
+ const verb = typeof st.verb === "string" ? st.verb.trim().toLowerCase() : "";
398
+ if (!verb) err(".statement.verb", "what the visitor is trying to do, as a verb: find, choose, book, fix");
399
+ 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.`);
400
+ 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.`);
401
+ const object = typeof st.object === "string" ? st.object.trim() : "";
402
+ if (words(object).length < 3) err(".statement.object", "the object needs a few words: what they are trying to get, and what makes it hard");
403
+ 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".`);
404
+ if (typeof st.context !== "string" || words(st.context).length < 3) err(".statement.context", "the context: when, under what pressure, or what they are trying to avoid");
405
+ }
406
+ for (const part of ["functional", "emotional", "social"]) {
407
+ const p = j[part];
408
+ if (!p || typeof p !== "object") {
409
+ err(`.${part}`, `the ${part} side of the job, with the customer-voice source that shows it`);
410
+ continue;
411
+ }
412
+ if (typeof p.value !== "string" || words(p.value).length < 6) err(`.${part}.value`, "a sentence: what they need, in the terms they would use");
413
+ 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") })));
414
+ }
415
+ const c = j.consideration;
416
+ if (!c || typeof c !== "object") err(".consideration", `how long and how carefully they decide: ${CONSIDERATION.join(", ")}`);
417
+ else {
418
+ if (!CONSIDERATION.includes(c.value)) err(".consideration.value", `one of ${CONSIDERATION.join(", ")}`);
419
+ problems.push(...checkReason({ at: "job.consideration", because: c.because, evidence: c.evidence, sources, tie: "customer" }));
420
+ }
421
+ const objections = Array.isArray(j.objections) ? j.objections : [];
422
+ if (objections.length === 0) err(".objections", "what stops them: the worries they bring to the page");
423
+ objections.forEach((o, i) => {
424
+ if (typeof o !== "string" || words(o).length < 3) err(`.objections[${i}]`, "an objection is a few words: what they worry about");
425
+ });
426
+ const evidence = Array.isArray(j.evidence) ? j.evidence : [];
427
+ const voice = evidence.map((id) => ids.get(id)).filter(isCustomerVoice);
428
+ if (evidence.length === 0 || voice.length === 0) err(".evidence", "cite the reviews, enquiries or conversations the job comes from");
429
+ for (const id of evidence) if (!ids.has(id)) err(".evidence", `no source called "${id}"`);
430
+ const language = Array.isArray(j.language) ? j.language : [];
431
+ if (language.length === 0) err(".language", "quote the customer: the words they use for the problem");
432
+ const texts = voice.map((s) => normalise(`${s.note} ${ctx.readSource?.(s) ?? ""}`));
433
+ language.forEach((q, i) => {
434
+ if (typeof q !== "string" || !q.trim()) return err(`.language[${i}]`, "an empty quote");
435
+ const n = normalise(q);
436
+ 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.`);
437
+ });
438
+ return { problems, decided: problems.every((p) => p.severity !== "error") };
439
+ }
440
+
441
+ // src/direction/licences.ts
442
+ var LICENCES = [
443
+ {
444
+ name: "Basteleur",
445
+ subject: "face",
446
+ licence: "OFL-1.1",
447
+ multiClient: "yes",
448
+ checked: "2026-09-24",
449
+ source: "https://velvetyne.fr/fonts/basteleur/",
450
+ note: "Velvetyne, designed by Keussel. Keep the OFL notice with the font files."
451
+ },
452
+ {
453
+ name: "Gulax",
454
+ subject: "face",
455
+ licence: "OFL-1.1",
456
+ multiClient: "yes",
457
+ checked: "2026-09-24",
458
+ source: "https://velvetyne.fr/fonts/gulax/",
459
+ note: "Velvetyne. Keep the OFL notice with the font files."
460
+ },
461
+ {
462
+ name: "Le Murmure",
463
+ subject: "face",
464
+ licence: "OFL-1.1",
465
+ multiClient: "yes",
466
+ checked: "2026-09-24",
467
+ source: "https://velvetyne.fr/fonts/le-murmure/",
468
+ note: "Velvetyne. Keep the OFL notice with the font files."
469
+ },
470
+ {
471
+ name: "Blaze Type",
472
+ subject: "foundry",
473
+ licence: "commercial",
474
+ multiClient: "per-site",
475
+ cap: 10,
476
+ checked: "2026-09-24",
477
+ source: "https://blazetype.eu/eula",
478
+ note: "The EULA sells a professional licence for 1 to 10 websites, and lets an agency buy in a client's name at the listed price. Its /license page says one licence covers every domain; the EULA is the contract, so buy per client."
479
+ },
480
+ {
481
+ name: "Power Type",
482
+ subject: "foundry",
483
+ licence: "commercial",
484
+ multiClient: "unverified",
485
+ checked: "2026-09-24",
486
+ source: "https://power-type.com/eula",
487
+ note: "Research on 2026-09-11 reported a Super License capped at 10 self-hosted domains. The EULA page did not load when checked; read it before relying on the cap."
488
+ },
489
+ {
490
+ name: "Grilli Type",
491
+ subject: "foundry",
492
+ licence: "commercial",
493
+ multiClient: "per-site",
494
+ checked: "2026-09-24",
495
+ source: "https://www.grillitype.com/information",
496
+ note: "Web licences cover any number of domains controlled by the licensee, priced by monthly visitors, so a client site needs the client's own licence. Unlimited tiers start at $10,000 per style."
497
+ },
498
+ {
499
+ name: "Phosphor",
500
+ subject: "icons",
501
+ licence: "MIT",
502
+ multiClient: "yes",
503
+ checked: "2026-09-24",
504
+ source: "https://github.com/phosphor-icons/core/blob/main/LICENSE",
505
+ note: "Six weights. The icon-tile tells treat it exactly as they treat Lucide: the library is not the tell, the tinted tile is."
506
+ },
507
+ {
508
+ name: "Tabler Icons",
509
+ subject: "icons",
510
+ licence: "MIT",
511
+ multiClient: "yes",
512
+ checked: "2026-09-24",
513
+ source: "https://github.com/tabler/tabler-icons/blob/main/LICENSE",
514
+ note: "One stroke grid across the set."
515
+ },
516
+ {
517
+ name: "Iconoir",
518
+ subject: "icons",
519
+ licence: "MIT",
520
+ multiClient: "yes",
521
+ checked: "2026-09-24",
522
+ source: "https://github.com/iconoir-icons/iconoir/blob/main/LICENSE",
523
+ note: "Smaller set, no paid tier."
524
+ },
525
+ {
526
+ name: "Unicons",
527
+ subject: "icons",
528
+ licence: "IconScout Simple License",
529
+ multiClient: "yes",
530
+ checked: "2026-09-24",
531
+ source: "https://github.com/Iconscout/unicons/blob/master/LICENSE",
532
+ note: "IconScout's free set, personal and commercial use, attribution optional."
533
+ },
534
+ {
535
+ name: "IconScout premium",
536
+ subject: "icons",
537
+ licence: "commercial",
538
+ multiClient: "per-site",
539
+ checked: "2026-09-24",
540
+ source: "https://iconscout.com/licenses",
541
+ note: "Each licence covers a single end product. Never in a tool, template or shared kit (create-site, craft, a house component library), even modified. Record the assets each site uses. Assets come from different contributors, so take one site's set from one contributor."
542
+ }
543
+ ];
544
+ function faceLicence(family) {
545
+ const name = family.trim().toLowerCase();
546
+ return LICENCES.find((l) => l.subject === "face" && l.name.toLowerCase() === name);
547
+ }
548
+
549
+ // src/direction/validate.ts
550
+ function tellFor(key, value) {
551
+ if (key === "accent") {
552
+ const c = parseColour(value);
553
+ return c && isAiViolet(c.oklch) ? "ai-violet" : null;
554
+ }
555
+ if (key === "ground") {
556
+ const c = parseColour(value);
557
+ return c && isCream(c.oklch) ? "cream-palette" : null;
558
+ }
559
+ if (key === "display" || key === "body") {
560
+ const name = normaliseFamily(value).toLowerCase();
561
+ if (REFLEX_FONTS_1.some((f) => f.toLowerCase() === name)) return "reflex-font";
562
+ if (REFLEX_FONTS_2.some((f) => f.toLowerCase() === name)) return "reflex-font-2";
563
+ }
564
+ return null;
565
+ }
566
+ var MIN_TOKENS = 5;
567
+ var emptyLayers = () => ({ job: false, hierarchy: [], tokens: { decided: 0, total: CHOICE_KEYS.length }, complete: false });
568
+ function validateDirection(input, ctx = {}) {
569
+ const problems = [];
570
+ const err = (at, message) => problems.push({ severity: "error", at, message });
571
+ const warn = (at, message) => problems.push({ severity: "warn", at, message });
572
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
573
+ err("", "art-direction.json must be a JSON object");
574
+ return { valid: false, problems, decided: 0, layers: emptyLayers() };
575
+ }
576
+ const d = input;
577
+ const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
578
+ if (legacy) {
579
+ warn("", "exceptions only: run craft direction init to record the choices and their reasons");
580
+ return { valid: true, problems, decided: 0, layers: emptyLayers() };
581
+ }
582
+ if (!DIRECTION_VERSIONS.includes(d.version)) err("version", `version must be ${DIRECTION_VERSIONS.join(" or ")}`);
583
+ 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.");
584
+ if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
585
+ 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");
586
+ const rawSources = Array.isArray(d.sources) ? d.sources : [];
587
+ const sources = rawSources.filter((s) => Boolean(s && typeof s === "object" && !Array.isArray(s)));
588
+ if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
589
+ if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
590
+ const ids = /* @__PURE__ */ new Set();
591
+ rawSources.forEach((s, i) => {
592
+ const at = `sources[${i}]`;
593
+ if (!s || typeof s !== "object" || Array.isArray(s)) return err(at, "a source must be an object");
594
+ if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
595
+ else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
596
+ else ids.add(s.id);
597
+ if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
598
+ if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
599
+ if (s.path !== void 0 && typeof s.path !== "string") err(`${at}.path`, "a path must be text");
600
+ if (typeof s.path === "string" && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
601
+ if (s.colours !== void 0 && !Array.isArray(s.colours)) err(`${at}.colours`, "colours must be a list");
602
+ 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`);
603
+ if (s.kind === "reference") {
604
+ if (typeof s.category !== "string" || !s.category.trim()) err(`${at}.category`, "say where the reference comes from: print, a place, another trade");
605
+ 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 : "")) {
606
+ 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.`);
607
+ }
608
+ 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");
609
+ }
610
+ 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");
611
+ });
612
+ const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
613
+ if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
614
+ const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
615
+ let decided = 0;
616
+ for (const key of Object.keys(choices)) {
617
+ 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.`);
618
+ }
619
+ for (const key of CHOICE_KEYS) {
620
+ const c = choices[key];
621
+ const at = `choices.${key}`;
622
+ if (!c) {
623
+ warn(at, "not decided yet");
624
+ continue;
625
+ }
626
+ let ok = true;
627
+ if (typeof c.value !== "string" || !c.value.trim()) {
628
+ err(`${at}.value`, "no value");
629
+ ok = false;
630
+ } else if ((key === "accent" || key === "ground") && !parseColour(c.value)) {
631
+ err(`${at}.value`, `"${c.value}" is not a colour`);
632
+ ok = false;
633
+ }
634
+ const reasons = checkReason({ at, because: c.because, evidence: c.evidence, sources, tie: "source" });
635
+ problems.push(...reasons);
636
+ if (reasons.length) ok = false;
637
+ if (typeof c.value === "string") {
638
+ const tell = tellFor(key, c.value);
639
+ if (tell && !exceptions.some((e) => e.tell === tell)) {
640
+ err(`${at}.value`, `${c.value} is on the tell catalogue (${tell}). Keep it only with an exception for ${tell}, carrying the same reason.`);
641
+ ok = false;
642
+ }
643
+ }
644
+ if ((key === "display" || key === "body") && typeof c.value === "string" && c.value.trim()) {
645
+ const family = normaliseFamily(c.value);
646
+ const entry = faceLicence(family);
647
+ if (!entry) {
648
+ warn(`${at}.value`, `${family}: licence not in craft's register. Read the licensor's own terms for use on this client's site, then add it to src/direction/licences.ts.`);
649
+ } else if (entry.multiClient === "unverified") {
650
+ warn(`${at}.value`, `${family}: licence unverified (${entry.note})`);
651
+ } else if (entry.multiClient === "capped" || entry.multiClient === "per-site") {
652
+ const scope = entry.multiClient === "capped" ? `covers up to ${entry.cap} sites` : "needs a licence for this site, in the client's name";
653
+ warn(`${at}.value`, `${family}: ${entry.licence} licence ${scope}. Record the purchase before launch.`);
654
+ }
655
+ }
656
+ const fp = ctx.fingerprint;
657
+ if (fp && typeof c.value === "string") {
658
+ if ((key === "accent" || key === "ground") && parseColour(c.value)) {
659
+ const declared = parseColour(c.value).oklch;
660
+ const measured = key === "accent" ? fp.accent : fp.ground;
661
+ if (measured && deltaEOk(declared, measured) > 0.08) {
662
+ warn(`${at}.value`, `the page does not show it: its ${key} measures oklch(${measured.l.toFixed(2)} ${measured.c.toFixed(3)} ${measured.h.toFixed(0)})`);
663
+ }
664
+ }
665
+ if (key === "display" || key === "body") {
666
+ const measured = key === "display" ? fp.display.family : fp.body.family;
667
+ if (normaliseFamily(c.value).toLowerCase() !== measured.toLowerCase()) warn(`${at}.value`, `the page sets ${measured}, not ${c.value}`);
668
+ }
669
+ }
670
+ if (ok) decided += 1;
671
+ }
672
+ let job = false;
673
+ let hierarchy = [];
674
+ if (d.version === 2) {
675
+ const j = validateJob(d.job, sources, { brief: typeof d.brief === "string" ? d.brief : "", readSource: ctx.readSource });
676
+ problems.push(...j.problems);
677
+ job = j.decided && d.job !== void 0;
678
+ const h = validateHierarchy(d.hierarchy, d.job, sources);
679
+ problems.push(...h.problems);
680
+ hierarchy = h.pages;
681
+ if (ctx.snapshot) problems.push(...checkOrder(d.hierarchy?.[ctx.page ?? "home"], ctx.page ?? "home", ctx.snapshot, ctx.strict));
682
+ }
683
+ if (ctx.estate?.length) {
684
+ for (const m of compareToDeclared(summariseDirection(d), ctx.estate, ctx.estateId)) {
685
+ 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.`);
686
+ }
687
+ }
688
+ if (ctx.strict) {
689
+ for (const p of problems) if (p.severity === "warn" && /the page (?:does not show|sets|runs)/.test(p.message)) p.severity = "error";
690
+ }
691
+ const home = hierarchy.find((p) => p.page === "home");
692
+ const complete = job && Boolean(home) && hierarchy.every((p) => p.decided === p.total) && decided >= MIN_TOKENS;
693
+ return {
694
+ valid: problems.every((p) => p.severity !== "error"),
695
+ problems,
696
+ decided,
697
+ layers: { job, hierarchy, tokens: { decided, total: CHOICE_KEYS.length }, complete: complete && problems.every((p) => p.severity !== "error") }
698
+ };
699
+ }
700
+
701
+ export {
702
+ DIRECTION_VERSION,
703
+ PAGE_TYPES,
704
+ PROPOSED,
705
+ researchPrompt,
706
+ summariseDirection,
707
+ compareDirections,
708
+ compareToDeclared,
709
+ formatDirectionMatches,
710
+ validateDirection
711
+ };