@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
@@ -4,13 +4,19 @@ import {
4
4
  HOUSE_LOCKED,
5
5
  applyHouseGate,
6
6
  checkCopy,
7
+ compareCompetitor,
8
+ compareFacts,
7
9
  extractCopy,
8
- extractStrings,
9
10
  fileKind,
11
+ formatComparison,
12
+ formatCompetitor,
10
13
  holdHouseBlocks,
11
14
  houseRule,
12
- scanSource
13
- } from "./chunk-GMVXADJ6.js";
15
+ pageCopy,
16
+ protectedFacts,
17
+ scanSource,
18
+ visibleText
19
+ } from "./chunk-LFNSB6W7.js";
14
20
 
15
21
  // src/character/config.ts
16
22
  var DEFAULT_IGNORE = [".claude", ".agents", ".cursor"];
@@ -205,87 +211,6 @@ function toSarif(report, options) {
205
211
  };
206
212
  }
207
213
 
208
- // src/character/facts.ts
209
- var MONTH = "(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)";
210
- var PATTERNS = [
211
- ["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
212
- ["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
213
- ["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
214
- ["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
215
- ["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
216
- ["date", new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH}\\b(?:\\s+\\d{4})?|\\b${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?\\b(?:,?\\s+\\d{4})?|\\b${MONTH}\\s+\\d{4}\\b|\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b|\\b(?:19|20)\\d{2}\\b`, "g")],
217
- ["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
218
- ["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
219
- ];
220
- var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
221
- var normalise = (kind, value) => {
222
- const v = value.trim();
223
- if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
224
- if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
225
- if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
226
- if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
227
- return v.replace(/\s+/g, " ").toLowerCase();
228
- };
229
- function visibleText(file) {
230
- const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
231
- return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
232
- }
233
- function protectedFacts(text) {
234
- const facts = [];
235
- let rest = text;
236
- const mask = (start, length) => {
237
- rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
238
- };
239
- for (const [kind, pattern] of PATTERNS) {
240
- for (const m of [...rest.matchAll(pattern)]) {
241
- const value = m[0].replace(/[.,;:]+$/, "");
242
- facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
243
- mask(m.index ?? 0, m[0].length);
244
- }
245
- }
246
- const lines = rest.split("\n");
247
- for (const line of lines) {
248
- if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
249
- for (const m of line.matchAll(NAME)) {
250
- const at = m.index ?? 0;
251
- const before = line.slice(0, at).trimEnd();
252
- const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
253
- const word = m[0];
254
- const acronym = /^[A-Z]{2,}$/.test(word);
255
- if (opensSentence && !acronym && !/\s/.test(word)) continue;
256
- if (word === "I") continue;
257
- facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
258
- }
259
- }
260
- return facts;
261
- }
262
- var unique = (facts) => [...new Map(facts.map((f) => [f.key, f])).values()];
263
- function compareFacts(before, after) {
264
- const a = unique(protectedFacts(before));
265
- const b = unique(protectedFacts(after));
266
- const inA = new Set(a.map((f) => f.key));
267
- const inB = new Set(b.map((f) => f.key));
268
- return {
269
- before: a.length,
270
- after: b.length,
271
- lost: a.filter((f) => !inB.has(f.key)),
272
- added: b.filter((f) => !inA.has(f.key))
273
- };
274
- }
275
- function formatComparison(result, beforePath, afterPath) {
276
- const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
277
- if (result.lost.length) {
278
- lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
279
- for (const f of result.lost) lines.push(` ${f.kind.padEnd(9)} ${f.value}`);
280
- }
281
- if (result.added.length) {
282
- lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
283
- for (const f of result.added) lines.push(` ${f.kind.padEnd(9)} ${f.value}`);
284
- }
285
- if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
286
- return lines.join("\n");
287
- }
288
-
289
214
  // src/character/claims.ts
290
215
  var CLAIM_KINDS = /* @__PURE__ */ new Set(["money", "number", "date"]);
291
216
  var PROPER = "[A-Z][\\w&.\u2019'-]*(?:\\s+(?:of|and|&|the|for)?\\s*[A-Z][\\w&.\u2019'-]*)*";
@@ -445,18 +370,20 @@ Usage
445
370
  craft scan [paths...] [--staged] [--json] [--strict] [--direction <file>] [ci options]
446
371
  craft copy [paths...] [--gate] [--json] [--strict] [--direction <file>] [ci options]
447
372
  craft copy compare <before> <after> [--json]
373
+ craft copy compare <url | snapshot.json> --competitor <url | snapshot.json> [--brief "<text>"] [--json]
448
374
  craft copy claims <paths...> [--json]
449
375
  craft tells list [--json]
450
376
  craft tells harvest <null.json | dir>... [--share 0.25] [--json]
451
377
  craft snapshot <url> [--out <file>] [--width <px>] [--height <px>]
452
378
  craft audit <url | snapshot.json> [--repo <dir>] [--null <null.json>] [--out <file>] [--json] [--strict] [ci options]
453
379
  craft audit --pages <sitemap.xml | urls.txt> [--viewport 390,768,1440] [the same]
454
- craft direction init [--snapshot <file>] [--client <name>] [--out <file>]
455
- craft direction validate [--snapshot <file>] [--direction <file>] [--json]
380
+ craft direction init [--snapshot <file | url>] [--client <name>] [--brief "<text>"] [--out <file>]
381
+ craft direction validate [--snapshot <file | url>] [--page home] [--estate <estate.json>] [--direction <file>] [--strict] [--json]
382
+ craft direction research [--brief "<who, where, what>"] [--visual]
456
383
  craft direction propose [--snapshot <file>] [--estate <estate.json | dir>] [--out <file>]
457
384
  craft null build --brief "<text>" --out <dir> [--runs 20] [--parallel 4] [--model <name>]
458
- craft estate add <url | snapshot.json> --id <id> [--client <name>] [--estate <file>]
459
- craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--json] [--strict]
385
+ craft estate add [<url | snapshot.json>] --id <id> [--client <name>] [--direction <art-direction.json>] [--estate <file>]
386
+ craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--directions] [--json] [--strict]
460
387
  craft report <url | snapshot.json> [--repo <dir>] [--null <dir>] [--estate <file>] [--direction <file>] [--json] [--strict]
461
388
  craft retrofit <url | snapshot.json> [the same] [--out RETROFIT.md]
462
389
 
@@ -468,6 +395,11 @@ copy The prose in Markdown, markup and content files.
468
395
  compare lists the protected facts (prices, numbers, dates, times,
469
396
  phones, emails, links, postcodes, names) a rewrite lost or added.
470
397
  Exit 1 if any: restore it, source it, or say why.
398
+ With --competitor, reads both rendered pages instead and lists the
399
+ hero and service sentences holding no specific (place, name,
400
+ number, price, job) the other page does not also say. The brief's
401
+ terms come from --brief, else art-direction.json, else --null's
402
+ null.json. A measure, not a gate: always exit 0.
471
403
  claims lists every sentence holding a price, figure, date or named
472
404
  source, marked sourced or UNSOURCED, for a person to check against
473
405
  the primary source. It cannot tell true from false. Always exit 0.
@@ -529,10 +461,12 @@ var VALUE_FLAGS = {
529
461
  "--sarif": "sarif",
530
462
  "--pages": "pages",
531
463
  "--viewport": "viewport",
532
- "--component": "component"
464
+ "--component": "component",
465
+ "--competitor": "competitor",
466
+ "--page": "page"
533
467
  };
534
468
  function parseFlags(args) {
535
- const flags = { positional: [], json: false, strict: false, staged: false, gate: false, updateBaseline: false };
469
+ const flags = { positional: [], json: false, strict: false, staged: false, gate: false, updateBaseline: false, directions: false, visual: false };
536
470
  for (let i = 0; i < args.length; i += 1) {
537
471
  const a = args[i];
538
472
  if (a === "--json") flags.json = true;
@@ -540,6 +474,8 @@ function parseFlags(args) {
540
474
  else if (a === "--staged") flags.staged = true;
541
475
  else if (a === "--gate") flags.gate = true;
542
476
  else if (a === "--update-baseline") flags.updateBaseline = true;
477
+ else if (a === "--directions") flags.directions = true;
478
+ else if (a === "--visual") flags.visual = true;
543
479
  else if (a in VALUE_FLAGS) {
544
480
  const value = args[i + 1];
545
481
  i += 1;
@@ -637,28 +573,69 @@ function finish(report, flags, title, io) {
637
573
  var SOURCE_FILE = (p) => ["markup", "script", "css"].includes(fileKind(p)) && !/\.d\.ts$|\.min\.js$/.test(p);
638
574
  var COPY_FILE = (p) => (["markup", "prose", "script"].includes(fileKind(p)) || /\.jsonl?$/i.test(p)) && !NOT_SITE_COPY.test(p.split("/").pop() ?? p) && !/(?:^|\/)(?:package(?:-lock)?|tsconfig[\w.-]*|art-direction)\.json$/.test(p);
639
575
  var NOT_COPY_DIR = (name) => name.startsWith("_");
576
+ async function pageSnapshot(target, cwd) {
577
+ const local = resolve(cwd, target);
578
+ if (/\.json$/i.test(target) && existsSync(local)) {
579
+ const { readSnapshot } = await import("./migrate-VIFAYIBM.js");
580
+ return readSnapshot(JSON.parse(readFileSync(local, "utf8")), target);
581
+ }
582
+ if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL nor a snapshot file`);
583
+ const { snapshotUrl } = await import("./audit-MTUC24XU.js");
584
+ return snapshotUrl(target);
585
+ }
586
+ async function briefFor(flags, cwd) {
587
+ if (flags.brief) return flags.brief;
588
+ const direction = resolve(cwd, flags.direction ?? "art-direction.json");
589
+ if (existsSync(direction)) {
590
+ const brief = JSON.parse(readFileSync(direction, "utf8")).brief;
591
+ if (typeof brief === "string" && brief.trim()) return brief;
592
+ } else if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
593
+ if (flags.null) {
594
+ const { loadNull } = await import("./cli-FLG3ZCAF.js");
595
+ return loadNull(flags.null, cwd).brief;
596
+ }
597
+ return void 0;
598
+ }
599
+ async function compareWithCompetitor(flags, io) {
600
+ try {
601
+ if (flags.positional.length !== 1 || !flags.competitor) throw new Error('usage: craft copy compare <url | snapshot.json> --competitor <url | snapshot.json> [--brief "<text>"] [--json]');
602
+ const brief = await briefFor(flags, io.cwd);
603
+ const [ours, theirs] = await Promise.all([pageSnapshot(flags.positional[0], io.cwd), pageSnapshot(flags.competitor, io.cwd)]);
604
+ const copy = [ours, theirs].map((snap, i) => {
605
+ const page = pageCopy(snap);
606
+ if (!page) throw new Error(`${[flags.positional[0], flags.competitor][i]} has no page text: snapshot it again with this craft`);
607
+ return page;
608
+ });
609
+ const result = compareCompetitor(copy[0], copy[1], brief);
610
+ io.out(flags.json ? toJson(result) : formatCompetitor(result));
611
+ return 0;
612
+ } catch (error) {
613
+ io.err(`craft: ${error.message}`);
614
+ return 2;
615
+ }
616
+ }
640
617
  function run(argv, io) {
641
618
  const [command, ...rest] = argv;
642
619
  if (command === "direction") {
643
- return import("./cli-DRKAMFAH.js").then((m) => m.runDirection(rest, io));
620
+ return import("./cli-7OZCYOQY.js").then((m) => m.runDirection(rest, io));
644
621
  }
645
622
  if (command === "report" || command === "retrofit") {
646
- return import("./cli-J5ECRCD4.js").then((m) => m.runReport(command, rest, io));
623
+ return import("./cli-FCKHYAVZ.js").then((m) => m.runReport(command, rest, io));
647
624
  }
648
625
  if (command === "estate") {
649
- return import("./cli-BKJMLD3H.js").then((m) => m.runEstate(rest, io));
626
+ return import("./cli-22TSWAX6.js").then((m) => m.runEstate(rest, io));
650
627
  }
651
628
  if (command === "null") {
652
- return import("./cli-ILH7OO5G.js").then((m) => m.runNull(rest, io));
629
+ return import("./cli-FLG3ZCAF.js").then((m) => m.runNull(rest, io));
653
630
  }
654
631
  if (command === "tells" && rest[0] === "harvest") {
655
- return import("./cli-ILH7OO5G.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
632
+ return import("./cli-FLG3ZCAF.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
656
633
  io.err(`craft: ${error.message}`);
657
634
  return 2;
658
635
  });
659
636
  }
660
637
  if (command === "snapshot" || command === "audit") {
661
- return import("./cli-N2IDXH7W.js").then((m) => m.runAudit(command, rest, io));
638
+ return import("./cli-5ACKICBO.js").then((m) => m.runAudit(command, rest, io));
662
639
  }
663
640
  if (!command || command === "help" || command === "--help" || command === "-h") {
664
641
  io.out(HELP);
@@ -690,6 +667,7 @@ function run(argv, io) {
690
667
  if (command === "copy" && rest[0] === "compare") {
691
668
  const flags = parseFlags(rest.slice(1));
692
669
  if (typeof flags === "string") throw new Error(flags);
670
+ if (flags.competitor) return compareWithCompetitor(flags, io);
693
671
  if (flags.positional.length !== 2) throw new Error("usage: craft copy compare <before> <after> [--json]");
694
672
  const [before, after] = flags.positional.map((p) => {
695
673
  const abs = resolve(io.cwd, p);
@@ -0,0 +1,27 @@
1
+ // src/null/generate.ts
2
+ import { spawn } from "child_process";
3
+ import { mkdtempSync, rmSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import { join } from "path";
6
+ function generate(prompt, model) {
7
+ const bin = process.env.CRAFT_CLAUDE ?? "claude";
8
+ const args = ["-p", "--tools", "", "--no-session-persistence", "--disable-slash-commands", "--setting-sources", "", "--strict-mcp-config", ...model ? ["--model", model] : [], prompt];
9
+ const cwd = mkdtempSync(join(tmpdir(), "craft-null-"));
10
+ return new Promise((done, fail) => {
11
+ const child = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
12
+ let out = "";
13
+ let err = "";
14
+ child.stdout.on("data", (d) => out += d.toString());
15
+ child.stderr.on("data", (d) => err += d.toString());
16
+ child.on("error", (e) => fail(new Error(`${bin}: ${e.message}. Install Claude Code, or set CRAFT_CLAUDE.`)));
17
+ child.on("close", (code) => {
18
+ rmSync(cwd, { recursive: true, force: true });
19
+ if (code === 0) done(out);
20
+ else fail(new Error(`${bin} exited ${code}: ${(err || out).trim().slice(0, 200)}`));
21
+ });
22
+ });
23
+ }
24
+
25
+ export {
26
+ generate
27
+ };
@@ -2,10 +2,12 @@ import {
2
2
  decodePng
3
3
  } from "./chunk-JYNXGCYZ.js";
4
4
  import {
5
- SNAPSHOT_VERSION,
6
5
  auditSnapshot,
7
6
  fingerprint
8
- } from "./chunk-GMVXADJ6.js";
7
+ } from "./chunk-LFNSB6W7.js";
8
+ import {
9
+ SNAPSHOT_VERSION
10
+ } from "./chunk-QYBAVU3P.js";
9
11
 
10
12
  // src/audit/collect.ts
11
13
  function collectInPage() {
@@ -218,7 +220,7 @@ function collectInPage() {
218
220
  let covered = 0;
219
221
  let minX = Infinity;
220
222
  let maxX = -Infinity;
221
- let textBlocks = 0;
223
+ let textBlocks2 = 0;
222
224
  let centred = 0;
223
225
  for (const n of leaves) {
224
226
  const r = box(n);
@@ -230,14 +232,14 @@ function collectInPage() {
230
232
  left += split * r.height;
231
233
  right += (r.width - split) * r.height;
232
234
  if (ownText(n).length > 1) {
233
- textBlocks += 1;
235
+ textBlocks2 += 1;
234
236
  const mid = r.left + r.width / 2;
235
237
  if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
236
238
  }
237
239
  }
238
240
  const round = (v) => Math.round(v * 1e3) / 1e3;
239
241
  return {
240
- centredShare: textBlocks ? round(centred / textBlocks) : 0,
242
+ centredShare: textBlocks2 ? round(centred / textBlocks2) : 0,
241
243
  mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
242
244
  whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
243
245
  contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
@@ -302,6 +304,43 @@ function collectInPage() {
302
304
  });
303
305
  return { badges, avatars, carousel };
304
306
  };
307
+ const TEXT_BLOCK = "h1, h2, h3, h4, h5, h6, p, li, blockquote, figcaption, dt, dd, td, th, summary, cite, q";
308
+ const CHROME = "nav, [role=navigation], footer, [role=contentinfo], button, [role=button], [aria-hidden=true], script, style, noscript, template";
309
+ const readable = (el) => {
310
+ if (!shown(el)) return false;
311
+ const r = el.getBoundingClientRect();
312
+ return r.width > 2 && r.height > 2;
313
+ };
314
+ const textBlocks = (scope, limit, keep = () => true) => {
315
+ const out = [];
316
+ const seen = /* @__PURE__ */ new Set();
317
+ const add = (el) => {
318
+ const t = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
319
+ if (t.length < 2 || seen.has(t)) return;
320
+ seen.add(t);
321
+ out.push(t);
322
+ };
323
+ for (const el of Array.from(scope.querySelectorAll(TEXT_BLOCK))) {
324
+ if (out.length >= limit) break;
325
+ if (el.parentElement?.closest(TEXT_BLOCK) || el.closest(CHROME) || !readable(el) || !keep(el)) continue;
326
+ add(el);
327
+ }
328
+ for (const el of Array.from(scope.querySelectorAll("div, span"))) {
329
+ if (out.length >= limit) break;
330
+ if (ownText(el).split(" ").length < 3) continue;
331
+ if (el.closest(TEXT_BLOCK) || el.closest(CHROME) || el.closest("a") || !readable(el) || !keep(el)) continue;
332
+ add(el);
333
+ }
334
+ return out;
335
+ };
336
+ const inChromeHeader = (el) => {
337
+ const h = el.closest("header");
338
+ return Boolean(h && !h.querySelector("h1"));
339
+ };
340
+ const firstScreenText = textBlocks(document.body, 12, (el) => {
341
+ const b = box(el);
342
+ return b.top < vh && b.top + b.height > 0 && !inChromeHeader(el) && !el.closest("a");
343
+ });
305
344
  const kept = blocks.slice(0, 40);
306
345
  const sections = kept.map((el, index) => {
307
346
  const b = box(el);
@@ -330,7 +369,8 @@ function collectInPage() {
330
369
  role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
331
370
  geometry,
332
371
  ...parts,
333
- figures: numbers
372
+ figures: numbers,
373
+ text: textBlocks(el, 40)
334
374
  };
335
375
  });
336
376
  if (header && sections.length === 0) {
@@ -507,6 +547,7 @@ function collectInPage() {
507
547
  })(),
508
548
  effects,
509
549
  images: images.slice(0, 60),
550
+ firstScreenText,
510
551
  motion: {
511
552
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
512
553
  sections: sections.length,
@@ -1,15 +1,23 @@
1
1
  import {
2
- loadNull
3
- } from "./chunk-ESCVTKPN.js";
2
+ compareDirections,
3
+ compareToDeclared,
4
+ formatDirectionMatches,
5
+ summariseDirection,
6
+ validateDirection
7
+ } from "./chunk-YDURAOYR.js";
4
8
  import {
5
- readSnapshot
6
- } from "./chunk-CZDOMIVS.js";
9
+ loadNull
10
+ } from "./chunk-RXXRXV3V.js";
7
11
  import {
8
12
  parseFlags,
9
13
  toJson
10
- } from "./chunk-3VTPI7BU.js";
14
+ } from "./chunk-MUVFECFC.js";
15
+ import {
16
+ readSnapshot
17
+ } from "./chunk-WUACK2HX.js";
11
18
  import {
12
19
  COMPONENT_ROLES,
20
+ SHARED_PART,
13
21
  auditSnapshot,
14
22
  closestComponents,
15
23
  estateComponentPairs,
@@ -18,7 +26,7 @@ import {
18
26
  groundName,
19
27
  hueName,
20
28
  shapeName
21
- } from "./chunk-GMVXADJ6.js";
29
+ } from "./chunk-LFNSB6W7.js";
22
30
 
23
31
  // src/estate/cli.ts
24
32
  import { existsSync, readFileSync, writeFileSync } from "fs";
@@ -37,7 +45,7 @@ function addSite(register, site) {
37
45
  }
38
46
  function sharedParts(a, b, d = fingerprintDistance(a, b)) {
39
47
  const out = [];
40
- const close = (k) => d.parts[k] <= 0.2;
48
+ const close = (k) => d.parts[k] <= SHARED_PART;
41
49
  if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
42
50
  if (close("type")) {
43
51
  if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
@@ -52,14 +60,14 @@ function sharedParts(a, b, d = fingerprintDistance(a, b)) {
52
60
  }
53
61
  function compareToEstate(target, register, options = {}) {
54
62
  const at = options.siblingAt ?? SIBLING_AT;
55
- return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
63
+ return register.sites.filter((s) => s.id !== options.exclude && s.fingerprint).map((s) => {
56
64
  const d = fingerprintDistance(target, s.fingerprint);
57
65
  return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target, s.fingerprint, d) };
58
66
  }).sort((a, b) => a.distance - b.distance);
59
67
  }
60
68
  function estatePairs(register, siblingAt = SIBLING_AT) {
61
69
  const out = [];
62
- const s = register.sites;
70
+ const s = register.sites.filter((site) => site.fingerprint);
63
71
  for (let i = 0; i < s.length; i += 1) {
64
72
  for (let j = i + 1; j < s.length; j += 1) {
65
73
  const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
@@ -81,7 +89,7 @@ function siblingLine(briefs) {
81
89
  }
82
90
 
83
91
  // src/estate/cli.ts
84
- var USAGE = "usage: craft estate add <url | snapshot.json> --id <id> [--client <name>] | craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--component <role>] [--json] [--strict]";
92
+ var USAGE = "usage: craft estate add [<url | snapshot.json>] --id <id> [--client <name>] [--direction <art-direction.json>] | craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--component <role>] [--directions] [--json] [--strict]";
85
93
  function loadEstate(path) {
86
94
  if (!existsSync(path)) return emptyEstate();
87
95
  const data = JSON.parse(readFileSync(path, "utf8"));
@@ -95,7 +103,7 @@ async function snapshotOf(target, cwd) {
95
103
  return snap;
96
104
  }
97
105
  if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL, a snapshot file, nor a site in the register`);
98
- const { snapshotUrl } = await import("./audit-5TYDYRXV.js");
106
+ const { snapshotUrl } = await import("./audit-MTUC24XU.js");
99
107
  return snapshotUrl(target);
100
108
  }
101
109
  var line = (distance, sibling, shared) => `${distance.toFixed(2)}${sibling ? " SIBLING" : ""}${shared.length ? ` shares ${shared.join(", ")}` : ""}`;
@@ -115,7 +123,7 @@ function componentRole(name) {
115
123
  if (!COMPONENT_ROLES.includes(name)) throw new Error(`--component takes one of ${COMPONENT_ROLES.join(", ")}`);
116
124
  return name;
117
125
  }
118
- var withRole = (register, role) => register.sites.filter((s) => (s.fingerprint.sections ?? []).some((x) => x.role === role)).length;
126
+ var withRole = (register, role) => register.sites.filter((s) => (s.fingerprint?.sections ?? []).some((x) => x.role === role)).length;
119
127
  function formatComponentPairs(role, pairs, measured, sites) {
120
128
  const lines = [`craft estate: ${role} components, ${measured} of ${sites} sites have one, closest pairs first (no sibling line yet: ranked, not judged)`];
121
129
  for (const p of pairs) lines.push(` ${`${p.a} + ${p.b}`.padEnd(34)} ${p.distance.toFixed(2)}${p.shared.length ? ` shares ${p.shared.join(", ")}` : ""}`);
@@ -134,6 +142,19 @@ async function runEstate(args, io) {
134
142
  const file = resolve(io.cwd, flags.estate ?? "estate.json");
135
143
  const register = loadEstate(file);
136
144
  const at = flags.null ? siblingLine(flags.null.split(",").map((p) => loadNull(p.trim(), io.cwd).runs.map((r) => r.fingerprint))) : SIBLING_AT;
145
+ const directionFile = flags.direction ? JSON.parse(readFileSync(resolve(io.cwd, flags.direction), "utf8")) : void 0;
146
+ if (directionFile && !validateDirection(directionFile).valid) throw new Error("the direction has errors: run craft direction validate before registering it");
147
+ const declared = () => summariseDirection(directionFile);
148
+ if (sub === "add" && !target && flags.id && flags.direction) {
149
+ const existing = register.sites.find((s) => s.id === flags.id);
150
+ const direction = declared();
151
+ const next = addSite(register, { ...existing ?? { id: flags.id, client: flags.client ?? directionFile.client, url: "", tells: [], addedAt: (/* @__PURE__ */ new Date()).toISOString() }, direction });
152
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}
153
+ `);
154
+ io.out(`craft estate: ${flags.id} direction recorded.`);
155
+ io.out(formatDirectionMatches(flags.id, compareToDeclared(direction, next.sites, flags.id)));
156
+ return 0;
157
+ }
137
158
  if (sub === "add") {
138
159
  if (!target || !flags.id) throw new Error(USAGE);
139
160
  const snap = await snapshotOf(target, io.cwd);
@@ -144,7 +165,8 @@ async function runEstate(args, io) {
144
165
  url: snap.url,
145
166
  fingerprint: fp,
146
167
  tells: [...new Set(auditSnapshot(snap).findings.map((f) => f.tell))].sort(),
147
- addedAt: snap.capturedAt
168
+ addedAt: snap.capturedAt,
169
+ ...flags.direction ? { direction: declared() } : register.sites.find((s) => s.id === flags.id)?.direction ? { direction: register.sites.find((s) => s.id === flags.id).direction } : {}
148
170
  });
149
171
  writeFileSync(file, `${JSON.stringify(next, null, 2)}
150
172
  `);
@@ -152,6 +174,17 @@ async function runEstate(args, io) {
152
174
  io.out(formatMatches(flags.id, compareToEstate(fp, next, { exclude: flags.id, siblingAt: at }).slice(0, 3), at));
153
175
  return 0;
154
176
  }
177
+ if (sub === "compare" && flags.directions) {
178
+ const withDirection = register.sites.filter((s) => s.direction);
179
+ const pairs = withDirection.flatMap((a, i) => withDirection.slice(i + 1).map((b) => ({ a: a.id, b: b.id, ...compareDirections(a.direction, b.direction) })));
180
+ pairs.sort((x, y) => y.shared.length - x.shared.length);
181
+ if (flags.json) io.out(toJson({ sites: withDirection.length, pairs }));
182
+ else {
183
+ io.out(`craft estate: ${withDirection.length} of ${register.sites.length} sites have a declared direction, ${pairs.filter((p) => p.flagged).length} pair(s) worth a look (ranked, not judged)`);
184
+ for (const p of pairs) io.out(` ${`${p.a} + ${p.b}`.padEnd(34)} ${p.flagged ? "LOOK " : " "}${p.shared.length ? `shares ${p.shared.join(", ")}` : "nothing shared"}`);
185
+ }
186
+ return 0;
187
+ }
155
188
  if (sub === "compare" && flags.component) {
156
189
  const role = componentRole(flags.component);
157
190
  if (!target) {
@@ -161,9 +194,10 @@ async function runEstate(args, io) {
161
194
  return 0;
162
195
  }
163
196
  const known = register.sites.find((s) => s.id === target);
197
+ if (known && !known.fingerprint) throw new Error(`${target} has a declared direction only; add a snapshot to compare rendered components`);
164
198
  const fp = known ? known.fingerprint : fingerprint(await snapshotOf(target, io.cwd));
165
199
  if (!(fp.sections ?? []).some((x) => x.role === role)) throw new Error(`${known?.id ?? target} has no ${role} section measured (roles need snapshot version 2)`);
166
- const pairs = register.sites.filter((s) => s.id !== known?.id).flatMap((s) => {
200
+ const pairs = register.sites.filter((s) => s.id !== known?.id && s.fingerprint).flatMap((s) => {
167
201
  const c = closestComponents(fp, s.fingerprint, role);
168
202
  return c ? [{ a: known?.id ?? target, b: s.id, ...c }] : [];
169
203
  }).sort((x, y) => x.distance - y.distance);
@@ -177,6 +211,7 @@ async function runEstate(args, io) {
177
211
  return flags.strict && pairs.some((p) => p.sibling) ? 1 : 0;
178
212
  }
179
213
  const known = register.sites.find((s) => s.id === target);
214
+ if (known && !known.fingerprint) throw new Error(`${target} has a declared direction only; add a snapshot to compare rendered sites`);
180
215
  const fp = known ? known.fingerprint : fingerprint(await snapshotOf(target, io.cwd));
181
216
  const matches = compareToEstate(fp, register, { exclude: known?.id, siblingAt: at });
182
217
  io.out(flags.json ? toJson({ siblingAt: at, matches }) : formatMatches(known?.id ?? target, matches, at));
@@ -0,0 +1,6 @@
1
+ // src/snapshot/types.ts
2
+ var SNAPSHOT_VERSION = 2;
3
+
4
+ export {
5
+ SNAPSHOT_VERSION
6
+ };
@@ -1,10 +1,13 @@
1
1
  import {
2
- readSnapshot
3
- } from "./chunk-CZDOMIVS.js";
2
+ generate
3
+ } from "./chunk-OQXTQQAU.js";
4
4
  import {
5
5
  parseFlags,
6
6
  toJson
7
- } from "./chunk-3VTPI7BU.js";
7
+ } from "./chunk-MUVFECFC.js";
8
+ import {
9
+ readSnapshot
10
+ } from "./chunk-WUACK2HX.js";
8
11
  import {
9
12
  CATALOGUE_VERSION,
10
13
  NULL_VERSION,
@@ -17,34 +20,14 @@ import {
17
20
  hueSwatch,
18
21
  nullPrompt,
19
22
  scanSource
20
- } from "./chunk-GMVXADJ6.js";
23
+ } from "./chunk-LFNSB6W7.js";
21
24
 
22
25
  // src/null/cli.ts
23
- import { spawn } from "child_process";
24
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
25
- import { tmpdir } from "os";
26
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
26
27
  import { join, relative, resolve } from "path";
27
28
  import { pathToFileURL } from "url";
28
29
  var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
29
30
  var pad = (n) => String(n).padStart(2, "0");
30
- function generate(prompt, model) {
31
- const bin = process.env.CRAFT_CLAUDE ?? "claude";
32
- const args = ["-p", "--tools", "", "--no-session-persistence", "--disable-slash-commands", "--setting-sources", "", "--strict-mcp-config", ...model ? ["--model", model] : [], prompt];
33
- const cwd = mkdtempSync(join(tmpdir(), "craft-null-"));
34
- return new Promise((done, fail) => {
35
- const child = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
36
- let out = "";
37
- let err = "";
38
- child.stdout.on("data", (d) => out += d.toString());
39
- child.stderr.on("data", (d) => err += d.toString());
40
- child.on("error", (e) => fail(new Error(`${bin}: ${e.message}. Install Claude Code, or set CRAFT_CLAUDE.`)));
41
- child.on("close", (code) => {
42
- rmSync(cwd, { recursive: true, force: true });
43
- if (code === 0) done(out);
44
- else fail(new Error(`${bin} exited ${code}: ${(err || out).trim().slice(0, 200)}`));
45
- });
46
- });
47
- }
48
31
  async function pool(items, size, work) {
49
32
  const queue = [...items];
50
33
  await Promise.all(Array.from({ length: Math.max(1, Math.min(size, queue.length)) }, async () => {
@@ -115,7 +98,7 @@ async function build(flags, io) {
115
98
  const toShoot = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && !existsSync(join(shots, `${id}.json`)));
116
99
  if (toShoot.length) {
117
100
  io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"}`);
118
- const { snapshotUrls } = await import("./audit-5TYDYRXV.js");
101
+ const { snapshotUrls } = await import("./audit-MTUC24XU.js");
119
102
  const results = await snapshotUrls(toShoot.map((id) => pathToFileURL(join(pages, `${id}.html`)).href));
120
103
  results.forEach((r, i) => {
121
104
  const id = toShoot[i];
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SNAPSHOT_VERSION
3
- } from "./chunk-GMVXADJ6.js";
3
+ } from "./chunk-QYBAVU3P.js";
4
4
 
5
5
  // src/snapshot/migrate.ts
6
6
  var READABLE_SNAPSHOT_VERSIONS = [1, 2];
@@ -15,5 +15,6 @@ function readSnapshot(data, source = "snapshot") {
15
15
  }
16
16
 
17
17
  export {
18
+ READABLE_SNAPSHOT_VERSIONS,
18
19
  readSnapshot
19
20
  };