@domandigital/craft 0.13.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 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": {
@@ -3036,7 +3572,7 @@ var picture = (patch = {}) => ({
3036
3572
  var withImages = (images) => ({ ...makeSnapshot(), images });
3037
3573
  var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
3038
3574
  var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
3039
- var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3575
+ var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3040
3576
  var IMAGERY_TELLS = [
3041
3577
  {
3042
3578
  id: "stock-photo",
@@ -3051,7 +3587,7 @@ var IMAGERY_TELLS = [
3051
3587
  const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
3052
3588
  if (found.length === 0) return [];
3053
3589
  const names = [...new Set(found.map((x) => x.from))].join(", ");
3054
- return [hit3(s, `${count2(found.length, "picture")} from ${names}`, found[0].image.src)];
3590
+ return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
3055
3591
  },
3056
3592
  fixtures: {
3057
3593
  flag: [
@@ -3076,7 +3612,7 @@ var IMAGERY_TELLS = [
3076
3612
  const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
3077
3613
  if (found.length === 0) return [];
3078
3614
  const names = [...new Set(found.map((x) => x.from))].join(", ");
3079
- return [hit3(s, `${count2(found.length, "portrait")} from ${names}`, found[0].image.src)];
3615
+ return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
3080
3616
  },
3081
3617
  fixtures: {
3082
3618
  flag: [
@@ -3101,7 +3637,7 @@ var IMAGERY_TELLS = [
3101
3637
  if (found.length === 0) return [];
3102
3638
  const first = found[0].provenance;
3103
3639
  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)];
3640
+ return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
3105
3641
  },
3106
3642
  fixtures: {
3107
3643
  flag: [
@@ -3126,7 +3662,7 @@ var IMAGERY_TELLS = [
3126
3662
  if (!s.images || s.sections.length < 4) return [];
3127
3663
  if (s.images.some((i) => i.role === "photo")) return [];
3128
3664
  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` : ""}`)];
3665
+ return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
3130
3666
  },
3131
3667
  fixtures: {
3132
3668
  flag: [
@@ -3141,11 +3677,13 @@ var IMAGERY_TELLS = [
3141
3677
  ];
3142
3678
 
3143
3679
  // src/character/check.ts
3144
- var CATALOGUE_VERSION = "2026.09.11";
3680
+ var CATALOGUE_VERSION = "2026.09.12";
3145
3681
  var CATALOGUE = [
3146
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,
3147
3684
  ...RENDERED_TELLS,
3148
- ...IMAGERY_TELLS
3685
+ ...IMAGERY_TELLS,
3686
+ ...SPECIFICITY_RENDERED_TELLS
3149
3687
  ];
3150
3688
  function tellById(id) {
3151
3689
  return CATALOGUE.find((t) => t.id === id);
@@ -3222,7 +3760,7 @@ function report(tells, files, run, options) {
3222
3760
  catalogueVersion: CATALOGUE_VERSION,
3223
3761
  findings,
3224
3762
  rejectedExceptions: rejected,
3225
- excepted: [...excepted].map(([e, count3]) => ({ tell: e.tell, because: e.because, count: count3 })),
3763
+ excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
3226
3764
  suppressed,
3227
3765
  summary: {
3228
3766
  files: files.length,
@@ -3423,16 +3961,16 @@ function collectInPage() {
3423
3961
  if (row.length < 3) continue;
3424
3962
  const w = row.map((b) => b.width);
3425
3963
  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) => {
3964
+ const withText2 = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
3965
+ if (withText2.length < 3) continue;
3966
+ const iconCards = withText2.filter((k) => {
3429
3967
  const first = k.querySelector("svg, img");
3430
3968
  if (!first) return false;
3431
3969
  const fb = box(first);
3432
3970
  const kb = box(k);
3433
3971
  return fb.width <= 72 && fb.height <= 72 && fb.top - kb.top < 120;
3434
3972
  }).length;
3435
- if (withText.length > best.cards) best = { cards: withText.length, iconCards };
3973
+ if (withText2.length > best.cards) best = { cards: withText2.length, iconCards };
3436
3974
  }
3437
3975
  return best;
3438
3976
  };
@@ -3451,7 +3989,7 @@ function collectInPage() {
3451
3989
  let covered = 0;
3452
3990
  let minX = Infinity;
3453
3991
  let maxX = -Infinity;
3454
- let textBlocks = 0;
3992
+ let textBlocks2 = 0;
3455
3993
  let centred = 0;
3456
3994
  for (const n of leaves) {
3457
3995
  const r = box(n);
@@ -3463,14 +4001,14 @@ function collectInPage() {
3463
4001
  left += split * r.height;
3464
4002
  right += (r.width - split) * r.height;
3465
4003
  if (ownText(n).length > 1) {
3466
- textBlocks += 1;
4004
+ textBlocks2 += 1;
3467
4005
  const mid = r.left + r.width / 2;
3468
4006
  if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
3469
4007
  }
3470
4008
  }
3471
4009
  const round = (v) => Math.round(v * 1e3) / 1e3;
3472
4010
  return {
3473
- centredShare: textBlocks ? round(centred / textBlocks) : 0,
4011
+ centredShare: textBlocks2 ? round(centred / textBlocks2) : 0,
3474
4012
  mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
3475
4013
  whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
3476
4014
  contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
@@ -3535,6 +4073,43 @@ function collectInPage() {
3535
4073
  });
3536
4074
  return { badges, avatars, carousel };
3537
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
+ });
3538
4113
  const kept = blocks.slice(0, 40);
3539
4114
  const sections = kept.map((el, index) => {
3540
4115
  const b = box(el);
@@ -3563,7 +4138,8 @@ function collectInPage() {
3563
4138
  role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
3564
4139
  geometry,
3565
4140
  ...parts2,
3566
- figures: numbers
4141
+ figures: numbers,
4142
+ text: textBlocks(el, 40)
3567
4143
  };
3568
4144
  });
3569
4145
  if (header && sections.length === 0) {
@@ -3740,6 +4316,7 @@ function collectInPage() {
3740
4316
  })(),
3741
4317
  effects,
3742
4318
  images: images.slice(0, 60),
4319
+ firstScreenText,
3743
4320
  motion: {
3744
4321
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
3745
4322
  sections: sections.length,