@domandigital/craft 0.6.0 → 0.8.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.
@@ -0,0 +1,163 @@
1
+ import {
2
+ groundName,
3
+ hueName,
4
+ loadNull,
5
+ shapeName
6
+ } from "./chunk-K253ZXB3.js";
7
+ import {
8
+ parseFlags
9
+ } from "./chunk-X65RDW65.js";
10
+ import {
11
+ fingerprint,
12
+ fingerprintDistance
13
+ } from "./chunk-OGSYVCMU.js";
14
+ import {
15
+ SNAPSHOT_VERSION,
16
+ auditSnapshot
17
+ } from "./chunk-CZHRNPDD.js";
18
+
19
+ // src/estate/cli.ts
20
+ import { existsSync, readFileSync, writeFileSync } from "fs";
21
+ import { resolve } from "path";
22
+
23
+ // src/estate/index.ts
24
+ var ESTATE_VERSION = 1;
25
+ var SIBLING_AT = 0.31;
26
+ function emptyEstate() {
27
+ return { version: ESTATE_VERSION, sites: [] };
28
+ }
29
+ function addSite(register, site) {
30
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(site.id)) throw new Error(`site id "${site.id}" must be short kebab-case`);
31
+ const sites = [...register.sites.filter((s) => s.id !== site.id), site].sort((a, b) => a.id.localeCompare(b.id));
32
+ return { version: ESTATE_VERSION, sites };
33
+ }
34
+ function sharedParts(a, b, d = fingerprintDistance(a, b)) {
35
+ const out = [];
36
+ const close = (k) => d.parts[k] <= 0.2;
37
+ if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
38
+ if (close("type")) {
39
+ if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
40
+ if (a.body.family === b.body.family) out.push(`${a.body.family} body`);
41
+ }
42
+ if (close("ground")) out.push(`${groundName(a.ground)} ground`);
43
+ if (close("shape")) out.push(`${shapeName(a.roundness)} buttons`);
44
+ if (close("motion")) out.push(a.motion > 0 || b.motion > 0 ? "scroll reveals" : "no reveals");
45
+ if (close("effects") && a.effects.length) out.push(a.effects.filter((e) => b.effects.includes(e)).join(", "));
46
+ if (close("layout")) out.push("running order");
47
+ return out;
48
+ }
49
+ function compareToEstate(target, register, options = {}) {
50
+ const at = options.siblingAt ?? SIBLING_AT;
51
+ return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
52
+ const d = fingerprintDistance(target, s.fingerprint);
53
+ return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target, s.fingerprint, d) };
54
+ }).sort((a, b) => a.distance - b.distance);
55
+ }
56
+ function estatePairs(register, siblingAt = SIBLING_AT) {
57
+ const out = [];
58
+ const s = register.sites;
59
+ for (let i = 0; i < s.length; i += 1) {
60
+ for (let j = i + 1; j < s.length; j += 1) {
61
+ const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
62
+ out.push({ a: s[i].id, b: s[j].id, distance: d.total, sibling: d.total < siblingAt, shared: sharedParts(s[i].fingerprint, s[j].fingerprint, d) });
63
+ }
64
+ }
65
+ return out.sort((x, y) => x.distance - y.distance);
66
+ }
67
+ function siblingLine(briefs) {
68
+ const d = [];
69
+ for (const runs of briefs) {
70
+ for (let i = 0; i < runs.length; i += 1) for (let j = i + 1; j < runs.length; j += 1) d.push(fingerprintDistance(runs[i], runs[j]).total);
71
+ }
72
+ if (d.length === 0) throw new Error("no null pages to measure the sibling line from");
73
+ d.sort((a, b) => a - b);
74
+ const mid = Math.floor(d.length / 2);
75
+ const m = d.length % 2 ? d[mid] : (d[mid - 1] + d[mid]) / 2;
76
+ return Math.round(m * 1e3) / 1e3;
77
+ }
78
+
79
+ // src/estate/cli.ts
80
+ var USAGE = "usage: craft estate add <url | snapshot.json> --id <id> [--client <name>] | craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--json] [--strict]";
81
+ function loadEstate(path) {
82
+ if (!existsSync(path)) return emptyEstate();
83
+ const data = JSON.parse(readFileSync(path, "utf8"));
84
+ if (data.version !== ESTATE_VERSION || !Array.isArray(data.sites)) throw new Error(`${path} is not an estate register (version ${ESTATE_VERSION})`);
85
+ return data;
86
+ }
87
+ async function snapshotOf(target, cwd) {
88
+ const local = resolve(cwd, target);
89
+ if (/\.json$/i.test(target) && existsSync(local)) {
90
+ const snap = JSON.parse(readFileSync(local, "utf8"));
91
+ if (snap.version !== SNAPSHOT_VERSION) throw new Error(`${target} is not a snapshot`);
92
+ return snap;
93
+ }
94
+ if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL, a snapshot file, nor a site in the register`);
95
+ const { snapshotUrl } = await import("./audit-W57YDZ2K.js");
96
+ return snapshotUrl(target);
97
+ }
98
+ var line = (distance, sibling, shared) => `${distance.toFixed(2)}${sibling ? " SIBLING" : ""}${shared.length ? ` shares ${shared.join(", ")}` : ""}`;
99
+ function formatMatches(label, matches, at) {
100
+ const lines = [`${label}: nearest sites in the estate (siblings below ${at.toFixed(2)})`];
101
+ for (const m of matches) lines.push(` ${m.id.padEnd(20)} ${line(m.distance, m.sibling, m.shared)}`);
102
+ if (matches.length === 0) lines.push(" the register is empty");
103
+ return lines.join("\n");
104
+ }
105
+ function formatPairs(pairs, at, sites) {
106
+ const siblings = pairs.filter((p) => p.sibling).length;
107
+ const lines = [`craft estate: ${sites} sites, ${pairs.length} pair${pairs.length === 1 ? "" : "s"}, ${siblings} sibling pair${siblings === 1 ? "" : "s"} (below ${at.toFixed(2)})`];
108
+ for (const p of pairs) lines.push(` ${`${p.a} + ${p.b}`.padEnd(34)} ${line(p.distance, p.sibling, p.shared)}`);
109
+ return lines.join("\n");
110
+ }
111
+ async function runEstate(args, io) {
112
+ try {
113
+ const flags = parseFlags(args);
114
+ if (typeof flags === "string") throw new Error(flags);
115
+ const [sub, target] = flags.positional;
116
+ const file = resolve(io.cwd, flags.estate ?? "estate.json");
117
+ const register = loadEstate(file);
118
+ const at = flags.null ? siblingLine(flags.null.split(",").map((p) => loadNull(p.trim(), io.cwd).runs.map((r) => r.fingerprint))) : SIBLING_AT;
119
+ if (sub === "add") {
120
+ if (!target || !flags.id) throw new Error(USAGE);
121
+ const snap = await snapshotOf(target, io.cwd);
122
+ const fp = fingerprint(snap);
123
+ const next = addSite(register, {
124
+ id: flags.id,
125
+ client: flags.client ?? flags.id,
126
+ url: snap.url,
127
+ fingerprint: fp,
128
+ tells: [...new Set(auditSnapshot(snap).findings.map((f) => f.tell))].sort(),
129
+ addedAt: snap.capturedAt
130
+ });
131
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}
132
+ `);
133
+ io.out(`craft estate: ${flags.id} ${register.sites.some((s) => s.id === flags.id) ? "updated" : "added"}, ${next.sites.length} sites in ${flags.estate ?? "estate.json"}`);
134
+ io.out(formatMatches(flags.id, compareToEstate(fp, next, { exclude: flags.id, siblingAt: at }).slice(0, 3), at));
135
+ return 0;
136
+ }
137
+ if (sub === "compare") {
138
+ if (!target) {
139
+ const pairs = estatePairs(register, at);
140
+ io.out(flags.json ? JSON.stringify({ siblingAt: at, pairs }, null, 2) : formatPairs(pairs, at, register.sites.length));
141
+ return flags.strict && pairs.some((p) => p.sibling) ? 1 : 0;
142
+ }
143
+ const known = register.sites.find((s) => s.id === target);
144
+ const fp = known ? known.fingerprint : fingerprint(await snapshotOf(target, io.cwd));
145
+ const matches = compareToEstate(fp, register, { exclude: known?.id, siblingAt: at });
146
+ io.out(flags.json ? JSON.stringify({ siblingAt: at, matches }, null, 2) : formatMatches(known?.id ?? target, matches, at));
147
+ return flags.strict && matches.some((m) => m.sibling) ? 1 : 0;
148
+ }
149
+ throw new Error(USAGE);
150
+ } catch (error) {
151
+ io.err(`craft: ${error.message}`);
152
+ return 2;
153
+ }
154
+ }
155
+
156
+ export {
157
+ SIBLING_AT,
158
+ compareToEstate,
159
+ loadEstate,
160
+ formatMatches,
161
+ formatPairs,
162
+ runEstate
163
+ };
@@ -1,20 +1,10 @@
1
1
  import {
2
2
  fingerprint
3
- } from "./chunk-HYGFP3LB.js";
3
+ } from "./chunk-OGSYVCMU.js";
4
4
  import {
5
5
  SNAPSHOT_VERSION,
6
- SOURCE_FILE,
7
- auditSnapshot,
8
- finish,
9
- loadExceptions,
10
- parseFlags,
11
- readPaths,
12
- scanSource
13
- } from "./chunk-GWMSSRIQ.js";
14
-
15
- // src/audit/cli.ts
16
- import { existsSync, readFileSync, writeFileSync } from "fs";
17
- import { resolve } from "path";
6
+ auditSnapshot
7
+ } from "./chunk-CZHRNPDD.js";
18
8
 
19
9
  // src/audit/collect.ts
20
10
  function collectInPage() {
@@ -167,13 +157,18 @@ function collectInPage() {
167
157
  pushBlocks(root, 0);
168
158
  const header = document.querySelector("body > header, header");
169
159
  const h1 = document.querySelector("h1");
160
+ const waiting = (c) => {
161
+ const cs = style(c);
162
+ const op = parseFloat(cs.opacity);
163
+ return op < 0.1 || op < 1 && cs.transform !== "none" || c.hasAttribute("data-aos") || /\breveal\b|\bfade-?(?:in|up)\b|\banimate-on-scroll\b/i.test(typeof c.className === "string" ? c.className : "");
164
+ };
170
165
  const hiddenAtLoad = (el) => {
171
- const check = [el, ...Array.from(el.children).slice(0, 6)];
172
- return check.some((c) => {
173
- const cs = style(c);
174
- const op = parseFloat(cs.opacity);
175
- return op < 0.1 || op < 1 && cs.transform !== "none" || c.hasAttribute("data-aos") || /\breveal\b|\bfade-?(?:in|up)\b|\banimate-on-scroll\b/i.test(typeof c.className === "string" ? c.className : "");
176
- });
166
+ if ([el, ...Array.from(el.children).slice(0, 6)].some(waiting)) return true;
167
+ const blocks2 = Array.from(el.querySelectorAll("div, article, li, figure, p, h2, h3")).slice(0, 200).filter((d) => {
168
+ const b = box(d);
169
+ return b.width > 100 && b.height > 40 && (d.textContent ?? "").trim().length > 12 && style(d).position !== "fixed";
170
+ }).slice(0, 12);
171
+ return blocks2.filter(waiting).length >= Math.min(2, blocks2.length) && blocks2.some(waiting);
177
172
  };
178
173
  const cardsIn = (section) => {
179
174
  let best = { cards: 0, iconCards: 0 };
@@ -372,99 +367,53 @@ async function snapshotPage(page, url, options = {}) {
372
367
  motion: { ...data.motion, introOverlay: early && !late }
373
368
  };
374
369
  }
375
- async function snapshotUrl(url, options = {}) {
376
- const pw = await loadPlaywright();
370
+ async function launch(pw, url, options) {
377
371
  const env = globalThis.process?.env ?? {};
378
372
  const executablePath = options.executablePath ?? env.CRAFT_CHROMIUM ?? void 0;
379
373
  const host = new URL(url).hostname;
380
374
  const noProxy = [`localhost`, `127.0.0.1`, `::1`, ...(env.NO_PROXY ?? env.no_proxy ?? "").split(",")].map((h) => h.trim().replace(/^\*?\./, "")).filter(Boolean);
381
375
  const direct = /^(?:localhost|127\.|\[?::1\]?$)/.test(host) || noProxy.some((h) => host === h || host.endsWith(`.${h}`));
382
376
  const proxy = direct ? void 0 : env.HTTPS_PROXY ?? env.https_proxy;
383
- const browser = await pw.chromium.launch({ headless: true, ...executablePath ? { executablePath } : {}, ...proxy ? { proxy: { server: proxy } } : {} });
384
- try {
385
- const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
386
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
387
- return await snapshotPage(page, url, options);
388
- } finally {
389
- await browser.close();
390
- }
391
- }
392
-
393
- // src/audit/cli.ts
394
- var fmt = (o) => o ? `oklch(${o.l.toFixed(3)} ${o.c.toFixed(3)} ${o.h.toFixed(1)})` : "none";
395
- function describeFingerprint(fp) {
396
- return [
397
- "Fingerprint",
398
- ` accent ${fmt(fp.accent)}`,
399
- ` ground ${fmt(fp.ground)}`,
400
- ` display ${fp.display.family} (${fp.display.class})`,
401
- ` body ${fp.body.family} (${fp.body.class})`,
402
- ` roundness ${fp.roundness === null ? "no buttons" : fp.roundness.toFixed(2)}`,
403
- ` motion ${Math.round(fp.motion * 100)}% of sections below the fold reveal on scroll`,
404
- ` effects ${fp.effects.join(", ") || "none"}`,
405
- ` layout ${fp.layout.join(" > ") || "none"}`
406
- ].join("\n");
407
- }
408
- function merge(a, b) {
409
- const byTell = { ...a.summary.byTell };
410
- for (const [k, v] of Object.entries(b.summary.byTell)) byTell[k] = (byTell[k] ?? 0) + v;
411
- return {
412
- catalogueVersion: a.catalogueVersion,
413
- findings: [...a.findings, ...b.findings],
414
- rejectedExceptions: a.rejectedExceptions,
415
- excepted: [...a.excepted, ...b.excepted],
416
- suppressed: [...a.suppressed, ...b.suppressed],
417
- summary: {
418
- files: a.summary.files + b.summary.files,
419
- findings: a.summary.findings + b.summary.findings,
420
- byGeneration: { 1: a.summary.byGeneration[1] + b.summary.byGeneration[1], 2: a.summary.byGeneration[2] + b.summary.byGeneration[2], 3: a.summary.byGeneration[3] + b.summary.byGeneration[3] },
421
- byTell,
422
- blocking: a.summary.blocking + b.summary.blocking
423
- }
424
- };
377
+ return pw.chromium.launch({ headless: true, ...executablePath ? { executablePath } : {}, ...proxy ? { proxy: { server: proxy } } : {} });
425
378
  }
426
- function readSnapshot(path) {
427
- const data = JSON.parse(readFileSync(path, "utf8"));
428
- if (data.version !== SNAPSHOT_VERSION) throw new Error(`${path} is snapshot version ${String(data.version)}; this craft reads ${SNAPSHOT_VERSION}`);
429
- return data;
379
+ async function snapshotUrl(url, options = {}) {
380
+ const [result] = await snapshotUrls([url], options);
381
+ if (!result.snapshot) throw new Error(result.error);
382
+ return result.snapshot;
430
383
  }
431
- async function runAudit(command, args, io) {
384
+ async function snapshotUrls(urls, options = {}) {
385
+ if (urls.length === 0) return [];
386
+ const pw = await loadPlaywright();
387
+ const browser = await launch(pw, urls[0], options);
388
+ const out = [];
432
389
  try {
433
- const flags = parseFlags(args);
434
- if (typeof flags === "string") throw new Error(flags);
435
- const target = flags.positional[0];
436
- if (!target) throw new Error(`usage: craft ${command} <url${command === "audit" ? " | snapshot.json" : ""}>`);
437
- const viewport = flags.width || flags.height ? { width: Number(flags.width ?? 1440), height: Number(flags.height ?? 900) } : void 0;
438
- const local = resolve(io.cwd, target);
439
- const snapshot = command === "audit" && /\.json$/i.test(target) && existsSync(local) ? readSnapshot(local) : await snapshotUrl(target, { viewport });
440
- if (flags.out) writeFileSync(resolve(io.cwd, flags.out), `${JSON.stringify(snapshot, null, 2)}
441
- `);
442
- if (command === "snapshot") {
443
- if (!flags.out) io.out(JSON.stringify(snapshot, null, 2));
444
- else io.out(`craft snapshot: ${snapshot.url} saved to ${flags.out}`);
445
- return 0;
446
- }
447
- const exceptions = loadExceptions(flags, flags.repo ? resolve(io.cwd, flags.repo) : io.cwd);
448
- let report = auditSnapshot(snapshot, { exceptions });
449
- if (flags.repo) {
450
- const repo = resolve(io.cwd, flags.repo);
451
- report = merge(scanSource(readPaths(["."], repo, SOURCE_FILE), { exceptions }), report);
452
- }
453
- const fp = fingerprint(snapshot);
454
- if (flags.json) {
455
- io.out(JSON.stringify({ report, fingerprint: fp, snapshot: flags.out ?? null }, null, 2));
456
- return report.summary.blocking > 0 || flags.strict && report.summary.findings > 0 ? 1 : 0;
390
+ for (const url of urls) {
391
+ const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
392
+ try {
393
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
394
+ out.push({ url, snapshot: await snapshotPage(page, url, options) });
395
+ } catch (error) {
396
+ out.push({ url, error: error.message });
397
+ } finally {
398
+ await page.close?.();
399
+ }
457
400
  }
458
- const code = finish(report, { ...flags, json: false }, `craft audit ${snapshot.url}`, io);
459
- io.out("");
460
- io.out(describeFingerprint(fp));
461
- return code;
462
- } catch (error) {
463
- io.err(`craft: ${error.message}`);
464
- return 2;
401
+ return out;
402
+ } finally {
403
+ await browser.close();
465
404
  }
466
405
  }
406
+ async function audit(url, options = {}) {
407
+ const snapshot = await snapshotUrl(url, options);
408
+ return { snapshot, report: auditSnapshot(snapshot, options), fingerprint: fingerprint(snapshot) };
409
+ }
410
+
467
411
  export {
468
- describeFingerprint,
469
- runAudit
412
+ collectInPage,
413
+ overlayInPage,
414
+ loadPlaywright,
415
+ snapshotPage,
416
+ snapshotUrl,
417
+ snapshotUrls,
418
+ audit
470
419
  };
@@ -0,0 +1,175 @@
1
+ import {
2
+ REFLEX_FONTS_1,
3
+ REFLEX_FONTS_2,
4
+ deltaEOk,
5
+ isAiViolet,
6
+ isCream,
7
+ normaliseFamily,
8
+ parseColour
9
+ } from "./chunk-CZHRNPDD.js";
10
+
11
+ // src/direction/types.ts
12
+ var DIRECTION_VERSION = 1;
13
+ var SOURCE_KINDS = [
14
+ "livery",
15
+ "shopfront",
16
+ "signage",
17
+ "packaging",
18
+ "product",
19
+ "uniform",
20
+ "interior",
21
+ "place",
22
+ "material",
23
+ "trade",
24
+ "print",
25
+ "photo",
26
+ "founder"
27
+ ];
28
+ var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
29
+
30
+ // src/direction/validate.ts
31
+ 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;
32
+ 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;
33
+ 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"]);
34
+ 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"]);
35
+ function words(text) {
36
+ return new Set((text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).map((w) => w.replace(/'s$/, "")).filter((w) => !STOP.has(w) && !VALUE_WORDS.has(w)));
37
+ }
38
+ function tellFor(key, value) {
39
+ if (key === "accent") {
40
+ const c = parseColour(value);
41
+ return c && isAiViolet(c.oklch) ? "ai-violet" : null;
42
+ }
43
+ if (key === "ground") {
44
+ const c = parseColour(value);
45
+ return c && isCream(c.oklch) ? "cream-palette" : null;
46
+ }
47
+ if (key === "display" || key === "body") {
48
+ const name = normaliseFamily(value).toLowerCase();
49
+ if (REFLEX_FONTS_1.some((f) => f.toLowerCase() === name)) return "reflex-font";
50
+ if (REFLEX_FONTS_2.some((f) => f.toLowerCase() === name)) return "reflex-font-2";
51
+ }
52
+ return null;
53
+ }
54
+ function validateDirection(input, ctx = {}) {
55
+ const problems = [];
56
+ const err = (at, message) => problems.push({ severity: "error", at, message });
57
+ const warn = (at, message) => problems.push({ severity: "warn", at, message });
58
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
59
+ err("", "art-direction.json must be a JSON object");
60
+ return { valid: false, problems, decided: 0 };
61
+ }
62
+ const d = input;
63
+ const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
64
+ if (legacy) {
65
+ warn("", "exceptions only: run craft direction init to record the choices and their reasons");
66
+ return { valid: true, problems, decided: 0 };
67
+ }
68
+ if (d.version !== DIRECTION_VERSION) err("version", `version must be ${DIRECTION_VERSION}`);
69
+ if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
70
+ 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");
71
+ const sources = Array.isArray(d.sources) ? d.sources : [];
72
+ if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
73
+ if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
74
+ const ids = /* @__PURE__ */ new Set();
75
+ sources.forEach((s, i) => {
76
+ const at = `sources[${i}]`;
77
+ if (!s || typeof s !== "object") return err(at, "a source must be an object");
78
+ if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
79
+ else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
80
+ else ids.add(s.id);
81
+ if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
82
+ if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
83
+ if (s.path && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
84
+ for (const [j, c] of (s.colours ?? []).entries()) if (!parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
85
+ });
86
+ const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
87
+ if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
88
+ const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
89
+ let decided = 0;
90
+ for (const key of Object.keys(choices)) {
91
+ if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Layout and navigation stay conventional.`);
92
+ }
93
+ for (const key of CHOICE_KEYS) {
94
+ const c = choices[key];
95
+ const at = `choices.${key}`;
96
+ if (!c) {
97
+ warn(at, "not decided yet");
98
+ continue;
99
+ }
100
+ let ok = true;
101
+ if (typeof c.value !== "string" || !c.value.trim()) {
102
+ err(`${at}.value`, "no value");
103
+ ok = false;
104
+ } else if ((key === "accent" || key === "ground") && !parseColour(c.value)) {
105
+ err(`${at}.value`, `"${c.value}" is not a colour`);
106
+ ok = false;
107
+ }
108
+ const because = typeof c.because === "string" ? c.because.trim() : "";
109
+ const evidence = Array.isArray(c.evidence) ? c.evidence : [];
110
+ if (because.split(/\s+/).filter(Boolean).length < 8) {
111
+ err(`${at}.because`, "a reason is at least a sentence. Without one this is a default, however good it looks.");
112
+ ok = false;
113
+ } else if (because.startsWith("PROPOSED:")) {
114
+ err(`${at}.because`, "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
115
+ ok = false;
116
+ } else {
117
+ if (PREFERENCE.test(because)) {
118
+ err(`${at}.because`, "that is a preference, not a reason. Say what in the client's world this comes from.");
119
+ ok = false;
120
+ }
121
+ const moods = because.match(MOOD) ?? [];
122
+ if (moods.length >= 2) {
123
+ err(`${at}.because`, `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
124
+ ok = false;
125
+ }
126
+ }
127
+ if (evidence.length === 0) {
128
+ err(`${at}.evidence`, "cite at least one source");
129
+ ok = false;
130
+ }
131
+ for (const id of evidence) {
132
+ if (!ids.has(id)) {
133
+ err(`${at}.evidence`, `no source called "${id}"`);
134
+ ok = false;
135
+ }
136
+ }
137
+ if (ok) {
138
+ const cited = sources.filter((s) => evidence.includes(s.id));
139
+ const reasonWords = words(because);
140
+ const tied = cited.some((s) => reasonWords.has(s.id) || [...words(`${s.note} ${s.lettering ?? ""}`)].some((w) => reasonWords.has(w)));
141
+ if (!tied) {
142
+ err(`${at}.because`, `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
143
+ ok = false;
144
+ }
145
+ }
146
+ if (typeof c.value === "string") {
147
+ const tell = tellFor(key, c.value);
148
+ if (tell && !exceptions.some((e) => e.tell === tell)) {
149
+ err(`${at}.value`, `${c.value} is on the tell catalogue (${tell}). Keep it only with an exception for ${tell}, carrying the same reason.`);
150
+ ok = false;
151
+ }
152
+ }
153
+ const fp = ctx.fingerprint;
154
+ if (fp && typeof c.value === "string") {
155
+ if ((key === "accent" || key === "ground") && parseColour(c.value)) {
156
+ const declared = parseColour(c.value).oklch;
157
+ const measured = key === "accent" ? fp.accent : fp.ground;
158
+ if (measured && deltaEOk(declared, measured) > 0.08) {
159
+ 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)})`);
160
+ }
161
+ }
162
+ if (key === "display" || key === "body") {
163
+ const measured = key === "display" ? fp.display.family : fp.body.family;
164
+ if (normaliseFamily(c.value).toLowerCase() !== measured.toLowerCase()) warn(`${at}.value`, `the page sets ${measured}, not ${c.value}`);
165
+ }
166
+ }
167
+ if (ok) decided += 1;
168
+ }
169
+ return { valid: problems.every((p) => p.severity !== "error"), problems, decided };
170
+ }
171
+
172
+ export {
173
+ DIRECTION_VERSION,
174
+ validateDirection
175
+ };