@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.js CHANGED
@@ -1000,14 +1000,14 @@ function contractionHits(ctx) {
1000
1000
  for (const doc2 of documents(ctx)) {
1001
1001
  if (doc2.words < 800) continue;
1002
1002
  const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
1003
- const count2 = (text.match(CONTRACTION) ?? []).length;
1004
- const rate = count2 * 1e3 / doc2.words;
1003
+ const count4 = (text.match(CONTRACTION) ?? []).length;
1004
+ const rate = count4 * 1e3 / doc2.words;
1005
1005
  if (rate < 1) {
1006
1006
  const first = sentences(doc2)[0];
1007
1007
  hits.push({
1008
1008
  path: doc2.path,
1009
1009
  offset: first?.offset ?? 0,
1010
- message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
1010
+ message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
1011
1011
  });
1012
1012
  }
1013
1013
  }
@@ -1130,16 +1130,16 @@ function filler(words, seed = 1, options = {}) {
1130
1130
  return state % n;
1131
1131
  };
1132
1132
  const out = [];
1133
- let count2 = 0;
1133
+ let count4 = 0;
1134
1134
  let i = 0;
1135
- while (count2 < words) {
1135
+ while (count4 < words) {
1136
1136
  const length = uniform ?? shape[i % shape.length];
1137
1137
  const s = [];
1138
1138
  for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
1139
1139
  if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
1140
1140
  s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
1141
1141
  out.push(`${s.join(" ")}.`);
1142
- count2 += s.length;
1142
+ count4 += s.length;
1143
1143
  i += 1;
1144
1144
  }
1145
1145
  const paragraphs = [];
@@ -2322,70 +2322,6 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
2322
2322
  }
2323
2323
  ];
2324
2324
 
2325
- // src/character/house.ts
2326
- var HOUSE = {
2327
- "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
2328
- emoji: { tier: "block", label: "emoji" },
2329
- "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
2330
- "no-x-no-y": {
2331
- tier: "block",
2332
- label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
2333
- },
2334
- "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
2335
- "ai-phrase": { tier: "block", label: "AI tell" },
2336
- "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
2337
- "plain-english": { tier: "block", label: 'use "properly explained"' },
2338
- buzzword: { tier: "block", label: "forbidden phrase" },
2339
- "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
2340
- // Evidence of a pasted chat reply, not a matter of style. Moved from review
2341
- // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
2342
- "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
2343
- "review-phrase": { tier: "review", label: "review tier" },
2344
- "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
2345
- "stock-phrase": { tier: "review", label: "stock phrase" },
2346
- "hollow-imperative": { tier: "review", label: "hollow imperative" },
2347
- "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
2348
- "staccato-triplet": { tier: "review", label: "staccato triplet" },
2349
- "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
2350
- "ing-tail": { tier: "review", label: "empty -ing tail" },
2351
- "vague-attribution": { tier: "review", label: "unnamed source" },
2352
- "closing-summary": { tier: "review", label: "closing summary" },
2353
- "false-range": { tier: "review", label: "false range" },
2354
- "placeholder": { tier: "review", label: "unfilled placeholder" },
2355
- // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
2356
- // a proposal can honestly say "the attached research". Review until measured there.
2357
- "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
2358
- "question-reveal": { tier: "review", label: "staged reveal" },
2359
- "inline-label-list": { tier: "review", label: "bold-label bullets" },
2360
- "phrase-density": { tier: "review", label: "density" },
2361
- "aphorism-density": { tier: "review", label: "density" },
2362
- "contraction-scarcity": { tier: "review", label: "density" },
2363
- "sentence-rhythm": { tier: "review", label: "density" },
2364
- "repeated-sentence": { tier: "review", label: "repetition" },
2365
- "heading-shape": { tier: "review", label: "headings" },
2366
- "heading-echo": { tier: "review", label: "headings" },
2367
- // Banned only "when vague", per the rules. Too noisy for a sweep.
2368
- "vague-word": { tier: "explicit", label: "vague" }
2369
- };
2370
- function houseRule(id, name = id) {
2371
- return HOUSE[id] ?? { tier: "review", label: name };
2372
- }
2373
-
2374
- // src/character/parse.ts
2375
- function lineAt(text, offset) {
2376
- let line = 1;
2377
- for (let i = 0; i < offset && i < text.length; i += 1) {
2378
- if (text.charCodeAt(i) === 10) line += 1;
2379
- }
2380
- return line;
2381
- }
2382
- function excerptAt(text, offset, max = 140) {
2383
- const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
2384
- const endRaw = text.indexOf("\n", offset);
2385
- const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
2386
- return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
2387
- }
2388
-
2389
2325
  // src/snapshot/types.ts
2390
2326
  var SNAPSHOT_VERSION = 2;
2391
2327
 
@@ -2458,6 +2394,606 @@ function makeSnapshot(patch = {}) {
2458
2394
  return out;
2459
2395
  }
2460
2396
 
2397
+ // src/character/parse.ts
2398
+ var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
2399
+ var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
2400
+ var CSS = /\.(css|scss|sass|less|pcss)$/i;
2401
+ var PROSE = /\.(md|markdown|txt)$/i;
2402
+ function fileKind(path) {
2403
+ if (MARKUP.test(path)) return "markup";
2404
+ if (CSS.test(path)) return "css";
2405
+ if (SCRIPT.test(path)) return "script";
2406
+ if (PROSE.test(path)) return "prose";
2407
+ return "other";
2408
+ }
2409
+ function lineAt(text, offset) {
2410
+ let line = 1;
2411
+ for (let i = 0; i < offset && i < text.length; i += 1) {
2412
+ if (text.charCodeAt(i) === 10) line += 1;
2413
+ }
2414
+ return line;
2415
+ }
2416
+ function excerptAt(text, offset, max = 140) {
2417
+ const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
2418
+ const endRaw = text.indexOf("\n", offset);
2419
+ const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
2420
+ return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
2421
+ }
2422
+
2423
+ // src/character/facts.ts
2424
+ 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)?)";
2425
+ var PATTERNS = [
2426
+ ["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
2427
+ ["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
2428
+ ["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
2429
+ ["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
2430
+ ["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
2431
+ ["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")],
2432
+ ["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
2433
+ ["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
2434
+ ];
2435
+ var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
2436
+ var normalise = (kind, value) => {
2437
+ const v = value.trim();
2438
+ if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
2439
+ if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
2440
+ if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
2441
+ if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
2442
+ return v.replace(/\s+/g, " ").toLowerCase();
2443
+ };
2444
+ function protectedFacts(text) {
2445
+ const facts = [];
2446
+ let rest = text;
2447
+ const mask = (start, length) => {
2448
+ rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
2449
+ };
2450
+ for (const [kind, pattern] of PATTERNS) {
2451
+ for (const m of [...rest.matchAll(pattern)]) {
2452
+ const value = m[0].replace(/[.,;:]+$/, "");
2453
+ facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
2454
+ mask(m.index ?? 0, m[0].length);
2455
+ }
2456
+ }
2457
+ const lines = rest.split("\n");
2458
+ for (const line of lines) {
2459
+ if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
2460
+ for (const m of line.matchAll(NAME)) {
2461
+ const at = m.index ?? 0;
2462
+ const before = line.slice(0, at).trimEnd();
2463
+ const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
2464
+ const word = m[0];
2465
+ const acronym = /^[A-Z]{2,}$/.test(word);
2466
+ if (opensSentence && !acronym && !/\s/.test(word)) continue;
2467
+ if (word === "I") continue;
2468
+ facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
2469
+ }
2470
+ }
2471
+ return facts;
2472
+ }
2473
+
2474
+ // src/character/specificity.ts
2475
+ var TRADE_NOUNS = [
2476
+ // heating, plumbing, electrical
2477
+ "boiler",
2478
+ "radiator",
2479
+ "combi",
2480
+ "cylinder",
2481
+ "thermostat",
2482
+ "underfloor heating",
2483
+ "heat pump",
2484
+ "solar panel",
2485
+ "plumbing",
2486
+ "plumber",
2487
+ "leak",
2488
+ "toilet",
2489
+ "shower",
2490
+ "bathroom",
2491
+ "wet room",
2492
+ "drain",
2493
+ "fuse board",
2494
+ "fuseboard",
2495
+ "consumer unit",
2496
+ "rewire",
2497
+ "socket",
2498
+ "ev charger",
2499
+ "electrician",
2500
+ "gas engineer",
2501
+ "power flush",
2502
+ "landlord certificate",
2503
+ "eicr",
2504
+ // building and outside
2505
+ "extension",
2506
+ "loft conversion",
2507
+ "loft",
2508
+ "kitchen",
2509
+ "roof",
2510
+ "roofer",
2511
+ "gutter",
2512
+ "chimney",
2513
+ "driveway",
2514
+ "patio",
2515
+ "fence",
2516
+ "fencing",
2517
+ "decking",
2518
+ "window",
2519
+ "conservatory",
2520
+ "plastering",
2521
+ "plasterer",
2522
+ "render",
2523
+ "brickwork",
2524
+ "bricklayer",
2525
+ "tiling",
2526
+ "tiler",
2527
+ "flooring",
2528
+ "carpet",
2529
+ "joinery",
2530
+ "joiner",
2531
+ "carpenter",
2532
+ "staircase",
2533
+ "damp",
2534
+ "scaffolding",
2535
+ "garage conversion",
2536
+ "lawn",
2537
+ "hedge",
2538
+ "tree surgery",
2539
+ "stump",
2540
+ "landscaping",
2541
+ "gardener",
2542
+ "gate",
2543
+ "locksmith",
2544
+ "pest control",
2545
+ "wasp",
2546
+ "cleaning",
2547
+ "oven",
2548
+ "end of tenancy",
2549
+ // vehicles
2550
+ "mot",
2551
+ "tyre",
2552
+ "brake",
2553
+ "clutch",
2554
+ "cambelt",
2555
+ "exhaust",
2556
+ "gearbox",
2557
+ "valeting",
2558
+ // personal and health
2559
+ "haircut",
2560
+ "skin fade",
2561
+ "beard",
2562
+ "barber",
2563
+ "balayage",
2564
+ "nails",
2565
+ "lashes",
2566
+ "massage",
2567
+ "physio",
2568
+ "physiotherapy",
2569
+ "osteopath",
2570
+ "chiropractor",
2571
+ "dentist",
2572
+ "dental",
2573
+ "implant",
2574
+ "brace",
2575
+ "invisalign",
2576
+ "hygienist",
2577
+ "optician",
2578
+ "eye test",
2579
+ "vet",
2580
+ "grooming",
2581
+ "dog walking",
2582
+ "personal trainer",
2583
+ "yoga",
2584
+ "pilates",
2585
+ // professional
2586
+ "accountant",
2587
+ "tax return",
2588
+ "bookkeeping",
2589
+ "payroll",
2590
+ "vat",
2591
+ "self assessment",
2592
+ "solicitor",
2593
+ "conveyancing",
2594
+ "probate",
2595
+ "divorce",
2596
+ "lasting power of attorney",
2597
+ "mortgage",
2598
+ "surveyor",
2599
+ "architect",
2600
+ "planning permission",
2601
+ "building regulations",
2602
+ // web and digital, for the agency's own pages
2603
+ "website",
2604
+ "web design",
2605
+ "seo",
2606
+ "hosting",
2607
+ "google business profile",
2608
+ "online shop",
2609
+ "ecommerce",
2610
+ // food and hospitality
2611
+ "breakfast",
2612
+ "lunch",
2613
+ "dinner",
2614
+ "coffee",
2615
+ "bakery",
2616
+ "cake",
2617
+ "catering",
2618
+ "wedding",
2619
+ "venue"
2620
+ ];
2621
+ var STOCK_FIGURES = [
2622
+ /\b24\s?\/\s?7\b/gi,
2623
+ /\b24[\s-]hours?\b/gi,
2624
+ /\b1[01]0\s?%/g,
2625
+ /\b(?:5|five)[\s-]?star\b/gi,
2626
+ /#\s?1\b/g,
2627
+ /\bno\.?\s?1\b/gi,
2628
+ /\b365\s+days\b/gi
2629
+ ];
2630
+ var NOT_NAMES = new Set(
2631
+ "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(" ")
2632
+ );
2633
+ var BRIEF_STOP = new Set(
2634
+ "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(" ")
2635
+ );
2636
+ var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
2637
+ var countWords2 = (text) => (text.match(WORD2) ?? []).length;
2638
+ var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2639
+ var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
2640
+ var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
2641
+ var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
2642
+ function caseless(line) {
2643
+ const words = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
2644
+ const letters = words.filter((w) => w.length >= 2);
2645
+ if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
2646
+ const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
2647
+ if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
2648
+ return null;
2649
+ }
2650
+ var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
2651
+ function briefTerms(brief) {
2652
+ const terms = /* @__PURE__ */ new Set();
2653
+ for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
2654
+ const lower = w.toLowerCase();
2655
+ if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower) && !NOT_NAMES.has(lower)) terms.add(lower);
2656
+ }
2657
+ for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
2658
+ for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
2659
+ return [...terms];
2660
+ }
2661
+ function specifics(text, brief) {
2662
+ const found = /* @__PURE__ */ new Map();
2663
+ const add = (kind, value) => {
2664
+ const key = value.toLowerCase().replace(/\s+/g, " ").trim();
2665
+ if (key && !found.has(key)) found.set(key, { kind, value, key });
2666
+ };
2667
+ for (const raw of text.split("\n")) {
2668
+ const line = maskStock(raw);
2669
+ if (!line.trim()) continue;
2670
+ const capitals = caseless(line);
2671
+ for (const f4 of protectedFacts(line)) {
2672
+ if (f4.kind === "name") {
2673
+ if (capitals === "shout") continue;
2674
+ if (capitals === "title") {
2675
+ for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
2676
+ continue;
2677
+ }
2678
+ const words = f4.value.split(/\s+/);
2679
+ const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
2680
+ const first = words.findIndex(named);
2681
+ if (first === -1) continue;
2682
+ const last = words.length - 1 - [...words].reverse().findIndex(named);
2683
+ add("name", words.slice(first, last + 1).join(" "));
2684
+ continue;
2685
+ }
2686
+ add(f4.kind, f4.value);
2687
+ }
2688
+ for (const [term, re] of TRADE_PATTERNS) {
2689
+ const m = re.exec(line);
2690
+ if (m) add("trade", term);
2691
+ }
2692
+ }
2693
+ if (brief) {
2694
+ for (const term of briefTerms(brief)) {
2695
+ if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
2696
+ if (termPattern(term).test(text)) add("brief", term);
2697
+ }
2698
+ }
2699
+ return [...found.values()];
2700
+ }
2701
+ function specificity(text, brief) {
2702
+ const list = specifics(text, brief);
2703
+ const words = countWords2(text);
2704
+ const trade = list.filter((s) => s.kind === "trade").length;
2705
+ const hard = list.length - trade;
2706
+ return {
2707
+ words,
2708
+ specifics: list,
2709
+ hard,
2710
+ trade,
2711
+ perHundred: words ? Math.round(list.length / words * 1e3) / 10 : 0,
2712
+ generic: hard === 0 && trade < 2
2713
+ };
2714
+ }
2715
+ function sentencesOf(blocks) {
2716
+ const out = [];
2717
+ for (const block of blocks) {
2718
+ for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
2719
+ const s = m[0].trim();
2720
+ if (countWords2(s) >= 3) out.push(s);
2721
+ }
2722
+ }
2723
+ return [...new Set(out)];
2724
+ }
2725
+
2726
+ // src/character/tells/specificity.ts
2727
+ var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
2728
+ var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
2729
+ var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
2730
+ function literalText(inner) {
2731
+ const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
2732
+ if (/[{}]/.test(t)) return null;
2733
+ return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
2734
+ }
2735
+ 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;
2736
+ var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
2737
+ var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
2738
+ var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
2739
+ function headlines(ctx) {
2740
+ const out = [];
2741
+ for (const file of ctx.files) {
2742
+ if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
2743
+ for (const m of file.text.matchAll(H1)) {
2744
+ const h1 = literalText(m[1]);
2745
+ if (!h1) continue;
2746
+ const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
2747
+ const p = NEXT_P.exec(after);
2748
+ const sub = p ? literalText(p[1]) : null;
2749
+ out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
2750
+ }
2751
+ }
2752
+ return out;
2753
+ }
2754
+ var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
2755
+ 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;
2756
+ var PROOF_NAMES = [
2757
+ "Gas Safe",
2758
+ "NICEIC",
2759
+ "NAPIT",
2760
+ "ELECSA",
2761
+ "Stroma",
2762
+ "TrustMark",
2763
+ "Which? Trusted Trader",
2764
+ "Checkatrade",
2765
+ "TrustATrader",
2766
+ "Rated People",
2767
+ "MyBuilder",
2768
+ "Federation of Master Builders",
2769
+ "FMB",
2770
+ "Guild of Master Craftsmen",
2771
+ "OFTEC",
2772
+ "HETAS",
2773
+ "CIPHE",
2774
+ "APHC",
2775
+ "CHAS",
2776
+ "SafeContractor",
2777
+ "Constructionline",
2778
+ "FENSA",
2779
+ "CERTASS",
2780
+ "MCS",
2781
+ "RECC",
2782
+ "BPEC",
2783
+ "City & Guilds",
2784
+ "NVQ",
2785
+ "CSCS",
2786
+ "Worcester Bosch",
2787
+ "Vaillant",
2788
+ "Viessmann",
2789
+ "BAFE",
2790
+ "NSI",
2791
+ "SSAIB",
2792
+ "BPCA",
2793
+ "RICS",
2794
+ "CIOB",
2795
+ "RIBA",
2796
+ "ICAEW",
2797
+ "ACCA",
2798
+ "CIMA",
2799
+ "SRA",
2800
+ "Law Society",
2801
+ "GDC",
2802
+ "GMC",
2803
+ "HCPC",
2804
+ "CQC",
2805
+ "Ofsted",
2806
+ "ISO 9001",
2807
+ "ISO 27001",
2808
+ "Cyber Essentials",
2809
+ "Companies House",
2810
+ "FCA",
2811
+ "Trustpilot",
2812
+ "Feefo",
2813
+ "Reviews.io",
2814
+ "Yell",
2815
+ "Houzz"
2816
+ ];
2817
+ var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2818
+ var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
2819
+ var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
2820
+ var PROOF_PATTERNS = [
2821
+ PROOF_NAME,
2822
+ /\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
2823
+ // A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
2824
+ /\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,
2825
+ // A registration or licence number.
2826
+ /\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
2827
+ ];
2828
+ var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
2829
+ function sectionHasProof(s, section, index) {
2830
+ if ((section.role ?? section.kind) === "testimonials") return true;
2831
+ const text = section.text ?? [];
2832
+ if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
2833
+ return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
2834
+ }
2835
+ function unprovenClaims(s) {
2836
+ const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
2837
+ const out = [];
2838
+ s.sections.forEach((section, i) => {
2839
+ if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
2840
+ for (const sentence of sentencesOf(section.text)) {
2841
+ const m = STANDING_CLAIM.exec(sentence);
2842
+ if (m) out.push({ claim: m[0], sentence, section: section.label });
2843
+ }
2844
+ });
2845
+ return out;
2846
+ }
2847
+ var withText = (texts, patch = () => ({})) => {
2848
+ const base = makeSnapshot();
2849
+ return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
2850
+ };
2851
+ var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
2852
+ var SPECIFICITY_COPY_TELLS = [
2853
+ {
2854
+ id: "generic-hero-claim",
2855
+ name: "Headline that fits any business",
2856
+ generation: 1,
2857
+ severity: "warn",
2858
+ surface: "copy",
2859
+ 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.",
2860
+ 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".',
2861
+ detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
2862
+ fixtures: {
2863
+ flag: [
2864
+ { 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>' },
2865
+ // Shouted and Title Case: its capitals are not names, and its figures are stock.
2866
+ { path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
2867
+ ],
2868
+ pass: [
2869
+ { path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
2870
+ // A stock headline rescued by the line under it.
2871
+ { path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
2872
+ // Two trade nouns say what the business does.
2873
+ { path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
2874
+ // The words come from data: nothing to read.
2875
+ { path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
2876
+ // Another page's headline is a label, not the site's claim.
2877
+ { path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
2878
+ ]
2879
+ },
2880
+ rendered: {
2881
+ detect: (s) => {
2882
+ if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
2883
+ const text = s.firstScreenText.join("\n");
2884
+ if (!text.trim() || !specificity(text).generic) return [];
2885
+ const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
2886
+ return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
2887
+ },
2888
+ fixtures: {
2889
+ flag: [
2890
+ makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
2891
+ makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
2892
+ ],
2893
+ pass: [
2894
+ makeSnapshot(),
2895
+ makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
2896
+ makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
2897
+ // A first screen that is a picture and nothing else is not judged on its words.
2898
+ makeSnapshot({ firstScreenText: [] }),
2899
+ // Nor is any page but the home page.
2900
+ makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
2901
+ ]
2902
+ }
2903
+ }
2904
+ }
2905
+ ];
2906
+ var SPECIFICITY_RENDERED_TELLS = [
2907
+ {
2908
+ id: "unproven-claim",
2909
+ name: "Claim with no proof next to it",
2910
+ generation: 1,
2911
+ severity: "warn",
2912
+ surface: "rendered",
2913
+ 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.`,
2914
+ 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.",
2915
+ rendered: {
2916
+ detect: (s) => {
2917
+ if (!s.sections.some((x) => x.text)) return [];
2918
+ const found = unprovenClaims(s);
2919
+ if (found.length === 0) return [];
2920
+ const first = found[0];
2921
+ const where = first.section ? `"${first.section}"` : "an unnamed section";
2922
+ return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
2923
+ },
2924
+ fixtures: {
2925
+ flag: [
2926
+ withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
2927
+ // Proof two sections away is not next to the claim.
2928
+ 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" } : {})
2929
+ ],
2930
+ pass: [
2931
+ makeSnapshot(),
2932
+ withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
2933
+ withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
2934
+ withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
2935
+ {
2936
+ ...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
2937
+ 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 }]
2938
+ }
2939
+ ]
2940
+ }
2941
+ }
2942
+ }
2943
+ ];
2944
+
2945
+ // src/character/house.ts
2946
+ var HOUSE = {
2947
+ "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
2948
+ emoji: { tier: "block", label: "emoji" },
2949
+ "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
2950
+ "no-x-no-y": {
2951
+ tier: "block",
2952
+ label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
2953
+ },
2954
+ "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
2955
+ "ai-phrase": { tier: "block", label: "AI tell" },
2956
+ "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
2957
+ "plain-english": { tier: "block", label: 'use "properly explained"' },
2958
+ buzzword: { tier: "block", label: "forbidden phrase" },
2959
+ "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
2960
+ // Evidence of a pasted chat reply, not a matter of style. Moved from review
2961
+ // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
2962
+ "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
2963
+ "review-phrase": { tier: "review", label: "review tier" },
2964
+ "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
2965
+ "stock-phrase": { tier: "review", label: "stock phrase" },
2966
+ "hollow-imperative": { tier: "review", label: "hollow imperative" },
2967
+ "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
2968
+ "staccato-triplet": { tier: "review", label: "staccato triplet" },
2969
+ "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
2970
+ "ing-tail": { tier: "review", label: "empty -ing tail" },
2971
+ "vague-attribution": { tier: "review", label: "unnamed source" },
2972
+ "closing-summary": { tier: "review", label: "closing summary" },
2973
+ "false-range": { tier: "review", label: "false range" },
2974
+ "placeholder": { tier: "review", label: "unfilled placeholder" },
2975
+ // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
2976
+ // a proposal can honestly say "the attached research". Review until measured there.
2977
+ "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
2978
+ "question-reveal": { tier: "review", label: "staged reveal" },
2979
+ "inline-label-list": { tier: "review", label: "bold-label bullets" },
2980
+ "phrase-density": { tier: "review", label: "density" },
2981
+ "aphorism-density": { tier: "review", label: "density" },
2982
+ "contraction-scarcity": { tier: "review", label: "density" },
2983
+ "sentence-rhythm": { tier: "review", label: "density" },
2984
+ "repeated-sentence": { tier: "review", label: "repetition" },
2985
+ "heading-shape": { tier: "review", label: "headings" },
2986
+ "heading-echo": { tier: "review", label: "headings" },
2987
+ // A threshold on a count, not a named pattern: a person decides whether the
2988
+ // headline needed a particular. Review until its estate hits are read.
2989
+ "generic-hero-claim": { tier: "review", label: "headline that fits any business" },
2990
+ // Banned only "when vague", per the rules. Too noisy for a sweep.
2991
+ "vague-word": { tier: "explicit", label: "vague" }
2992
+ };
2993
+ function houseRule(id, name = id) {
2994
+ return HOUSE[id] ?? { tier: "review", label: name };
2995
+ }
2996
+
2461
2997
  // src/snapshot/fonts.ts
2462
2998
  var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
2463
2999
  function normaliseFamily(raw) {
@@ -2575,7 +3111,7 @@ function blueToPurple(s) {
2575
3111
  }
2576
3112
  return hits;
2577
3113
  }
2578
- var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3114
+ var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
2579
3115
  var RENDERED_PATHS = {
2580
3116
  "reflex-font": {
2581
3117
  detect: reflexFonts(REFLEX_FONTS_1),
@@ -2613,11 +3149,11 @@ var RENDERED_PATHS = {
2613
3149
  }
2614
3150
  },
2615
3151
  "gradient-text": {
2616
- detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
3152
+ detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
2617
3153
  fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
2618
3154
  },
2619
3155
  "glass-panel": {
2620
- detect: (s) => s.effects.glass > 0 ? [hit(s, `${count(s.effects.glass, "translucent blurred panel")}`)] : [],
3156
+ detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
2621
3157
  fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
2622
3158
  },
2623
3159
  "cream-palette": {
@@ -2695,7 +3231,7 @@ var RENDERED_PATHS = {
2695
3231
  detect: (s) => {
2696
3232
  const radial = s.effects.gradients.filter((g) => g.radial).length;
2697
3233
  const n = s.effects.glows + radial;
2698
- return n > 0 ? [hit(s, `${count(n, "soft glow")} behind the content`)] : [];
3234
+ return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
2699
3235
  },
2700
3236
  fixtures: {
2701
3237
  flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
@@ -2703,11 +3239,11 @@ var RENDERED_PATHS = {
2703
3239
  }
2704
3240
  },
2705
3241
  "grid-background": {
2706
- detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
3242
+ detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
2707
3243
  fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
2708
3244
  },
2709
3245
  marquee: {
2710
- detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count(s.effects.marquees, "endless scrolling strip")}`)] : [],
3246
+ detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
2711
3247
  fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
2712
3248
  },
2713
3249
  "thin-border-wide-shadow": {
@@ -2720,11 +3256,435 @@ var RENDERED_PATHS = {
2720
3256
  }
2721
3257
  };
2722
3258
 
3259
+ // src/fingerprint/index.ts
3260
+ var FINGERPRINT_VERSION = 2;
3261
+ var CHROMATIC = 0.03;
3262
+ var MIN_ACCENT_AREA = 2e4;
3263
+ var oklch = (value) => {
3264
+ const parsed = parseColour(value);
3265
+ return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
3266
+ };
3267
+ function accentOf(s) {
3268
+ const buttons = /* @__PURE__ */ new Map();
3269
+ for (const c of s.controls) {
3270
+ const o = oklch(c.background);
3271
+ if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
3272
+ }
3273
+ const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
3274
+ if (topButton) return oklch(topButton[0]);
3275
+ 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];
3276
+ if (bg) return bg.o;
3277
+ 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];
3278
+ return text ? text.o : null;
3279
+ }
3280
+ function faces(s) {
3281
+ const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
3282
+ const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
3283
+ const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
3284
+ const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
3285
+ return {
3286
+ display: { family: displayName, class: fontClass(displayName) },
3287
+ body: { family: bodyName, class: fontClass(bodyName) }
3288
+ };
3289
+ }
3290
+ function median(values) {
3291
+ if (values.length === 0) return null;
3292
+ const sorted = [...values].sort((a, b) => a - b);
3293
+ const mid = Math.floor(sorted.length / 2);
3294
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
3295
+ }
3296
+ function fingerprint(s) {
3297
+ const effects = [];
3298
+ if (s.effects.glass > 0) effects.push("glass-panel");
3299
+ if (s.effects.gradientText > 0) effects.push("gradient-text");
3300
+ if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
3301
+ if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
3302
+ if (s.effects.marquees > 0) effects.push("marquee");
3303
+ if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
3304
+ if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
3305
+ if (s.motion.introOverlay) effects.push("intro-cinematic");
3306
+ const below = s.sections.filter((x) => x.top > s.viewport.height).length;
3307
+ return {
3308
+ version: FINGERPRINT_VERSION,
3309
+ accent: accentOf(s),
3310
+ ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
3311
+ ...faces(s),
3312
+ roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
3313
+ motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
3314
+ effects: effects.sort(),
3315
+ layout: s.sections.map((x) => x.kind),
3316
+ ...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? {
3317
+ sections: s.sections.map((x) => ({
3318
+ role: x.role,
3319
+ centred: x.geometry.centredShare,
3320
+ width: x.geometry.contentWidthRatio,
3321
+ symmetry: x.geometry.mirrorSymmetry,
3322
+ controls: x.geometry.controls,
3323
+ cards: x.cards,
3324
+ background: x.geometry.background === s.ground ? null : oklch(x.geometry.background),
3325
+ badges: (x.badges ?? []).map((b) => b.toLowerCase()),
3326
+ avatars: x.avatars ?? 0,
3327
+ carousel: x.carousel ?? false,
3328
+ figures: x.figures ?? 0
3329
+ }))
3330
+ } : {}
3331
+ };
3332
+ }
3333
+
3334
+ // src/fingerprint/component.ts
3335
+ var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
3336
+
3337
+ // src/snapshot/rendered-tells.ts
3338
+ var hit2 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
3339
+ var GROUND = "rgb(255, 255, 255)";
3340
+ var BAND = "rgb(31, 58, 46)";
3341
+ var geo = (patch = {}) => ({
3342
+ centredShare: 0.1,
3343
+ mirrorSymmetry: 0.6,
3344
+ whitespaceRatio: 0.7,
3345
+ contentWidthRatio: 0.85,
3346
+ background: GROUND,
3347
+ controls: 0,
3348
+ ...patch
3349
+ });
3350
+ function withSection(index, patch, roles = []) {
3351
+ const base = makeSnapshot();
3352
+ return {
3353
+ ...base,
3354
+ sections: base.sections.map((s, i) => ({ ...s, role: roles[i] ?? s.kind, geometry: geo(), badges: [], avatars: 0, carousel: false, figures: 0, ...i === index ? patch : {} }))
3355
+ };
3356
+ }
3357
+ var measured = (s) => s.geometry !== void 0 && s.role !== void 0;
3358
+ var RENDERED_TELLS = [
3359
+ {
3360
+ id: "cta-band-stock",
3361
+ name: "Stock call-to-action band",
3362
+ generation: 1,
3363
+ severity: "warn",
3364
+ surface: "rendered",
3365
+ 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.",
3366
+ 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.",
3367
+ rendered: {
3368
+ detect: (s) => s.sections.filter((x) => measured(x) && (x.role === "cta-band" || x.role === "footer-cta")).filter((x) => {
3369
+ const g = x.geometry;
3370
+ return g.centredShare >= 0.8 && g.background !== s.ground && g.controls >= 1 && g.controls <= 2 && g.contentWidthRatio <= 0.7;
3371
+ }).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)),
3372
+ fixtures: {
3373
+ flag: [
3374
+ withSection(4, { role: "footer-cta", label: "Boiler out today?", geometry: geo({ centredShare: 1, background: BAND, controls: 1, contentWidthRatio: 0.45 }) }),
3375
+ withSection(3, { role: "cta-band", label: "Ready to book?", geometry: geo({ centredShare: 0.9, background: BAND, controls: 2, contentWidthRatio: 0.5 }) })
3376
+ ],
3377
+ pass: [
3378
+ makeSnapshot(),
3379
+ // The same ask, laid out like the page: left-aligned on the ground.
3380
+ withSection(4, { role: "footer-cta", label: "Book a visit", geometry: geo({ centredShare: 0.1, background: GROUND, controls: 1, contentWidthRatio: 0.85 }) })
3381
+ ]
3382
+ }
3383
+ }
3384
+ },
3385
+ {
3386
+ id: "pricing-trio-popular",
3387
+ name: "Three-tier pricing with a popular badge",
3388
+ generation: 1,
3389
+ severity: "warn",
3390
+ surface: "rendered",
3391
+ 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.",
3392
+ 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.",
3393
+ rendered: {
3394
+ 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)),
3395
+ fixtures: {
3396
+ flag: [withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: ["Most popular"], label: "Plans" })],
3397
+ pass: [
3398
+ makeSnapshot(),
3399
+ withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: [], label: "Prices" }),
3400
+ withSection(3, { role: "pricing", kind: "text", cards: 0, badges: [], label: "Price list" })
3401
+ ]
3402
+ }
3403
+ }
3404
+ },
3405
+ {
3406
+ id: "testimonial-avatar-carousel",
3407
+ name: "Avatar testimonial carousel",
3408
+ generation: 1,
3409
+ severity: "warn",
3410
+ surface: "rendered",
3411
+ 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.",
3412
+ 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.",
3413
+ rendered: {
3414
+ 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)),
3415
+ fixtures: {
3416
+ flag: [withSection(2, { role: "testimonials", avatars: 3, carousel: true, label: "What our clients say" })],
3417
+ pass: [
3418
+ makeSnapshot(),
3419
+ withSection(2, { role: "testimonials", avatars: 3, carousel: false, label: "Reviews" }),
3420
+ withSection(2, { role: "testimonials", avatars: 0, carousel: true, label: "Reviews" })
3421
+ ]
3422
+ }
3423
+ }
3424
+ },
3425
+ {
3426
+ id: "faq-accordion-closer",
3427
+ name: "FAQ accordion as the closer",
3428
+ generation: 2,
3429
+ severity: "warn",
3430
+ surface: "rendered",
3431
+ 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.",
3432
+ 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.",
3433
+ rendered: {
3434
+ detect: (s) => {
3435
+ const body = s.sections.filter((x) => x.role !== "footer-cta" && x.role !== "cta-band");
3436
+ const last = body[body.length - 1];
3437
+ return last && measured(last) && last.role === "faq" ? [hit2(s, "the page ends on a question-and-answer list", last.label)] : [];
3438
+ },
3439
+ fixtures: {
3440
+ flag: [
3441
+ withSection(4, { role: "faq", label: "Common questions" }),
3442
+ {
3443
+ ...withSection(3, { role: "faq", label: "FAQ" }),
3444
+ sections: withSection(3, { role: "faq", label: "FAQ" }).sections.map((x, i) => i === 4 ? { ...x, role: "footer-cta" } : x)
3445
+ }
3446
+ ],
3447
+ pass: [makeSnapshot(), withSection(2, { role: "faq", label: "Common questions" })]
3448
+ }
3449
+ }
3450
+ },
3451
+ {
3452
+ id: "stats-row",
3453
+ name: "Row of big numbers",
3454
+ generation: 1,
3455
+ severity: "warn",
3456
+ surface: "rendered",
3457
+ 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.",
3458
+ 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.",
3459
+ rendered: {
3460
+ 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)),
3461
+ fixtures: {
3462
+ flag: [withSection(3, { role: "stats", kind: "stats", figures: 4, label: "500+" })],
3463
+ pass: [
3464
+ makeSnapshot(),
3465
+ withSection(3, { role: "text", kind: "text", figures: 1, label: "Since 2009" }),
3466
+ withSection(1, { role: "stats", kind: "stats", figures: 4, label: "500+" })
3467
+ ]
3468
+ }
3469
+ }
3470
+ },
3471
+ {
3472
+ id: "centred-everything",
3473
+ name: "Every section centred",
3474
+ generation: 2,
3475
+ severity: "warn",
3476
+ surface: "rendered",
3477
+ 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.",
3478
+ fix: "Pick an alignment and hold it: left-aligned text with a clear edge, keeping centring for the one moment that earns it.",
3479
+ rendered: {
3480
+ detect: (s) => {
3481
+ const m = s.sections.filter(measured);
3482
+ if (m.length < 4) return [];
3483
+ const centred = m.filter((x) => x.geometry.centredShare >= 0.7).length;
3484
+ return centred / m.length >= 0.8 ? [hit2(s, `${centred} of ${m.length} sections centre their text`)] : [];
3485
+ },
3486
+ fixtures: {
3487
+ flag: [{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x) => ({ ...x, role: x.kind, geometry: geo({ centredShare: 0.9 }) })) }],
3488
+ pass: [
3489
+ makeSnapshot(),
3490
+ { ...makeSnapshot(), sections: makeSnapshot().sections.map((x, i) => ({ ...x, role: x.kind, geometry: geo({ centredShare: i < 2 ? 0.9 : 0.1 }) })) }
3491
+ ]
3492
+ }
3493
+ }
3494
+ }
3495
+ ];
3496
+
3497
+ // src/snapshot/imagery-tells.ts
3498
+ var hit3 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
3499
+ var STOCK_PHOTO_HOSTS = {
3500
+ "unsplash.com": "Unsplash",
3501
+ "pexels.com": "Pexels",
3502
+ "pixabay.com": "Pixabay",
3503
+ "istockphoto.com": "iStock",
3504
+ "gettyimages.com": "Getty Images",
3505
+ "shutterstock.com": "Shutterstock",
3506
+ "ftcdn.net": "Adobe Stock",
3507
+ "dreamstime.com": "Dreamstime",
3508
+ "depositphotos.com": "Depositphotos",
3509
+ "freepik.com": "Freepik",
3510
+ "rawpixel.com": "rawpixel",
3511
+ "stocksnap.io": "StockSnap",
3512
+ "burst.shopifycdn.com": "Burst",
3513
+ "picsum.photos": "Lorem Picsum",
3514
+ "loremflickr.com": "LoremFlickr",
3515
+ "placehold.co": "a placeholder service",
3516
+ "placeholder.com": "a placeholder service",
3517
+ "dummyimage.com": "a placeholder service"
3518
+ };
3519
+ var STOCK_AVATAR_HOSTS = {
3520
+ "randomuser.me": "randomuser.me",
3521
+ "pravatar.cc": "Pravatar",
3522
+ "dicebear.com": "DiceBear",
3523
+ "xsgames.co": "a random-face service",
3524
+ "thispersondoesnotexist.com": "a generated face",
3525
+ "generated.photos": "a generated face"
3526
+ };
3527
+ var STOCK_FILENAMES = [
3528
+ [/(?:^|[/_-])shutterstock[_-]?\d{4,}/i, "Shutterstock"],
3529
+ [/(?:^|[/_-])istock(?:photo)?[_-]?\d{4,}/i, "iStock"],
3530
+ [/(?:^|[/_-])adobestock[_-]?\d{4,}/i, "Adobe Stock"],
3531
+ [/(?:^|[/_-])gettyimages[_-]?\d{4,}/i, "Getty Images"],
3532
+ [/(?:^|[/_-])depositphotos[_-]?\d{4,}/i, "Depositphotos"],
3533
+ [/(?:^|[/_-])dreamstime[_-]/i, "Dreamstime"],
3534
+ [/(?:^|[/_-])pexels-[a-z0-9-]*\d{4,}/i, "Pexels"],
3535
+ [/-unsplash\.(?:jpe?g|png|webp|avif)(?:[?#]|$)/i, "Unsplash"],
3536
+ // Unsplash's photo ids: photo-<13-digit timestamp>-<12 hex>.
3537
+ [/photo-1\d{12}-[0-9a-f]{12}/, "Unsplash"]
3538
+ ];
3539
+ var byHost = (host, table) => {
3540
+ if (!host) return null;
3541
+ const h = host.toLowerCase();
3542
+ for (const [domain, name] of Object.entries(table)) if (h === domain || h.endsWith(`.${domain}`)) return name;
3543
+ return null;
3544
+ };
3545
+ function stockSource(image) {
3546
+ const host = byHost(image.host, STOCK_PHOTO_HOSTS);
3547
+ if (host) return host;
3548
+ let path = image.src.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, "").replace(/[?#].*$/, "");
3549
+ try {
3550
+ path = decodeURIComponent(path);
3551
+ } catch {
3552
+ }
3553
+ for (const [re, name] of STOCK_FILENAMES) if (re.test(path)) return name;
3554
+ return null;
3555
+ }
3556
+ function stockAvatarSource(image) {
3557
+ return byHost(image.host, STOCK_AVATAR_HOSTS) ?? stockSource(image);
3558
+ }
3559
+ var picture = (patch = {}) => ({
3560
+ src: "https://example.test/images/boiler-swap-hinton.jpg",
3561
+ host: "example.test",
3562
+ width: 640,
3563
+ height: 480,
3564
+ top: 1800,
3565
+ role: "photo",
3566
+ alt: "The new combi boiler fitted in a kitchen cupboard in Hinton",
3567
+ decorative: false,
3568
+ section: 2,
3569
+ background: false,
3570
+ ...patch
3571
+ });
3572
+ var withImages = (images) => ({ ...makeSnapshot(), images });
3573
+ var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
3574
+ var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
3575
+ var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3576
+ var IMAGERY_TELLS = [
3577
+ {
3578
+ id: "stock-photo",
3579
+ name: "Stock photography",
3580
+ generation: 1,
3581
+ severity: "warn",
3582
+ surface: "rendered",
3583
+ 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.",
3584
+ 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.",
3585
+ rendered: {
3586
+ detect: (s) => {
3587
+ const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
3588
+ if (found.length === 0) return [];
3589
+ const names = [...new Set(found.map((x) => x.from))].join(", ");
3590
+ return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
3591
+ },
3592
+ fixtures: {
3593
+ flag: [
3594
+ withImages([picture({ src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=1600", host: "images.unsplash.com", alt: "Plumber fixing a sink" })]),
3595
+ // Downloaded and self-hosted, found by the library's own file name.
3596
+ withImages([picture({ src: "https://example.test/img/shutterstock_1234567890.jpg" })])
3597
+ ],
3598
+ pass: [makeSnapshot(), withImages([picture()]), withImages([picture({ role: "icon", width: 48, height: 48, src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952", host: "images.unsplash.com" })])]
3599
+ }
3600
+ }
3601
+ },
3602
+ {
3603
+ id: "stock-avatar",
3604
+ name: "Stock or placeholder faces",
3605
+ generation: 1,
3606
+ severity: "warn",
3607
+ surface: "rendered",
3608
+ 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.",
3609
+ fix: "Drop the portraits. Name the reviewer, the town and the job, and link the review to where it was left.",
3610
+ rendered: {
3611
+ detect: (s) => {
3612
+ const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
3613
+ if (found.length === 0) return [];
3614
+ const names = [...new Set(found.map((x) => x.from))].join(", ");
3615
+ return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
3616
+ },
3617
+ fixtures: {
3618
+ flag: [
3619
+ withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://i.pravatar.cc/150?img=12", host: "i.pravatar.cc", alt: "Sarah" })]),
3620
+ withImages([picture({ role: "avatar", width: 64, height: 64, src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?crop=faces", host: "images.unsplash.com" })])
3621
+ ],
3622
+ pass: [makeSnapshot(), withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://example.test/team/sam.jpg", alt: "Sam, engineer" })])]
3623
+ }
3624
+ }
3625
+ },
3626
+ {
3627
+ id: "ai-image",
3628
+ name: "Generated image",
3629
+ generation: 2,
3630
+ severity: "warn",
3631
+ surface: "rendered",
3632
+ 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.",
3633
+ fix: "Replace it with a photo of the real thing: the work, the premises, the people. Where there is none yet, use fewer pictures.",
3634
+ rendered: {
3635
+ detect: (s) => {
3636
+ const found = (s.images ?? []).filter((i) => i.provenance?.digitalSourceType);
3637
+ if (found.length === 0) return [];
3638
+ const first = found[0].provenance;
3639
+ const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
3640
+ return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
3641
+ },
3642
+ fixtures: {
3643
+ flag: [
3644
+ withImages([picture({ provenance: AI_XMP })]),
3645
+ withImages([picture({ provenance: { digitalSourceType: "compositeWithTrainedAlgorithmicMedia", source: "c2pa", c2pa: true, bytes: 12e4 } })])
3646
+ ],
3647
+ // Credentials that say nothing about a model, and an image never read, are not findings.
3648
+ pass: [makeSnapshot(), withImages([picture({ provenance: NOTHING })]), withImages([picture({ provenance: { ...NOTHING, c2pa: true } })]), withImages([picture()])]
3649
+ }
3650
+ }
3651
+ },
3652
+ {
3653
+ id: "no-real-imagery",
3654
+ name: "No photograph on the page",
3655
+ generation: 1,
3656
+ severity: "warn",
3657
+ surface: "rendered",
3658
+ 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.",
3659
+ 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.",
3660
+ rendered: {
3661
+ detect: (s) => {
3662
+ if (!s.images || s.sections.length < 4) return [];
3663
+ if (s.images.some((i) => i.role === "photo")) return [];
3664
+ const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
3665
+ return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
3666
+ },
3667
+ fixtures: {
3668
+ flag: [
3669
+ 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" })]),
3670
+ withImages([])
3671
+ ],
3672
+ // A snapshot with no image record is never judged; nor is a page too short to be a site.
3673
+ pass: [makeSnapshot(), withImages([picture()]), { ...withImages([]), sections: makeSnapshot().sections.slice(0, 3) }]
3674
+ }
3675
+ }
3676
+ }
3677
+ ];
3678
+
2723
3679
  // src/character/check.ts
2724
- var CATALOGUE_VERSION = "2026.09.9";
2725
- var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2726
- (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2727
- );
3680
+ var CATALOGUE_VERSION = "2026.09.12";
3681
+ var CATALOGUE = [
3682
+ ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
3683
+ ...SPECIFICITY_COPY_TELLS,
3684
+ ...RENDERED_TELLS,
3685
+ ...IMAGERY_TELLS,
3686
+ ...SPECIFICITY_RENDERED_TELLS
3687
+ ];
2728
3688
  function tellById(id) {
2729
3689
  return CATALOGUE.find((t) => t.id === id);
2730
3690
  }
@@ -2746,17 +3706,17 @@ var MARKER = /\b(?:copy|craft)-ok\b/;
2746
3706
  function marked(lines, line) {
2747
3707
  return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
2748
3708
  }
2749
- function toFinding(tell, hit2, texts) {
2750
- const text = texts.get(hit2.path) ?? "";
3709
+ function toFinding(tell, hit4, texts) {
3710
+ const text = texts.get(hit4.path) ?? "";
2751
3711
  return {
2752
3712
  tell: tell.id,
2753
3713
  name: tell.name,
2754
3714
  generation: tell.generation,
2755
3715
  severity: tell.severity,
2756
- path: hit2.path,
2757
- line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
2758
- excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
2759
- message: hit2.message,
3716
+ path: hit4.path,
3717
+ line: texts.has(hit4.path) ? lineAt(text, hit4.offset) : 0,
3718
+ excerpt: hit4.excerpt ?? excerptAt(text, hit4.offset),
3719
+ message: hit4.message,
2760
3720
  fix: tell.fix,
2761
3721
  ...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
2762
3722
  };
@@ -2775,14 +3735,14 @@ function report(tells, files, run, options) {
2775
3735
  };
2776
3736
  const excepted = /* @__PURE__ */ new Map();
2777
3737
  for (const tell of selected) {
2778
- for (const hit2 of run(tell)) {
2779
- const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit2.path.includes(e.path)));
3738
+ for (const hit4 of run(tell)) {
3739
+ const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit4.path.includes(e.path)));
2780
3740
  if (exception) {
2781
3741
  excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
2782
3742
  continue;
2783
3743
  }
2784
- const finding = toFinding(tell, hit2, texts);
2785
- if (marked(linesOf(hit2.path), finding.line)) {
3744
+ const finding = toFinding(tell, hit4, texts);
3745
+ if (marked(linesOf(hit4.path), finding.line)) {
2786
3746
  suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
2787
3747
  continue;
2788
3748
  }
@@ -2800,7 +3760,7 @@ function report(tells, files, run, options) {
2800
3760
  catalogueVersion: CATALOGUE_VERSION,
2801
3761
  findings,
2802
3762
  rejectedExceptions: rejected,
2803
- excepted: [...excepted].map(([e, count2]) => ({ tell: e.tell, because: e.because, count: count2 })),
3763
+ excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
2804
3764
  suppressed,
2805
3765
  summary: {
2806
3766
  files: files.length,
@@ -2818,67 +3778,6 @@ function auditSnapshot(snapshot, options = {}) {
2818
3778
  return result;
2819
3779
  }
2820
3780
 
2821
- // src/fingerprint/index.ts
2822
- var FINGERPRINT_VERSION = 2;
2823
- var CHROMATIC = 0.03;
2824
- var MIN_ACCENT_AREA = 2e4;
2825
- var oklch = (value) => {
2826
- const parsed = parseColour(value);
2827
- return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
2828
- };
2829
- function accentOf(s) {
2830
- const buttons = /* @__PURE__ */ new Map();
2831
- for (const c of s.controls) {
2832
- const o = oklch(c.background);
2833
- if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
2834
- }
2835
- const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
2836
- if (topButton) return oklch(topButton[0]);
2837
- 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];
2838
- if (bg) return bg.o;
2839
- 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];
2840
- return text ? text.o : null;
2841
- }
2842
- function faces(s) {
2843
- const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
2844
- const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
2845
- const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
2846
- const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
2847
- return {
2848
- display: { family: displayName, class: fontClass(displayName) },
2849
- body: { family: bodyName, class: fontClass(bodyName) }
2850
- };
2851
- }
2852
- function median(values) {
2853
- if (values.length === 0) return null;
2854
- const sorted = [...values].sort((a, b) => a - b);
2855
- const mid = Math.floor(sorted.length / 2);
2856
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
2857
- }
2858
- function fingerprint(s) {
2859
- const effects = [];
2860
- if (s.effects.glass > 0) effects.push("glass-panel");
2861
- if (s.effects.gradientText > 0) effects.push("gradient-text");
2862
- if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
2863
- if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
2864
- if (s.effects.marquees > 0) effects.push("marquee");
2865
- if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
2866
- if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
2867
- if (s.motion.introOverlay) effects.push("intro-cinematic");
2868
- const below = s.sections.filter((x) => x.top > s.viewport.height).length;
2869
- return {
2870
- version: FINGERPRINT_VERSION,
2871
- accent: accentOf(s),
2872
- ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
2873
- ...faces(s),
2874
- roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
2875
- motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
2876
- effects: effects.sort(),
2877
- layout: s.sections.map((x) => x.kind),
2878
- ...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 })) } : {}
2879
- };
2880
- }
2881
-
2882
3781
  // src/audit/collect.ts
2883
3782
  function collectInPage() {
2884
3783
  const vw = window.innerWidth;
@@ -3062,16 +3961,16 @@ function collectInPage() {
3062
3961
  if (row.length < 3) continue;
3063
3962
  const w = row.map((b) => b.width);
3064
3963
  if (Math.max(...w) > Math.min(...w) * 1.35 || Math.max(...w) > vw * 0.45) continue;
3065
- const withText = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
3066
- if (withText.length < 3) continue;
3067
- const iconCards = withText.filter((k) => {
3964
+ const withText2 = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
3965
+ if (withText2.length < 3) continue;
3966
+ const iconCards = withText2.filter((k) => {
3068
3967
  const first = k.querySelector("svg, img");
3069
3968
  if (!first) return false;
3070
3969
  const fb = box(first);
3071
3970
  const kb = box(k);
3072
3971
  return fb.width <= 72 && fb.height <= 72 && fb.top - kb.top < 120;
3073
3972
  }).length;
3074
- if (withText.length > best.cards) best = { cards: withText.length, iconCards };
3973
+ if (withText2.length > best.cards) best = { cards: withText2.length, iconCards };
3075
3974
  }
3076
3975
  return best;
3077
3976
  };
@@ -3090,7 +3989,7 @@ function collectInPage() {
3090
3989
  let covered = 0;
3091
3990
  let minX = Infinity;
3092
3991
  let maxX = -Infinity;
3093
- let textBlocks = 0;
3992
+ let textBlocks2 = 0;
3094
3993
  let centred = 0;
3095
3994
  for (const n of leaves) {
3096
3995
  const r = box(n);
@@ -3102,14 +4001,14 @@ function collectInPage() {
3102
4001
  left += split * r.height;
3103
4002
  right += (r.width - split) * r.height;
3104
4003
  if (ownText(n).length > 1) {
3105
- textBlocks += 1;
4004
+ textBlocks2 += 1;
3106
4005
  const mid = r.left + r.width / 2;
3107
4006
  if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
3108
4007
  }
3109
4008
  }
3110
4009
  const round = (v) => Math.round(v * 1e3) / 1e3;
3111
4010
  return {
3112
- centredShare: textBlocks ? round(centred / textBlocks) : 0,
4011
+ centredShare: textBlocks2 ? round(centred / textBlocks2) : 0,
3113
4012
  mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
3114
4013
  whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
3115
4014
  contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
@@ -3118,7 +4017,7 @@ function collectInPage() {
3118
4017
  };
3119
4018
  };
3120
4019
  const CURRENCY = /[£$€]\s?\d/;
3121
- const roleOf = (el, kind, geo, b, cards, iconCards, last) => {
4020
+ const roleOf = (el, kind, geo2, b, cards, iconCards, last) => {
3122
4021
  if (kind === "marquee" || kind === "hero") return kind;
3123
4022
  const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
3124
4023
  const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
@@ -3142,27 +4041,92 @@ function collectInPage() {
3142
4041
  if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
3143
4042
  if (kind === "stats" || kind === "logos") return kind;
3144
4043
  const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
3145
- const setApart = geo.background !== ground || geo.centredShare >= 0.6;
3146
- if (setApart && heading && b.height <= vh * 0.9 && geo.controls >= 1 && geo.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
4044
+ const setApart = geo2.background !== ground || geo2.centredShare >= 0.6;
4045
+ if (setApart && heading && b.height <= vh * 0.9 && geo2.controls >= 1 && geo2.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
3147
4046
  if (cards >= 3 && iconCards >= 2) return "features";
3148
4047
  return kind;
3149
4048
  };
4049
+ const partsOf = (el) => {
4050
+ const inside = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter(shown);
4051
+ const badges = [];
4052
+ for (const n of inside) {
4053
+ const t = ownText(n);
4054
+ if (t.length < 2 || t.length > 24 || badges.length >= 5) continue;
4055
+ const cs = style(n);
4056
+ const r = box(n);
4057
+ const radius = px(cs.borderTopLeftRadius);
4058
+ const drawn = painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1;
4059
+ if (drawn && r.height <= 40 && r.width <= 240 && radius >= r.height / 3 && !isControl(n)) badges.push(t);
4060
+ }
4061
+ const avatars = inside.filter((n) => {
4062
+ if (n.tagName !== "IMG") return false;
4063
+ const r = box(n);
4064
+ const rad = style(n).borderTopLeftRadius;
4065
+ const round = rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= r.width * 0.4;
4066
+ return r.width <= 96 && r.width >= 16 && Math.abs(r.width - r.height) <= r.width * 0.15 && round;
4067
+ }).length;
4068
+ const carousel = inside.some((n) => {
4069
+ const cs = style(n);
4070
+ if (n.getAttribute("aria-roledescription") === "carousel" || cs.scrollSnapType && cs.scrollSnapType !== "none") return true;
4071
+ if (/^(auto|scroll|hidden)$/.test(cs.overflowX) && n.children.length >= 3 && n.scrollWidth > n.clientWidth * 1.2) return true;
4072
+ return /^(BUTTON|A)$/.test(n.tagName) && /\b(prev|previous|next)\b/i.test(n.getAttribute("aria-label") ?? "");
4073
+ });
4074
+ return { badges, avatars, carousel };
4075
+ };
4076
+ const TEXT_BLOCK = "h1, h2, h3, h4, h5, h6, p, li, blockquote, figcaption, dt, dd, td, th, summary, cite, q";
4077
+ const CHROME = "nav, [role=navigation], footer, [role=contentinfo], button, [role=button], [aria-hidden=true], script, style, noscript, template";
4078
+ const readable = (el) => {
4079
+ if (!shown(el)) return false;
4080
+ const r = el.getBoundingClientRect();
4081
+ return r.width > 2 && r.height > 2;
4082
+ };
4083
+ const textBlocks = (scope, limit, keep = () => true) => {
4084
+ const out = [];
4085
+ const seen = /* @__PURE__ */ new Set();
4086
+ const add = (el) => {
4087
+ const t = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
4088
+ if (t.length < 2 || seen.has(t)) return;
4089
+ seen.add(t);
4090
+ out.push(t);
4091
+ };
4092
+ for (const el of Array.from(scope.querySelectorAll(TEXT_BLOCK))) {
4093
+ if (out.length >= limit) break;
4094
+ if (el.parentElement?.closest(TEXT_BLOCK) || el.closest(CHROME) || !readable(el) || !keep(el)) continue;
4095
+ add(el);
4096
+ }
4097
+ for (const el of Array.from(scope.querySelectorAll("div, span"))) {
4098
+ if (out.length >= limit) break;
4099
+ if (ownText(el).split(" ").length < 3) continue;
4100
+ if (el.closest(TEXT_BLOCK) || el.closest(CHROME) || el.closest("a") || !readable(el) || !keep(el)) continue;
4101
+ add(el);
4102
+ }
4103
+ return out;
4104
+ };
4105
+ const inChromeHeader = (el) => {
4106
+ const h = el.closest("header");
4107
+ return Boolean(h && !h.querySelector("h1"));
4108
+ };
4109
+ const firstScreenText = textBlocks(document.body, 12, (el) => {
4110
+ const b = box(el);
4111
+ return b.top < vh && b.top + b.height > 0 && !inChromeHeader(el) && !el.closest("a");
4112
+ });
3150
4113
  const kept = blocks.slice(0, 40);
3151
4114
  const sections = kept.map((el, index) => {
3152
4115
  const b = box(el);
3153
4116
  const heading = el.querySelector("h1, h2, h3");
3154
4117
  const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
3155
- const images = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
4118
+ const images2 = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
3156
4119
  const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
3157
4120
  const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
3158
4121
  const { cards, iconCards } = cardsIn(el);
3159
4122
  let kind = "text";
3160
4123
  if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
3161
4124
  else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
3162
- else if (images.length >= 4 && textChars < 300) kind = "logos";
4125
+ else if (images2.length >= 4 && textChars < 300) kind = "logos";
3163
4126
  else if (numbers >= 3 && textChars < 600) kind = "stats";
3164
4127
  else if (cards >= 3) kind = "cards";
3165
4128
  const geometry = geometryOf(el, b);
4129
+ const parts2 = partsOf(el);
3166
4130
  return {
3167
4131
  top: Math.round(b.top),
3168
4132
  height: Math.round(b.height),
@@ -3172,7 +4136,10 @@ function collectInPage() {
3172
4136
  iconCards,
3173
4137
  hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
3174
4138
  role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
3175
- geometry
4139
+ geometry,
4140
+ ...parts2,
4141
+ figures: numbers,
4142
+ text: textBlocks(el, 40)
3176
4143
  };
3177
4144
  });
3178
4145
  if (header && sections.length === 0) {
@@ -3254,6 +4221,81 @@ function collectInPage() {
3254
4221
  const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
3255
4222
  if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
3256
4223
  }
4224
+ const unwrap = (raw) => {
4225
+ if (raw.startsWith("data:")) return raw.slice(0, 64);
4226
+ try {
4227
+ const u = new URL(raw, location.href);
4228
+ const inner = /\/_(?:next|vercel)\/image$/.test(u.pathname) ? u.searchParams.get("url") : null;
4229
+ if (inner) return new URL(inner, location.href).href;
4230
+ const cf = u.pathname.match(/^\/cdn-cgi\/image\/[^/]+\/(.+)$/);
4231
+ if (cf) return /^https?:\/\//.test(cf[1]) ? cf[1] : new URL(`/${cf[1]}`, location.href).href;
4232
+ return u.href;
4233
+ } catch {
4234
+ return raw.slice(0, 200);
4235
+ }
4236
+ };
4237
+ const roundBox = (el, w) => {
4238
+ const rad = style(el).borderTopLeftRadius;
4239
+ return rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= w * 0.4;
4240
+ };
4241
+ const sectionOf = (el) => {
4242
+ const i = kept.findIndex((k) => k.contains(el));
4243
+ return i === -1 ? null : i;
4244
+ };
4245
+ const roleOfImage = (el, src, w, h, alt) => {
4246
+ const vector = /\.svg(?:[?#]|$)/i.test(src) || src.startsWith("data:image/svg");
4247
+ const square = Math.abs(w - h) <= w * 0.15;
4248
+ if (w >= 16 && w <= 96 && square && (roundBox(el, w) || el.parentElement !== null && roundBox(el.parentElement, w))) return "avatar";
4249
+ const section = sectionOf(el);
4250
+ const hint = `${alt ?? ""} ${src} ${typeof el.className === "string" ? el.className : ""}`;
4251
+ if (/\blogo\b/i.test(hint) || section !== null && sections[section]?.kind === "logos" || el.closest("header, footer, nav") && h <= 120) return "logo";
4252
+ if (Math.max(w, h) <= (vector ? 120 : 72)) return "icon";
4253
+ if (vector) return "illustration";
4254
+ return w >= 160 && h >= 120 ? "photo" : "icon";
4255
+ };
4256
+ const images = [];
4257
+ const seenImage = /* @__PURE__ */ new Set();
4258
+ const addImage = (el, raw, alt, decorative, background) => {
4259
+ const b = box(el);
4260
+ if (!raw || b.width < 8 || b.height < 8) return;
4261
+ const src = unwrap(raw);
4262
+ const key = `${src} ${Math.round(b.top)} ${Math.round(b.left)}`;
4263
+ if (seenImage.has(key)) return;
4264
+ seenImage.add(key);
4265
+ let host = null;
4266
+ try {
4267
+ host = src.startsWith("data:") ? null : new URL(src).hostname;
4268
+ } catch {
4269
+ host = null;
4270
+ }
4271
+ images.push({
4272
+ src,
4273
+ host,
4274
+ width: Math.round(b.width),
4275
+ height: Math.round(b.height),
4276
+ top: Math.round(b.top),
4277
+ role: roleOfImage(el, src, b.width, b.height, alt),
4278
+ alt,
4279
+ decorative,
4280
+ section: sectionOf(el),
4281
+ background
4282
+ });
4283
+ };
4284
+ for (const img of Array.from(document.querySelectorAll("img")).slice(0, 400)) {
4285
+ if (!shown(img)) continue;
4286
+ const alt = img.getAttribute("alt");
4287
+ const decorative = alt === "" || img.getAttribute("aria-hidden") === "true" || /^(?:presentation|none)$/.test(img.getAttribute("role") ?? "");
4288
+ addImage(img, img.currentSrc || img.getAttribute("src") || "", alt, decorative, false);
4289
+ }
4290
+ for (const el of all) {
4291
+ const bgImage = style(el).backgroundImage || "";
4292
+ if (!bgImage.includes("url(")) continue;
4293
+ const b = box(el);
4294
+ if (b.width < 160 || b.height < 120 || !shown(el)) continue;
4295
+ const m = bgImage.match(/url\(\s*["']?([^"')]+)["']?\s*\)/);
4296
+ if (m) addImage(el, m[1], null, true, true);
4297
+ }
4298
+ images.sort((a, b) => b.width * b.height - a.width * a.height);
3257
4299
  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);
3258
4300
  const nums = (s) => Array.from(s).sort((a, b) => a - b);
3259
4301
  return {
@@ -3273,6 +4315,8 @@ function collectInPage() {
3273
4315
  return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
3274
4316
  })(),
3275
4317
  effects,
4318
+ images: images.slice(0, 60),
4319
+ firstScreenText,
3276
4320
  motion: {
3277
4321
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
3278
4322
  sections: sections.length,
@@ -3436,7 +4480,60 @@ function visualMeasures(img, maxSamples = 1e6) {
3436
4480
  };
3437
4481
  }
3438
4482
 
4483
+ // src/snapshot/provenance.ts
4484
+ var PROVENANCE_SCAN_BYTES = 256 * 1024;
4485
+ var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
4486
+ var MARKER2 = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
4487
+ var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
4488
+ var C2PA_PNG = /caBX/;
4489
+ function latin1(bytes) {
4490
+ let out = "";
4491
+ for (let i = 0; i < bytes.length; i += 8192) {
4492
+ out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
4493
+ }
4494
+ return out;
4495
+ }
4496
+ function xmpPackets(text) {
4497
+ const out = [];
4498
+ const open = /<x:xmpmeta\b|<\?xpacket begin/g;
4499
+ let m;
4500
+ while (m = open.exec(text)) {
4501
+ const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
4502
+ out.push([m.index, close === -1 ? text.length : close]);
4503
+ if (close === -1) break;
4504
+ open.lastIndex = close;
4505
+ }
4506
+ return out;
4507
+ }
4508
+ function scanProvenance(bytes) {
4509
+ const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
4510
+ const text = latin1(head);
4511
+ const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
4512
+ const m = MARKER2.exec(text);
4513
+ if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
4514
+ const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
4515
+ return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
4516
+ }
4517
+
3439
4518
  // src/audit/index.ts
4519
+ var PROVENANCE_MAX_IMAGES = 24;
4520
+ var PROVENANCE_BUDGET_MS = 2e4;
4521
+ async function readProvenance(page, images, timeoutMs) {
4522
+ if (!page.request) return;
4523
+ const deadline = Date.now() + PROVENANCE_BUDGET_MS;
4524
+ 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);
4525
+ for (const src of wanted) {
4526
+ const left = deadline - Date.now();
4527
+ if (left < 500) break;
4528
+ try {
4529
+ const res = await page.request.get(src, { headers: { Range: `bytes=0-${PROVENANCE_SCAN_BYTES - 1}` }, timeout: Math.min(timeoutMs, left) });
4530
+ if (!res.ok()) continue;
4531
+ const provenance = scanProvenance(await res.body());
4532
+ for (const image of images) if (image.src === src) image.provenance = provenance;
4533
+ } catch {
4534
+ }
4535
+ }
4536
+ }
3440
4537
  var VISUAL_MAX_HEIGHT = 6e3;
3441
4538
  async function loadPlaywright() {
3442
4539
  for (const name of ["playwright", "playwright-core"]) {
@@ -3455,6 +4552,7 @@ async function snapshotPage(page, url, options = {}) {
3455
4552
  await page.waitForTimeout(options.introWindowMs ?? 5e3);
3456
4553
  const late = await page.evaluate(overlayInPage).catch(() => false);
3457
4554
  const data = await page.evaluate(collectInPage);
4555
+ if (data.images && options.provenance !== false) await readProvenance(page, data.images, 8e3);
3458
4556
  let visual;
3459
4557
  if (page.screenshot && options.visual !== false) {
3460
4558
  try {
@@ -3517,6 +4615,8 @@ async function audit(url, options = {}) {
3517
4615
  return { snapshot, report: auditSnapshot(snapshot, options), fingerprint: fingerprint(snapshot) };
3518
4616
  }
3519
4617
  export {
4618
+ PROVENANCE_BUDGET_MS,
4619
+ PROVENANCE_MAX_IMAGES,
3520
4620
  VISUAL_MAX_HEIGHT,
3521
4621
  audit,
3522
4622
  collectInPage,