@domandigital/craft 0.12.0 → 0.14.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.
package/dist/audit.cjs CHANGED
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/audit/index.ts
21
21
  var audit_exports = {};
22
22
  __export(audit_exports, {
23
+ PROVENANCE_BUDGET_MS: () => PROVENANCE_BUDGET_MS,
24
+ PROVENANCE_MAX_IMAGES: () => PROVENANCE_MAX_IMAGES,
23
25
  VISUAL_MAX_HEIGHT: () => VISUAL_MAX_HEIGHT,
24
26
  audit: () => audit,
25
27
  collectInPage: () => collectInPage,
@@ -1033,14 +1035,14 @@ function contractionHits(ctx) {
1033
1035
  for (const doc2 of documents(ctx)) {
1034
1036
  if (doc2.words < 800) continue;
1035
1037
  const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
1036
- const count2 = (text.match(CONTRACTION) ?? []).length;
1037
- const rate = count2 * 1e3 / doc2.words;
1038
+ const count4 = (text.match(CONTRACTION) ?? []).length;
1039
+ const rate = count4 * 1e3 / doc2.words;
1038
1040
  if (rate < 1) {
1039
1041
  const first = sentences(doc2)[0];
1040
1042
  hits.push({
1041
1043
  path: doc2.path,
1042
1044
  offset: first?.offset ?? 0,
1043
- message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
1045
+ message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
1044
1046
  });
1045
1047
  }
1046
1048
  }
@@ -1163,16 +1165,16 @@ function filler(words, seed = 1, options = {}) {
1163
1165
  return state % n;
1164
1166
  };
1165
1167
  const out = [];
1166
- let count2 = 0;
1168
+ let count4 = 0;
1167
1169
  let i = 0;
1168
- while (count2 < words) {
1170
+ while (count4 < words) {
1169
1171
  const length = uniform ?? shape[i % shape.length];
1170
1172
  const s = [];
1171
1173
  for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
1172
1174
  if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
1173
1175
  s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
1174
1176
  out.push(`${s.join(" ")}.`);
1175
- count2 += s.length;
1177
+ count4 += s.length;
1176
1178
  i += 1;
1177
1179
  }
1178
1180
  const paragraphs = [];
@@ -2355,70 +2357,6 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
2355
2357
  }
2356
2358
  ];
2357
2359
 
2358
- // src/character/house.ts
2359
- var HOUSE = {
2360
- "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
2361
- emoji: { tier: "block", label: "emoji" },
2362
- "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
2363
- "no-x-no-y": {
2364
- tier: "block",
2365
- label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
2366
- },
2367
- "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
2368
- "ai-phrase": { tier: "block", label: "AI tell" },
2369
- "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
2370
- "plain-english": { tier: "block", label: 'use "properly explained"' },
2371
- buzzword: { tier: "block", label: "forbidden phrase" },
2372
- "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
2373
- // Evidence of a pasted chat reply, not a matter of style. Moved from review
2374
- // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
2375
- "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
2376
- "review-phrase": { tier: "review", label: "review tier" },
2377
- "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
2378
- "stock-phrase": { tier: "review", label: "stock phrase" },
2379
- "hollow-imperative": { tier: "review", label: "hollow imperative" },
2380
- "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
2381
- "staccato-triplet": { tier: "review", label: "staccato triplet" },
2382
- "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
2383
- "ing-tail": { tier: "review", label: "empty -ing tail" },
2384
- "vague-attribution": { tier: "review", label: "unnamed source" },
2385
- "closing-summary": { tier: "review", label: "closing summary" },
2386
- "false-range": { tier: "review", label: "false range" },
2387
- "placeholder": { tier: "review", label: "unfilled placeholder" },
2388
- // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
2389
- // a proposal can honestly say "the attached research". Review until measured there.
2390
- "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
2391
- "question-reveal": { tier: "review", label: "staged reveal" },
2392
- "inline-label-list": { tier: "review", label: "bold-label bullets" },
2393
- "phrase-density": { tier: "review", label: "density" },
2394
- "aphorism-density": { tier: "review", label: "density" },
2395
- "contraction-scarcity": { tier: "review", label: "density" },
2396
- "sentence-rhythm": { tier: "review", label: "density" },
2397
- "repeated-sentence": { tier: "review", label: "repetition" },
2398
- "heading-shape": { tier: "review", label: "headings" },
2399
- "heading-echo": { tier: "review", label: "headings" },
2400
- // Banned only "when vague", per the rules. Too noisy for a sweep.
2401
- "vague-word": { tier: "explicit", label: "vague" }
2402
- };
2403
- function houseRule(id, name = id) {
2404
- return HOUSE[id] ?? { tier: "review", label: name };
2405
- }
2406
-
2407
- // src/character/parse.ts
2408
- function lineAt(text, offset) {
2409
- let line = 1;
2410
- for (let i = 0; i < offset && i < text.length; i += 1) {
2411
- if (text.charCodeAt(i) === 10) line += 1;
2412
- }
2413
- return line;
2414
- }
2415
- function excerptAt(text, offset, max = 140) {
2416
- const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
2417
- const endRaw = text.indexOf("\n", offset);
2418
- const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
2419
- return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
2420
- }
2421
-
2422
2360
  // src/snapshot/types.ts
2423
2361
  var SNAPSHOT_VERSION = 2;
2424
2362
 
@@ -2491,6 +2429,606 @@ function makeSnapshot(patch = {}) {
2491
2429
  return out;
2492
2430
  }
2493
2431
 
2432
+ // src/character/parse.ts
2433
+ var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
2434
+ var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
2435
+ var CSS = /\.(css|scss|sass|less|pcss)$/i;
2436
+ var PROSE = /\.(md|markdown|txt)$/i;
2437
+ function fileKind(path) {
2438
+ if (MARKUP.test(path)) return "markup";
2439
+ if (CSS.test(path)) return "css";
2440
+ if (SCRIPT.test(path)) return "script";
2441
+ if (PROSE.test(path)) return "prose";
2442
+ return "other";
2443
+ }
2444
+ function lineAt(text, offset) {
2445
+ let line = 1;
2446
+ for (let i = 0; i < offset && i < text.length; i += 1) {
2447
+ if (text.charCodeAt(i) === 10) line += 1;
2448
+ }
2449
+ return line;
2450
+ }
2451
+ function excerptAt(text, offset, max = 140) {
2452
+ const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
2453
+ const endRaw = text.indexOf("\n", offset);
2454
+ const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
2455
+ return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
2456
+ }
2457
+
2458
+ // src/character/facts.ts
2459
+ 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)?)";
2460
+ var PATTERNS = [
2461
+ ["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
2462
+ ["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
2463
+ ["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
2464
+ ["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
2465
+ ["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
2466
+ ["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")],
2467
+ ["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
2468
+ ["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
2469
+ ];
2470
+ var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
2471
+ var normalise = (kind, value) => {
2472
+ const v = value.trim();
2473
+ if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
2474
+ if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
2475
+ if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
2476
+ if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
2477
+ return v.replace(/\s+/g, " ").toLowerCase();
2478
+ };
2479
+ function protectedFacts(text) {
2480
+ const facts = [];
2481
+ let rest = text;
2482
+ const mask = (start, length) => {
2483
+ rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
2484
+ };
2485
+ for (const [kind, pattern] of PATTERNS) {
2486
+ for (const m of [...rest.matchAll(pattern)]) {
2487
+ const value = m[0].replace(/[.,;:]+$/, "");
2488
+ facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
2489
+ mask(m.index ?? 0, m[0].length);
2490
+ }
2491
+ }
2492
+ const lines = rest.split("\n");
2493
+ for (const line of lines) {
2494
+ if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
2495
+ for (const m of line.matchAll(NAME)) {
2496
+ const at = m.index ?? 0;
2497
+ const before = line.slice(0, at).trimEnd();
2498
+ const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
2499
+ const word = m[0];
2500
+ const acronym = /^[A-Z]{2,}$/.test(word);
2501
+ if (opensSentence && !acronym && !/\s/.test(word)) continue;
2502
+ if (word === "I") continue;
2503
+ facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
2504
+ }
2505
+ }
2506
+ return facts;
2507
+ }
2508
+
2509
+ // src/character/specificity.ts
2510
+ var TRADE_NOUNS = [
2511
+ // heating, plumbing, electrical
2512
+ "boiler",
2513
+ "radiator",
2514
+ "combi",
2515
+ "cylinder",
2516
+ "thermostat",
2517
+ "underfloor heating",
2518
+ "heat pump",
2519
+ "solar panel",
2520
+ "plumbing",
2521
+ "plumber",
2522
+ "leak",
2523
+ "toilet",
2524
+ "shower",
2525
+ "bathroom",
2526
+ "wet room",
2527
+ "drain",
2528
+ "fuse board",
2529
+ "fuseboard",
2530
+ "consumer unit",
2531
+ "rewire",
2532
+ "socket",
2533
+ "ev charger",
2534
+ "electrician",
2535
+ "gas engineer",
2536
+ "power flush",
2537
+ "landlord certificate",
2538
+ "eicr",
2539
+ // building and outside
2540
+ "extension",
2541
+ "loft conversion",
2542
+ "loft",
2543
+ "kitchen",
2544
+ "roof",
2545
+ "roofer",
2546
+ "gutter",
2547
+ "chimney",
2548
+ "driveway",
2549
+ "patio",
2550
+ "fence",
2551
+ "fencing",
2552
+ "decking",
2553
+ "window",
2554
+ "conservatory",
2555
+ "plastering",
2556
+ "plasterer",
2557
+ "render",
2558
+ "brickwork",
2559
+ "bricklayer",
2560
+ "tiling",
2561
+ "tiler",
2562
+ "flooring",
2563
+ "carpet",
2564
+ "joinery",
2565
+ "joiner",
2566
+ "carpenter",
2567
+ "staircase",
2568
+ "damp",
2569
+ "scaffolding",
2570
+ "garage conversion",
2571
+ "lawn",
2572
+ "hedge",
2573
+ "tree surgery",
2574
+ "stump",
2575
+ "landscaping",
2576
+ "gardener",
2577
+ "gate",
2578
+ "locksmith",
2579
+ "pest control",
2580
+ "wasp",
2581
+ "cleaning",
2582
+ "oven",
2583
+ "end of tenancy",
2584
+ // vehicles
2585
+ "mot",
2586
+ "tyre",
2587
+ "brake",
2588
+ "clutch",
2589
+ "cambelt",
2590
+ "exhaust",
2591
+ "gearbox",
2592
+ "valeting",
2593
+ // personal and health
2594
+ "haircut",
2595
+ "skin fade",
2596
+ "beard",
2597
+ "barber",
2598
+ "balayage",
2599
+ "nails",
2600
+ "lashes",
2601
+ "massage",
2602
+ "physio",
2603
+ "physiotherapy",
2604
+ "osteopath",
2605
+ "chiropractor",
2606
+ "dentist",
2607
+ "dental",
2608
+ "implant",
2609
+ "brace",
2610
+ "invisalign",
2611
+ "hygienist",
2612
+ "optician",
2613
+ "eye test",
2614
+ "vet",
2615
+ "grooming",
2616
+ "dog walking",
2617
+ "personal trainer",
2618
+ "yoga",
2619
+ "pilates",
2620
+ // professional
2621
+ "accountant",
2622
+ "tax return",
2623
+ "bookkeeping",
2624
+ "payroll",
2625
+ "vat",
2626
+ "self assessment",
2627
+ "solicitor",
2628
+ "conveyancing",
2629
+ "probate",
2630
+ "divorce",
2631
+ "lasting power of attorney",
2632
+ "mortgage",
2633
+ "surveyor",
2634
+ "architect",
2635
+ "planning permission",
2636
+ "building regulations",
2637
+ // web and digital, for the agency's own pages
2638
+ "website",
2639
+ "web design",
2640
+ "seo",
2641
+ "hosting",
2642
+ "google business profile",
2643
+ "online shop",
2644
+ "ecommerce",
2645
+ // food and hospitality
2646
+ "breakfast",
2647
+ "lunch",
2648
+ "dinner",
2649
+ "coffee",
2650
+ "bakery",
2651
+ "cake",
2652
+ "catering",
2653
+ "wedding",
2654
+ "venue"
2655
+ ];
2656
+ var STOCK_FIGURES = [
2657
+ /\b24\s?\/\s?7\b/gi,
2658
+ /\b24[\s-]hours?\b/gi,
2659
+ /\b1[01]0\s?%/g,
2660
+ /\b(?:5|five)[\s-]?star\b/gi,
2661
+ /#\s?1\b/g,
2662
+ /\bno\.?\s?1\b/gi,
2663
+ /\b365\s+days\b/gi
2664
+ ];
2665
+ var NOT_NAMES = new Set(
2666
+ "a an the and of for to in on at by with your you our we us it get free quote quotes call now today book booking contact learn more read see view explore discover start started home about services service quality trusted trust expert experts expertise professional professionals best solutions solution digital team welcome why choose how works local reliable affordable fast friendly premium leading excellence excellent results success growth business businesses customer customers client clients care support help new our story mission vision values innovation innovative modern simple easy smart better great amazing perfect ultimate transform elevate empower unlock experience experienced dedicated passionate committed tailored bespoke seamless peace mind can do all every everything more less here there this that what who where when need needs make difference satisfaction guaranteed".split(" ")
2667
+ );
2668
+ var BRIEF_STOP = new Set(
2669
+ "a an the and or of for to in on at by with from who that which what their they them its it is are was were be been being has have had do does did can will would should could may might must this these those there here as into over under business company small local family run based owned services service provides provide offering offers people customers clients home homes house houses area areas around near across nearby town city county village uk".split(" ")
2670
+ );
2671
+ var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
2672
+ var countWords2 = (text) => (text.match(WORD2) ?? []).length;
2673
+ var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2674
+ var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
2675
+ var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
2676
+ var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
2677
+ function caseless(line) {
2678
+ const words = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
2679
+ const letters = words.filter((w) => w.length >= 2);
2680
+ if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
2681
+ const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
2682
+ if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
2683
+ return null;
2684
+ }
2685
+ var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
2686
+ function briefTerms(brief) {
2687
+ const terms = /* @__PURE__ */ new Set();
2688
+ for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
2689
+ const lower = w.toLowerCase();
2690
+ if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower) && !NOT_NAMES.has(lower)) terms.add(lower);
2691
+ }
2692
+ for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
2693
+ for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
2694
+ return [...terms];
2695
+ }
2696
+ function specifics(text, brief) {
2697
+ const found = /* @__PURE__ */ new Map();
2698
+ const add = (kind, value) => {
2699
+ const key = value.toLowerCase().replace(/\s+/g, " ").trim();
2700
+ if (key && !found.has(key)) found.set(key, { kind, value, key });
2701
+ };
2702
+ for (const raw of text.split("\n")) {
2703
+ const line = maskStock(raw);
2704
+ if (!line.trim()) continue;
2705
+ const capitals = caseless(line);
2706
+ for (const f4 of protectedFacts(line)) {
2707
+ if (f4.kind === "name") {
2708
+ if (capitals === "shout") continue;
2709
+ if (capitals === "title") {
2710
+ for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
2711
+ continue;
2712
+ }
2713
+ const words = f4.value.split(/\s+/);
2714
+ const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
2715
+ const first = words.findIndex(named);
2716
+ if (first === -1) continue;
2717
+ const last = words.length - 1 - [...words].reverse().findIndex(named);
2718
+ add("name", words.slice(first, last + 1).join(" "));
2719
+ continue;
2720
+ }
2721
+ add(f4.kind, f4.value);
2722
+ }
2723
+ for (const [term, re] of TRADE_PATTERNS) {
2724
+ const m = re.exec(line);
2725
+ if (m) add("trade", term);
2726
+ }
2727
+ }
2728
+ if (brief) {
2729
+ for (const term of briefTerms(brief)) {
2730
+ if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
2731
+ if (termPattern(term).test(text)) add("brief", term);
2732
+ }
2733
+ }
2734
+ return [...found.values()];
2735
+ }
2736
+ function specificity(text, brief) {
2737
+ const list = specifics(text, brief);
2738
+ const words = countWords2(text);
2739
+ const trade = list.filter((s) => s.kind === "trade").length;
2740
+ const hard = list.length - trade;
2741
+ return {
2742
+ words,
2743
+ specifics: list,
2744
+ hard,
2745
+ trade,
2746
+ perHundred: words ? Math.round(list.length / words * 1e3) / 10 : 0,
2747
+ generic: hard === 0 && trade < 2
2748
+ };
2749
+ }
2750
+ function sentencesOf(blocks) {
2751
+ const out = [];
2752
+ for (const block of blocks) {
2753
+ for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
2754
+ const s = m[0].trim();
2755
+ if (countWords2(s) >= 3) out.push(s);
2756
+ }
2757
+ }
2758
+ return [...new Set(out)];
2759
+ }
2760
+
2761
+ // src/character/tells/specificity.ts
2762
+ var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
2763
+ var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
2764
+ var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
2765
+ function literalText(inner) {
2766
+ const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
2767
+ if (/[{}]/.test(t)) return null;
2768
+ return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
2769
+ }
2770
+ var HOME_FILE = /(?:^|\/)app\/(?:\([^/]+\)\/)*page\.(?:tsx|jsx|mdx)$|(?:^|\/)pages\/index\.(?:tsx|jsx|astro|vue|mdx)$|(?:^|\/)routes\/(?:\([^/]+\)\/)*\+page\.svelte$|^(?:(?:public|src|site|www)\/)?index\.html?$|(?:^|\/)[\w-]*(?:hero|home)[\w-]*\.(?:tsx|jsx|astro|vue|svelte|html?)$/i;
2771
+ var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
2772
+ var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
2773
+ var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
2774
+ function headlines(ctx) {
2775
+ const out = [];
2776
+ for (const file of ctx.files) {
2777
+ if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
2778
+ for (const m of file.text.matchAll(H1)) {
2779
+ const h1 = literalText(m[1]);
2780
+ if (!h1) continue;
2781
+ const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
2782
+ const p = NEXT_P.exec(after);
2783
+ const sub = p ? literalText(p[1]) : null;
2784
+ out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
2785
+ }
2786
+ }
2787
+ return out;
2788
+ }
2789
+ var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
2790
+ var STANDING_CLAIM = /(?<![\w#])(?:trusted|reliable|reputable|award[- ]winning|top[- ]rated|best[- ]rated|highly (?:rated|recommended|experienced|skilled|qualified)|fully (?:qualified|certified|accredited|licensed|insured)|(?:qualified|certified|accredited|approved|registered|experienced|expert) (?:engineers?|electricians?|plumbers?|builders?|technicians?|installers?|team|tradesmen|professionals?|staff)|(?:years|decades) of experience|(?:5|five)[- ]star (?:service|rated|reviews?)|(?:happy|satisfied) (?:customers|clients)|(?:customers|clients) (?:love|trust|recommend)|number one|no\.? ?1|#1)(?![\w])/i;
2791
+ var PROOF_NAMES = [
2792
+ "Gas Safe",
2793
+ "NICEIC",
2794
+ "NAPIT",
2795
+ "ELECSA",
2796
+ "Stroma",
2797
+ "TrustMark",
2798
+ "Which? Trusted Trader",
2799
+ "Checkatrade",
2800
+ "TrustATrader",
2801
+ "Rated People",
2802
+ "MyBuilder",
2803
+ "Federation of Master Builders",
2804
+ "FMB",
2805
+ "Guild of Master Craftsmen",
2806
+ "OFTEC",
2807
+ "HETAS",
2808
+ "CIPHE",
2809
+ "APHC",
2810
+ "CHAS",
2811
+ "SafeContractor",
2812
+ "Constructionline",
2813
+ "FENSA",
2814
+ "CERTASS",
2815
+ "MCS",
2816
+ "RECC",
2817
+ "BPEC",
2818
+ "City & Guilds",
2819
+ "NVQ",
2820
+ "CSCS",
2821
+ "Worcester Bosch",
2822
+ "Vaillant",
2823
+ "Viessmann",
2824
+ "BAFE",
2825
+ "NSI",
2826
+ "SSAIB",
2827
+ "BPCA",
2828
+ "RICS",
2829
+ "CIOB",
2830
+ "RIBA",
2831
+ "ICAEW",
2832
+ "ACCA",
2833
+ "CIMA",
2834
+ "SRA",
2835
+ "Law Society",
2836
+ "GDC",
2837
+ "GMC",
2838
+ "HCPC",
2839
+ "CQC",
2840
+ "Ofsted",
2841
+ "ISO 9001",
2842
+ "ISO 27001",
2843
+ "Cyber Essentials",
2844
+ "Companies House",
2845
+ "FCA",
2846
+ "Trustpilot",
2847
+ "Feefo",
2848
+ "Reviews.io",
2849
+ "Yell",
2850
+ "Houzz"
2851
+ ];
2852
+ var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2853
+ var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
2854
+ var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
2855
+ var PROOF_PATTERNS = [
2856
+ PROOF_NAME,
2857
+ /\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
2858
+ // A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
2859
+ /\b[1-5](?:\.\d)?\s?(?:\/\s?5|out of 5)\b|\brated\s+[1-5](?:\.\d)?\b|★{3,}|\b\d[\d,]*\+?\s+(?:verified\s+)?(?:reviews|ratings)\b/i,
2860
+ // A registration or licence number.
2861
+ /\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
2862
+ ];
2863
+ var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
2864
+ function sectionHasProof(s, section, index) {
2865
+ if ((section.role ?? section.kind) === "testimonials") return true;
2866
+ const text = section.text ?? [];
2867
+ if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
2868
+ return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
2869
+ }
2870
+ function unprovenClaims(s) {
2871
+ const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
2872
+ const out = [];
2873
+ s.sections.forEach((section, i) => {
2874
+ if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
2875
+ for (const sentence of sentencesOf(section.text)) {
2876
+ const m = STANDING_CLAIM.exec(sentence);
2877
+ if (m) out.push({ claim: m[0], sentence, section: section.label });
2878
+ }
2879
+ });
2880
+ return out;
2881
+ }
2882
+ var withText = (texts, patch = () => ({})) => {
2883
+ const base = makeSnapshot();
2884
+ return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
2885
+ };
2886
+ var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
2887
+ var SPECIFICITY_COPY_TELLS = [
2888
+ {
2889
+ id: "generic-hero-claim",
2890
+ name: "Headline that fits any business",
2891
+ generation: 1,
2892
+ severity: "warn",
2893
+ surface: "copy",
2894
+ why: "The first thing a visitor reads on the home page names no place, no person, no price and no job, so it would fit every competitor's site unchanged. A model writes it when it has nothing particular to say.",
2895
+ fix: 'Put what only this business can say on the first screen: the job, the town, a price, the name. "Boilers fixed the same day in Brackley" beats "Quality you can trust".',
2896
+ detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
2897
+ fixtures: {
2898
+ flag: [
2899
+ { path: "app/page.tsx", text: '<section>\n <h1 className="text-6xl">Quality you can trust</h1>\n <p>We deliver reliable solutions tailored to your needs.</p>\n</section>' },
2900
+ // Shouted and Title Case: its capitals are not names, and its figures are stock.
2901
+ { path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
2902
+ ],
2903
+ pass: [
2904
+ { path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
2905
+ // A stock headline rescued by the line under it.
2906
+ { path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
2907
+ // Two trade nouns say what the business does.
2908
+ { path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
2909
+ // The words come from data: nothing to read.
2910
+ { path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
2911
+ // Another page's headline is a label, not the site's claim.
2912
+ { path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
2913
+ ]
2914
+ },
2915
+ rendered: {
2916
+ detect: (s) => {
2917
+ if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
2918
+ const text = s.firstScreenText.join("\n");
2919
+ if (!text.trim() || !specificity(text).generic) return [];
2920
+ const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
2921
+ return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
2922
+ },
2923
+ fixtures: {
2924
+ flag: [
2925
+ makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
2926
+ makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
2927
+ ],
2928
+ pass: [
2929
+ makeSnapshot(),
2930
+ makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
2931
+ makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
2932
+ // A first screen that is a picture and nothing else is not judged on its words.
2933
+ makeSnapshot({ firstScreenText: [] }),
2934
+ // Nor is any page but the home page.
2935
+ makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
2936
+ ]
2937
+ }
2938
+ }
2939
+ }
2940
+ ];
2941
+ var SPECIFICITY_RENDERED_TELLS = [
2942
+ {
2943
+ id: "unproven-claim",
2944
+ name: "Claim with no proof next to it",
2945
+ generation: 1,
2946
+ severity: "warn",
2947
+ surface: "rendered",
2948
+ why: `"Trusted", "fully qualified" and "award-winning" ask the visitor to take the business's word for it. A page built with no reviews and no registration number makes the claim anyway, with nothing beside it.`,
2949
+ fix: "Put the proof where the claim is: the Gas Safe or NICEIC number, the named review with its town, the rating and where it was left. Or cut the claim.",
2950
+ rendered: {
2951
+ detect: (s) => {
2952
+ if (!s.sections.some((x) => x.text)) return [];
2953
+ const found = unprovenClaims(s);
2954
+ if (found.length === 0) return [];
2955
+ const first = found[0];
2956
+ const where = first.section ? `"${first.section}"` : "an unnamed section";
2957
+ return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
2958
+ },
2959
+ fixtures: {
2960
+ flag: [
2961
+ withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
2962
+ // Proof two sections away is not next to the claim.
2963
+ withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["Recent jobs"], ["\u201CThey turned up on time and fixed the boiler in an hour.\u201D", "Sam, Brackley"]], (i) => i === 3 ? { role: "testimonials" } : {})
2964
+ ],
2965
+ pass: [
2966
+ makeSnapshot(),
2967
+ withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
2968
+ withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
2969
+ withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
2970
+ {
2971
+ ...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
2972
+ images: [{ src: "https://example.test/img/gas-safe-register.svg", host: "example.test", width: 96, height: 96, top: 1200, role: "logo", alt: "Gas Safe Register", decorative: false, section: 1, background: false }]
2973
+ }
2974
+ ]
2975
+ }
2976
+ }
2977
+ }
2978
+ ];
2979
+
2980
+ // src/character/house.ts
2981
+ var HOUSE = {
2982
+ "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
2983
+ emoji: { tier: "block", label: "emoji" },
2984
+ "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
2985
+ "no-x-no-y": {
2986
+ tier: "block",
2987
+ label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
2988
+ },
2989
+ "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
2990
+ "ai-phrase": { tier: "block", label: "AI tell" },
2991
+ "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
2992
+ "plain-english": { tier: "block", label: 'use "properly explained"' },
2993
+ buzzword: { tier: "block", label: "forbidden phrase" },
2994
+ "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
2995
+ // Evidence of a pasted chat reply, not a matter of style. Moved from review
2996
+ // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
2997
+ "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
2998
+ "review-phrase": { tier: "review", label: "review tier" },
2999
+ "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3000
+ "stock-phrase": { tier: "review", label: "stock phrase" },
3001
+ "hollow-imperative": { tier: "review", label: "hollow imperative" },
3002
+ "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
3003
+ "staccato-triplet": { tier: "review", label: "staccato triplet" },
3004
+ "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
3005
+ "ing-tail": { tier: "review", label: "empty -ing tail" },
3006
+ "vague-attribution": { tier: "review", label: "unnamed source" },
3007
+ "closing-summary": { tier: "review", label: "closing summary" },
3008
+ "false-range": { tier: "review", label: "false range" },
3009
+ "placeholder": { tier: "review", label: "unfilled placeholder" },
3010
+ // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
3011
+ // a proposal can honestly say "the attached research". Review until measured there.
3012
+ "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
3013
+ "question-reveal": { tier: "review", label: "staged reveal" },
3014
+ "inline-label-list": { tier: "review", label: "bold-label bullets" },
3015
+ "phrase-density": { tier: "review", label: "density" },
3016
+ "aphorism-density": { tier: "review", label: "density" },
3017
+ "contraction-scarcity": { tier: "review", label: "density" },
3018
+ "sentence-rhythm": { tier: "review", label: "density" },
3019
+ "repeated-sentence": { tier: "review", label: "repetition" },
3020
+ "heading-shape": { tier: "review", label: "headings" },
3021
+ "heading-echo": { tier: "review", label: "headings" },
3022
+ // A threshold on a count, not a named pattern: a person decides whether the
3023
+ // headline needed a particular. Review until its estate hits are read.
3024
+ "generic-hero-claim": { tier: "review", label: "headline that fits any business" },
3025
+ // Banned only "when vague", per the rules. Too noisy for a sweep.
3026
+ "vague-word": { tier: "explicit", label: "vague" }
3027
+ };
3028
+ function houseRule(id, name = id) {
3029
+ return HOUSE[id] ?? { tier: "review", label: name };
3030
+ }
3031
+
2494
3032
  // src/snapshot/fonts.ts
2495
3033
  var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
2496
3034
  function normaliseFamily(raw) {
@@ -2608,7 +3146,7 @@ function blueToPurple(s) {
2608
3146
  }
2609
3147
  return hits;
2610
3148
  }
2611
- var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3149
+ var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
2612
3150
  var RENDERED_PATHS = {
2613
3151
  "reflex-font": {
2614
3152
  detect: reflexFonts(REFLEX_FONTS_1),
@@ -2646,11 +3184,11 @@ var RENDERED_PATHS = {
2646
3184
  }
2647
3185
  },
2648
3186
  "gradient-text": {
2649
- detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
3187
+ detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
2650
3188
  fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
2651
3189
  },
2652
3190
  "glass-panel": {
2653
- detect: (s) => s.effects.glass > 0 ? [hit(s, `${count(s.effects.glass, "translucent blurred panel")}`)] : [],
3191
+ detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
2654
3192
  fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
2655
3193
  },
2656
3194
  "cream-palette": {
@@ -2728,7 +3266,7 @@ var RENDERED_PATHS = {
2728
3266
  detect: (s) => {
2729
3267
  const radial = s.effects.gradients.filter((g) => g.radial).length;
2730
3268
  const n = s.effects.glows + radial;
2731
- return n > 0 ? [hit(s, `${count(n, "soft glow")} behind the content`)] : [];
3269
+ return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
2732
3270
  },
2733
3271
  fixtures: {
2734
3272
  flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
@@ -2736,11 +3274,11 @@ var RENDERED_PATHS = {
2736
3274
  }
2737
3275
  },
2738
3276
  "grid-background": {
2739
- detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
3277
+ detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
2740
3278
  fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
2741
3279
  },
2742
3280
  marquee: {
2743
- detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count(s.effects.marquees, "endless scrolling strip")}`)] : [],
3281
+ detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
2744
3282
  fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
2745
3283
  },
2746
3284
  "thin-border-wide-shadow": {
@@ -2753,11 +3291,435 @@ var RENDERED_PATHS = {
2753
3291
  }
2754
3292
  };
2755
3293
 
3294
+ // src/fingerprint/index.ts
3295
+ var FINGERPRINT_VERSION = 2;
3296
+ var CHROMATIC = 0.03;
3297
+ var MIN_ACCENT_AREA = 2e4;
3298
+ var oklch = (value) => {
3299
+ const parsed = parseColour(value);
3300
+ return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
3301
+ };
3302
+ function accentOf(s) {
3303
+ const buttons = /* @__PURE__ */ new Map();
3304
+ for (const c of s.controls) {
3305
+ const o = oklch(c.background);
3306
+ if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
3307
+ }
3308
+ const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
3309
+ if (topButton) return oklch(topButton[0]);
3310
+ const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC && x.w >= MIN_ACCENT_AREA).sort((a, b) => b.w - a.w)[0];
3311
+ if (bg) return bg.o;
3312
+ const text = s.colours.text.map((t) => ({ o: oklch(t.value), w: t.chars })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
3313
+ return text ? text.o : null;
3314
+ }
3315
+ function faces(s) {
3316
+ const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
3317
+ const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
3318
+ const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
3319
+ const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
3320
+ return {
3321
+ display: { family: displayName, class: fontClass(displayName) },
3322
+ body: { family: bodyName, class: fontClass(bodyName) }
3323
+ };
3324
+ }
3325
+ function median(values) {
3326
+ if (values.length === 0) return null;
3327
+ const sorted = [...values].sort((a, b) => a - b);
3328
+ const mid = Math.floor(sorted.length / 2);
3329
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
3330
+ }
3331
+ function fingerprint(s) {
3332
+ const effects = [];
3333
+ if (s.effects.glass > 0) effects.push("glass-panel");
3334
+ if (s.effects.gradientText > 0) effects.push("gradient-text");
3335
+ if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
3336
+ if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
3337
+ if (s.effects.marquees > 0) effects.push("marquee");
3338
+ if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
3339
+ if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
3340
+ if (s.motion.introOverlay) effects.push("intro-cinematic");
3341
+ const below = s.sections.filter((x) => x.top > s.viewport.height).length;
3342
+ return {
3343
+ version: FINGERPRINT_VERSION,
3344
+ accent: accentOf(s),
3345
+ ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
3346
+ ...faces(s),
3347
+ roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
3348
+ motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
3349
+ effects: effects.sort(),
3350
+ layout: s.sections.map((x) => x.kind),
3351
+ ...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? {
3352
+ sections: s.sections.map((x) => ({
3353
+ role: x.role,
3354
+ centred: x.geometry.centredShare,
3355
+ width: x.geometry.contentWidthRatio,
3356
+ symmetry: x.geometry.mirrorSymmetry,
3357
+ controls: x.geometry.controls,
3358
+ cards: x.cards,
3359
+ background: x.geometry.background === s.ground ? null : oklch(x.geometry.background),
3360
+ badges: (x.badges ?? []).map((b) => b.toLowerCase()),
3361
+ avatars: x.avatars ?? 0,
3362
+ carousel: x.carousel ?? false,
3363
+ figures: x.figures ?? 0
3364
+ }))
3365
+ } : {}
3366
+ };
3367
+ }
3368
+
3369
+ // src/fingerprint/component.ts
3370
+ var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
3371
+
3372
+ // src/snapshot/rendered-tells.ts
3373
+ var hit2 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
3374
+ var GROUND = "rgb(255, 255, 255)";
3375
+ var BAND = "rgb(31, 58, 46)";
3376
+ var geo = (patch = {}) => ({
3377
+ centredShare: 0.1,
3378
+ mirrorSymmetry: 0.6,
3379
+ whitespaceRatio: 0.7,
3380
+ contentWidthRatio: 0.85,
3381
+ background: GROUND,
3382
+ controls: 0,
3383
+ ...patch
3384
+ });
3385
+ function withSection(index, patch, roles = []) {
3386
+ const base = makeSnapshot();
3387
+ return {
3388
+ ...base,
3389
+ sections: base.sections.map((s, i) => ({ ...s, role: roles[i] ?? s.kind, geometry: geo(), badges: [], avatars: 0, carousel: false, figures: 0, ...i === index ? patch : {} }))
3390
+ };
3391
+ }
3392
+ var measured = (s) => s.geometry !== void 0 && s.role !== void 0;
3393
+ var RENDERED_TELLS = [
3394
+ {
3395
+ id: "cta-band-stock",
3396
+ name: "Stock call-to-action band",
3397
+ generation: 1,
3398
+ severity: "warn",
3399
+ surface: "rendered",
3400
+ why: "A full-width coloured band with a centred heading, one line and one or two buttons is what a model closes every section run with. It asks for the booking the same way on every site.",
3401
+ fix: "Keep the ask, change the band: put it where the decision is made (next to the prices, after the proof), in the client's own words, laid out like the rest of the page rather than as a centred slab.",
3402
+ rendered: {
3403
+ detect: (s) => s.sections.filter((x) => measured(x) && (x.role === "cta-band" || x.role === "footer-cta")).filter((x) => {
3404
+ const g = x.geometry;
3405
+ return g.centredShare >= 0.8 && g.background !== s.ground && g.controls >= 1 && g.controls <= 2 && g.contentWidthRatio <= 0.7;
3406
+ }).map((x) => hit2(s, `a centred ${x.role === "footer-cta" ? "closing " : ""}band on its own colour with ${x.geometry.controls === 1 ? "one button" : "two buttons"}`, x.label)),
3407
+ fixtures: {
3408
+ flag: [
3409
+ withSection(4, { role: "footer-cta", label: "Boiler out today?", geometry: geo({ centredShare: 1, background: BAND, controls: 1, contentWidthRatio: 0.45 }) }),
3410
+ withSection(3, { role: "cta-band", label: "Ready to book?", geometry: geo({ centredShare: 0.9, background: BAND, controls: 2, contentWidthRatio: 0.5 }) })
3411
+ ],
3412
+ pass: [
3413
+ makeSnapshot(),
3414
+ // The same ask, laid out like the page: left-aligned on the ground.
3415
+ withSection(4, { role: "footer-cta", label: "Book a visit", geometry: geo({ centredShare: 0.1, background: GROUND, controls: 1, contentWidthRatio: 0.85 }) })
3416
+ ]
3417
+ }
3418
+ }
3419
+ },
3420
+ {
3421
+ id: "pricing-trio-popular",
3422
+ name: "Three-tier pricing with a popular badge",
3423
+ generation: 1,
3424
+ severity: "warn",
3425
+ surface: "rendered",
3426
+ why: "Three price cards with the middle one badged 'Most popular' is the SaaS pricing page, applied to a plumber or a salon whether or not anyone chose the middle option.",
3427
+ fix: "Keep the prices. Show them the way the trade quotes (a price list, per job, per visit), and drop the badge unless it is true and the client can say how many people chose it.",
3428
+ rendered: {
3429
+ detect: (s) => s.sections.filter((x) => measured(x) && x.role === "pricing" && x.cards === 3).filter((x) => (x.badges ?? []).some((b) => PROMO_BADGE.test(b))).map((x) => hit2(s, `three price cards, one badged "${(x.badges ?? []).find((b) => PROMO_BADGE.test(b))}"`, x.label)),
3430
+ fixtures: {
3431
+ flag: [withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: ["Most popular"], label: "Plans" })],
3432
+ pass: [
3433
+ makeSnapshot(),
3434
+ withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: [], label: "Prices" }),
3435
+ withSection(3, { role: "pricing", kind: "text", cards: 0, badges: [], label: "Price list" })
3436
+ ]
3437
+ }
3438
+ }
3439
+ },
3440
+ {
3441
+ id: "testimonial-avatar-carousel",
3442
+ name: "Avatar testimonial carousel",
3443
+ generation: 1,
3444
+ severity: "warn",
3445
+ surface: "rendered",
3446
+ why: "Round headshots over quotes in a sliding carousel is the stock testimonial block. Most visitors see the first slide only, and stock or AI faces make every quote read as invented.",
3447
+ fix: "Show the reviews all at once, where they come from (the Google rating, the named job, the town), with real photos of the work rather than of the reviewer.",
3448
+ rendered: {
3449
+ detect: (s) => s.sections.filter((x) => measured(x) && x.role === "testimonials" && x.carousel === true && (x.avatars ?? 0) >= 2).map((x) => hit2(s, `${x.avatars} round portraits in a sliding testimonial carousel`, x.label)),
3450
+ fixtures: {
3451
+ flag: [withSection(2, { role: "testimonials", avatars: 3, carousel: true, label: "What our clients say" })],
3452
+ pass: [
3453
+ makeSnapshot(),
3454
+ withSection(2, { role: "testimonials", avatars: 3, carousel: false, label: "Reviews" }),
3455
+ withSection(2, { role: "testimonials", avatars: 0, carousel: true, label: "Reviews" })
3456
+ ]
3457
+ }
3458
+ }
3459
+ },
3460
+ {
3461
+ id: "faq-accordion-closer",
3462
+ name: "FAQ accordion as the closer",
3463
+ generation: 2,
3464
+ severity: "warn",
3465
+ surface: "rendered",
3466
+ why: "Ending the page on a collapsed FAQ, often followed only by a band, is the model's default page ending. The answers a buyer needs are hidden behind clicks at the point they decide.",
3467
+ fix: "Answer the questions that block a booking where they arise (price next to prices, area next to the map), and end on the call to action or the proof.",
3468
+ rendered: {
3469
+ detect: (s) => {
3470
+ const body = s.sections.filter((x) => x.role !== "footer-cta" && x.role !== "cta-band");
3471
+ const last = body[body.length - 1];
3472
+ return last && measured(last) && last.role === "faq" ? [hit2(s, "the page ends on a question-and-answer list", last.label)] : [];
3473
+ },
3474
+ fixtures: {
3475
+ flag: [
3476
+ withSection(4, { role: "faq", label: "Common questions" }),
3477
+ {
3478
+ ...withSection(3, { role: "faq", label: "FAQ" }),
3479
+ sections: withSection(3, { role: "faq", label: "FAQ" }).sections.map((x, i) => i === 4 ? { ...x, role: "footer-cta" } : x)
3480
+ }
3481
+ ],
3482
+ pass: [makeSnapshot(), withSection(2, { role: "faq", label: "Common questions" })]
3483
+ }
3484
+ }
3485
+ },
3486
+ {
3487
+ id: "stats-row",
3488
+ name: "Row of big numbers",
3489
+ generation: 1,
3490
+ severity: "warn",
3491
+ surface: "rendered",
3492
+ why: "Three or four large figures in a row (500+ clients, 98% satisfaction, 10 years) is the stock proof block, and the numbers are often round, unsourced or invented.",
3493
+ fix: "Keep a figure only if it is true and sourced, and put it in a sentence next to what it proves. One real number in context beats four in a row.",
3494
+ rendered: {
3495
+ detect: (s) => s.sections.filter((x, i) => measured(x) && (x.role === "stats" || x.kind === "stats") && (x.figures ?? 0) >= 3 && (x.figures ?? 0) <= 6 && !(i === 1 && s.sections[0]?.kind === "hero")).map((x) => hit2(s, `${x.figures} large figures in a row`, x.label)),
3496
+ fixtures: {
3497
+ flag: [withSection(3, { role: "stats", kind: "stats", figures: 4, label: "500+" })],
3498
+ pass: [
3499
+ makeSnapshot(),
3500
+ withSection(3, { role: "text", kind: "text", figures: 1, label: "Since 2009" }),
3501
+ withSection(1, { role: "stats", kind: "stats", figures: 4, label: "500+" })
3502
+ ]
3503
+ }
3504
+ }
3505
+ },
3506
+ {
3507
+ id: "centred-everything",
3508
+ name: "Every section centred",
3509
+ generation: 2,
3510
+ severity: "warn",
3511
+ surface: "rendered",
3512
+ why: "When almost every section stacks a centred heading over centred text, the page has no reading line and every block looks like the one before. It is the layout nothing was decided for.",
3513
+ fix: "Pick an alignment and hold it: left-aligned text with a clear edge, keeping centring for the one moment that earns it.",
3514
+ rendered: {
3515
+ detect: (s) => {
3516
+ const m = s.sections.filter(measured);
3517
+ if (m.length < 4) return [];
3518
+ const centred = m.filter((x) => x.geometry.centredShare >= 0.7).length;
3519
+ return centred / m.length >= 0.8 ? [hit2(s, `${centred} of ${m.length} sections centre their text`)] : [];
3520
+ },
3521
+ fixtures: {
3522
+ flag: [{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x) => ({ ...x, role: x.kind, geometry: geo({ centredShare: 0.9 }) })) }],
3523
+ pass: [
3524
+ makeSnapshot(),
3525
+ { ...makeSnapshot(), sections: makeSnapshot().sections.map((x, i) => ({ ...x, role: x.kind, geometry: geo({ centredShare: i < 2 ? 0.9 : 0.1 }) })) }
3526
+ ]
3527
+ }
3528
+ }
3529
+ }
3530
+ ];
3531
+
3532
+ // src/snapshot/imagery-tells.ts
3533
+ var hit3 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
3534
+ var STOCK_PHOTO_HOSTS = {
3535
+ "unsplash.com": "Unsplash",
3536
+ "pexels.com": "Pexels",
3537
+ "pixabay.com": "Pixabay",
3538
+ "istockphoto.com": "iStock",
3539
+ "gettyimages.com": "Getty Images",
3540
+ "shutterstock.com": "Shutterstock",
3541
+ "ftcdn.net": "Adobe Stock",
3542
+ "dreamstime.com": "Dreamstime",
3543
+ "depositphotos.com": "Depositphotos",
3544
+ "freepik.com": "Freepik",
3545
+ "rawpixel.com": "rawpixel",
3546
+ "stocksnap.io": "StockSnap",
3547
+ "burst.shopifycdn.com": "Burst",
3548
+ "picsum.photos": "Lorem Picsum",
3549
+ "loremflickr.com": "LoremFlickr",
3550
+ "placehold.co": "a placeholder service",
3551
+ "placeholder.com": "a placeholder service",
3552
+ "dummyimage.com": "a placeholder service"
3553
+ };
3554
+ var STOCK_AVATAR_HOSTS = {
3555
+ "randomuser.me": "randomuser.me",
3556
+ "pravatar.cc": "Pravatar",
3557
+ "dicebear.com": "DiceBear",
3558
+ "xsgames.co": "a random-face service",
3559
+ "thispersondoesnotexist.com": "a generated face",
3560
+ "generated.photos": "a generated face"
3561
+ };
3562
+ var STOCK_FILENAMES = [
3563
+ [/(?:^|[/_-])shutterstock[_-]?\d{4,}/i, "Shutterstock"],
3564
+ [/(?:^|[/_-])istock(?:photo)?[_-]?\d{4,}/i, "iStock"],
3565
+ [/(?:^|[/_-])adobestock[_-]?\d{4,}/i, "Adobe Stock"],
3566
+ [/(?:^|[/_-])gettyimages[_-]?\d{4,}/i, "Getty Images"],
3567
+ [/(?:^|[/_-])depositphotos[_-]?\d{4,}/i, "Depositphotos"],
3568
+ [/(?:^|[/_-])dreamstime[_-]/i, "Dreamstime"],
3569
+ [/(?:^|[/_-])pexels-[a-z0-9-]*\d{4,}/i, "Pexels"],
3570
+ [/-unsplash\.(?:jpe?g|png|webp|avif)(?:[?#]|$)/i, "Unsplash"],
3571
+ // Unsplash's photo ids: photo-<13-digit timestamp>-<12 hex>.
3572
+ [/photo-1\d{12}-[0-9a-f]{12}/, "Unsplash"]
3573
+ ];
3574
+ var byHost = (host, table) => {
3575
+ if (!host) return null;
3576
+ const h = host.toLowerCase();
3577
+ for (const [domain, name] of Object.entries(table)) if (h === domain || h.endsWith(`.${domain}`)) return name;
3578
+ return null;
3579
+ };
3580
+ function stockSource(image) {
3581
+ const host = byHost(image.host, STOCK_PHOTO_HOSTS);
3582
+ if (host) return host;
3583
+ let path = image.src.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, "").replace(/[?#].*$/, "");
3584
+ try {
3585
+ path = decodeURIComponent(path);
3586
+ } catch {
3587
+ }
3588
+ for (const [re, name] of STOCK_FILENAMES) if (re.test(path)) return name;
3589
+ return null;
3590
+ }
3591
+ function stockAvatarSource(image) {
3592
+ return byHost(image.host, STOCK_AVATAR_HOSTS) ?? stockSource(image);
3593
+ }
3594
+ var picture = (patch = {}) => ({
3595
+ src: "https://example.test/images/boiler-swap-hinton.jpg",
3596
+ host: "example.test",
3597
+ width: 640,
3598
+ height: 480,
3599
+ top: 1800,
3600
+ role: "photo",
3601
+ alt: "The new combi boiler fitted in a kitchen cupboard in Hinton",
3602
+ decorative: false,
3603
+ section: 2,
3604
+ background: false,
3605
+ ...patch
3606
+ });
3607
+ var withImages = (images) => ({ ...makeSnapshot(), images });
3608
+ var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
3609
+ var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
3610
+ var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3611
+ var IMAGERY_TELLS = [
3612
+ {
3613
+ id: "stock-photo",
3614
+ name: "Stock photography",
3615
+ generation: 1,
3616
+ severity: "warn",
3617
+ surface: "rendered",
3618
+ why: "A picture from a stock library is the picture every other site in the trade can use. It shows a job the client did not do, in a kitchen that is not theirs, and a visitor who has seen it before stops believing the rest.",
3619
+ fix: "Use a photo of the client's own work, premises or people, even a plain one taken on a phone. Where there is none yet, use fewer pictures rather than a library one.",
3620
+ rendered: {
3621
+ detect: (s) => {
3622
+ const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
3623
+ if (found.length === 0) return [];
3624
+ const names = [...new Set(found.map((x) => x.from))].join(", ");
3625
+ return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
3626
+ },
3627
+ fixtures: {
3628
+ flag: [
3629
+ withImages([picture({ src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=1600", host: "images.unsplash.com", alt: "Plumber fixing a sink" })]),
3630
+ // Downloaded and self-hosted, found by the library's own file name.
3631
+ withImages([picture({ src: "https://example.test/img/shutterstock_1234567890.jpg" })])
3632
+ ],
3633
+ pass: [makeSnapshot(), withImages([picture()]), withImages([picture({ role: "icon", width: 48, height: 48, src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952", host: "images.unsplash.com" })])]
3634
+ }
3635
+ }
3636
+ },
3637
+ {
3638
+ id: "stock-avatar",
3639
+ name: "Stock or placeholder faces",
3640
+ generation: 1,
3641
+ severity: "warn",
3642
+ surface: "rendered",
3643
+ why: "Round portraits from a placeholder-face service or a stock library next to reviews make every quote read as invented, and some of those faces are generated people who do not exist.",
3644
+ fix: "Drop the portraits. Name the reviewer, the town and the job, and link the review to where it was left.",
3645
+ rendered: {
3646
+ detect: (s) => {
3647
+ const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
3648
+ if (found.length === 0) return [];
3649
+ const names = [...new Set(found.map((x) => x.from))].join(", ");
3650
+ return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
3651
+ },
3652
+ fixtures: {
3653
+ flag: [
3654
+ withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://i.pravatar.cc/150?img=12", host: "i.pravatar.cc", alt: "Sarah" })]),
3655
+ withImages([picture({ role: "avatar", width: 64, height: 64, src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?crop=faces", host: "images.unsplash.com" })])
3656
+ ],
3657
+ pass: [makeSnapshot(), withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://example.test/team/sam.jpg", alt: "Sam, engineer" })])]
3658
+ }
3659
+ }
3660
+ },
3661
+ {
3662
+ id: "ai-image",
3663
+ name: "Generated image",
3664
+ generation: 2,
3665
+ severity: "warn",
3666
+ surface: "rendered",
3667
+ why: "The file itself says a model made it (its XMP or Content Credentials name a trained-model source). A generated picture stands in for work the client did not photograph, and anyone can read the label.",
3668
+ fix: "Replace it with a photo of the real thing: the work, the premises, the people. Where there is none yet, use fewer pictures.",
3669
+ rendered: {
3670
+ detect: (s) => {
3671
+ const found = (s.images ?? []).filter((i) => i.provenance?.digitalSourceType);
3672
+ if (found.length === 0) return [];
3673
+ const first = found[0].provenance;
3674
+ const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
3675
+ return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
3676
+ },
3677
+ fixtures: {
3678
+ flag: [
3679
+ withImages([picture({ provenance: AI_XMP })]),
3680
+ withImages([picture({ provenance: { digitalSourceType: "compositeWithTrainedAlgorithmicMedia", source: "c2pa", c2pa: true, bytes: 12e4 } })])
3681
+ ],
3682
+ // Credentials that say nothing about a model, and an image never read, are not findings.
3683
+ pass: [makeSnapshot(), withImages([picture({ provenance: NOTHING })]), withImages([picture({ provenance: { ...NOTHING, c2pa: true } })]), withImages([picture()])]
3684
+ }
3685
+ }
3686
+ },
3687
+ {
3688
+ id: "no-real-imagery",
3689
+ name: "No photograph on the page",
3690
+ generation: 1,
3691
+ severity: "warn",
3692
+ surface: "rendered",
3693
+ why: "A page with no photograph at all, only icons and illustrations, is the page a model builds when it has no assets. Icon tiles stand in for the evidence a buyer looks for: the work, the premises, the people.",
3694
+ fix: "Add one real photo of the work where the claim is made: a finished job next to the service, the team next to the booking. One plain photo beats any number of icons.",
3695
+ rendered: {
3696
+ detect: (s) => {
3697
+ if (!s.images || s.sections.length < 4) return [];
3698
+ if (s.images.some((i) => i.role === "photo")) return [];
3699
+ const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
3700
+ return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
3701
+ },
3702
+ fixtures: {
3703
+ flag: [
3704
+ withImages([picture({ role: "icon", width: 48, height: 48, src: "https://example.test/icons/wrench.svg" }), picture({ role: "illustration", width: 480, height: 360, src: "https://example.test/hero.svg" })]),
3705
+ withImages([])
3706
+ ],
3707
+ // A snapshot with no image record is never judged; nor is a page too short to be a site.
3708
+ pass: [makeSnapshot(), withImages([picture()]), { ...withImages([]), sections: makeSnapshot().sections.slice(0, 3) }]
3709
+ }
3710
+ }
3711
+ }
3712
+ ];
3713
+
2756
3714
  // src/character/check.ts
2757
- var CATALOGUE_VERSION = "2026.09.9";
2758
- var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2759
- (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2760
- );
3715
+ var CATALOGUE_VERSION = "2026.09.12";
3716
+ var CATALOGUE = [
3717
+ ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
3718
+ ...SPECIFICITY_COPY_TELLS,
3719
+ ...RENDERED_TELLS,
3720
+ ...IMAGERY_TELLS,
3721
+ ...SPECIFICITY_RENDERED_TELLS
3722
+ ];
2761
3723
  function tellById(id) {
2762
3724
  return CATALOGUE.find((t) => t.id === id);
2763
3725
  }
@@ -2779,17 +3741,17 @@ var MARKER = /\b(?:copy|craft)-ok\b/;
2779
3741
  function marked(lines, line) {
2780
3742
  return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
2781
3743
  }
2782
- function toFinding(tell, hit2, texts) {
2783
- const text = texts.get(hit2.path) ?? "";
3744
+ function toFinding(tell, hit4, texts) {
3745
+ const text = texts.get(hit4.path) ?? "";
2784
3746
  return {
2785
3747
  tell: tell.id,
2786
3748
  name: tell.name,
2787
3749
  generation: tell.generation,
2788
3750
  severity: tell.severity,
2789
- path: hit2.path,
2790
- line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
2791
- excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
2792
- message: hit2.message,
3751
+ path: hit4.path,
3752
+ line: texts.has(hit4.path) ? lineAt(text, hit4.offset) : 0,
3753
+ excerpt: hit4.excerpt ?? excerptAt(text, hit4.offset),
3754
+ message: hit4.message,
2793
3755
  fix: tell.fix,
2794
3756
  ...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
2795
3757
  };
@@ -2808,14 +3770,14 @@ function report(tells, files, run, options) {
2808
3770
  };
2809
3771
  const excepted = /* @__PURE__ */ new Map();
2810
3772
  for (const tell of selected) {
2811
- for (const hit2 of run(tell)) {
2812
- const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit2.path.includes(e.path)));
3773
+ for (const hit4 of run(tell)) {
3774
+ const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit4.path.includes(e.path)));
2813
3775
  if (exception) {
2814
3776
  excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
2815
3777
  continue;
2816
3778
  }
2817
- const finding = toFinding(tell, hit2, texts);
2818
- if (marked(linesOf(hit2.path), finding.line)) {
3779
+ const finding = toFinding(tell, hit4, texts);
3780
+ if (marked(linesOf(hit4.path), finding.line)) {
2819
3781
  suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
2820
3782
  continue;
2821
3783
  }
@@ -2833,7 +3795,7 @@ function report(tells, files, run, options) {
2833
3795
  catalogueVersion: CATALOGUE_VERSION,
2834
3796
  findings,
2835
3797
  rejectedExceptions: rejected,
2836
- excepted: [...excepted].map(([e, count2]) => ({ tell: e.tell, because: e.because, count: count2 })),
3798
+ excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
2837
3799
  suppressed,
2838
3800
  summary: {
2839
3801
  files: files.length,
@@ -2851,67 +3813,6 @@ function auditSnapshot(snapshot, options = {}) {
2851
3813
  return result;
2852
3814
  }
2853
3815
 
2854
- // src/fingerprint/index.ts
2855
- var FINGERPRINT_VERSION = 2;
2856
- var CHROMATIC = 0.03;
2857
- var MIN_ACCENT_AREA = 2e4;
2858
- var oklch = (value) => {
2859
- const parsed = parseColour(value);
2860
- return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
2861
- };
2862
- function accentOf(s) {
2863
- const buttons = /* @__PURE__ */ new Map();
2864
- for (const c of s.controls) {
2865
- const o = oklch(c.background);
2866
- if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
2867
- }
2868
- const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
2869
- if (topButton) return oklch(topButton[0]);
2870
- const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC && x.w >= MIN_ACCENT_AREA).sort((a, b) => b.w - a.w)[0];
2871
- if (bg) return bg.o;
2872
- const text = s.colours.text.map((t) => ({ o: oklch(t.value), w: t.chars })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
2873
- return text ? text.o : null;
2874
- }
2875
- function faces(s) {
2876
- const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
2877
- const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
2878
- const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
2879
- const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
2880
- return {
2881
- display: { family: displayName, class: fontClass(displayName) },
2882
- body: { family: bodyName, class: fontClass(bodyName) }
2883
- };
2884
- }
2885
- function median(values) {
2886
- if (values.length === 0) return null;
2887
- const sorted = [...values].sort((a, b) => a - b);
2888
- const mid = Math.floor(sorted.length / 2);
2889
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
2890
- }
2891
- function fingerprint(s) {
2892
- const effects = [];
2893
- if (s.effects.glass > 0) effects.push("glass-panel");
2894
- if (s.effects.gradientText > 0) effects.push("gradient-text");
2895
- if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
2896
- if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
2897
- if (s.effects.marquees > 0) effects.push("marquee");
2898
- if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
2899
- if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
2900
- if (s.motion.introOverlay) effects.push("intro-cinematic");
2901
- const below = s.sections.filter((x) => x.top > s.viewport.height).length;
2902
- return {
2903
- version: FINGERPRINT_VERSION,
2904
- accent: accentOf(s),
2905
- ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
2906
- ...faces(s),
2907
- roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
2908
- motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
2909
- effects: effects.sort(),
2910
- layout: s.sections.map((x) => x.kind),
2911
- ...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 })) } : {}
2912
- };
2913
- }
2914
-
2915
3816
  // src/audit/collect.ts
2916
3817
  function collectInPage() {
2917
3818
  const vw = window.innerWidth;
@@ -3095,16 +3996,16 @@ function collectInPage() {
3095
3996
  if (row.length < 3) continue;
3096
3997
  const w = row.map((b) => b.width);
3097
3998
  if (Math.max(...w) > Math.min(...w) * 1.35 || Math.max(...w) > vw * 0.45) continue;
3098
- const withText = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
3099
- if (withText.length < 3) continue;
3100
- const iconCards = withText.filter((k) => {
3999
+ const withText2 = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
4000
+ if (withText2.length < 3) continue;
4001
+ const iconCards = withText2.filter((k) => {
3101
4002
  const first = k.querySelector("svg, img");
3102
4003
  if (!first) return false;
3103
4004
  const fb = box(first);
3104
4005
  const kb = box(k);
3105
4006
  return fb.width <= 72 && fb.height <= 72 && fb.top - kb.top < 120;
3106
4007
  }).length;
3107
- if (withText.length > best.cards) best = { cards: withText.length, iconCards };
4008
+ if (withText2.length > best.cards) best = { cards: withText2.length, iconCards };
3108
4009
  }
3109
4010
  return best;
3110
4011
  };
@@ -3123,7 +4024,7 @@ function collectInPage() {
3123
4024
  let covered = 0;
3124
4025
  let minX = Infinity;
3125
4026
  let maxX = -Infinity;
3126
- let textBlocks = 0;
4027
+ let textBlocks2 = 0;
3127
4028
  let centred = 0;
3128
4029
  for (const n of leaves) {
3129
4030
  const r = box(n);
@@ -3135,14 +4036,14 @@ function collectInPage() {
3135
4036
  left += split * r.height;
3136
4037
  right += (r.width - split) * r.height;
3137
4038
  if (ownText(n).length > 1) {
3138
- textBlocks += 1;
4039
+ textBlocks2 += 1;
3139
4040
  const mid = r.left + r.width / 2;
3140
4041
  if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
3141
4042
  }
3142
4043
  }
3143
4044
  const round = (v) => Math.round(v * 1e3) / 1e3;
3144
4045
  return {
3145
- centredShare: textBlocks ? round(centred / textBlocks) : 0,
4046
+ centredShare: textBlocks2 ? round(centred / textBlocks2) : 0,
3146
4047
  mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
3147
4048
  whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
3148
4049
  contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
@@ -3151,7 +4052,7 @@ function collectInPage() {
3151
4052
  };
3152
4053
  };
3153
4054
  const CURRENCY = /[£$€]\s?\d/;
3154
- const roleOf = (el, kind, geo, b, cards, iconCards, last) => {
4055
+ const roleOf = (el, kind, geo2, b, cards, iconCards, last) => {
3155
4056
  if (kind === "marquee" || kind === "hero") return kind;
3156
4057
  const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
3157
4058
  const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
@@ -3175,27 +4076,92 @@ function collectInPage() {
3175
4076
  if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
3176
4077
  if (kind === "stats" || kind === "logos") return kind;
3177
4078
  const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
3178
- const setApart = geo.background !== ground || geo.centredShare >= 0.6;
3179
- if (setApart && heading && b.height <= vh * 0.9 && geo.controls >= 1 && geo.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
4079
+ const setApart = geo2.background !== ground || geo2.centredShare >= 0.6;
4080
+ if (setApart && heading && b.height <= vh * 0.9 && geo2.controls >= 1 && geo2.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
3180
4081
  if (cards >= 3 && iconCards >= 2) return "features";
3181
4082
  return kind;
3182
4083
  };
4084
+ const partsOf = (el) => {
4085
+ const inside = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter(shown);
4086
+ const badges = [];
4087
+ for (const n of inside) {
4088
+ const t = ownText(n);
4089
+ if (t.length < 2 || t.length > 24 || badges.length >= 5) continue;
4090
+ const cs = style(n);
4091
+ const r = box(n);
4092
+ const radius = px(cs.borderTopLeftRadius);
4093
+ const drawn = painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1;
4094
+ if (drawn && r.height <= 40 && r.width <= 240 && radius >= r.height / 3 && !isControl(n)) badges.push(t);
4095
+ }
4096
+ const avatars = inside.filter((n) => {
4097
+ if (n.tagName !== "IMG") return false;
4098
+ const r = box(n);
4099
+ const rad = style(n).borderTopLeftRadius;
4100
+ const round = rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= r.width * 0.4;
4101
+ return r.width <= 96 && r.width >= 16 && Math.abs(r.width - r.height) <= r.width * 0.15 && round;
4102
+ }).length;
4103
+ const carousel = inside.some((n) => {
4104
+ const cs = style(n);
4105
+ if (n.getAttribute("aria-roledescription") === "carousel" || cs.scrollSnapType && cs.scrollSnapType !== "none") return true;
4106
+ if (/^(auto|scroll|hidden)$/.test(cs.overflowX) && n.children.length >= 3 && n.scrollWidth > n.clientWidth * 1.2) return true;
4107
+ return /^(BUTTON|A)$/.test(n.tagName) && /\b(prev|previous|next)\b/i.test(n.getAttribute("aria-label") ?? "");
4108
+ });
4109
+ return { badges, avatars, carousel };
4110
+ };
4111
+ const TEXT_BLOCK = "h1, h2, h3, h4, h5, h6, p, li, blockquote, figcaption, dt, dd, td, th, summary, cite, q";
4112
+ const CHROME = "nav, [role=navigation], footer, [role=contentinfo], button, [role=button], [aria-hidden=true], script, style, noscript, template";
4113
+ const readable = (el) => {
4114
+ if (!shown(el)) return false;
4115
+ const r = el.getBoundingClientRect();
4116
+ return r.width > 2 && r.height > 2;
4117
+ };
4118
+ const textBlocks = (scope, limit, keep = () => true) => {
4119
+ const out = [];
4120
+ const seen = /* @__PURE__ */ new Set();
4121
+ const add = (el) => {
4122
+ const t = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
4123
+ if (t.length < 2 || seen.has(t)) return;
4124
+ seen.add(t);
4125
+ out.push(t);
4126
+ };
4127
+ for (const el of Array.from(scope.querySelectorAll(TEXT_BLOCK))) {
4128
+ if (out.length >= limit) break;
4129
+ if (el.parentElement?.closest(TEXT_BLOCK) || el.closest(CHROME) || !readable(el) || !keep(el)) continue;
4130
+ add(el);
4131
+ }
4132
+ for (const el of Array.from(scope.querySelectorAll("div, span"))) {
4133
+ if (out.length >= limit) break;
4134
+ if (ownText(el).split(" ").length < 3) continue;
4135
+ if (el.closest(TEXT_BLOCK) || el.closest(CHROME) || el.closest("a") || !readable(el) || !keep(el)) continue;
4136
+ add(el);
4137
+ }
4138
+ return out;
4139
+ };
4140
+ const inChromeHeader = (el) => {
4141
+ const h = el.closest("header");
4142
+ return Boolean(h && !h.querySelector("h1"));
4143
+ };
4144
+ const firstScreenText = textBlocks(document.body, 12, (el) => {
4145
+ const b = box(el);
4146
+ return b.top < vh && b.top + b.height > 0 && !inChromeHeader(el) && !el.closest("a");
4147
+ });
3183
4148
  const kept = blocks.slice(0, 40);
3184
4149
  const sections = kept.map((el, index) => {
3185
4150
  const b = box(el);
3186
4151
  const heading = el.querySelector("h1, h2, h3");
3187
4152
  const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
3188
- const images = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
4153
+ const images2 = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
3189
4154
  const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
3190
4155
  const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
3191
4156
  const { cards, iconCards } = cardsIn(el);
3192
4157
  let kind = "text";
3193
4158
  if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
3194
4159
  else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
3195
- else if (images.length >= 4 && textChars < 300) kind = "logos";
4160
+ else if (images2.length >= 4 && textChars < 300) kind = "logos";
3196
4161
  else if (numbers >= 3 && textChars < 600) kind = "stats";
3197
4162
  else if (cards >= 3) kind = "cards";
3198
4163
  const geometry = geometryOf(el, b);
4164
+ const parts2 = partsOf(el);
3199
4165
  return {
3200
4166
  top: Math.round(b.top),
3201
4167
  height: Math.round(b.height),
@@ -3205,7 +4171,10 @@ function collectInPage() {
3205
4171
  iconCards,
3206
4172
  hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
3207
4173
  role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
3208
- geometry
4174
+ geometry,
4175
+ ...parts2,
4176
+ figures: numbers,
4177
+ text: textBlocks(el, 40)
3209
4178
  };
3210
4179
  });
3211
4180
  if (header && sections.length === 0) {
@@ -3287,6 +4256,81 @@ function collectInPage() {
3287
4256
  const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
3288
4257
  if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
3289
4258
  }
4259
+ const unwrap = (raw) => {
4260
+ if (raw.startsWith("data:")) return raw.slice(0, 64);
4261
+ try {
4262
+ const u = new URL(raw, location.href);
4263
+ const inner = /\/_(?:next|vercel)\/image$/.test(u.pathname) ? u.searchParams.get("url") : null;
4264
+ if (inner) return new URL(inner, location.href).href;
4265
+ const cf = u.pathname.match(/^\/cdn-cgi\/image\/[^/]+\/(.+)$/);
4266
+ if (cf) return /^https?:\/\//.test(cf[1]) ? cf[1] : new URL(`/${cf[1]}`, location.href).href;
4267
+ return u.href;
4268
+ } catch {
4269
+ return raw.slice(0, 200);
4270
+ }
4271
+ };
4272
+ const roundBox = (el, w) => {
4273
+ const rad = style(el).borderTopLeftRadius;
4274
+ return rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= w * 0.4;
4275
+ };
4276
+ const sectionOf = (el) => {
4277
+ const i = kept.findIndex((k) => k.contains(el));
4278
+ return i === -1 ? null : i;
4279
+ };
4280
+ const roleOfImage = (el, src, w, h, alt) => {
4281
+ const vector = /\.svg(?:[?#]|$)/i.test(src) || src.startsWith("data:image/svg");
4282
+ const square = Math.abs(w - h) <= w * 0.15;
4283
+ if (w >= 16 && w <= 96 && square && (roundBox(el, w) || el.parentElement !== null && roundBox(el.parentElement, w))) return "avatar";
4284
+ const section = sectionOf(el);
4285
+ const hint = `${alt ?? ""} ${src} ${typeof el.className === "string" ? el.className : ""}`;
4286
+ if (/\blogo\b/i.test(hint) || section !== null && sections[section]?.kind === "logos" || el.closest("header, footer, nav") && h <= 120) return "logo";
4287
+ if (Math.max(w, h) <= (vector ? 120 : 72)) return "icon";
4288
+ if (vector) return "illustration";
4289
+ return w >= 160 && h >= 120 ? "photo" : "icon";
4290
+ };
4291
+ const images = [];
4292
+ const seenImage = /* @__PURE__ */ new Set();
4293
+ const addImage = (el, raw, alt, decorative, background) => {
4294
+ const b = box(el);
4295
+ if (!raw || b.width < 8 || b.height < 8) return;
4296
+ const src = unwrap(raw);
4297
+ const key = `${src} ${Math.round(b.top)} ${Math.round(b.left)}`;
4298
+ if (seenImage.has(key)) return;
4299
+ seenImage.add(key);
4300
+ let host = null;
4301
+ try {
4302
+ host = src.startsWith("data:") ? null : new URL(src).hostname;
4303
+ } catch {
4304
+ host = null;
4305
+ }
4306
+ images.push({
4307
+ src,
4308
+ host,
4309
+ width: Math.round(b.width),
4310
+ height: Math.round(b.height),
4311
+ top: Math.round(b.top),
4312
+ role: roleOfImage(el, src, b.width, b.height, alt),
4313
+ alt,
4314
+ decorative,
4315
+ section: sectionOf(el),
4316
+ background
4317
+ });
4318
+ };
4319
+ for (const img of Array.from(document.querySelectorAll("img")).slice(0, 400)) {
4320
+ if (!shown(img)) continue;
4321
+ const alt = img.getAttribute("alt");
4322
+ const decorative = alt === "" || img.getAttribute("aria-hidden") === "true" || /^(?:presentation|none)$/.test(img.getAttribute("role") ?? "");
4323
+ addImage(img, img.currentSrc || img.getAttribute("src") || "", alt, decorative, false);
4324
+ }
4325
+ for (const el of all) {
4326
+ const bgImage = style(el).backgroundImage || "";
4327
+ if (!bgImage.includes("url(")) continue;
4328
+ const b = box(el);
4329
+ if (b.width < 160 || b.height < 120 || !shown(el)) continue;
4330
+ const m = bgImage.match(/url\(\s*["']?([^"')]+)["']?\s*\)/);
4331
+ if (m) addImage(el, m[1], null, true, true);
4332
+ }
4333
+ images.sort((a, b) => b.width * b.height - a.width * a.height);
3290
4334
  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);
3291
4335
  const nums = (s) => Array.from(s).sort((a, b) => a - b);
3292
4336
  return {
@@ -3306,6 +4350,8 @@ function collectInPage() {
3306
4350
  return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
3307
4351
  })(),
3308
4352
  effects,
4353
+ images: images.slice(0, 60),
4354
+ firstScreenText,
3309
4355
  motion: {
3310
4356
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
3311
4357
  sections: sections.length,
@@ -3469,7 +4515,60 @@ function visualMeasures(img, maxSamples = 1e6) {
3469
4515
  };
3470
4516
  }
3471
4517
 
4518
+ // src/snapshot/provenance.ts
4519
+ var PROVENANCE_SCAN_BYTES = 256 * 1024;
4520
+ var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
4521
+ var MARKER2 = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
4522
+ var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
4523
+ var C2PA_PNG = /caBX/;
4524
+ function latin1(bytes) {
4525
+ let out = "";
4526
+ for (let i = 0; i < bytes.length; i += 8192) {
4527
+ out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
4528
+ }
4529
+ return out;
4530
+ }
4531
+ function xmpPackets(text) {
4532
+ const out = [];
4533
+ const open = /<x:xmpmeta\b|<\?xpacket begin/g;
4534
+ let m;
4535
+ while (m = open.exec(text)) {
4536
+ const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
4537
+ out.push([m.index, close === -1 ? text.length : close]);
4538
+ if (close === -1) break;
4539
+ open.lastIndex = close;
4540
+ }
4541
+ return out;
4542
+ }
4543
+ function scanProvenance(bytes) {
4544
+ const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
4545
+ const text = latin1(head);
4546
+ const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
4547
+ const m = MARKER2.exec(text);
4548
+ if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
4549
+ const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
4550
+ return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
4551
+ }
4552
+
3472
4553
  // src/audit/index.ts
4554
+ var PROVENANCE_MAX_IMAGES = 24;
4555
+ var PROVENANCE_BUDGET_MS = 2e4;
4556
+ async function readProvenance(page, images, timeoutMs) {
4557
+ if (!page.request) return;
4558
+ const deadline = Date.now() + PROVENANCE_BUDGET_MS;
4559
+ 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);
4560
+ for (const src of wanted) {
4561
+ const left = deadline - Date.now();
4562
+ if (left < 500) break;
4563
+ try {
4564
+ const res = await page.request.get(src, { headers: { Range: `bytes=0-${PROVENANCE_SCAN_BYTES - 1}` }, timeout: Math.min(timeoutMs, left) });
4565
+ if (!res.ok()) continue;
4566
+ const provenance = scanProvenance(await res.body());
4567
+ for (const image of images) if (image.src === src) image.provenance = provenance;
4568
+ } catch {
4569
+ }
4570
+ }
4571
+ }
3473
4572
  var VISUAL_MAX_HEIGHT = 6e3;
3474
4573
  async function loadPlaywright() {
3475
4574
  for (const name of ["playwright", "playwright-core"]) {
@@ -3488,6 +4587,7 @@ async function snapshotPage(page, url, options = {}) {
3488
4587
  await page.waitForTimeout(options.introWindowMs ?? 5e3);
3489
4588
  const late = await page.evaluate(overlayInPage).catch(() => false);
3490
4589
  const data = await page.evaluate(collectInPage);
4590
+ if (data.images && options.provenance !== false) await readProvenance(page, data.images, 8e3);
3491
4591
  let visual;
3492
4592
  if (page.screenshot && options.visual !== false) {
3493
4593
  try {
@@ -3551,6 +4651,8 @@ async function audit(url, options = {}) {
3551
4651
  }
3552
4652
  // Annotate the CommonJS export names for ESM import in node:
3553
4653
  0 && (module.exports = {
4654
+ PROVENANCE_BUDGET_MS,
4655
+ PROVENANCE_MAX_IMAGES,
3554
4656
  VISUAL_MAX_HEIGHT,
3555
4657
  audit,
3556
4658
  collectInPage,