@domandigital/craft 0.11.0 → 0.12.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.
@@ -3,19 +3,21 @@ import {
3
3
  hueName,
4
4
  loadNull,
5
5
  shapeName
6
- } from "./chunk-3OZQ4C5R.js";
6
+ } from "./chunk-LHGY43LL.js";
7
+ import {
8
+ readSnapshot
9
+ } from "./chunk-ZKVJ7K6X.js";
7
10
  import {
8
11
  parseFlags,
9
12
  toJson
10
- } from "./chunk-2AJB27BJ.js";
13
+ } from "./chunk-J3QUROJ5.js";
11
14
  import {
12
15
  fingerprint,
13
16
  fingerprintDistance
14
- } from "./chunk-QMYERCBB.js";
17
+ } from "./chunk-PZ4MF2VV.js";
15
18
  import {
16
- SNAPSHOT_VERSION,
17
19
  auditSnapshot
18
- } from "./chunk-3NGWXZZE.js";
20
+ } from "./chunk-AL6MRG6V.js";
19
21
 
20
22
  // src/estate/cli.ts
21
23
  import { existsSync, readFileSync, writeFileSync } from "fs";
@@ -88,12 +90,11 @@ function loadEstate(path) {
88
90
  async function snapshotOf(target, cwd) {
89
91
  const local = resolve(cwd, target);
90
92
  if (/\.json$/i.test(target) && existsSync(local)) {
91
- const snap = JSON.parse(readFileSync(local, "utf8"));
92
- if (snap.version !== SNAPSHOT_VERSION) throw new Error(`${target} is not a snapshot`);
93
+ const snap = readSnapshot(JSON.parse(readFileSync(local, "utf8")), target);
93
94
  return snap;
94
95
  }
95
96
  if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL, a snapshot file, nor a site in the register`);
96
- const { snapshotUrl } = await import("./audit-QIPXL7E4.js");
97
+ const { snapshotUrl } = await import("./audit-5QWFLZ6L.js");
97
98
  return snapshotUrl(target);
98
99
  }
99
100
  var line = (distance, sibling, shared) => `${distance.toFixed(2)}${sibling ? " SIBLING" : ""}${shared.length ? ` shares ${shared.join(", ")}` : ""}`;
@@ -1,14 +1,16 @@
1
1
  import {
2
2
  CATALOGUE,
3
3
  CATALOGUE_VERSION,
4
+ HOUSE_LOCKED,
4
5
  applyHouseGate,
5
6
  checkCopy,
6
7
  extractCopy,
7
8
  extractStrings,
8
9
  fileKind,
10
+ holdHouseBlocks,
9
11
  houseRule,
10
12
  scanSource
11
- } from "./chunk-3NGWXZZE.js";
13
+ } from "./chunk-AL6MRG6V.js";
12
14
 
13
15
  // src/character/config.ts
14
16
  var DEFAULT_IGNORE = [".claude", ".agents", ".cursor"];
@@ -637,25 +639,25 @@ var NOT_COPY_DIR = (name) => name.startsWith("_");
637
639
  function run(argv, io) {
638
640
  const [command, ...rest] = argv;
639
641
  if (command === "direction") {
640
- return import("./cli-U3SZVHSK.js").then((m) => m.runDirection(rest, io));
642
+ return import("./cli-ZPJIWVEP.js").then((m) => m.runDirection(rest, io));
641
643
  }
642
644
  if (command === "report" || command === "retrofit") {
643
- return import("./cli-DBZMVYYV.js").then((m) => m.runReport(command, rest, io));
645
+ return import("./cli-ZB2VJ7CY.js").then((m) => m.runReport(command, rest, io));
644
646
  }
645
647
  if (command === "estate") {
646
- return import("./cli-VREYWCT4.js").then((m) => m.runEstate(rest, io));
648
+ return import("./cli-2DY3V3SF.js").then((m) => m.runEstate(rest, io));
647
649
  }
648
650
  if (command === "null") {
649
- return import("./cli-4ESXOLKV.js").then((m) => m.runNull(rest, io));
651
+ return import("./cli-C3JLFXMG.js").then((m) => m.runNull(rest, io));
650
652
  }
651
653
  if (command === "tells" && rest[0] === "harvest") {
652
- return import("./cli-4ESXOLKV.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
654
+ return import("./cli-C3JLFXMG.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
653
655
  io.err(`craft: ${error.message}`);
654
656
  return 2;
655
657
  });
656
658
  }
657
659
  if (command === "snapshot" || command === "audit") {
658
- return import("./cli-T5RD2BTK.js").then((m) => m.runAudit(command, rest, io));
660
+ return import("./cli-QM2AQQMF.js").then((m) => m.runAudit(command, rest, io));
659
661
  }
660
662
  if (!command || command === "help" || command === "--help" || command === "-h") {
661
663
  io.out(HELP);
@@ -713,10 +715,22 @@ function run(argv, io) {
713
715
  const paths = flags.positional.length > 0 ? flags.positional : command === "copy" ? config.copyPaths ?? [] : ["."];
714
716
  if (command === "copy" && paths.length === 0 && !flags.staged) throw new Error("usage: craft copy <paths...> (or copyPaths in craft.config.json)");
715
717
  const files = flags.staged ? readStaged(io.cwd, wanted, config.ignore) : readPaths(paths, io.cwd, wanted, command === "copy" ? NOT_COPY_DIR : void 0, config.ignore);
716
- const exceptions = loadExceptions(flags, io.cwd);
718
+ let exceptions = loadExceptions(flags, io.cwd);
719
+ let effective = config;
720
+ let refused = [];
721
+ if (command === "copy" && flags.gate) {
722
+ const held = holdHouseBlocks(exceptions, config.severity);
723
+ exceptions = held.exceptions;
724
+ effective = { ...config, severity: held.severity };
725
+ const warned = held.refusedSeverity.filter((tell) => config.severity[tell].level === "warn").map((tell) => ({ tell, because: `craft.config.json: ${config.severity[tell].because}` }));
726
+ refused = [...held.refused, ...warned];
727
+ }
717
728
  let report = command === "scan" ? scanSource(files, { exceptions }) : checkCopy(files, { exceptions });
718
- if (command === "copy" && flags.gate) report = applyHouseGate(report);
719
- const prepared = prepareReport(report, flags, io, config);
729
+ if (command === "copy" && flags.gate) {
730
+ report = applyHouseGate(report);
731
+ report = { ...report, rejectedExceptions: [...report.rejectedExceptions, ...refused.map((exception) => ({ exception, reason: HOUSE_LOCKED }))] };
732
+ }
733
+ const prepared = prepareReport(report, flags, io, effective);
720
734
  if (!prepared) return 0;
721
735
  return finish(prepared, flags, command === "scan" ? "craft scan" : "craft copy", io);
722
736
  }
@@ -0,0 +1,75 @@
1
+ // src/direction/png.ts
2
+ import { inflateSync } from "zlib";
3
+ function decodePng(buf) {
4
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
5
+ if (!sig.every((b, i) => buf[i] === b)) throw new Error("not a PNG");
6
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
7
+ let pos = 8;
8
+ let width = 0;
9
+ let height = 0;
10
+ let depth = 0;
11
+ let colour = 0;
12
+ let interlace = 0;
13
+ const idat = [];
14
+ while (pos < buf.length) {
15
+ const len = view.getUint32(pos);
16
+ const type = String.fromCharCode(...buf.subarray(pos + 4, pos + 8));
17
+ const data = buf.subarray(pos + 8, pos + 8 + len);
18
+ if (type === "IHDR") {
19
+ width = view.getUint32(pos + 8);
20
+ height = view.getUint32(pos + 12);
21
+ depth = data[8];
22
+ colour = data[9];
23
+ interlace = data[12];
24
+ } else if (type === "IDAT") idat.push(data);
25
+ else if (type === "IEND") break;
26
+ pos += 12 + len;
27
+ }
28
+ const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colour];
29
+ if (depth !== 8 || !channels || interlace !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour type ${colour}${interlace ? ", interlaced" : ""}); export it as 8-bit RGB`);
30
+ const raw = inflateSync(Buffer.concat(idat.map((d) => Buffer.from(d))));
31
+ const stride = width * channels;
32
+ const out = new Uint8Array(width * height * 4);
33
+ const prev = new Uint8Array(stride);
34
+ const line = new Uint8Array(stride);
35
+ for (let y = 0; y < height; y += 1) {
36
+ const filter = raw[y * (stride + 1)];
37
+ const src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
38
+ for (let x = 0; x < stride; x += 1) {
39
+ const a = x >= channels ? line[x - channels] : 0;
40
+ const b = prev[x];
41
+ const c = x >= channels ? prev[x - channels] : 0;
42
+ let v = src[x];
43
+ if (filter === 1) v += a;
44
+ else if (filter === 2) v += b;
45
+ else if (filter === 3) v += a + b >> 1;
46
+ else if (filter === 4) {
47
+ const p = a + b - c;
48
+ const pa = Math.abs(p - a);
49
+ const pb = Math.abs(p - b);
50
+ const pc = Math.abs(p - c);
51
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
52
+ }
53
+ line[x] = v & 255;
54
+ }
55
+ for (let x = 0; x < width; x += 1) {
56
+ const i = x * channels;
57
+ const o = (y * width + x) * 4;
58
+ if (channels >= 3) {
59
+ out[o] = line[i];
60
+ out[o + 1] = line[i + 1];
61
+ out[o + 2] = line[i + 2];
62
+ out[o + 3] = channels === 4 ? line[i + 3] : 255;
63
+ } else {
64
+ out[o] = out[o + 1] = out[o + 2] = line[i];
65
+ out[o + 3] = channels === 2 ? line[i + 1] : 255;
66
+ }
67
+ }
68
+ prev.set(line);
69
+ }
70
+ return { width, height, rgba: out };
71
+ }
72
+
73
+ export {
74
+ decodePng
75
+ };
@@ -1,11 +1,14 @@
1
+ import {
2
+ readSnapshot
3
+ } from "./chunk-ZKVJ7K6X.js";
1
4
  import {
2
5
  parseFlags,
3
6
  toJson
4
- } from "./chunk-2AJB27BJ.js";
7
+ } from "./chunk-J3QUROJ5.js";
5
8
  import {
6
9
  fingerprint,
7
10
  fingerprintDistance
8
- } from "./chunk-QMYERCBB.js";
11
+ } from "./chunk-PZ4MF2VV.js";
9
12
  import {
10
13
  CATALOGUE_VERSION,
11
14
  REFLEX_FONTS_1,
@@ -19,7 +22,7 @@ import {
19
22
  isCream,
20
23
  oklchToRgb,
21
24
  scanSource
22
- } from "./chunk-3NGWXZZE.js";
25
+ } from "./chunk-AL6MRG6V.js";
23
26
 
24
27
  // src/null/cli.ts
25
28
  import { spawn } from "child_process";
@@ -353,7 +356,7 @@ async function build(flags, io) {
353
356
  const toShoot = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && !existsSync(join(shots, `${id}.json`)));
354
357
  if (toShoot.length) {
355
358
  io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"}`);
356
- const { snapshotUrls } = await import("./audit-QIPXL7E4.js");
359
+ const { snapshotUrls } = await import("./audit-5QWFLZ6L.js");
357
360
  const results = await snapshotUrls(toShoot.map((id) => pathToFileURL(join(pages, `${id}.html`)).href));
358
361
  results.forEach((r, i) => {
359
362
  const id = toShoot[i];
@@ -362,7 +365,7 @@ async function build(flags, io) {
362
365
  else failed.push(`${id}: the page would not render: ${r.error}`);
363
366
  });
364
367
  }
365
- const measured = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && existsSync(join(shots, `${id}.json`))).map((id) => measureRun(id, readFileSync(join(pages, `${id}.html`), "utf8"), readJson(join(shots, `${id}.json`))));
368
+ const measured = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && existsSync(join(shots, `${id}.json`))).map((id) => measureRun(id, readFileSync(join(pages, `${id}.html`), "utf8"), readSnapshot(readJson(join(shots, `${id}.json`)), `${id}.json`)));
366
369
  const model = {
367
370
  version: NULL_VERSION,
368
371
  brief: brief.trim(),
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  snapshotUrl,
3
3
  snapshotUrls
4
- } from "./chunk-JP75P2DU.js";
4
+ } from "./chunk-ULPI335S.js";
5
5
  import {
6
6
  describeTypicality,
7
7
  loadNull,
8
8
  typicality
9
- } from "./chunk-3OZQ4C5R.js";
9
+ } from "./chunk-LHGY43LL.js";
10
+ import {
11
+ readSnapshot
12
+ } from "./chunk-ZKVJ7K6X.js";
10
13
  import {
11
14
  SOURCE_FILE,
12
15
  finish,
@@ -16,15 +19,14 @@ import {
16
19
  prepareReport,
17
20
  readPaths,
18
21
  toJson
19
- } from "./chunk-2AJB27BJ.js";
22
+ } from "./chunk-J3QUROJ5.js";
20
23
  import {
21
24
  fingerprint
22
- } from "./chunk-QMYERCBB.js";
25
+ } from "./chunk-PZ4MF2VV.js";
23
26
  import {
24
- SNAPSHOT_VERSION,
25
27
  auditSnapshot,
26
28
  scanSource
27
- } from "./chunk-3NGWXZZE.js";
29
+ } from "./chunk-AL6MRG6V.js";
28
30
 
29
31
  // src/audit/cli.ts
30
32
  import { existsSync, readFileSync, writeFileSync } from "fs";
@@ -81,10 +83,8 @@ function merge(a, b) {
81
83
  }
82
84
  };
83
85
  }
84
- function readSnapshot(path) {
85
- const data = JSON.parse(readFileSync(path, "utf8"));
86
- if (data.version !== SNAPSHOT_VERSION) throw new Error(`${path} is snapshot version ${String(data.version)}; this craft reads ${SNAPSHOT_VERSION}`);
87
- return data;
86
+ function readSnapshotFile(path) {
87
+ return readSnapshot(JSON.parse(readFileSync(path, "utf8")), path);
88
88
  }
89
89
  async function readText(source, cwd) {
90
90
  if (/^https?:\/\//i.test(source)) {
@@ -123,7 +123,7 @@ async function runAudit(command, args, io) {
123
123
  const failed = [];
124
124
  const local = target ? resolve(io.cwd, target) : "";
125
125
  if (target && command === "audit" && /\.json$/i.test(target) && existsSync(local)) {
126
- audited.push({ url: target, viewport: null, snapshot: readSnapshot(local) });
126
+ audited.push({ url: target, viewport: null, snapshot: readSnapshotFile(local) });
127
127
  } else if (target && viewports.length === 1) {
128
128
  const snapshot = await snapshotUrl(target, { viewport: viewports[0] });
129
129
  audited.push({ url: target, viewport: viewports[0] ?? null, snapshot });
@@ -3,10 +3,10 @@ import {
3
3
  fontClass,
4
4
  normaliseFamily,
5
5
  parseColour
6
- } from "./chunk-3NGWXZZE.js";
6
+ } from "./chunk-AL6MRG6V.js";
7
7
 
8
8
  // src/fingerprint/index.ts
9
- var FINGERPRINT_VERSION = 1;
9
+ var FINGERPRINT_VERSION = 2;
10
10
  var CHROMATIC = 0.03;
11
11
  var MIN_ACCENT_AREA = 2e4;
12
12
  var oklch = (value) => {
@@ -61,23 +61,36 @@ function fingerprint(s) {
61
61
  roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
62
62
  motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
63
63
  effects: effects.sort(),
64
- layout: s.sections.map((x) => x.kind)
64
+ layout: s.sections.map((x) => x.kind),
65
+ ...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? { sections: s.sections.map((x) => ({ role: x.role, centred: x.geometry.centredShare, width: x.geometry.contentWidthRatio })) } : {}
65
66
  };
66
67
  }
67
68
  var DISTANCE_WEIGHTS = { accent: 0.25, type: 0.25, ground: 0.1, shape: 0.1, motion: 0.1, effects: 0.1, layout: 0.1 };
68
- function levenshtein(a, b) {
69
+ function levenshtein(a, b, cost = (x, y) => x === y ? 0 : 1) {
69
70
  const row = Array.from({ length: b.length + 1 }, (_, i) => i);
70
71
  for (let i = 1; i <= a.length; i += 1) {
71
72
  let prev = row[0];
72
73
  row[0] = i;
73
74
  for (let j = 1; j <= b.length; j += 1) {
74
75
  const cur = row[j];
75
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
76
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost(a[i - 1], b[j - 1]));
76
77
  prev = cur;
77
78
  }
78
79
  }
79
80
  return row[b.length];
80
81
  }
82
+ function sectionCost(x, y) {
83
+ if (x.role !== y.role) return 1;
84
+ return Math.min(0.5, (Math.abs(x.centred - y.centred) + Math.abs(x.width - y.width)) / 2);
85
+ }
86
+ function layoutDistance(a, b) {
87
+ if (a.sections && b.sections) {
88
+ const longest2 = Math.max(a.sections.length, b.sections.length);
89
+ return longest2 === 0 ? 0 : levenshtein(a.sections, b.sections, sectionCost) / longest2;
90
+ }
91
+ const longest = Math.max(a.layout.length, b.layout.length);
92
+ return longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
93
+ }
81
94
  var clamp = (n) => Math.max(0, Math.min(1, n));
82
95
  function faceDistance(a, b) {
83
96
  if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
@@ -92,8 +105,7 @@ function fingerprintDistance(a, b) {
92
105
  const union = /* @__PURE__ */ new Set([...a.effects, ...b.effects]);
93
106
  const shared = a.effects.filter((e) => b.effects.includes(e)).length;
94
107
  const effects = union.size === 0 ? 0 : 1 - shared / union.size;
95
- const longest = Math.max(a.layout.length, b.layout.length);
96
- const layout = longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
108
+ const layout = layoutDistance(a, b);
97
109
  const parts = { accent, ground, type, shape, motion, effects, layout };
98
110
  const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts[k], 0);
99
111
  return { total: Math.round(total * 1e3) / 1e3, parts };
@@ -6,7 +6,115 @@ import {
6
6
  isCream,
7
7
  normaliseFamily,
8
8
  parseColour
9
- } from "./chunk-3NGWXZZE.js";
9
+ } from "./chunk-AL6MRG6V.js";
10
+
11
+ // src/direction/licences.ts
12
+ var LICENCES = [
13
+ {
14
+ name: "Basteleur",
15
+ subject: "face",
16
+ licence: "OFL-1.1",
17
+ multiClient: "yes",
18
+ checked: "2026-09-24",
19
+ source: "https://velvetyne.fr/fonts/basteleur/",
20
+ note: "Velvetyne, designed by Keussel. Keep the OFL notice with the font files."
21
+ },
22
+ {
23
+ name: "Gulax",
24
+ subject: "face",
25
+ licence: "OFL-1.1",
26
+ multiClient: "yes",
27
+ checked: "2026-09-24",
28
+ source: "https://velvetyne.fr/fonts/gulax/",
29
+ note: "Velvetyne. Keep the OFL notice with the font files."
30
+ },
31
+ {
32
+ name: "Le Murmure",
33
+ subject: "face",
34
+ licence: "OFL-1.1",
35
+ multiClient: "yes",
36
+ checked: "2026-09-24",
37
+ source: "https://velvetyne.fr/fonts/le-murmure/",
38
+ note: "Velvetyne. Keep the OFL notice with the font files."
39
+ },
40
+ {
41
+ name: "Blaze Type",
42
+ subject: "foundry",
43
+ licence: "commercial",
44
+ multiClient: "per-site",
45
+ cap: 10,
46
+ checked: "2026-09-24",
47
+ source: "https://blazetype.eu/eula",
48
+ 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."
49
+ },
50
+ {
51
+ name: "Power Type",
52
+ subject: "foundry",
53
+ licence: "commercial",
54
+ multiClient: "unverified",
55
+ checked: "2026-09-24",
56
+ source: "https://power-type.com/eula",
57
+ 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."
58
+ },
59
+ {
60
+ name: "Grilli Type",
61
+ subject: "foundry",
62
+ licence: "commercial",
63
+ multiClient: "per-site",
64
+ checked: "2026-09-24",
65
+ source: "https://www.grillitype.com/information",
66
+ 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."
67
+ },
68
+ {
69
+ name: "Phosphor",
70
+ subject: "icons",
71
+ licence: "MIT",
72
+ multiClient: "yes",
73
+ checked: "2026-09-24",
74
+ source: "https://github.com/phosphor-icons/core/blob/main/LICENSE",
75
+ note: "Six weights. The icon-tile tells treat it exactly as they treat Lucide: the library is not the tell, the tinted tile is."
76
+ },
77
+ {
78
+ name: "Tabler Icons",
79
+ subject: "icons",
80
+ licence: "MIT",
81
+ multiClient: "yes",
82
+ checked: "2026-09-24",
83
+ source: "https://github.com/tabler/tabler-icons/blob/main/LICENSE",
84
+ note: "One stroke grid across the set."
85
+ },
86
+ {
87
+ name: "Iconoir",
88
+ subject: "icons",
89
+ licence: "MIT",
90
+ multiClient: "yes",
91
+ checked: "2026-09-24",
92
+ source: "https://github.com/iconoir-icons/iconoir/blob/main/LICENSE",
93
+ note: "Smaller set, no paid tier."
94
+ },
95
+ {
96
+ name: "Unicons",
97
+ subject: "icons",
98
+ licence: "IconScout Simple License",
99
+ multiClient: "yes",
100
+ checked: "2026-09-24",
101
+ source: "https://github.com/Iconscout/unicons/blob/master/LICENSE",
102
+ note: "IconScout's free set, personal and commercial use, attribution optional."
103
+ },
104
+ {
105
+ name: "IconScout premium",
106
+ subject: "icons",
107
+ licence: "commercial",
108
+ multiClient: "per-site",
109
+ checked: "2026-09-24",
110
+ source: "https://iconscout.com/licenses",
111
+ 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."
112
+ }
113
+ ];
114
+ function faceLicence(family) {
115
+ const name = family.trim().toLowerCase();
116
+ return LICENCES.find((l) => l.subject === "face" && l.name.toLowerCase() === name);
117
+ }
10
118
 
11
119
  // src/direction/types.ts
12
120
  var DIRECTION_VERSION = 1;
@@ -150,6 +258,18 @@ function validateDirection(input, ctx = {}) {
150
258
  ok = false;
151
259
  }
152
260
  }
261
+ if ((key === "display" || key === "body") && typeof c.value === "string" && c.value.trim()) {
262
+ const family = normaliseFamily(c.value);
263
+ const entry = faceLicence(family);
264
+ if (!entry) {
265
+ 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.`);
266
+ } else if (entry.multiClient === "unverified") {
267
+ warn(`${at}.value`, `${family}: licence unverified (${entry.note})`);
268
+ } else if (entry.multiClient === "capped" || entry.multiClient === "per-site") {
269
+ const scope = entry.multiClient === "capped" ? `covers up to ${entry.cap} sites` : "needs a licence for this site, in the client's name";
270
+ warn(`${at}.value`, `${family}: ${entry.licence} licence ${scope}. Record the purchase before launch.`);
271
+ }
272
+ }
153
273
  const fp = ctx.fingerprint;
154
274
  if (fp && typeof c.value === "string") {
155
275
  if ((key === "accent" || key === "ground") && parseColour(c.value)) {