@domandigital/craft 0.6.0 → 0.8.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
@@ -4,15 +4,22 @@ var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
4
4
  function allText(ctx) {
5
5
  return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
6
6
  }
7
+ var LINK = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+|\]\([^)\s]+\)/gi;
8
+ function linkSpans(text) {
9
+ return [...text.matchAll(LINK)].map((m) => [m.index ?? 0, (m.index ?? 0) + m[0].length]);
10
+ }
11
+ var insideLink = (spans, at) => spans.some(([a, b]) => at >= a && at < b);
7
12
  function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
8
13
  return (ctx) => {
9
14
  const hits = [];
10
15
  for (const block of source(ctx)) {
11
16
  const spans = [];
17
+ const links = linkSpans(block.text);
12
18
  for (const pattern of patterns) {
13
19
  const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
14
20
  for (const m of block.text.matchAll(global)) {
15
21
  if (skip(block, m[0], m.index ?? 0)) continue;
22
+ if (insideLink(links, m.index ?? 0)) continue;
16
23
  spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
17
24
  }
18
25
  }
@@ -53,7 +60,14 @@ var AI_WORDS = [
53
60
  "moreover",
54
61
  "navigating",
55
62
  "ever-evolving",
56
- "unparalleled"
63
+ "unparalleled",
64
+ // Measured: Kobak et al. 2024 (arXiv 2406.07016) found these 10 to 14 times
65
+ // above their expected rate in post-ChatGPT abstracts. Academic, so warn only.
66
+ "underscore",
67
+ "underscores",
68
+ "underscoring",
69
+ "showcasing",
70
+ "pivotal"
57
71
  ];
58
72
  var STOCK_PHRASES = [
59
73
  "in today's digital age",
@@ -67,7 +81,11 @@ var STOCK_PHRASES = [
67
81
  "curated",
68
82
  "crafted with care",
69
83
  "tailored to your",
70
- "peace of mind"
84
+ "peace of mind",
85
+ "so here's",
86
+ "in an era where",
87
+ "the future looks bright",
88
+ "only time will tell"
71
89
  ];
72
90
  var AI_PHRASES = [
73
91
  "let's dive in",
@@ -200,7 +218,7 @@ function lineAround(text, index) {
200
218
  var emptyState = (block, match, index = 0) => SIGNED_IN.test(block.path) || DATA_ABSENCE.test(isProsePath(block.path) ? lineAround(block.text, index) : block.text) || DATA_ABSENCE.test(match);
201
219
  var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
202
220
  var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
203
- var EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}]+/u;
221
+ var EMOJI = /(?:[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u2728\u2705\u274C\u274E\u2757\u2753\u26A1\u2B50\u2764]|[\u2600-\u27BF]\uFE0F)+/u;
204
222
  var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
205
223
  function badgeHits(ctx) {
206
224
  const hits = [];
@@ -243,10 +261,16 @@ var COPY_TELLS = [
243
261
  surface: "copy",
244
262
  why: "Phrases every generated services page uses. They fill space where a fact should be.",
245
263
  fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
246
- detect: phrases([wordList(STOCK_PHRASES)], quoted),
264
+ detect: phrases([wordList(STOCK_PHRASES), /\b(?:in|on|along)\s+(?:its|their|his|her)\s+journey\b/i], quoted),
247
265
  fixtures: {
248
- flag: [f("content/home.md", "Take your plumbing to the next level with peace of mind.")],
249
- pass: [f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley.")]
266
+ flag: [
267
+ f("content/home.md", "Take your plumbing to the next level with peace of mind."),
268
+ f("content/home.md", "We meet each practice where it is in its journey.")
269
+ ],
270
+ pass: [
271
+ f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley."),
272
+ f("content/home.md", "We mapped the patient journey from booking to aftercare.")
273
+ ]
250
274
  }
251
275
  },
252
276
  {
@@ -271,7 +295,11 @@ var COPY_TELLS = [
271
295
  f("content/home.md", "In today's rapidly evolving market, we help."),
272
296
  f("content/home.md", "Whether you're a landlord or a tenant, call us.")
273
297
  ],
274
- pass: [f("content/home.md", "Book in three taps. We confirm by text within the hour.")]
298
+ pass: [
299
+ f("content/home.md", "Book in three taps. We confirm by text within the hour."),
300
+ f("content/home.md", "Book at https://example.com/seamless-booking today."),
301
+ f("content/home.md", "[Book a slot](/seamless-booking) today.")
302
+ ]
275
303
  }
276
304
  },
277
305
  {
@@ -420,8 +448,14 @@ var COPY_TELLS = [
420
448
  fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
421
449
  detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
422
450
  fixtures: {
423
- flag: [f("content/home.md", "New treatments this month \u2728\u{1F680}"), f("app/cta.tsx", `export const label = "Book \u{1F680}";`)],
424
- pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192")]
451
+ flag: [
452
+ f("content/home.md", "New treatments this month \u2728\u{1F680}"),
453
+ f("app/cta.tsx", `export const label = "Book \u{1F680}";`),
454
+ f("content/home.md", "New treatments this month \u2728"),
455
+ f("content/home.md", "Same-day callouts \u2705"),
456
+ f("content/home.md", "Open Sundays \u2600\uFE0F")
457
+ ],
458
+ pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192"), f("content/home.md", "\u2713 Gas Safe registered")]
425
459
  }
426
460
  },
427
461
  {
@@ -519,6 +553,695 @@ var COPY_TELLS = [
519
553
  }
520
554
  }
521
555
  ];
556
+ var ING_TAIL = /,\s+(?:ensuring|highlighting|underscoring|showcasing|reflecting|demonstrating|emphasi[sz]ing|fostering|cementing|solidifying|reinforcing|symboli[sz]ing|signalling|signaling)\b[^.!?\n]{0,80}/i;
557
+ var VAGUE_ATTRIBUTION = [
558
+ /\b(?:studies|research|experts|scientists|surveys|statistics|the data)\s+(?:show|shows|suggest|suggests|agree|say|says|confirm|confirms|prove|proves|indicate|indicates)\b/i,
559
+ /\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
560
+ /\bit\s+is\s+(?:widely|generally|commonly)\s+(?:known|accepted|believed|agreed)\b/i
561
+ ];
562
+ function sourced(block, _match, index) {
563
+ const before = block.text.slice(Math.max(0, index - 40), index);
564
+ if (/(?:\b[A-Z][\w&]*[’']s|\b[A-Z]{2,}[a-z]*)\s+$/.test(before)) return true;
565
+ const rest = block.text.slice(index, index + 400);
566
+ const end = rest.search(/[.!?](?:\s|$)/);
567
+ const sentence = rest.slice(0, end === -1 ? rest.length : end + 16);
568
+ return /\[\^\w+\]|\]\(https?:|\(source|according to [A-Z]|\bper [A-Z]/.test(sentence);
569
+ }
570
+ var CLOSING_SUMMARY = /(?:^|[.!?]\s+|\n\s*|>\s*)(?:Overall|In conclusion|In summary|To sum up|All in all|In short|To summari[sz]e),/;
571
+ var FALSE_RANGE = [
572
+ /\bfrom\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+alike\b/i,
573
+ /\b(?:everyone|anyone)\s+from\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s*){1,4}/i
574
+ ];
575
+ var RESIDUE = [
576
+ /\bas an AI(?:\s+language)?\s+(?:model|assistant)\b/i,
577
+ /(?:^|\n|>)\s*(?:Certainly|Sure|Absolutely|Of course|Great question)[!,.]\s+(?:Here(?:['’]s| is| are)|Below)\b/i,
578
+ /\bhere(?:['’]s| is)\s+(?:a|an|the|your)\s+(?:revised|rewritten|updated|polished|refined|improved|draft|more concise)\s+(?:version|draft|copy)\b/i,
579
+ /\blet me know if you(?:['’]d| would) like me to\b/i,
580
+ /\b(?:my|the) (?:knowledge|training) cut-?off\b|\bas of my last (?:update|training)\b/i,
581
+ /\bI hope this helps\b/i,
582
+ // Citation tokens a chat tool leaves in copied text.
583
+ // A run of them is one paste: "citeturn11search1turn10view0" is one finding.
584
+ // ChatGPT wraps them in invisible private-use characters (U+E200 to U+E202),
585
+ // which survive a copy and paste into a CMS: found live, 2026-09-23.
586
+ /[\uE200-\uE2FF]*(?:cite)?[\uE200-\uE2FF]*(?:turn\d+(?:search|news|view|fetch|file|image)\d+[\uE200-\uE2FF]*)+/,
587
+ /:?contentReference\[oaicite:\d+\](?:\{index=\d+\})?/,
588
+ /【\d+(?:[:†][^】]{0,40})?】/
589
+ ];
590
+ var AI_UTM = /utm_source=(?:chatgpt\.com|chat\.openai\.com|openai|perplexity(?:\.ai)?|claude\.ai|copilot(?:\.microsoft\.com)?|gemini(?:\.google\.com)?)\b/gi;
591
+ function residueHits(ctx) {
592
+ const hits = phrases(RESIDUE, quoted, allText)(ctx);
593
+ for (const file of ctx.files) {
594
+ for (const m of file.text.matchAll(AI_UTM)) hits.push({ path: file.path, offset: m.index ?? 0, message: `link tracked as copied from a chat assistant: "${m[0]}"` });
595
+ }
596
+ return hits;
597
+ }
598
+ var PLACEHOLDER = [
599
+ /\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n]{0,40}\](?!\()/i,
600
+ /\blorem ipsum\b/i,
601
+ /\bTODO_PLACEHOLDER\b/
602
+ ];
603
+ var PLACEHOLDER_ELEMENT = />\s*(\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n<]{0,40}\])\s*</gi;
604
+ function insideQuotes(block, _match, index) {
605
+ const lineStart = block.text.lastIndexOf("\n", index - 1) + 1;
606
+ const before = block.text.slice(lineStart, index);
607
+ const straight = (before.match(/"/g) ?? []).length;
608
+ const curly = (before.match(/“/g) ?? []).length - (before.match(/”/g) ?? []).length;
609
+ return straight % 2 === 1 || curly > 0;
610
+ }
611
+ function placeholderHits(ctx) {
612
+ const hits = phrases(PLACEHOLDER, quoted, allText, insideQuotes)(ctx);
613
+ const seen = new Set(hits.map((h) => `${h.path}:${h.offset}`));
614
+ for (const file of ctx.files) {
615
+ if (isProsePath(file.path)) continue;
616
+ for (const m of file.text.matchAll(PLACEHOLDER_ELEMENT)) {
617
+ const offset = (m.index ?? 0) + m[0].indexOf(m[1]);
618
+ if (!seen.has(`${file.path}:${offset}`)) hits.push({ path: file.path, offset, message: quoted(m[1]) });
619
+ }
620
+ }
621
+ return hits;
622
+ }
623
+ var QUESTION_REVEAL = [
624
+ /\b(?:The|Our|What['’]s the|Here['’]s the)\s+(?:result|catch|secret|answer|difference|best part|twist|kicker|bottom line|upshot)\?\s+[A-Z0-9]/,
625
+ /\?\s+It means\b/
626
+ ];
627
+ function inlineLabelHits(ctx) {
628
+ const hits = [];
629
+ for (const file of ctx.files) {
630
+ if (!isProsePath(file.path)) continue;
631
+ const lines = file.text.split("\n");
632
+ let run = [];
633
+ let offset = 0;
634
+ const flush = () => {
635
+ if (run.length >= 3) hits.push({ path: file.path, offset: run[0], message: `${run.length} bullets in a row open with a bold label and a colon` });
636
+ run = [];
637
+ };
638
+ for (const line of lines) {
639
+ const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
640
+ if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
641
+ else flush();
642
+ offset += line.length + 1;
643
+ }
644
+ flush();
645
+ }
646
+ return hits;
647
+ }
648
+ var RESEARCH_TELLS = [
649
+ {
650
+ id: "chatbot-residue",
651
+ name: "Chatbot residue",
652
+ generation: 1,
653
+ severity: "warn",
654
+ surface: "copy",
655
+ why: "'Certainly! Here's a revised version', 'as an AI language model', a citeturn0search0 token, a link tracked utm_source=chatgpt.com: text that exists only because a chat reply was pasted whole. This is evidence, not style.",
656
+ fix: "Delete the residue. Then read the whole passage again, because the rest of it came from the same paste.",
657
+ detect: residueHits,
658
+ fixtures: {
659
+ flag: [
660
+ f("content/home.md", "Certainly! Here's a revised version of your home page."),
661
+ f("content/home.md", "As an AI language model, I cannot visit your salon."),
662
+ f("content/home.md", "Let me know if you'd like me to shorten it."),
663
+ f("content/home.md", "Prices start at \xA340 citeturn0search3 for a full set."),
664
+ f("content/home.md", "Prices start at \xA340. citeturn11search1turn10view0turn2view3"),
665
+ f("content/home.md", "Prices start at \xA340. \uE200cite\uE202turn11search1\uE202turn10view0\uE201"),
666
+ f("content/home.md", "Prices start at \xA340 :contentReference[oaicite:2]{index=2} for a full set."),
667
+ f("content/home.md", "Prices start at \xA340\u30104\u2020source\u3011 for a full set."),
668
+ f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=chatgpt.com">Read the guide</a>;`),
669
+ f("content/home.md", "Here's a more concise version for the hero.")
670
+ ],
671
+ pass: [
672
+ f("content/home.md", "Certainly the busiest week of the year: book early."),
673
+ f("content/home.md", "Here's the price list for 2026."),
674
+ f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=newsletter">Read the guide</a>;`)
675
+ ]
676
+ }
677
+ },
678
+ {
679
+ id: "placeholder",
680
+ name: "Unfilled placeholder",
681
+ generation: 1,
682
+ severity: "warn",
683
+ surface: "copy",
684
+ why: "'[Insert testimonial]', '[Your Name]', lorem ipsum, TODO_PLACEHOLDER: scaffolding a reader must never see, and the clearest sign a page shipped before anyone read it.",
685
+ fix: "Fill it from the client or the proof source, or remove the element. Never invent the missing fact to close it.",
686
+ detect: placeholderHits,
687
+ fixtures: {
688
+ flag: [
689
+ f("content/home.md", "Rated 5 stars by [Insert client name] and others."),
690
+ f("content/home.md", "Lorem ipsum dolor sit amet."),
691
+ f("content/home.md", "Rated TODO_PLACEHOLDER: google_rating from our reviews."),
692
+ f("app/card.tsx", `export const Quote = () => <blockquote>[Insert testimonial here]</blockquote>;`)
693
+ ],
694
+ pass: [
695
+ f("content/home.md", "Manage bookings in [your account](/account)."),
696
+ f("content/guide.md", 'After 90 days, check your ranking for "[your trade] [your town]" on Google.'),
697
+ f("content/guide.md", "Send a short message: \u201CThanks for choosing [Business Name]! Leave us a review.\u201D"),
698
+ f("content/home.md", "Rated 4.9 from 212 Google reviews.")
699
+ ]
700
+ }
701
+ },
702
+ {
703
+ id: "question-reveal",
704
+ name: "Staged reveal",
705
+ generation: 2,
706
+ severity: "warn",
707
+ surface: "copy",
708
+ why: "'The result? Faster growth.', 'What does this mean for you? It means...': the writer asks the reader's question for them and answers it. An infomercial hook, and a favourite of generated copy.",
709
+ fix: "State the answer: 'Most customers book again within six weeks.'",
710
+ detect: phrases(QUESTION_REVEAL, quoted),
711
+ fixtures: {
712
+ flag: [f("content/home.md", "We rebuilt the booking flow. The result? Twice the bookings."), f("content/home.md", "What does this mean for you? It means fewer missed calls.")],
713
+ pass: [f("content/faq.md", "How long does a set last? About three weeks with gel."), f("content/home.md", "We rebuilt the booking flow, and bookings doubled in a month.")]
714
+ }
715
+ },
716
+ {
717
+ id: "inline-label-list",
718
+ name: "Bold-label bullets",
719
+ generation: 2,
720
+ severity: "warn",
721
+ surface: "copy",
722
+ why: "'**Speed:** Faster pages' three times in a row: a short label restated in a few words, the list shape generated copy defaults to. A definition list, with a real explanation after each label, is not this.",
723
+ fix: "Drop the labels and write each bullet as the fact: 'Pages load in under a second'. Keep labels only where they help a reader navigate, as in a specification.",
724
+ detect: inlineLabelHits,
725
+ fixtures: {
726
+ flag: [f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n- **Support:** Help when you need it\n")],
727
+ pass: [
728
+ f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
729
+ f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
730
+ f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n")
731
+ ]
732
+ }
733
+ },
734
+ {
735
+ id: "ing-tail",
736
+ name: "Empty -ing tail",
737
+ generation: 2,
738
+ severity: "warn",
739
+ surface: "copy",
740
+ why: "'..., ensuring peace of mind', '..., highlighting our commitment': a participle tacked on the end that claims significance and names no mechanism. One of the most common shapes in the Wikipedia guide to AI writing.",
741
+ fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
742
+ detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
743
+ fixtures: {
744
+ flag: [
745
+ f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
746
+ f("app/about.tsx", `export const About = () => <p>We have served Brackley since 2009, highlighting our commitment to the town.</p>;`)
747
+ ],
748
+ pass: [
749
+ f("content/home.md", "We open at 7am, so you can drop the car off before work."),
750
+ f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
751
+ ]
752
+ }
753
+ },
754
+ {
755
+ id: "vague-attribution",
756
+ name: "Unnamed source",
757
+ generation: 1,
758
+ severity: "warn",
759
+ surface: "copy",
760
+ why: "'Studies show', 'experts agree', 'it is widely known': a claim credited to nobody. A reader cannot check it, and the house proof rule forbids a claim nobody can check.",
761
+ fix: "Name the source and the number ('A 2023 Which? survey of 2,000 drivers found...') or cut the claim.",
762
+ detect: phrases(VAGUE_ATTRIBUTION, quoted, (ctx) => ctx.blocks, sourced),
763
+ fixtures: {
764
+ flag: [
765
+ f("content/home.md", "Studies show that regular servicing saves money."),
766
+ f("content/home.md", "Most experts agree a boiler lasts fifteen years."),
767
+ f("content/home.md", "It is widely known that gel damages nails.")
768
+ ],
769
+ pass: [
770
+ f("content/home.md", "A 2023 Which? survey of 2,000 owners found serviced boilers failed half as often."),
771
+ f("content/home.md", "BrightLocal's research shows that reviews drive 20% of local ranking."),
772
+ f("content/home.md", "Research shows 62% of consumers avoid a business with wrong hours.[^7]"),
773
+ f("content/home.md", "Our customers rate us 4.9 from 212 Google reviews.")
774
+ ]
775
+ }
776
+ },
777
+ {
778
+ id: "closing-summary",
779
+ name: "Closing summary",
780
+ generation: 1,
781
+ severity: "warn",
782
+ surface: "copy",
783
+ why: "'Overall,', 'In conclusion,', 'In short,': a paragraph that announces it is summing up, then restates what the reader has just read.",
784
+ fix: "Cut the summary. End on the last new fact, or on the next step: 'Book a survey and we'll quote within two days'.",
785
+ detect: phrases([CLOSING_SUMMARY], (m) => quoted(m.replace(/^[.!?>\s]+/, ""))),
786
+ fixtures: {
787
+ flag: [
788
+ f("content/home.md", "We rewire, test and certify.\n\nOverall, we are the right choice for your home."),
789
+ f("content/home.md", "Prices start at \xA340. In conclusion, we offer great value.")
790
+ ],
791
+ pass: [f("content/home.md", "We rewire, test and certify.\n\nBook a survey and we'll quote within two days.")]
792
+ }
793
+ },
794
+ {
795
+ id: "false-range",
796
+ name: "False range",
797
+ generation: 1,
798
+ severity: "warn",
799
+ surface: "copy",
800
+ why: "'From first-time buyers to seasoned investors alike' names two ends of no real scale, to sound as if it covers everyone. It describes nobody.",
801
+ fix: "Say who you actually serve, or cut it. A real range is fine: 'from 9am to 5pm', 'from small repairs to full rewires'.",
802
+ detect: phrases(FALSE_RANGE, quoted),
803
+ fixtures: {
804
+ flag: [
805
+ f("content/home.md", "We help clients from first-time buyers to seasoned investors alike."),
806
+ f("content/home.md", "Everyone from students to retirees loves our cakes.")
807
+ ],
808
+ pass: [
809
+ f("content/home.md", "Open from 9am to 5pm, Monday to Saturday."),
810
+ f("content/home.md", "We take on everything from small repairs to full rewires.")
811
+ ]
812
+ }
813
+ }
814
+ ];
815
+
816
+ // src/character/tells/density.ts
817
+ var DOCUMENT = /\.(md|markdown|mdx|txt|html?)$/i;
818
+ var MIN_WORDS = 400;
819
+ var WORD = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
820
+ var countWords = (text) => (text.match(WORD) ?? []).length;
821
+ function documents(ctx) {
822
+ const byPath = /* @__PURE__ */ new Map();
823
+ for (const block of ctx.blocks) {
824
+ if (!DOCUMENT.test(block.path) || block.literal) continue;
825
+ const list = byPath.get(block.path) ?? [];
826
+ list.push(block);
827
+ byPath.set(block.path, list);
828
+ }
829
+ const docs = [];
830
+ for (const [path, blocks] of byPath) {
831
+ const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
832
+ const words = countWords(text);
833
+ if (words >= MIN_WORDS) docs.push({ path, blocks, words });
834
+ }
835
+ return docs;
836
+ }
837
+ var withoutHeadings = (text) => text.replace(/^\s{0,3}#{1,6}\s.*$/gm, "");
838
+ var allowed = (doc2, floor, per1000) => Math.max(floor, Math.floor(doc2.words * per1000 / 1e3));
839
+ var thousands = (n) => n.toLocaleString("en-GB");
840
+ function sentences(doc2) {
841
+ const out = [];
842
+ for (const block of doc2.blocks) {
843
+ const text = withoutHeadings(block.text);
844
+ for (const para of text.matchAll(/[^\n]+(?:\n(?!\s*\n)[^\n]+)*/g)) {
845
+ const body = para[0];
846
+ const start = para.index ?? 0;
847
+ for (const m of body.matchAll(/[^.!?]+[.!?]+["'’”)]*|[^.!?]+$/g)) {
848
+ const raw = m[0];
849
+ const trimmed = raw.trim();
850
+ if (countWords(trimmed) === 0) continue;
851
+ const lead = raw.length - raw.trimStart().length;
852
+ out.push({ text: trimmed.replace(/\s+/g, " "), offset: block.offset + start + (m.index ?? 0) + lead });
853
+ }
854
+ }
855
+ }
856
+ return out;
857
+ }
858
+ var RATES = [
859
+ { label: "rather than", pattern: /\brather\s+than\b/i, floor: 4, per1000: 0.9, why: "the document's one move" },
860
+ { label: "instead of", pattern: /\binstead\s+of\b/i, floor: 4, per1000: 0.7, why: "the reflex swap once 'rather than' is edited out" },
861
+ { label: "actually", pattern: /\bactually\b/i, floor: 2, per1000: 0.35, why: "filler, the sentence means the same without it" },
862
+ { label: "properly", pattern: /\bproperly\b/i, floor: 3, per1000: 0.4, why: "filler, the sentence means the same without it" },
863
+ { label: "genuinely", pattern: /\bgenuinely\b/i, floor: 1, per1000: 0.2, why: "reaching for sincerity: one is emphasis, two is a tic" },
864
+ {
865
+ label: "the single ...est",
866
+ pattern: /\bthe\s+single\s+(?:\w+est|most|\w+\s+most)\b/i,
867
+ floor: 1,
868
+ per1000: 0.15,
869
+ why: "if three things are the single most important, none is"
870
+ },
871
+ { label: "That is / This is opener", pattern: /^(?:That\s+is|That['’]s|This\s+is)\b/, opener: true, floor: 4, per1000: 0.7, why: "pointing back instead of moving on" },
872
+ { label: "Every opener", pattern: /^Every\b/, opener: true, floor: 4, per1000: 0.6, why: "the same sweeping opener, again" },
873
+ {
874
+ label: "Worth noting opener",
875
+ pattern: /^(?:Worth\s+(?:knowing|noting|saying|mentioning|remembering)|It(?:['’]s|\s+is)\s+worth\s+(?:knowing|noting|saying|mentioning|remembering))\b/,
876
+ opener: true,
877
+ floor: 3,
878
+ per1000: 0.5,
879
+ why: "a hedge that lets a sentence start without committing"
880
+ },
881
+ {
882
+ label: "negation-led opener",
883
+ pattern: /^(?:None\s+of|Not\s+one|Not\s+a\s+single|Nothing|No\s+one|Nobody|Never)\b/,
884
+ opener: true,
885
+ floor: 5,
886
+ per1000: 1,
887
+ why: "each earns its place once or twice; at twenty the document has one move"
888
+ },
889
+ {
890
+ label: "participle triad",
891
+ pattern: /\b[a-z]{3,}ed,\s+[a-z]{3,}ed,?\s+and\s+[a-z]{3,}ed\b/i,
892
+ floor: 3,
893
+ per1000: 0.6,
894
+ why: "'researched, written and referenced' is fine once and a cadence in bulk"
895
+ }
896
+ ];
897
+ function rateHits(ctx) {
898
+ const hits = [];
899
+ for (const doc2 of documents(ctx)) {
900
+ const list = sentences(doc2);
901
+ for (const rate of RATES) {
902
+ const found = [];
903
+ const global = new RegExp(rate.pattern.source, rate.pattern.flags.replace("g", "") + "g");
904
+ for (const s of list) {
905
+ if (rate.opener) {
906
+ if (rate.pattern.test(s.text)) found.push(s);
907
+ } else {
908
+ const n = (s.text.match(global) ?? []).length;
909
+ for (let k = 0; k < n; k += 1) found.push(s);
910
+ }
911
+ }
912
+ const limit = allowed(doc2, rate.floor, rate.per1000);
913
+ if (found.length > limit) {
914
+ hits.push({
915
+ path: doc2.path,
916
+ offset: found[limit].offset,
917
+ message: `"${rate.label}" ${found.length} times in ${thousands(doc2.words)} words (allows ${limit}): ${rate.why}`,
918
+ excerpt: found[limit].text.slice(0, 120)
919
+ });
920
+ }
921
+ }
922
+ }
923
+ return hits;
924
+ }
925
+ function aphorismHits(ctx) {
926
+ const hits = [];
927
+ for (const doc2 of documents(ctx)) {
928
+ const found = [];
929
+ for (const block of doc2.blocks) {
930
+ let at = 0;
931
+ for (const line of withoutHeadings(block.text).split("\n")) {
932
+ const text = line.trim().replace(/^[-*>]\s+/, "");
933
+ const parts2 = text.match(/[^.!?]+[.!?]+/g) ?? [];
934
+ if (parts2.length === 2 && parts2.join("").length >= text.length - 1 && parts2.every((p) => countWords(p) >= 2 && countWords(p) <= 7)) {
935
+ found.push({ text, offset: block.offset + at + line.indexOf(text.charAt(0)) });
936
+ }
937
+ at += line.length + 1;
938
+ }
939
+ }
940
+ const limit = allowed(doc2, 5, 1.2);
941
+ if (found.length > limit) {
942
+ hits.push({
943
+ path: doc2.path,
944
+ offset: found[limit].offset,
945
+ message: `${found.length} two-line aphorisms in ${thousands(doc2.words)} words (allows ${limit}). Keep the one that lands hardest`,
946
+ excerpt: found[limit].text
947
+ });
948
+ }
949
+ }
950
+ return hits;
951
+ }
952
+ var CONTRACTION = /\b(?:\w+n['’]t|\w+['’](?:re|ve|ll|m|d)|(?:it|that|there|here|what|who|he|she|let|where)['’]s)\b/gi;
953
+ function contractionHits(ctx) {
954
+ const hits = [];
955
+ for (const doc2 of documents(ctx)) {
956
+ if (doc2.words < 800) continue;
957
+ const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
958
+ const count2 = (text.match(CONTRACTION) ?? []).length;
959
+ const rate = count2 * 1e3 / doc2.words;
960
+ if (rate < 1) {
961
+ const first = sentences(doc2)[0];
962
+ hits.push({
963
+ path: doc2.path,
964
+ offset: first?.offset ?? 0,
965
+ message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
966
+ });
967
+ }
968
+ }
969
+ return hits;
970
+ }
971
+ function rhythmHits(ctx) {
972
+ const hits = [];
973
+ for (const doc2 of documents(ctx)) {
974
+ const list = sentences(doc2);
975
+ if (list.length < 40) continue;
976
+ const lengths = list.map((s) => countWords(s.text));
977
+ const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
978
+ const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
979
+ const cv = sd / mean;
980
+ if (cv < 0.42) {
981
+ hits.push({
982
+ path: doc2.path,
983
+ offset: list[0].offset,
984
+ message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
985
+ });
986
+ }
987
+ }
988
+ return hits;
989
+ }
990
+ var normal = (s) => s.toLowerCase().replace(/[’]/g, "'").match(/[a-z0-9£$€'%]+/g) ?? [];
991
+ function repeatHits(ctx) {
992
+ const hits = [];
993
+ const RUN = 9;
994
+ for (const doc2 of documents(ctx)) {
995
+ const whole = /* @__PURE__ */ new Map();
996
+ const runs = /* @__PURE__ */ new Map();
997
+ for (const s of sentences(doc2)) {
998
+ const words = normal(s.text);
999
+ if (words.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
1000
+ const key = words.join(" ");
1001
+ const earlier = whole.get(key);
1002
+ if (earlier) {
1003
+ hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words.length} words)`, excerpt: s.text.slice(0, 120) });
1004
+ continue;
1005
+ }
1006
+ whole.set(key, s);
1007
+ let reported = false;
1008
+ for (let i = 0; i + RUN <= words.length && !reported; i += 1) {
1009
+ const run = words.slice(i, i + RUN).join(" ");
1010
+ const seen = runs.get(run);
1011
+ if (seen && seen.key !== key) {
1012
+ hits.push({ path: doc2.path, offset: s.offset, message: `shares a ${RUN}-word run with an earlier sentence: "${run}"`, excerpt: s.text.slice(0, 120) });
1013
+ reported = true;
1014
+ } else if (!seen) {
1015
+ runs.set(run, { sentence: s, key });
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+ return hits;
1021
+ }
1022
+ function headings(file) {
1023
+ const out = [];
1024
+ for (const m of file.text.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)) {
1025
+ out.push({ text: m[1], offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
1026
+ }
1027
+ for (const m of file.text.matchAll(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/gi)) {
1028
+ const text = m[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
1029
+ if (text) out.push({ text, offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
1030
+ }
1031
+ return out.sort((a, b) => a.offset - b.offset);
1032
+ }
1033
+ function longFiles(ctx) {
1034
+ const docs = new Map(documents(ctx).map((d) => [d.path, d]));
1035
+ return ctx.files.filter((f4) => docs.has(f4.path)).map((file) => ({ file, doc: docs.get(file.path) }));
1036
+ }
1037
+ var HEADING_SHAPES = [
1038
+ ["X, and Y", /^[^,]{2,}, and [^,]{2,}$/i],
1039
+ ["X, because Y", /^[^,]{2,}, because [^,]{2,}$/i],
1040
+ ["comma list", /^[A-Z][\w'’-]*(?:, [\w'’-]+){2,}(?:,? (?:and|or) [\w'’-]+)?\.?$/]
1041
+ ];
1042
+ function headingShapeHits(ctx) {
1043
+ const hits = [];
1044
+ for (const { file, doc: doc2 } of longFiles(ctx)) {
1045
+ const found = [];
1046
+ for (const heading of headings(file)) {
1047
+ const shape = HEADING_SHAPES.find(([, re]) => re.test(heading.text));
1048
+ if (shape) found.push({ heading, shape: shape[0] });
1049
+ }
1050
+ const limit = allowed(doc2, 3, 0.5);
1051
+ if (found.length > limit) {
1052
+ hits.push({
1053
+ path: file.path,
1054
+ offset: found[limit].heading.offset,
1055
+ message: `${found.length} headings share one shape (${[...new Set(found.map((f4) => f4.shape))].join(", ")}), allows ${limit}`,
1056
+ excerpt: found[limit].heading.text
1057
+ });
1058
+ }
1059
+ }
1060
+ return hits;
1061
+ }
1062
+ function headingEchoHits(ctx) {
1063
+ const hits = [];
1064
+ for (const { file } of longFiles(ctx)) {
1065
+ for (const heading of headings(file)) {
1066
+ const words = normal(heading.text);
1067
+ if (words.length < 4) continue;
1068
+ const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
1069
+ const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words.length);
1070
+ if (opening.join(" ") === words.join(" ")) {
1071
+ hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
1072
+ }
1073
+ }
1074
+ }
1075
+ return hits;
1076
+ }
1077
+ var FILLER_WORDS = "boiler van kitchen garden studio market street window roof tile paint brush ladder hedge lawn fence gate shop counter oven bread flour kettle chair table shelf lamp cable socket switch meter valve pipe tap sink drain gutter brick mortar timber plank screw bolt hinge lock key door frame glass mirror towel basin morning evening winter summer spring autumn monday tuesday friday weekend hour minute week month year fitted checked cleaned painted mended booked quoted delivered collected measured sanded sealed wired local quiet small large early late busy bright warm dry".split(" ");
1078
+ function filler(words, seed = 1, options = {}) {
1079
+ const { contractions = true, uniform } = options;
1080
+ const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
1081
+ let state = seed;
1082
+ const next = (n) => {
1083
+ state = (state * 1103515245 + 12345) % 2147483648;
1084
+ return state % n;
1085
+ };
1086
+ const out = [];
1087
+ let count2 = 0;
1088
+ let i = 0;
1089
+ while (count2 < words) {
1090
+ const length = uniform ?? shape[i % shape.length];
1091
+ const s = [];
1092
+ for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
1093
+ if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
1094
+ s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
1095
+ out.push(`${s.join(" ")}.`);
1096
+ count2 += s.length;
1097
+ i += 1;
1098
+ }
1099
+ const paragraphs = [];
1100
+ for (let p = 0; p < out.length; p += 4) paragraphs.push(out.slice(p, p + 4).join(" "));
1101
+ return paragraphs.join("\n\n");
1102
+ }
1103
+ var f2 = (path, text) => ({ path, text });
1104
+ var doc = (extra, seed = 1) => `${filler(520, seed)}
1105
+
1106
+ ${extra}
1107
+ `;
1108
+ var times = (n, sentence) => Array.from({ length: n }, (_, i) => sentence(i)).join(" ");
1109
+ var DENSITY_TELLS = [
1110
+ {
1111
+ id: "phrase-density",
1112
+ name: "Leaned-on phrase",
1113
+ generation: 2,
1114
+ severity: "warn",
1115
+ surface: "copy",
1116
+ why: "'Rather than', 'actually', 'That is...' openers, participle triads: each is ordinary English once, and a document that uses one far above its rate has one move and repeats it.",
1117
+ fix: "Keep the instances that land hardest and vary or cut the rest. Never delete every one: the count is the problem, not the phrase.",
1118
+ detect: rateHits,
1119
+ fixtures: {
1120
+ flag: [
1121
+ f2("content/proposal.md", doc(times(6, (i) => `We fitted boiler ${i} rather than patching it.`))),
1122
+ f2("content/proposal.md", doc(times(6, (i) => `We sealed roof ${i} instead of patching it.`))),
1123
+ f2("content/proposal.md", doc(times(4, (i) => `Room ${i} actually needed a new valve.`))),
1124
+ f2("content/proposal.md", doc(times(5, (i) => `Gate ${i} was properly hung.`))),
1125
+ f2("content/proposal.md", doc(times(3, (i) => `Kitchen ${i} was genuinely tidy.`))),
1126
+ f2("content/proposal.md", doc(times(3, (i) => `Pipe ${i} is the single biggest risk.`))),
1127
+ f2("content/proposal.md", doc(times(6, (i) => `That is why tap ${i} leaks.`))),
1128
+ f2("content/proposal.md", doc(times(6, (i) => `Every tap ${i} gets checked.`))),
1129
+ f2("content/proposal.md", doc(times(5, (i) => `Worth noting: valve ${i} sticks.`))),
1130
+ f2("content/proposal.md", doc(times(7, (i) => `None of room ${i} needed paint.`))),
1131
+ f2("content/proposal.md", doc(times(5, (i) => `Wall ${i} was measured, sanded and sealed.`))),
1132
+ f2("site/index.html", `<html><body><p>${filler(520, 3)}</p><p>${times(6, (i) => `We fitted boiler ${i} rather than patching it.`)}</p></body></html>`)
1133
+ ],
1134
+ pass: [
1135
+ f2("content/proposal.md", doc(times(3, (i) => `We fitted boiler ${i} rather than patching it.`))),
1136
+ f2("content/short.md", times(6, (i) => `We fitted boiler ${i} rather than patching it.`)),
1137
+ f2("app/page.tsx", `export const copy = [${Array.from({ length: 40 }, (_, i) => `"We fitted boiler ${i} rather than patching it."`).join(", ")}];`)
1138
+ ]
1139
+ }
1140
+ },
1141
+ {
1142
+ id: "aphorism-density",
1143
+ name: "Aphorism cadence",
1144
+ generation: 2,
1145
+ severity: "warn",
1146
+ surface: "copy",
1147
+ why: "Two clipped sentences alone on a line ('All of this already exists. The job is making it findable.') land once. Closing every section, they are a cadence a reader learns to hear.",
1148
+ fix: "Keep the strongest one. Fold the rest into the paragraph above as plain statements.",
1149
+ detect: aphorismHits,
1150
+ fixtures: {
1151
+ flag: [f2("content/proposal.md", doc(Array.from({ length: 7 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))],
1152
+ pass: [f2("content/proposal.md", doc(Array.from({ length: 3 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))]
1153
+ }
1154
+ },
1155
+ {
1156
+ id: "contraction-scarcity",
1157
+ name: "No contractions",
1158
+ generation: 1,
1159
+ severity: "warn",
1160
+ surface: "copy",
1161
+ why: "A long document with almost no contractions reads stiff and machine-made. People write 'we're' and 'don't'.",
1162
+ fix: "Contract where you would in speech: 'we are' to 'we're', 'do not' to 'don't'. Formal legal pages are the deliberate exception.",
1163
+ detect: contractionHits,
1164
+ fixtures: {
1165
+ flag: [f2("content/proposal.md", filler(900, 5, { contractions: false }))],
1166
+ pass: [f2("content/proposal.md", filler(900, 5)), f2("content/short.md", filler(500, 5, { contractions: false }))]
1167
+ }
1168
+ },
1169
+ {
1170
+ id: "sentence-rhythm",
1171
+ name: "Metronomic rhythm",
1172
+ generation: 2,
1173
+ severity: "warn",
1174
+ surface: "copy",
1175
+ why: "Sentence after sentence of the same length reads generated. A person's sentences run long, then short, then long again.",
1176
+ fix: "Break one long sentence in two, join two short ones, and let some sentences be flat and plain.",
1177
+ detect: rhythmHits,
1178
+ fixtures: {
1179
+ flag: [f2("content/proposal.md", filler(600, 7, { uniform: 12 }))],
1180
+ pass: [f2("content/proposal.md", filler(600, 7))]
1181
+ }
1182
+ },
1183
+ {
1184
+ id: "repeated-sentence",
1185
+ name: "Repeated sentence",
1186
+ generation: 1,
1187
+ severity: "warn",
1188
+ surface: "copy",
1189
+ why: "The same sentence on two pages, or the same nine words lightly edited, is one of the strongest signs a document was assembled rather than written.",
1190
+ fix: "Say it once, where it matters most, and write the second place fresh.",
1191
+ detect: repeatHits,
1192
+ fixtures: {
1193
+ flag: [
1194
+ f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nMore here.\n\nWe check every valve before the engineer leaves the house.")),
1195
+ f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nBy then we check every valve before the engineer leaves the site."))
1196
+ ],
1197
+ pass: [f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet."))]
1198
+ }
1199
+ },
1200
+ {
1201
+ id: "heading-shape",
1202
+ name: "One heading shape",
1203
+ generation: 2,
1204
+ severity: "warn",
1205
+ surface: "copy",
1206
+ why: "Headings that all share one shape ('X, and Y', 'X, because Y', 'Keep, rewrite, consolidate, retire') read as a template filled in section by section.",
1207
+ fix: "Write each heading for its section. Most should be a plain noun phrase.",
1208
+ detect: headingShapeHits,
1209
+ fixtures: {
1210
+ flag: [
1211
+ f2("content/proposal.md", ["## Fast, and fairly priced", "## Local, and insured", "## Tidy, and quiet", "## Early, and on time", "", filler(520, 9)].join("\n\n")),
1212
+ f2("site/index.html", `<h2>Fast, because we care</h2><h2>Tidy, because it matters</h2><h2>Early, because you asked</h2><h2>Quiet, because neighbours</h2><p>${filler(520, 9)}</p>`),
1213
+ f2("content/proposal.md", ["## Keep, rewrite, retire", "## Fix, paint, seal", "## Book, quote, fit", "## Plan, build, check", "", filler(520, 9)].join("\n\n"))
1214
+ ],
1215
+ pass: [f2("content/proposal.md", ["## Pricing", "## Fast, and fairly priced", "## Who we are", "## Areas we cover", "", filler(520, 9)].join("\n\n"))]
1216
+ }
1217
+ },
1218
+ {
1219
+ id: "heading-echo",
1220
+ name: "Heading echoed",
1221
+ generation: 2,
1222
+ severity: "warn",
1223
+ surface: "copy",
1224
+ why: "A heading repeated word for word as the first line under it spends the reader's attention twice on the same words.",
1225
+ fix: "Let the first sentence say something the heading did not.",
1226
+ detect: headingEchoHits,
1227
+ fixtures: {
1228
+ flag: [
1229
+ f2("content/proposal.md", `## We fix boilers the same day
1230
+
1231
+ We fix boilers the same day across Brackley.
1232
+
1233
+ ${filler(520, 11)}`),
1234
+ f2("site/index.html", `<h2>We fix boilers the same day</h2>
1235
+ <p>We fix boilers the same day across Brackley.</p><p>${filler(520, 11)}</p>`)
1236
+ ],
1237
+ pass: [f2("content/proposal.md", `## We fix boilers the same day
1238
+
1239
+ Call before noon and an engineer is with you by six.
1240
+
1241
+ ${filler(520, 11)}`)]
1242
+ }
1243
+ }
1244
+ ];
522
1245
 
523
1246
  // src/color/oklch.ts
524
1247
  var clamp01 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
@@ -582,8 +1305,8 @@ function parts(fn) {
582
1305
  function hslToRgb(h, s, l) {
583
1306
  const k = (n) => (n + h / 30) % 12;
584
1307
  const a = s * Math.min(l, 1 - l);
585
- const f3 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
586
- return { r: f3(0), g: f3(8), b: f3(4) };
1308
+ const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
1309
+ return { r: f4(0), g: f4(8), b: f4(4) };
587
1310
  }
588
1311
  var clamp012 = (n) => Math.min(1, Math.max(0, n));
589
1312
  function parseColour(raw) {
@@ -695,7 +1418,7 @@ function firstMatch(text, pattern) {
695
1418
  return m ? m.index : -1;
696
1419
  }
697
1420
  var FRAMEWORK_PALETTE = /^--(?:color-)?(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+$|^--tw-/;
698
- var f2 = (path, text) => ({ path, text });
1421
+ var f3 = (path, text) => ({ path, text });
699
1422
  function familyNames(value) {
700
1423
  return value.split(",").map((v) => v.trim().replace(/^["']|["']$/g, "").trim()).filter(Boolean);
701
1424
  }
@@ -1160,14 +1883,14 @@ var SOURCE_TELLS = [
1160
1883
  detect: fontTell(REFLEX_FONTS_1),
1161
1884
  fixtures: {
1162
1885
  flag: [
1163
- f2("app/layout.tsx", `import { Inter } from "next/font/google";
1886
+ f3("app/layout.tsx", `import { Inter } from "next/font/google";
1164
1887
  const inter = Inter({ subsets: ["latin"] });`),
1165
- f2("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
1166
- f2("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
1167
- f2("main.ts", `import "@fontsource/montserrat";`),
1168
- f2("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
1888
+ f3("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
1889
+ f3("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
1890
+ f3("main.ts", `import "@fontsource/montserrat";`),
1891
+ f3("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
1169
1892
  ],
1170
- pass: [f2("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
1893
+ pass: [f3("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
1171
1894
  }
1172
1895
  },
1173
1896
  {
@@ -1177,13 +1900,13 @@ const inter = Inter({ subsets: ["latin"] });`),
1177
1900
  severity: "warn",
1178
1901
  surface: "source",
1179
1902
  why: "Ban Inter and a model reaches for Inter Tight, DM Sans, Manrope, Space Grotesk or an Instrument/Fraunces serif. A swap inside the reflex list is not a decision.",
1180
- fix: "Same as reflex-font. If this face really is the right one, declare an exception with the reason and the evidence.",
1903
+ fix: "Pick the display face from the client's world (signage, packaging, the trade's lettering) and record why in art-direction.json. If this face really is the right one, declare an exception with the reason and the evidence.",
1181
1904
  detect: fontTell(REFLEX_FONTS_2),
1182
1905
  fixtures: {
1183
- flag: [f2("src/styles.css", `@theme {
1906
+ flag: [f3("src/styles.css", `@theme {
1184
1907
  --font-sans: "DM Sans", system-ui, sans-serif;
1185
1908
  }`)],
1186
- pass: [f2("src/styles.css", `@theme {
1909
+ pass: [f3("src/styles.css", `@theme {
1187
1910
  --font-sans: "Hanken Grotesk", system-ui, sans-serif;
1188
1911
  }`)]
1189
1912
  }
@@ -1199,10 +1922,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1199
1922
  detect: violetHits,
1200
1923
  fixtures: {
1201
1924
  flag: [
1202
- f2("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
1203
- f2("app/globals.css", `:root { --accent: #5a35d1; }`)
1925
+ f3("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
1926
+ f3("app/globals.css", `:root { --accent: #5a35d1; }`)
1204
1927
  ],
1205
- pass: [f2("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
1928
+ pass: [f3("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
1206
1929
  }
1207
1930
  },
1208
1931
  {
@@ -1216,10 +1939,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1216
1939
  detect: gradientHits,
1217
1940
  fixtures: {
1218
1941
  flag: [
1219
- f2("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
1220
- f2("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
1942
+ f3("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
1943
+ f3("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
1221
1944
  ],
1222
- pass: [f2("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
1945
+ pass: [f3("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
1223
1946
  }
1224
1947
  },
1225
1948
  {
@@ -1233,10 +1956,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1233
1956
  detect: gradientTextHits,
1234
1957
  fixtures: {
1235
1958
  flag: [
1236
- f2("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
1237
- f2("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
1959
+ f3("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
1960
+ f3("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
1238
1961
  ],
1239
- pass: [f2("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
1962
+ pass: [f3("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
1240
1963
  }
1241
1964
  },
1242
1965
  {
@@ -1250,10 +1973,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1250
1973
  detect: glassHits,
1251
1974
  fixtures: {
1252
1975
  flag: [
1253
- f2("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
1254
- f2("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
1976
+ f3("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
1977
+ f3("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
1255
1978
  ],
1256
- pass: [f2("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
1979
+ pass: [f3("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
1257
1980
  }
1258
1981
  },
1259
1982
  {
@@ -1266,10 +1989,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1266
1989
  fix: "Follow the hero with the thing only this business has: the work, the place, the person. Put proof where a doubt arises.",
1267
1990
  detect: heroThenProofHits,
1268
1991
  fixtures: {
1269
- flag: [f2("app/page.tsx", `export default function Page() {
1992
+ flag: [f3("app/page.tsx", `export default function Page() {
1270
1993
  return (<main><Hero /><TrustStrip /><Services /></main>);
1271
1994
  }`)],
1272
- pass: [f2("app/page.tsx", `export default function Page() {
1995
+ pass: [f3("app/page.tsx", `export default function Page() {
1273
1996
  return (<main><Hero /><Workshop /><TrustStrip /></main>);
1274
1997
  }`)]
1275
1998
  }
@@ -1285,7 +2008,7 @@ const inter = Inter({ subsets: ["latin"] });`),
1285
2008
  detect: iconGridHits,
1286
2009
  fixtures: {
1287
2010
  flag: [
1288
- f2(
2011
+ f3(
1289
2012
  "components/Features.tsx",
1290
2013
  `import { Heart } from "lucide-react";
1291
2014
  <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
@@ -1293,7 +2016,7 @@ const inter = Inter({ subsets: ["latin"] });`),
1293
2016
  </div>`
1294
2017
  )
1295
2018
  ],
1296
- pass: [f2("components/Features.tsx", `<ul className="divide-y">
2019
+ pass: [f3("components/Features.tsx", `<ul className="divide-y">
1297
2020
  <li className="flex justify-between py-3">Gel manicure <span>\xA332</span></li>
1298
2021
  </ul>`)]
1299
2022
  }
@@ -1308,8 +2031,8 @@ const inter = Inter({ subsets: ["latin"] });`),
1308
2031
  fix: `Keep one gated signature moment per page (STANDARD section 7) and let the rest arrive still. The limit here is ${REVEAL_LIMIT} per scan.`,
1309
2032
  detect: revealHits,
1310
2033
  fixtures: {
1311
- flag: [f2("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
1312
- pass: [f2("app/page.tsx", `<Reveal><Signature /></Reveal>
2034
+ flag: [f3("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
2035
+ pass: [f3("app/page.tsx", `<Reveal><Signature /></Reveal>
1313
2036
  <Block />
1314
2037
  <Block />`)]
1315
2038
  }
@@ -1324,8 +2047,8 @@ const inter = Inter({ subsets: ["latin"] });`),
1324
2047
  fix: `Choose the shape language on purpose (and record it), then use the pill only where it means something. The limit here is ${PILL_LIMIT} per scan.`,
1325
2048
  detect: pillHits,
1326
2049
  fixtures: {
1327
- flag: [f2("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
1328
- pass: [f2("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
2050
+ flag: [f3("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
2051
+ pass: [f3("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
1329
2052
  <a className="rounded-sm px-4">Book</a>`)]
1330
2053
  }
1331
2054
  },
@@ -1339,8 +2062,8 @@ const inter = Inter({ subsets: ["latin"] });`),
1339
2062
  fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
1340
2063
  detect: shadcnHits,
1341
2064
  fixtures: {
1342
- flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f2(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
1343
- pass: [f2("src/components/ui/button.tsx", `export function Button() { return null; }`)]
2065
+ flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f3(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
2066
+ pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
1344
2067
  }
1345
2068
  },
1346
2069
  {
@@ -1354,11 +2077,11 @@ const inter = Inter({ subsets: ["latin"] });`),
1354
2077
  detect: creamHits,
1355
2078
  fixtures: {
1356
2079
  flag: [
1357
- f2("app/globals.css", `body { background-color: #f5efe6; }`),
1358
- f2("app/page.tsx", `<main className="bg-cream text-ink">`),
1359
- f2("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
2080
+ f3("app/globals.css", `body { background-color: #f5efe6; }`),
2081
+ f3("app/page.tsx", `<main className="bg-cream text-ink">`),
2082
+ f3("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
1360
2083
  ],
1361
- pass: [f2("app/globals.css", `body { background-color: #ffffff; }`)]
2084
+ pass: [f3("app/globals.css", `body { background-color: #ffffff; }`)]
1362
2085
  }
1363
2086
  },
1364
2087
  {
@@ -1372,11 +2095,11 @@ const inter = Inter({ subsets: ["latin"] });`),
1372
2095
  detect: italicSerifHits,
1373
2096
  fixtures: {
1374
2097
  flag: [
1375
- f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
1376
- f2("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
1377
- f2("app/globals.css", `.hero h1 { font-style: italic; }`)
2098
+ f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
2099
+ f3("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
2100
+ f3("app/globals.css", `.hero h1 { font-style: italic; }`)
1378
2101
  ],
1379
- pass: [f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
2102
+ pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
1380
2103
  <p><em>Walk-ins</em> welcome.</p>`)]
1381
2104
  }
1382
2105
  },
@@ -1391,12 +2114,12 @@ const inter = Inter({ subsets: ["latin"] });`),
1391
2114
  detect: eyebrowChipHits,
1392
2115
  fixtures: {
1393
2116
  flag: [
1394
- f2("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
2117
+ f3("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
1395
2118
  <h1>Nails in Brackley</h1>`),
1396
- f2("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
2119
+ f3("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
1397
2120
  <h1>Nails</h1>`)
1398
2121
  ],
1399
- pass: [f2("components/Hero.tsx", `<h1>Nails in Brackley</h1>
2122
+ pass: [f3("components/Hero.tsx", `<h1>Nails in Brackley</h1>
1400
2123
  <span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
1401
2124
  }
1402
2125
  },
@@ -1410,9 +2133,9 @@ const inter = Inter({ subsets: ["latin"] });`),
1410
2133
  fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
1411
2134
  detect: iconTileStackHits,
1412
2135
  fixtures: {
1413
- flag: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
2136
+ flag: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
1414
2137
  <div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
1415
- pass: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
2138
+ pass: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
1416
2139
  <p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
1417
2140
  }
1418
2141
  },
@@ -1426,8 +2149,8 @@ const inter = Inter({ subsets: ["latin"] });`),
1426
2149
  fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
1427
2150
  detect: bentoHits,
1428
2151
  fixtures: {
1429
- flag: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
1430
- pass: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
2152
+ flag: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
2153
+ pass: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
1431
2154
  }
1432
2155
  },
1433
2156
  {
@@ -1441,10 +2164,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1441
2164
  detect: glowHits,
1442
2165
  fixtures: {
1443
2166
  flag: [
1444
- f2("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
1445
- f2("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
2167
+ f3("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
2168
+ f3("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
1446
2169
  ],
1447
- pass: [f2("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
2170
+ pass: [f3("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
1448
2171
  }
1449
2172
  },
1450
2173
  {
@@ -1458,13 +2181,13 @@ const inter = Inter({ subsets: ["latin"] });`),
1458
2181
  detect: gridBackgroundHits,
1459
2182
  fixtures: {
1460
2183
  flag: [
1461
- f2(
2184
+ f3(
1462
2185
  "app/globals.css",
1463
2186
  `.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
1464
2187
  ),
1465
- f2("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
2188
+ f3("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
1466
2189
  ],
1467
- pass: [f2("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
2190
+ pass: [f3("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
1468
2191
  }
1469
2192
  },
1470
2193
  {
@@ -1478,10 +2201,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1478
2201
  detect: marqueeHits,
1479
2202
  fixtures: {
1480
2203
  flag: [
1481
- f2("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
1482
- f2("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
2204
+ f3("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
2205
+ f3("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
1483
2206
  ],
1484
- pass: [f2("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
2207
+ pass: [f3("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
1485
2208
  }
1486
2209
  },
1487
2210
  {
@@ -1495,10 +2218,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1495
2218
  detect: thinBorderWideShadowHits,
1496
2219
  fixtures: {
1497
2220
  flag: [
1498
- f2("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
1499
- f2("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
2221
+ f3("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
2222
+ f3("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
1500
2223
  ],
1501
- pass: [f2("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
2224
+ pass: [f3("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
1502
2225
  }
1503
2226
  },
1504
2227
  {
@@ -1512,15 +2235,61 @@ const inter = Inter({ subsets: ["latin"] });`),
1512
2235
  detect: introHits,
1513
2236
  fixtures: {
1514
2237
  flag: [
1515
- f2("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
2238
+ f3("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
1516
2239
  export function IntroCinematic() { return <AnimatePresence /> }`),
1517
- f2("app/page.tsx", `<><Preloader /><Hero /></>`)
2240
+ f3("app/page.tsx", `<><Preloader /><Hero /></>`)
1518
2241
  ],
1519
- pass: [f2("components/Introduction.md", `An introduction to the studio.`)]
2242
+ pass: [f3("components/Introduction.md", `An introduction to the studio.`)]
1520
2243
  }
1521
2244
  }
1522
2245
  ];
1523
2246
 
2247
+ // src/character/house.ts
2248
+ var HOUSE = {
2249
+ "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
2250
+ emoji: { tier: "block", label: "emoji" },
2251
+ "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
2252
+ "no-x-no-y": {
2253
+ tier: "block",
2254
+ label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
2255
+ },
2256
+ "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
2257
+ "ai-phrase": { tier: "block", label: "AI tell" },
2258
+ "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
2259
+ "plain-english": { tier: "block", label: 'use "properly explained"' },
2260
+ buzzword: { tier: "block", label: "forbidden phrase" },
2261
+ "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
2262
+ "review-phrase": { tier: "review", label: "review tier" },
2263
+ "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
2264
+ "stock-phrase": { tier: "review", label: "stock phrase" },
2265
+ "hollow-imperative": { tier: "review", label: "hollow imperative" },
2266
+ "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
2267
+ "staccato-triplet": { tier: "review", label: "staccato triplet" },
2268
+ "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
2269
+ "ing-tail": { tier: "review", label: "empty -ing tail" },
2270
+ "vague-attribution": { tier: "review", label: "unnamed source" },
2271
+ "closing-summary": { tier: "review", label: "closing summary" },
2272
+ "false-range": { tier: "review", label: "false range" },
2273
+ // The first candidate for block: evidence of a pasted chat reply, not a
2274
+ // matter of style. Blocks once its estate hits have been read (ROADMAP.md).
2275
+ "chatbot-residue": { tier: "review", label: "chatbot residue" },
2276
+ "placeholder": { tier: "review", label: "unfilled placeholder" },
2277
+ "question-reveal": { tier: "review", label: "staged reveal" },
2278
+ "inline-label-list": { tier: "review", label: "bold-label bullets" },
2279
+ "phrase-density": { tier: "review", label: "density" },
2280
+ "aphorism-density": { tier: "review", label: "density" },
2281
+ "contraction-scarcity": { tier: "review", label: "density" },
2282
+ "sentence-rhythm": { tier: "review", label: "density" },
2283
+ "repeated-sentence": { tier: "review", label: "repetition" },
2284
+ "heading-shape": { tier: "review", label: "headings" },
2285
+ "heading-echo": { tier: "review", label: "headings" },
2286
+ // Banned only "when vague", per the rules. Too noisy for a sweep.
2287
+ "vague-word": { tier: "explicit", label: "vague" }
2288
+ };
2289
+ function houseRule(id, name = id) {
2290
+ return HOUSE[id] ?? { tier: "review", label: name };
2291
+ }
2292
+
1524
2293
  // src/character/parse.ts
1525
2294
  function lineAt(text, offset) {
1526
2295
  let line = 1;
@@ -1609,15 +2378,16 @@ function makeSnapshot(patch = {}) {
1609
2378
  }
1610
2379
 
1611
2380
  // src/snapshot/fonts.ts
2381
+ var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
1612
2382
  function normaliseFamily(raw) {
1613
2383
  let name = raw.replace(/["']/g, "").trim();
1614
2384
  const next = name.match(/^__(.+?)(?:_Fallback)?_[0-9a-f]{5,8}$/i);
1615
2385
  if (next) name = next[1].replace(/_/g, " ");
1616
2386
  name = name.replace(/\s+Fallback$/i, "").replace(/\s+Variable$/i, "");
2387
+ if (SYSTEM.test(name)) return "system-ui";
1617
2388
  if (!/\s/.test(name) && (/[a-z][A-Z]/.test(name) || /^[a-z]+(?:-[a-z]+)+$/.test(name))) {
1618
2389
  name = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/-/g, " ").replace(/\b([a-z])/g, (c) => c.toUpperCase()).replace(/\bDm\b/, "DM");
1619
2390
  }
1620
- if (/^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|sans-serif|serif|monospace)$/i.test(name)) return "system-ui";
1621
2391
  return name;
1622
2392
  }
1623
2393
  var KNOWN = {
@@ -1668,8 +2438,8 @@ var oklchOf = (value) => parseColour(value)?.oklch ?? null;
1668
2438
  function reflexFonts(list) {
1669
2439
  const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
1670
2440
  return (s) => {
1671
- const total = s.fonts.reduce((n, f3) => n + f3.chars, 0) || 1;
1672
- const display = s.fonts.reduce((n, f3) => n + f3.displayChars, 0) || 1;
2441
+ const total = s.fonts.reduce((n, f4) => n + f4.chars, 0) || 1;
2442
+ const display = s.fonts.reduce((n, f4) => n + f4.displayChars, 0) || 1;
1673
2443
  const hits = [];
1674
2444
  const seen = /* @__PURE__ */ new Set();
1675
2445
  const h1 = s.headings.find((h) => h.level === 1);
@@ -1870,8 +2640,8 @@ var RENDERED_PATHS = {
1870
2640
  };
1871
2641
 
1872
2642
  // src/character/check.ts
1873
- var CATALOGUE_VERSION = "2026.09.3";
1874
- var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS].map(
2643
+ var CATALOGUE_VERSION = "2026.09.6";
2644
+ var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
1875
2645
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
1876
2646
  );
1877
2647
  function tellById(id) {
@@ -1906,7 +2676,8 @@ function toFinding(tell, hit2, texts) {
1906
2676
  line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
1907
2677
  excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
1908
2678
  message: hit2.message,
1909
- fix: tell.fix
2679
+ fix: tell.fix,
2680
+ ...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
1910
2681
  };
1911
2682
  }
1912
2683
  function report(tells, files, run, options) {
@@ -1969,6 +2740,7 @@ function auditSnapshot(snapshot, options = {}) {
1969
2740
  // src/fingerprint/index.ts
1970
2741
  var FINGERPRINT_VERSION = 1;
1971
2742
  var CHROMATIC = 0.03;
2743
+ var MIN_ACCENT_AREA = 2e4;
1972
2744
  var oklch = (value) => {
1973
2745
  const parsed = parseColour(value);
1974
2746
  return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
@@ -1981,7 +2753,7 @@ function accentOf(s) {
1981
2753
  }
1982
2754
  const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
1983
2755
  if (topButton) return oklch(topButton[0]);
1984
- const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
2756
+ const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC && x.w >= MIN_ACCENT_AREA).sort((a, b) => b.w - a.w)[0];
1985
2757
  if (bg) return bg.o;
1986
2758
  const text = s.colours.text.map((t) => ({ o: oklch(t.value), w: t.chars })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
1987
2759
  return text ? text.o : null;
@@ -2084,14 +2856,14 @@ function collectInPage() {
2084
2856
  t.chars += chars;
2085
2857
  text.set(cs.color, t);
2086
2858
  const family = firstFamily(cs.fontFamily);
2087
- const f3 = fonts.get(family) ?? { chars: 0, displayChars: 0, italicChars: 0, weights: /* @__PURE__ */ new Set() };
2859
+ const f4 = fonts.get(family) ?? { chars: 0, displayChars: 0, italicChars: 0, weights: /* @__PURE__ */ new Set() };
2088
2860
  const size = px(cs.fontSize);
2089
2861
  const weight = parseInt(cs.fontWeight, 10) || 400;
2090
- f3.chars += chars;
2091
- if (/^H[1-3]$/.test(el.tagName) || size >= 28) f3.displayChars += chars;
2092
- if (cs.fontStyle === "italic" || cs.fontStyle.startsWith("oblique")) f3.italicChars += chars;
2093
- f3.weights.add(weight);
2094
- fonts.set(family, f3);
2862
+ f4.chars += chars;
2863
+ if (/^H[1-3]$/.test(el.tagName) || size >= 28) f4.displayChars += chars;
2864
+ if (cs.fontStyle === "italic" || cs.fontStyle.startsWith("oblique")) f4.italicChars += chars;
2865
+ f4.weights.add(weight);
2866
+ fonts.set(family, f4);
2095
2867
  sizes.add(Math.round(size));
2096
2868
  weights.add(weight);
2097
2869
  }
@@ -2107,7 +2879,7 @@ function collectInPage() {
2107
2879
  const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
2108
2880
  const bodyBg = getComputedStyle(document.body).backgroundColor;
2109
2881
  const ground = painted(bodyBg) ? bodyBg : painted(htmlBg) ? htmlBg : "rgb(255, 255, 255)";
2110
- const headings = Array.from(document.querySelectorAll("h1, h2, h3")).filter(shown).slice(0, 60).map((h) => {
2882
+ const headings2 = Array.from(document.querySelectorAll("h1, h2, h3")).filter(shown).slice(0, 60).map((h) => {
2111
2883
  const cs = style(h);
2112
2884
  const italic = cs.fontStyle !== "normal";
2113
2885
  const italicPart = !italic && Array.from(h.querySelectorAll("*")).some((c) => style(c).fontStyle !== "normal" && (c.textContent ?? "").trim().length > 0);
@@ -2176,13 +2948,18 @@ function collectInPage() {
2176
2948
  pushBlocks(root, 0);
2177
2949
  const header = document.querySelector("body > header, header");
2178
2950
  const h1 = document.querySelector("h1");
2951
+ const waiting = (c) => {
2952
+ const cs = style(c);
2953
+ const op = parseFloat(cs.opacity);
2954
+ return op < 0.1 || op < 1 && cs.transform !== "none" || c.hasAttribute("data-aos") || /\breveal\b|\bfade-?(?:in|up)\b|\banimate-on-scroll\b/i.test(typeof c.className === "string" ? c.className : "");
2955
+ };
2179
2956
  const hiddenAtLoad = (el) => {
2180
- const check = [el, ...Array.from(el.children).slice(0, 6)];
2181
- return check.some((c) => {
2182
- const cs = style(c);
2183
- const op = parseFloat(cs.opacity);
2184
- return op < 0.1 || op < 1 && cs.transform !== "none" || c.hasAttribute("data-aos") || /\breveal\b|\bfade-?(?:in|up)\b|\banimate-on-scroll\b/i.test(typeof c.className === "string" ? c.className : "");
2185
- });
2957
+ if ([el, ...Array.from(el.children).slice(0, 6)].some(waiting)) return true;
2958
+ const blocks2 = Array.from(el.querySelectorAll("div, article, li, figure, p, h2, h3")).slice(0, 200).filter((d) => {
2959
+ const b = box(d);
2960
+ return b.width > 100 && b.height > 40 && (d.textContent ?? "").trim().length > 12 && style(d).position !== "fixed";
2961
+ }).slice(0, 12);
2962
+ return blocks2.filter(waiting).length >= Math.min(2, blocks2.length) && blocks2.some(waiting);
2186
2963
  };
2187
2964
  const cardsIn = (section) => {
2188
2965
  let best = { cards: 0, iconCards: 0 };
@@ -2318,8 +3095,8 @@ function collectInPage() {
2318
3095
  pageHeight: document.documentElement.scrollHeight,
2319
3096
  ground,
2320
3097
  colours: { backgrounds: toList(bg).slice(0, 60), text: toList(text).sort((a, b) => b.chars - a.chars).slice(0, 40) },
2321
- fonts: Array.from(fonts, ([family, f3]) => ({ family, chars: f3.chars, displayChars: f3.displayChars, italicChars: f3.italicChars, weights: nums(f3.weights) })).sort((a, b) => b.chars - a.chars),
2322
- headings,
3098
+ fonts: Array.from(fonts, ([family, f4]) => ({ family, chars: f4.chars, displayChars: f4.displayChars, italicChars: f4.italicChars, weights: nums(f4.weights) })).sort((a, b) => b.chars - a.chars),
3099
+ headings: headings2,
2323
3100
  controls,
2324
3101
  sections,
2325
3102
  effects,
@@ -2381,19 +3158,38 @@ async function snapshotPage(page, url, options = {}) {
2381
3158
  motion: { ...data.motion, introOverlay: early && !late }
2382
3159
  };
2383
3160
  }
2384
- async function snapshotUrl(url, options = {}) {
2385
- const pw = await loadPlaywright();
3161
+ async function launch(pw, url, options) {
2386
3162
  const env = globalThis.process?.env ?? {};
2387
3163
  const executablePath = options.executablePath ?? env.CRAFT_CHROMIUM ?? void 0;
2388
3164
  const host = new URL(url).hostname;
2389
3165
  const noProxy = [`localhost`, `127.0.0.1`, `::1`, ...(env.NO_PROXY ?? env.no_proxy ?? "").split(",")].map((h) => h.trim().replace(/^\*?\./, "")).filter(Boolean);
2390
3166
  const direct = /^(?:localhost|127\.|\[?::1\]?$)/.test(host) || noProxy.some((h) => host === h || host.endsWith(`.${h}`));
2391
3167
  const proxy = direct ? void 0 : env.HTTPS_PROXY ?? env.https_proxy;
2392
- const browser = await pw.chromium.launch({ headless: true, ...executablePath ? { executablePath } : {}, ...proxy ? { proxy: { server: proxy } } : {} });
3168
+ return pw.chromium.launch({ headless: true, ...executablePath ? { executablePath } : {}, ...proxy ? { proxy: { server: proxy } } : {} });
3169
+ }
3170
+ async function snapshotUrl(url, options = {}) {
3171
+ const [result] = await snapshotUrls([url], options);
3172
+ if (!result.snapshot) throw new Error(result.error);
3173
+ return result.snapshot;
3174
+ }
3175
+ async function snapshotUrls(urls, options = {}) {
3176
+ if (urls.length === 0) return [];
3177
+ const pw = await loadPlaywright();
3178
+ const browser = await launch(pw, urls[0], options);
3179
+ const out = [];
2393
3180
  try {
2394
- const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
2395
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
2396
- return await snapshotPage(page, url, options);
3181
+ for (const url of urls) {
3182
+ const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
3183
+ try {
3184
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
3185
+ out.push({ url, snapshot: await snapshotPage(page, url, options) });
3186
+ } catch (error) {
3187
+ out.push({ url, error: error.message });
3188
+ } finally {
3189
+ await page.close?.();
3190
+ }
3191
+ }
3192
+ return out;
2397
3193
  } finally {
2398
3194
  await browser.close();
2399
3195
  }
@@ -2408,5 +3204,6 @@ export {
2408
3204
  loadPlaywright,
2409
3205
  overlayInPage,
2410
3206
  snapshotPage,
2411
- snapshotUrl
3207
+ snapshotUrl,
3208
+ snapshotUrls
2412
3209
  };