@domandigital/craft 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/CHARACTER.md +272 -39
  3. package/COPY.md +5 -0
  4. package/README.md +19 -1
  5. package/art-direction.schema.json +428 -25
  6. package/dist/{audit-5TYDYRXV.js → audit-MTUC24XU.js} +3 -2
  7. package/dist/audit.cjs +674 -92
  8. package/dist/audit.d.cts +15 -1
  9. package/dist/audit.d.ts +15 -1
  10. package/dist/audit.js +674 -92
  11. package/dist/{chunk-IMB4V34S.js → chunk-2IQ5OXFR.js} +7 -7
  12. package/dist/{chunk-GMVXADJ6.js → chunk-LFNSB6W7.js} +711 -92
  13. package/dist/{chunk-3VTPI7BU.js → chunk-MUVFECFC.js} +74 -96
  14. package/dist/chunk-OQXTQQAU.js +27 -0
  15. package/dist/{chunk-ZLDA2SYB.js → chunk-OUQ25LY2.js} +47 -6
  16. package/dist/{chunk-K2BZN4GA.js → chunk-QU6Q2QZZ.js} +49 -14
  17. package/dist/chunk-QYBAVU3P.js +6 -0
  18. package/dist/{chunk-ESCVTKPN.js → chunk-RXXRXV3V.js} +9 -26
  19. package/dist/{chunk-CZDOMIVS.js → chunk-WUACK2HX.js} +2 -1
  20. package/dist/chunk-YDURAOYR.js +711 -0
  21. package/dist/cli-22TSWAX6.js +21 -0
  22. package/dist/cli-5ACKICBO.js +22 -0
  23. package/dist/{cli-DRKAMFAH.js → cli-7OZCYOQY.js} +89 -22
  24. package/dist/{cli-J5ECRCD4.js → cli-FCKHYAVZ.js} +40 -16
  25. package/dist/{cli-ILH7OO5G.js → cli-FLG3ZCAF.js} +6 -4
  26. package/dist/cli.js +3 -2
  27. package/dist/generate.js +6 -0
  28. package/dist/index.cjs +1618 -328
  29. package/dist/index.d.cts +619 -58
  30. package/dist/index.d.ts +619 -58
  31. package/dist/index.js +1555 -328
  32. package/dist/migrate-VIFAYIBM.js +9 -0
  33. package/package.json +1 -1
  34. package/word-lists.json +1 -1
  35. package/dist/chunk-ZZ5CKOJP.js +0 -295
  36. package/dist/cli-BKJMLD3H.js +0 -18
  37. package/dist/cli-N2IDXH7W.js +0 -20
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 count3 = (text.match(CONTRACTION) ?? []).length;
1004
- const rate = count3 * 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: `${count3} contraction${count3 === 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 count3 = 0;
1133
+ let count4 = 0;
1134
1134
  let i = 0;
1135
- while (count3 < 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
- count3 += 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": {
@@ -2794,6 +3330,11 @@ function fingerprint(s) {
2794
3330
  } : {}
2795
3331
  };
2796
3332
  }
3333
+ var ACCENT_SCALE = 0.3;
3334
+ var GROUND_SCALE = 0.15;
3335
+ var SHARED_PART = 0.2;
3336
+ var ACCENT_SHARED = ACCENT_SCALE * SHARED_PART;
3337
+ var GROUND_SHARED = GROUND_SCALE * SHARED_PART;
2797
3338
 
2798
3339
  // src/fingerprint/component.ts
2799
3340
  var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
@@ -3036,7 +3577,7 @@ var picture = (patch = {}) => ({
3036
3577
  var withImages = (images) => ({ ...makeSnapshot(), images });
3037
3578
  var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
3038
3579
  var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
3039
- var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3580
+ var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3040
3581
  var IMAGERY_TELLS = [
3041
3582
  {
3042
3583
  id: "stock-photo",
@@ -3051,7 +3592,7 @@ var IMAGERY_TELLS = [
3051
3592
  const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
3052
3593
  if (found.length === 0) return [];
3053
3594
  const names = [...new Set(found.map((x) => x.from))].join(", ");
3054
- return [hit3(s, `${count2(found.length, "picture")} from ${names}`, found[0].image.src)];
3595
+ return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
3055
3596
  },
3056
3597
  fixtures: {
3057
3598
  flag: [
@@ -3076,7 +3617,7 @@ var IMAGERY_TELLS = [
3076
3617
  const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
3077
3618
  if (found.length === 0) return [];
3078
3619
  const names = [...new Set(found.map((x) => x.from))].join(", ");
3079
- return [hit3(s, `${count2(found.length, "portrait")} from ${names}`, found[0].image.src)];
3620
+ return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
3080
3621
  },
3081
3622
  fixtures: {
3082
3623
  flag: [
@@ -3101,7 +3642,7 @@ var IMAGERY_TELLS = [
3101
3642
  if (found.length === 0) return [];
3102
3643
  const first = found[0].provenance;
3103
3644
  const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
3104
- return [hit3(s, `${count2(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
3645
+ return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
3105
3646
  },
3106
3647
  fixtures: {
3107
3648
  flag: [
@@ -3126,7 +3667,7 @@ var IMAGERY_TELLS = [
3126
3667
  if (!s.images || s.sections.length < 4) return [];
3127
3668
  if (s.images.some((i) => i.role === "photo")) return [];
3128
3669
  const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
3129
- return [hit3(s, `no photograph on the page${icons ? `: ${count2(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
3670
+ return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
3130
3671
  },
3131
3672
  fixtures: {
3132
3673
  flag: [
@@ -3141,11 +3682,13 @@ var IMAGERY_TELLS = [
3141
3682
  ];
3142
3683
 
3143
3684
  // src/character/check.ts
3144
- var CATALOGUE_VERSION = "2026.09.11";
3685
+ var CATALOGUE_VERSION = "2026.09.12";
3145
3686
  var CATALOGUE = [
3146
3687
  ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
3688
+ ...SPECIFICITY_COPY_TELLS,
3147
3689
  ...RENDERED_TELLS,
3148
- ...IMAGERY_TELLS
3690
+ ...IMAGERY_TELLS,
3691
+ ...SPECIFICITY_RENDERED_TELLS
3149
3692
  ];
3150
3693
  function tellById(id) {
3151
3694
  return CATALOGUE.find((t) => t.id === id);
@@ -3222,7 +3765,7 @@ function report(tells, files, run, options) {
3222
3765
  catalogueVersion: CATALOGUE_VERSION,
3223
3766
  findings,
3224
3767
  rejectedExceptions: rejected,
3225
- excepted: [...excepted].map(([e, count3]) => ({ tell: e.tell, because: e.because, count: count3 })),
3768
+ excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
3226
3769
  suppressed,
3227
3770
  summary: {
3228
3771
  files: files.length,
@@ -3423,16 +3966,16 @@ function collectInPage() {
3423
3966
  if (row.length < 3) continue;
3424
3967
  const w = row.map((b) => b.width);
3425
3968
  if (Math.max(...w) > Math.min(...w) * 1.35 || Math.max(...w) > vw * 0.45) continue;
3426
- const withText = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
3427
- if (withText.length < 3) continue;
3428
- const iconCards = withText.filter((k) => {
3969
+ const withText2 = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
3970
+ if (withText2.length < 3) continue;
3971
+ const iconCards = withText2.filter((k) => {
3429
3972
  const first = k.querySelector("svg, img");
3430
3973
  if (!first) return false;
3431
3974
  const fb = box(first);
3432
3975
  const kb = box(k);
3433
3976
  return fb.width <= 72 && fb.height <= 72 && fb.top - kb.top < 120;
3434
3977
  }).length;
3435
- if (withText.length > best.cards) best = { cards: withText.length, iconCards };
3978
+ if (withText2.length > best.cards) best = { cards: withText2.length, iconCards };
3436
3979
  }
3437
3980
  return best;
3438
3981
  };
@@ -3451,7 +3994,7 @@ function collectInPage() {
3451
3994
  let covered = 0;
3452
3995
  let minX = Infinity;
3453
3996
  let maxX = -Infinity;
3454
- let textBlocks = 0;
3997
+ let textBlocks2 = 0;
3455
3998
  let centred = 0;
3456
3999
  for (const n of leaves) {
3457
4000
  const r = box(n);
@@ -3463,14 +4006,14 @@ function collectInPage() {
3463
4006
  left += split * r.height;
3464
4007
  right += (r.width - split) * r.height;
3465
4008
  if (ownText(n).length > 1) {
3466
- textBlocks += 1;
4009
+ textBlocks2 += 1;
3467
4010
  const mid = r.left + r.width / 2;
3468
4011
  if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
3469
4012
  }
3470
4013
  }
3471
4014
  const round = (v) => Math.round(v * 1e3) / 1e3;
3472
4015
  return {
3473
- centredShare: textBlocks ? round(centred / textBlocks) : 0,
4016
+ centredShare: textBlocks2 ? round(centred / textBlocks2) : 0,
3474
4017
  mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
3475
4018
  whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
3476
4019
  contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
@@ -3535,6 +4078,43 @@ function collectInPage() {
3535
4078
  });
3536
4079
  return { badges, avatars, carousel };
3537
4080
  };
4081
+ const TEXT_BLOCK = "h1, h2, h3, h4, h5, h6, p, li, blockquote, figcaption, dt, dd, td, th, summary, cite, q";
4082
+ const CHROME = "nav, [role=navigation], footer, [role=contentinfo], button, [role=button], [aria-hidden=true], script, style, noscript, template";
4083
+ const readable = (el) => {
4084
+ if (!shown(el)) return false;
4085
+ const r = el.getBoundingClientRect();
4086
+ return r.width > 2 && r.height > 2;
4087
+ };
4088
+ const textBlocks = (scope, limit, keep = () => true) => {
4089
+ const out = [];
4090
+ const seen = /* @__PURE__ */ new Set();
4091
+ const add = (el) => {
4092
+ const t = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
4093
+ if (t.length < 2 || seen.has(t)) return;
4094
+ seen.add(t);
4095
+ out.push(t);
4096
+ };
4097
+ for (const el of Array.from(scope.querySelectorAll(TEXT_BLOCK))) {
4098
+ if (out.length >= limit) break;
4099
+ if (el.parentElement?.closest(TEXT_BLOCK) || el.closest(CHROME) || !readable(el) || !keep(el)) continue;
4100
+ add(el);
4101
+ }
4102
+ for (const el of Array.from(scope.querySelectorAll("div, span"))) {
4103
+ if (out.length >= limit) break;
4104
+ if (ownText(el).split(" ").length < 3) continue;
4105
+ if (el.closest(TEXT_BLOCK) || el.closest(CHROME) || el.closest("a") || !readable(el) || !keep(el)) continue;
4106
+ add(el);
4107
+ }
4108
+ return out;
4109
+ };
4110
+ const inChromeHeader = (el) => {
4111
+ const h = el.closest("header");
4112
+ return Boolean(h && !h.querySelector("h1"));
4113
+ };
4114
+ const firstScreenText = textBlocks(document.body, 12, (el) => {
4115
+ const b = box(el);
4116
+ return b.top < vh && b.top + b.height > 0 && !inChromeHeader(el) && !el.closest("a");
4117
+ });
3538
4118
  const kept = blocks.slice(0, 40);
3539
4119
  const sections = kept.map((el, index) => {
3540
4120
  const b = box(el);
@@ -3563,7 +4143,8 @@ function collectInPage() {
3563
4143
  role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
3564
4144
  geometry,
3565
4145
  ...parts2,
3566
- figures: numbers
4146
+ figures: numbers,
4147
+ text: textBlocks(el, 40)
3567
4148
  };
3568
4149
  });
3569
4150
  if (header && sections.length === 0) {
@@ -3740,6 +4321,7 @@ function collectInPage() {
3740
4321
  })(),
3741
4322
  effects,
3742
4323
  images: images.slice(0, 60),
4324
+ firstScreenText,
3743
4325
  motion: {
3744
4326
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
3745
4327
  sections: sections.length,