@domandigital/craft 0.11.0 → 0.13.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.
@@ -1,10 +1,11 @@
1
1
  import {
2
- fingerprint
3
- } from "./chunk-QMYERCBB.js";
2
+ decodePng
3
+ } from "./chunk-JYNXGCYZ.js";
4
4
  import {
5
5
  SNAPSHOT_VERSION,
6
- auditSnapshot
7
- } from "./chunk-3NGWXZZE.js";
6
+ auditSnapshot,
7
+ fingerprint
8
+ } from "./chunk-GMVXADJ6.js";
8
9
 
9
10
  // src/audit/collect.ts
10
11
  function collectInPage() {
@@ -103,18 +104,26 @@ function collectInPage() {
103
104
  top: Math.round(box(h).top)
104
105
  };
105
106
  });
107
+ const isButtonEl = (el) => el.tagName === "BUTTON" || el.tagName === "INPUT" && /^(submit|button)$/i.test(el.type);
108
+ const isControl = (el) => {
109
+ const isButton = isButtonEl(el);
110
+ if (!isButton && el.tagName !== "A") return false;
111
+ if (!shown(el)) return false;
112
+ const cs = style(el);
113
+ const b = box(el);
114
+ const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
115
+ if (!label || b.height < 24 || b.height > 90) return false;
116
+ if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) return false;
117
+ if (!isButton && px(cs.paddingLeft) < 8) return false;
118
+ return true;
119
+ };
106
120
  const controls = [];
107
121
  for (const el of all) {
108
- const tag = el.tagName;
109
- const isButton = tag === "BUTTON" || tag === "INPUT" && /^(submit|button)$/i.test(el.type);
110
- if (!isButton && tag !== "A") continue;
111
- if (!shown(el)) continue;
122
+ if (!isControl(el)) continue;
123
+ const isButton = isButtonEl(el);
112
124
  const cs = style(el);
113
125
  const b = box(el);
114
126
  const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
115
- if (!label || b.height < 24 || b.height > 90) continue;
116
- if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) continue;
117
- if (!isButton && px(cs.paddingLeft) < 8) continue;
118
127
  const r = cs.borderTopLeftRadius;
119
128
  const radius = r.endsWith("%") ? parseFloat(r) / 100 * b.height : px(r);
120
129
  controls.push({
@@ -194,20 +203,122 @@ function collectInPage() {
194
203
  }
195
204
  return best;
196
205
  };
197
- const sections = blocks.slice(0, 40).map((el, index) => {
206
+ const backgroundOf = (el) => {
207
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
208
+ const c = style(n).backgroundColor;
209
+ if (painted(c)) return c;
210
+ }
211
+ return ground;
212
+ };
213
+ const geometryOf = (el, b) => {
214
+ const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter((n) => (ownText(n).length > 1 || /^(IMG|SVG|VIDEO|PICTURE|BUTTON|INPUT|TEXTAREA|SELECT)$/i.test(n.tagName)) && shown(n)).slice(0, 400);
215
+ const centreX = b.left + b.width / 2;
216
+ let left = 0;
217
+ let right = 0;
218
+ let covered = 0;
219
+ let minX = Infinity;
220
+ let maxX = -Infinity;
221
+ let textBlocks = 0;
222
+ let centred = 0;
223
+ for (const n of leaves) {
224
+ const r = box(n);
225
+ const area = r.width * r.height;
226
+ covered += area;
227
+ minX = Math.min(minX, r.left);
228
+ maxX = Math.max(maxX, r.left + r.width);
229
+ const split = Math.max(0, Math.min(r.width, centreX - r.left));
230
+ left += split * r.height;
231
+ right += (r.width - split) * r.height;
232
+ if (ownText(n).length > 1) {
233
+ textBlocks += 1;
234
+ const mid = r.left + r.width / 2;
235
+ if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
236
+ }
237
+ }
238
+ const round = (v) => Math.round(v * 1e3) / 1e3;
239
+ return {
240
+ centredShare: textBlocks ? round(centred / textBlocks) : 0,
241
+ mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
242
+ whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
243
+ contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
244
+ background: backgroundOf(el),
245
+ controls: Array.from(el.querySelectorAll("a, button, input")).slice(0, 200).filter(isControl).length
246
+ };
247
+ };
248
+ const CURRENCY = /[£$€]\s?\d/;
249
+ const roleOf = (el, kind, geo, b, cards, iconCards, last) => {
250
+ if (kind === "marquee" || kind === "hero") return kind;
251
+ const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
252
+ const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
253
+ const own = leaves.map(ownText);
254
+ const questions = own.filter((t) => /\?$/.test(t) && t.length > 8).length;
255
+ if (el.querySelectorAll("details").length >= 3 || el.querySelectorAll("[aria-expanded]").length >= 3 || /\bfaq|frequently asked|questions\b/.test(heading) && questions >= 3) return "faq";
256
+ const prices = own.filter((t) => CURRENCY.test(t) && t.length < 40).length;
257
+ if (prices >= 2 && (cards >= 2 || /pric|plan|package/.test(heading))) return "pricing";
258
+ const quotes = el.querySelectorAll("blockquote, q").length;
259
+ if (quotes >= 2 || /testimonial|review|what (?:our )?(?:clients|customers|people) say/.test(heading)) return "testimonials";
260
+ const steps = Math.max(0, ...Array.from(el.querySelectorAll("ol")).map((ol) => ol.children.length));
261
+ const numbered = own.filter((t) => /^(?:step\s*)?0?[1-9][.:]?$/i.test(t)).length;
262
+ if (steps >= 3 || numbered >= 3 || /how it works|our process|\bsteps?\b/.test(heading)) return "process";
263
+ const form = el.querySelector("form");
264
+ const fields = form ? form.querySelectorAll("input:not([type=hidden]):not([type=submit]), textarea, select").length : 0;
265
+ if (fields >= 2 || /contact|get in touch/.test(heading)) return "contact";
266
+ const portraits = Array.from(el.querySelectorAll("img")).filter((i) => {
267
+ const r = box(i);
268
+ return r.width >= 80 && r.width <= 420 && Math.abs(r.width - r.height) < r.width * 0.25;
269
+ }).length;
270
+ if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
271
+ if (kind === "stats" || kind === "logos") return kind;
272
+ const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
273
+ const setApart = geo.background !== ground || geo.centredShare >= 0.6;
274
+ if (setApart && heading && b.height <= vh * 0.9 && geo.controls >= 1 && geo.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
275
+ if (cards >= 3 && iconCards >= 2) return "features";
276
+ return kind;
277
+ };
278
+ const partsOf = (el) => {
279
+ const inside = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter(shown);
280
+ const badges = [];
281
+ for (const n of inside) {
282
+ const t = ownText(n);
283
+ if (t.length < 2 || t.length > 24 || badges.length >= 5) continue;
284
+ const cs = style(n);
285
+ const r = box(n);
286
+ const radius = px(cs.borderTopLeftRadius);
287
+ const drawn = painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1;
288
+ if (drawn && r.height <= 40 && r.width <= 240 && radius >= r.height / 3 && !isControl(n)) badges.push(t);
289
+ }
290
+ const avatars = inside.filter((n) => {
291
+ if (n.tagName !== "IMG") return false;
292
+ const r = box(n);
293
+ const rad = style(n).borderTopLeftRadius;
294
+ const round = rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= r.width * 0.4;
295
+ return r.width <= 96 && r.width >= 16 && Math.abs(r.width - r.height) <= r.width * 0.15 && round;
296
+ }).length;
297
+ const carousel = inside.some((n) => {
298
+ const cs = style(n);
299
+ if (n.getAttribute("aria-roledescription") === "carousel" || cs.scrollSnapType && cs.scrollSnapType !== "none") return true;
300
+ if (/^(auto|scroll|hidden)$/.test(cs.overflowX) && n.children.length >= 3 && n.scrollWidth > n.clientWidth * 1.2) return true;
301
+ return /^(BUTTON|A)$/.test(n.tagName) && /\b(prev|previous|next)\b/i.test(n.getAttribute("aria-label") ?? "");
302
+ });
303
+ return { badges, avatars, carousel };
304
+ };
305
+ const kept = blocks.slice(0, 40);
306
+ const sections = kept.map((el, index) => {
198
307
  const b = box(el);
199
308
  const heading = el.querySelector("h1, h2, h3");
200
309
  const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
201
- const images = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
310
+ const images2 = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
202
311
  const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
203
312
  const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
204
313
  const { cards, iconCards } = cardsIn(el);
205
314
  let kind = "text";
206
315
  if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
207
316
  else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
208
- else if (images.length >= 4 && textChars < 300) kind = "logos";
317
+ else if (images2.length >= 4 && textChars < 300) kind = "logos";
209
318
  else if (numbers >= 3 && textChars < 600) kind = "stats";
210
319
  else if (cards >= 3) kind = "cards";
320
+ const geometry = geometryOf(el, b);
321
+ const parts = partsOf(el);
211
322
  return {
212
323
  top: Math.round(b.top),
213
324
  height: Math.round(b.height),
@@ -215,7 +326,11 @@ function collectInPage() {
215
326
  label,
216
327
  cards,
217
328
  iconCards,
218
- hiddenAtLoad: b.top > vh && hiddenAtLoad(el)
329
+ hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
330
+ role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
331
+ geometry,
332
+ ...parts,
333
+ figures: numbers
219
334
  };
220
335
  });
221
336
  if (header && sections.length === 0) {
@@ -297,6 +412,81 @@ function collectInPage() {
297
412
  const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
298
413
  if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
299
414
  }
415
+ const unwrap = (raw) => {
416
+ if (raw.startsWith("data:")) return raw.slice(0, 64);
417
+ try {
418
+ const u = new URL(raw, location.href);
419
+ const inner = /\/_(?:next|vercel)\/image$/.test(u.pathname) ? u.searchParams.get("url") : null;
420
+ if (inner) return new URL(inner, location.href).href;
421
+ const cf = u.pathname.match(/^\/cdn-cgi\/image\/[^/]+\/(.+)$/);
422
+ if (cf) return /^https?:\/\//.test(cf[1]) ? cf[1] : new URL(`/${cf[1]}`, location.href).href;
423
+ return u.href;
424
+ } catch {
425
+ return raw.slice(0, 200);
426
+ }
427
+ };
428
+ const roundBox = (el, w) => {
429
+ const rad = style(el).borderTopLeftRadius;
430
+ return rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= w * 0.4;
431
+ };
432
+ const sectionOf = (el) => {
433
+ const i = kept.findIndex((k) => k.contains(el));
434
+ return i === -1 ? null : i;
435
+ };
436
+ const roleOfImage = (el, src, w, h, alt) => {
437
+ const vector = /\.svg(?:[?#]|$)/i.test(src) || src.startsWith("data:image/svg");
438
+ const square = Math.abs(w - h) <= w * 0.15;
439
+ if (w >= 16 && w <= 96 && square && (roundBox(el, w) || el.parentElement !== null && roundBox(el.parentElement, w))) return "avatar";
440
+ const section = sectionOf(el);
441
+ const hint = `${alt ?? ""} ${src} ${typeof el.className === "string" ? el.className : ""}`;
442
+ if (/\blogo\b/i.test(hint) || section !== null && sections[section]?.kind === "logos" || el.closest("header, footer, nav") && h <= 120) return "logo";
443
+ if (Math.max(w, h) <= (vector ? 120 : 72)) return "icon";
444
+ if (vector) return "illustration";
445
+ return w >= 160 && h >= 120 ? "photo" : "icon";
446
+ };
447
+ const images = [];
448
+ const seenImage = /* @__PURE__ */ new Set();
449
+ const addImage = (el, raw, alt, decorative, background) => {
450
+ const b = box(el);
451
+ if (!raw || b.width < 8 || b.height < 8) return;
452
+ const src = unwrap(raw);
453
+ const key = `${src} ${Math.round(b.top)} ${Math.round(b.left)}`;
454
+ if (seenImage.has(key)) return;
455
+ seenImage.add(key);
456
+ let host = null;
457
+ try {
458
+ host = src.startsWith("data:") ? null : new URL(src).hostname;
459
+ } catch {
460
+ host = null;
461
+ }
462
+ images.push({
463
+ src,
464
+ host,
465
+ width: Math.round(b.width),
466
+ height: Math.round(b.height),
467
+ top: Math.round(b.top),
468
+ role: roleOfImage(el, src, b.width, b.height, alt),
469
+ alt,
470
+ decorative,
471
+ section: sectionOf(el),
472
+ background
473
+ });
474
+ };
475
+ for (const img of Array.from(document.querySelectorAll("img")).slice(0, 400)) {
476
+ if (!shown(img)) continue;
477
+ const alt = img.getAttribute("alt");
478
+ const decorative = alt === "" || img.getAttribute("aria-hidden") === "true" || /^(?:presentation|none)$/.test(img.getAttribute("role") ?? "");
479
+ addImage(img, img.currentSrc || img.getAttribute("src") || "", alt, decorative, false);
480
+ }
481
+ for (const el of all) {
482
+ const bgImage = style(el).backgroundImage || "";
483
+ if (!bgImage.includes("url(")) continue;
484
+ const b = box(el);
485
+ if (b.width < 160 || b.height < 120 || !shown(el)) continue;
486
+ const m = bgImage.match(/url\(\s*["']?([^"')]+)["']?\s*\)/);
487
+ if (m) addImage(el, m[1], null, true, true);
488
+ }
489
+ images.sort((a, b) => b.width * b.height - a.width * a.height);
300
490
  const toList = (m) => Array.from(m, ([value, v]) => ({ value, area: Math.round(v.area), chars: v.chars })).sort((a, b) => b.area - a.area || b.chars - a.chars);
301
491
  const nums = (s) => Array.from(s).sort((a, b) => a - b);
302
492
  return {
@@ -308,7 +498,15 @@ function collectInPage() {
308
498
  headings,
309
499
  controls,
310
500
  sections,
501
+ rhythmVariance: (() => {
502
+ const hs = sections.map((x) => x.height);
503
+ if (hs.length < 2) return null;
504
+ const mean = hs.reduce((t, v) => t + v, 0) / hs.length;
505
+ const sd = Math.sqrt(hs.reduce((t, v) => t + (v - mean) ** 2, 0) / hs.length);
506
+ return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
507
+ })(),
311
508
  effects,
509
+ images: images.slice(0, 60),
312
510
  motion: {
313
511
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
314
512
  sections: sections.length,
@@ -341,7 +539,120 @@ function overlayInPage() {
341
539
  return false;
342
540
  }
343
541
 
542
+ // src/snapshot/visual.ts
543
+ var EDGE_STEP = 32;
544
+ function visualMeasures(img, maxSamples = 1e6) {
545
+ const { width: w, height: h, rgba } = img;
546
+ if (w < 2 || h < 2 || rgba.length < w * h * 4) throw new Error(`visualMeasures: ${w}x${h} image with ${rgba.length} bytes`);
547
+ const step = Math.max(1, Math.ceil(Math.sqrt(w * h / maxSamples)));
548
+ const lum = (x, y) => {
549
+ const i = (y * w + x) * 4;
550
+ return 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
551
+ };
552
+ let n = 0;
553
+ let sumRg = 0;
554
+ let sumYb = 0;
555
+ let sumRg2 = 0;
556
+ let sumYb2 = 0;
557
+ let edges = 0;
558
+ let edgeN = 0;
559
+ let asym = 0;
560
+ let asymN = 0;
561
+ for (let y = 0; y < h; y += step) {
562
+ for (let x = 0; x < w; x += step) {
563
+ const i = (y * w + x) * 4;
564
+ const r = rgba[i];
565
+ const g = rgba[i + 1];
566
+ const b = rgba[i + 2];
567
+ const rg = r - g;
568
+ const yb = 0.5 * (r + g) - b;
569
+ sumRg += rg;
570
+ sumYb += yb;
571
+ sumRg2 += rg * rg;
572
+ sumYb2 += yb * yb;
573
+ n += 1;
574
+ const l = lum(x, y);
575
+ if (x + step < w && y + step < h) {
576
+ if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
577
+ edgeN += 1;
578
+ }
579
+ if (x < w / 2) {
580
+ asym += Math.abs(l - lum(w - 1 - x, y));
581
+ asymN += 1;
582
+ }
583
+ }
584
+ }
585
+ const meanRg = sumRg / n;
586
+ const meanYb = sumYb / n;
587
+ const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
588
+ const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
589
+ const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
590
+ const round = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
591
+ return {
592
+ colourfulness: round(colourfulness, 1),
593
+ edgeDensity: round(edgeN ? edges / edgeN : 0),
594
+ symmetry: round(asymN ? 1 - asym / asymN / 255 : 1),
595
+ width: w,
596
+ height: h,
597
+ sampled: n
598
+ };
599
+ }
600
+
601
+ // src/snapshot/provenance.ts
602
+ var PROVENANCE_SCAN_BYTES = 256 * 1024;
603
+ var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
604
+ var MARKER = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
605
+ var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
606
+ var C2PA_PNG = /caBX/;
607
+ function latin1(bytes) {
608
+ let out = "";
609
+ for (let i = 0; i < bytes.length; i += 8192) {
610
+ out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
611
+ }
612
+ return out;
613
+ }
614
+ function xmpPackets(text) {
615
+ const out = [];
616
+ const open = /<x:xmpmeta\b|<\?xpacket begin/g;
617
+ let m;
618
+ while (m = open.exec(text)) {
619
+ const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
620
+ out.push([m.index, close === -1 ? text.length : close]);
621
+ if (close === -1) break;
622
+ open.lastIndex = close;
623
+ }
624
+ return out;
625
+ }
626
+ function scanProvenance(bytes) {
627
+ const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
628
+ const text = latin1(head);
629
+ const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
630
+ const m = MARKER.exec(text);
631
+ if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
632
+ const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
633
+ return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
634
+ }
635
+
344
636
  // src/audit/index.ts
637
+ var PROVENANCE_MAX_IMAGES = 24;
638
+ var PROVENANCE_BUDGET_MS = 2e4;
639
+ async function readProvenance(page, images, timeoutMs) {
640
+ if (!page.request) return;
641
+ const deadline = Date.now() + PROVENANCE_BUDGET_MS;
642
+ const wanted = [...new Set(images.filter((i) => i.role !== "icon" && i.role !== "logo" && /^https?:\/\//.test(i.src)).map((i) => i.src))].slice(0, PROVENANCE_MAX_IMAGES);
643
+ for (const src of wanted) {
644
+ const left = deadline - Date.now();
645
+ if (left < 500) break;
646
+ try {
647
+ const res = await page.request.get(src, { headers: { Range: `bytes=0-${PROVENANCE_SCAN_BYTES - 1}` }, timeout: Math.min(timeoutMs, left) });
648
+ if (!res.ok()) continue;
649
+ const provenance = scanProvenance(await res.body());
650
+ for (const image of images) if (image.src === src) image.provenance = provenance;
651
+ } catch {
652
+ }
653
+ }
654
+ }
655
+ var VISUAL_MAX_HEIGHT = 6e3;
345
656
  async function loadPlaywright() {
346
657
  for (const name of ["playwright", "playwright-core"]) {
347
658
  try {
@@ -359,12 +670,24 @@ async function snapshotPage(page, url, options = {}) {
359
670
  await page.waitForTimeout(options.introWindowMs ?? 5e3);
360
671
  const late = await page.evaluate(overlayInPage).catch(() => false);
361
672
  const data = await page.evaluate(collectInPage);
673
+ if (data.images && options.provenance !== false) await readProvenance(page, data.images, 8e3);
674
+ let visual;
675
+ if (page.screenshot && options.visual !== false) {
676
+ try {
677
+ const height = Math.min(data.pageHeight, VISUAL_MAX_HEIGHT);
678
+ const png = await page.screenshot({ fullPage: true, type: "png", clip: { x: 0, y: 0, width: data.viewport.width, height } });
679
+ visual = visualMeasures(decodePng(png));
680
+ } catch {
681
+ visual = void 0;
682
+ }
683
+ }
362
684
  return {
363
685
  version: SNAPSHOT_VERSION,
364
686
  url,
365
687
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
366
688
  ...data,
367
- motion: { ...data.motion, introOverlay: early && !late }
689
+ motion: { ...data.motion, introOverlay: early && !late },
690
+ ...visual ? { visual } : {}
368
691
  };
369
692
  }
370
693
  async function launch(pw, url, options) {
@@ -413,6 +736,9 @@ async function audit(url, options = {}) {
413
736
  export {
414
737
  collectInPage,
415
738
  overlayInPage,
739
+ PROVENANCE_MAX_IMAGES,
740
+ PROVENANCE_BUDGET_MS,
741
+ VISUAL_MAX_HEIGHT,
416
742
  loadPlaywright,
417
743
  snapshotPage,
418
744
  snapshotUrl,
@@ -6,7 +6,115 @@ import {
6
6
  isCream,
7
7
  normaliseFamily,
8
8
  parseColour
9
- } from "./chunk-3NGWXZZE.js";
9
+ } from "./chunk-GMVXADJ6.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)) {
@@ -0,0 +1,18 @@
1
+ import {
2
+ formatComponentPairs,
3
+ formatMatches,
4
+ formatPairs,
5
+ loadEstate,
6
+ runEstate
7
+ } from "./chunk-K2BZN4GA.js";
8
+ import "./chunk-ESCVTKPN.js";
9
+ import "./chunk-CZDOMIVS.js";
10
+ import "./chunk-3VTPI7BU.js";
11
+ import "./chunk-GMVXADJ6.js";
12
+ export {
13
+ formatComponentPairs,
14
+ formatMatches,
15
+ formatPairs,
16
+ loadEstate,
17
+ runEstate
18
+ };