@domandigital/craft 0.7.0 → 0.8.1

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.
@@ -338,15 +338,22 @@ var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
338
338
  function allText(ctx) {
339
339
  return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
340
340
  }
341
+ var LINK = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+|\]\([^)\s]+\)/gi;
342
+ function linkSpans(text) {
343
+ return [...text.matchAll(LINK)].map((m) => [m.index ?? 0, (m.index ?? 0) + m[0].length]);
344
+ }
345
+ var insideLink = (spans, at) => spans.some(([a, b]) => at >= a && at < b);
341
346
  function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
342
347
  return (ctx) => {
343
348
  const hits = [];
344
349
  for (const block of source(ctx)) {
345
350
  const spans = [];
351
+ const links = linkSpans(block.text);
346
352
  for (const pattern of patterns) {
347
353
  const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
348
354
  for (const m of block.text.matchAll(global)) {
349
355
  if (skip(block, m[0], m.index ?? 0)) continue;
356
+ if (insideLink(links, m.index ?? 0)) continue;
350
357
  spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
351
358
  }
352
359
  }
@@ -387,7 +394,14 @@ var AI_WORDS = [
387
394
  "moreover",
388
395
  "navigating",
389
396
  "ever-evolving",
390
- "unparalleled"
397
+ "unparalleled",
398
+ // Measured: Kobak et al. 2024 (arXiv 2406.07016) found these 10 to 14 times
399
+ // above their expected rate in post-ChatGPT abstracts. Academic, so warn only.
400
+ "underscore",
401
+ "underscores",
402
+ "underscoring",
403
+ "showcasing",
404
+ "pivotal"
391
405
  ];
392
406
  var STOCK_PHRASES = [
393
407
  "in today's digital age",
@@ -401,7 +415,11 @@ var STOCK_PHRASES = [
401
415
  "curated",
402
416
  "crafted with care",
403
417
  "tailored to your",
404
- "peace of mind"
418
+ "peace of mind",
419
+ "so here's",
420
+ "in an era where",
421
+ "the future looks bright",
422
+ "only time will tell"
405
423
  ];
406
424
  var AI_PHRASES = [
407
425
  "let's dive in",
@@ -534,7 +552,7 @@ function lineAround(text, index) {
534
552
  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);
535
553
  var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
536
554
  var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
537
- var EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}]+/u;
555
+ var EMOJI = /(?:[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u2728\u2705\u274C\u274E\u2757\u2753\u26A1\u2B50\u2764]|[\u2600-\u27BF]\uFE0F)+/u;
538
556
  var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
539
557
  function badgeHits(ctx) {
540
558
  const hits = [];
@@ -577,10 +595,16 @@ var COPY_TELLS = [
577
595
  surface: "copy",
578
596
  why: "Phrases every generated services page uses. They fill space where a fact should be.",
579
597
  fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
580
- detect: phrases([wordList(STOCK_PHRASES)], quoted),
598
+ detect: phrases([wordList(STOCK_PHRASES), /\b(?:in|on|along)\s+(?:its|their|his|her)\s+journey\b/i], quoted),
581
599
  fixtures: {
582
- flag: [f("content/home.md", "Take your plumbing to the next level with peace of mind.")],
583
- pass: [f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley.")]
600
+ flag: [
601
+ f("content/home.md", "Take your plumbing to the next level with peace of mind."),
602
+ f("content/home.md", "We meet each practice where it is in its journey.")
603
+ ],
604
+ pass: [
605
+ f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley."),
606
+ f("content/home.md", "We mapped the patient journey from booking to aftercare.")
607
+ ]
584
608
  }
585
609
  },
586
610
  {
@@ -605,7 +629,11 @@ var COPY_TELLS = [
605
629
  f("content/home.md", "In today's rapidly evolving market, we help."),
606
630
  f("content/home.md", "Whether you're a landlord or a tenant, call us.")
607
631
  ],
608
- pass: [f("content/home.md", "Book in three taps. We confirm by text within the hour.")]
632
+ pass: [
633
+ f("content/home.md", "Book in three taps. We confirm by text within the hour."),
634
+ f("content/home.md", "Book at https://example.com/seamless-booking today."),
635
+ f("content/home.md", "[Book a slot](/seamless-booking) today.")
636
+ ]
609
637
  }
610
638
  },
611
639
  {
@@ -754,8 +782,14 @@ var COPY_TELLS = [
754
782
  fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
755
783
  detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
756
784
  fixtures: {
757
- flag: [f("content/home.md", "New treatments this month \u2728\u{1F680}"), f("app/cta.tsx", `export const label = "Book \u{1F680}";`)],
758
- pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192")]
785
+ flag: [
786
+ f("content/home.md", "New treatments this month \u2728\u{1F680}"),
787
+ f("app/cta.tsx", `export const label = "Book \u{1F680}";`),
788
+ f("content/home.md", "New treatments this month \u2728"),
789
+ f("content/home.md", "Same-day callouts \u2705"),
790
+ f("content/home.md", "Open Sundays \u2600\uFE0F")
791
+ ],
792
+ pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192"), f("content/home.md", "\u2713 Gas Safe registered")]
759
793
  }
760
794
  },
761
795
  {
@@ -853,6 +887,698 @@ var COPY_TELLS = [
853
887
  }
854
888
  }
855
889
  ];
890
+ 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;
891
+ var VAGUE_ATTRIBUTION = [
892
+ /\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,
893
+ /\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
894
+ /\bit\s+is\s+(?:widely|generally|commonly)\s+(?:known|accepted|believed|agreed)\b/i
895
+ ];
896
+ function sourced(block, _match, index) {
897
+ const before = block.text.slice(Math.max(0, index - 40), index);
898
+ if (/(?:\b[A-Z][\w&]*[’']s|\b[A-Z]{2,}[a-z]*)\s+$/.test(before)) return true;
899
+ const rest = block.text.slice(index, index + 400);
900
+ const end = rest.search(/[.!?](?:\s|$)/);
901
+ const sentence = rest.slice(0, end === -1 ? rest.length : end + 16);
902
+ return /\[\^\w+\]|\]\(https?:|\(source|according to [A-Z]|\bper [A-Z]/.test(sentence);
903
+ }
904
+ var CLOSING_SUMMARY = /(?:^|[.!?]\s+|\n\s*|>\s*)(?:Overall|In conclusion|In summary|To sum up|All in all|In short|To summari[sz]e),/;
905
+ var FALSE_RANGE = [
906
+ /\bfrom\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+alike\b/i,
907
+ /\b(?:everyone|anyone)\s+from\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s*){1,4}/i
908
+ ];
909
+ var RESIDUE = [
910
+ /\bas an AI(?:\s+language)?\s+(?:model|assistant)\b/i,
911
+ /(?:^|\n|>)\s*(?:Certainly|Sure|Absolutely|Of course|Great question)[!,.]\s+(?:Here(?:['’]s| is| are)|Below)\b/i,
912
+ /\bhere(?:['’]s| is)\s+(?:a|an|the|your)\s+(?:revised|rewritten|updated|polished|refined|improved|draft|more concise)\s+(?:version|draft|copy)\b/i,
913
+ /\blet me know if you(?:['’]d| would) like me to\b/i,
914
+ /\b(?:my|the) (?:knowledge|training) cut-?off\b|\bas of my last (?:update|training)\b/i,
915
+ /\bI hope this helps\b/i,
916
+ // Citation tokens a chat tool leaves in copied text.
917
+ // A run of them is one paste: "citeturn11search1turn10view0" is one finding.
918
+ // ChatGPT wraps them in invisible private-use characters (U+E200 to U+E202),
919
+ // which survive a copy and paste into a CMS: found live, 2026-09-23.
920
+ /[\uE200-\uE2FF]*(?:cite)?[\uE200-\uE2FF]*(?:turn\d+(?:search|news|view|fetch|file|image)\d+[\uE200-\uE2FF]*)+/,
921
+ /:?contentReference\[oaicite:\d+\](?:\{index=\d+\})?/,
922
+ // Entity markers from the same paste: entity["company","Bark","services
923
+ // marketplace"] shows on the page where the plain name "Bark" belongs.
924
+ /[\uE200-\uE2FF]*entity[\uE200-\uE2FF]*\["[a-z_]+"\s*,\s*"[^"\n]{1,80}"(?:\s*,\s*"[^"\n]{0,160}")*\][\uE200-\uE2FF]*/,
925
+ /【\d+(?:[:†][^】]{0,40})?】/
926
+ ];
927
+ var AI_UTM = /utm_source=(?:chatgpt\.com|chat\.openai\.com|openai|perplexity(?:\.ai)?|claude\.ai|copilot(?:\.microsoft\.com)?|gemini(?:\.google\.com)?)\b/gi;
928
+ function residueHits(ctx) {
929
+ const hits = phrases(RESIDUE, quoted, allText)(ctx);
930
+ for (const file of ctx.files) {
931
+ 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]}"` });
932
+ }
933
+ return hits;
934
+ }
935
+ var PLACEHOLDER = [
936
+ /\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n]{0,40}\](?!\()/i,
937
+ /\blorem ipsum\b/i,
938
+ /\bTODO_PLACEHOLDER\b/
939
+ ];
940
+ var PLACEHOLDER_ELEMENT = />\s*(\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n<]{0,40}\])\s*</gi;
941
+ function insideQuotes(block, _match, index) {
942
+ const lineStart = block.text.lastIndexOf("\n", index - 1) + 1;
943
+ const before = block.text.slice(lineStart, index);
944
+ const straight = (before.match(/"/g) ?? []).length;
945
+ const curly = (before.match(/“/g) ?? []).length - (before.match(/”/g) ?? []).length;
946
+ return straight % 2 === 1 || curly > 0;
947
+ }
948
+ function placeholderHits(ctx) {
949
+ const hits = phrases(PLACEHOLDER, quoted, allText, insideQuotes)(ctx);
950
+ const seen = new Set(hits.map((h) => `${h.path}:${h.offset}`));
951
+ for (const file of ctx.files) {
952
+ if (isProsePath(file.path)) continue;
953
+ for (const m of file.text.matchAll(PLACEHOLDER_ELEMENT)) {
954
+ const offset = (m.index ?? 0) + m[0].indexOf(m[1]);
955
+ if (!seen.has(`${file.path}:${offset}`)) hits.push({ path: file.path, offset, message: quoted(m[1]) });
956
+ }
957
+ }
958
+ return hits;
959
+ }
960
+ var QUESTION_REVEAL = [
961
+ /\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]/,
962
+ /\?\s+It means\b/
963
+ ];
964
+ function inlineLabelHits(ctx) {
965
+ const hits = [];
966
+ for (const file of ctx.files) {
967
+ if (!isProsePath(file.path)) continue;
968
+ const lines = file.text.split("\n");
969
+ let run = [];
970
+ let offset = 0;
971
+ const flush = () => {
972
+ 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` });
973
+ run = [];
974
+ };
975
+ for (const line of lines) {
976
+ const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
977
+ if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
978
+ else flush();
979
+ offset += line.length + 1;
980
+ }
981
+ flush();
982
+ }
983
+ return hits;
984
+ }
985
+ var RESEARCH_TELLS = [
986
+ {
987
+ id: "chatbot-residue",
988
+ name: "Chatbot residue",
989
+ generation: 1,
990
+ severity: "warn",
991
+ surface: "copy",
992
+ 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.",
993
+ fix: "Delete the residue. Then read the whole passage again, because the rest of it came from the same paste.",
994
+ detect: residueHits,
995
+ fixtures: {
996
+ flag: [
997
+ f("content/home.md", "Certainly! Here's a revised version of your home page."),
998
+ f("content/home.md", "As an AI language model, I cannot visit your salon."),
999
+ f("content/home.md", "Let me know if you'd like me to shorten it."),
1000
+ f("content/home.md", "Prices start at \xA340 citeturn0search3 for a full set."),
1001
+ f("content/home.md", "Prices start at \xA340. citeturn11search1turn10view0turn2view3"),
1002
+ f("content/home.md", "Prices start at \xA340. \uE200cite\uE202turn11search1\uE202turn10view0\uE201"),
1003
+ f("content/home.md", "Prices start at \xA340 :contentReference[oaicite:2]{index=2} for a full set."),
1004
+ f("content/home.md", "Prices start at \xA340\u30104\u2020source\u3011 for a full set."),
1005
+ f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=chatgpt.com">Read the guide</a>;`),
1006
+ f("content/home.md", "Here's a more concise version for the hero.")
1007
+ ],
1008
+ pass: [
1009
+ f("content/home.md", "Certainly the busiest week of the year: book early."),
1010
+ f("content/home.md", "Here's the price list for 2026."),
1011
+ f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=newsletter">Read the guide</a>;`)
1012
+ ]
1013
+ }
1014
+ },
1015
+ {
1016
+ id: "placeholder",
1017
+ name: "Unfilled placeholder",
1018
+ generation: 1,
1019
+ severity: "warn",
1020
+ surface: "copy",
1021
+ 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.",
1022
+ fix: "Fill it from the client or the proof source, or remove the element. Never invent the missing fact to close it.",
1023
+ detect: placeholderHits,
1024
+ fixtures: {
1025
+ flag: [
1026
+ f("content/home.md", "Rated 5 stars by [Insert client name] and others."),
1027
+ f("content/home.md", "Lorem ipsum dolor sit amet."),
1028
+ f("content/home.md", "Rated TODO_PLACEHOLDER: google_rating from our reviews."),
1029
+ f("app/card.tsx", `export const Quote = () => <blockquote>[Insert testimonial here]</blockquote>;`)
1030
+ ],
1031
+ pass: [
1032
+ f("content/home.md", "Manage bookings in [your account](/account)."),
1033
+ f("content/guide.md", 'After 90 days, check your ranking for "[your trade] [your town]" on Google.'),
1034
+ f("content/guide.md", "Send a short message: \u201CThanks for choosing [Business Name]! Leave us a review.\u201D"),
1035
+ f("content/home.md", "Rated 4.9 from 212 Google reviews.")
1036
+ ]
1037
+ }
1038
+ },
1039
+ {
1040
+ id: "question-reveal",
1041
+ name: "Staged reveal",
1042
+ generation: 2,
1043
+ severity: "warn",
1044
+ surface: "copy",
1045
+ 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.",
1046
+ fix: "State the answer: 'Most customers book again within six weeks.'",
1047
+ detect: phrases(QUESTION_REVEAL, quoted),
1048
+ fixtures: {
1049
+ 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.")],
1050
+ 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.")]
1051
+ }
1052
+ },
1053
+ {
1054
+ id: "inline-label-list",
1055
+ name: "Bold-label bullets",
1056
+ generation: 2,
1057
+ severity: "warn",
1058
+ surface: "copy",
1059
+ 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.",
1060
+ 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.",
1061
+ detect: inlineLabelHits,
1062
+ fixtures: {
1063
+ flag: [f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n- **Support:** Help when you need it\n")],
1064
+ pass: [
1065
+ f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
1066
+ f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
1067
+ 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")
1068
+ ]
1069
+ }
1070
+ },
1071
+ {
1072
+ id: "ing-tail",
1073
+ name: "Empty -ing tail",
1074
+ generation: 2,
1075
+ severity: "warn",
1076
+ surface: "copy",
1077
+ 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.",
1078
+ fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
1079
+ detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
1080
+ fixtures: {
1081
+ flag: [
1082
+ f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
1083
+ f("app/about.tsx", `export const About = () => <p>We have served Brackley since 2009, highlighting our commitment to the town.</p>;`)
1084
+ ],
1085
+ pass: [
1086
+ f("content/home.md", "We open at 7am, so you can drop the car off before work."),
1087
+ f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
1088
+ ]
1089
+ }
1090
+ },
1091
+ {
1092
+ id: "vague-attribution",
1093
+ name: "Unnamed source",
1094
+ generation: 1,
1095
+ severity: "warn",
1096
+ surface: "copy",
1097
+ 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.",
1098
+ fix: "Name the source and the number ('A 2023 Which? survey of 2,000 drivers found...') or cut the claim.",
1099
+ detect: phrases(VAGUE_ATTRIBUTION, quoted, (ctx) => ctx.blocks, sourced),
1100
+ fixtures: {
1101
+ flag: [
1102
+ f("content/home.md", "Studies show that regular servicing saves money."),
1103
+ f("content/home.md", "Most experts agree a boiler lasts fifteen years."),
1104
+ f("content/home.md", "It is widely known that gel damages nails.")
1105
+ ],
1106
+ pass: [
1107
+ f("content/home.md", "A 2023 Which? survey of 2,000 owners found serviced boilers failed half as often."),
1108
+ f("content/home.md", "BrightLocal's research shows that reviews drive 20% of local ranking."),
1109
+ f("content/home.md", "Research shows 62% of consumers avoid a business with wrong hours.[^7]"),
1110
+ f("content/home.md", "Our customers rate us 4.9 from 212 Google reviews.")
1111
+ ]
1112
+ }
1113
+ },
1114
+ {
1115
+ id: "closing-summary",
1116
+ name: "Closing summary",
1117
+ generation: 1,
1118
+ severity: "warn",
1119
+ surface: "copy",
1120
+ why: "'Overall,', 'In conclusion,', 'In short,': a paragraph that announces it is summing up, then restates what the reader has just read.",
1121
+ 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'.",
1122
+ detect: phrases([CLOSING_SUMMARY], (m) => quoted(m.replace(/^[.!?>\s]+/, ""))),
1123
+ fixtures: {
1124
+ flag: [
1125
+ f("content/home.md", "We rewire, test and certify.\n\nOverall, we are the right choice for your home."),
1126
+ f("content/home.md", "Prices start at \xA340. In conclusion, we offer great value.")
1127
+ ],
1128
+ pass: [f("content/home.md", "We rewire, test and certify.\n\nBook a survey and we'll quote within two days.")]
1129
+ }
1130
+ },
1131
+ {
1132
+ id: "false-range",
1133
+ name: "False range",
1134
+ generation: 1,
1135
+ severity: "warn",
1136
+ surface: "copy",
1137
+ 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.",
1138
+ fix: "Say who you actually serve, or cut it. A real range is fine: 'from 9am to 5pm', 'from small repairs to full rewires'.",
1139
+ detect: phrases(FALSE_RANGE, quoted),
1140
+ fixtures: {
1141
+ flag: [
1142
+ f("content/home.md", "We help clients from first-time buyers to seasoned investors alike."),
1143
+ f("content/home.md", "Everyone from students to retirees loves our cakes.")
1144
+ ],
1145
+ pass: [
1146
+ f("content/home.md", "Open from 9am to 5pm, Monday to Saturday."),
1147
+ f("content/home.md", "We take on everything from small repairs to full rewires.")
1148
+ ]
1149
+ }
1150
+ }
1151
+ ];
1152
+
1153
+ // src/character/tells/density.ts
1154
+ var DOCUMENT = /\.(md|markdown|mdx|txt|html?)$/i;
1155
+ var MIN_WORDS = 400;
1156
+ var WORD = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
1157
+ var countWords = (text) => (text.match(WORD) ?? []).length;
1158
+ function documents(ctx) {
1159
+ const byPath = /* @__PURE__ */ new Map();
1160
+ for (const block of ctx.blocks) {
1161
+ if (!DOCUMENT.test(block.path) || block.literal) continue;
1162
+ const list = byPath.get(block.path) ?? [];
1163
+ list.push(block);
1164
+ byPath.set(block.path, list);
1165
+ }
1166
+ const docs = [];
1167
+ for (const [path, blocks] of byPath) {
1168
+ const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
1169
+ const words = countWords(text);
1170
+ if (words >= MIN_WORDS) docs.push({ path, blocks, words });
1171
+ }
1172
+ return docs;
1173
+ }
1174
+ var withoutHeadings = (text) => text.replace(/^\s{0,3}#{1,6}\s.*$/gm, "");
1175
+ var allowed = (doc2, floor, per1000) => Math.max(floor, Math.floor(doc2.words * per1000 / 1e3));
1176
+ var thousands = (n) => n.toLocaleString("en-GB");
1177
+ function sentences(doc2) {
1178
+ const out = [];
1179
+ for (const block of doc2.blocks) {
1180
+ const text = withoutHeadings(block.text);
1181
+ for (const para of text.matchAll(/[^\n]+(?:\n(?!\s*\n)[^\n]+)*/g)) {
1182
+ const body = para[0];
1183
+ const start = para.index ?? 0;
1184
+ for (const m of body.matchAll(/[^.!?]+[.!?]+["'’”)]*|[^.!?]+$/g)) {
1185
+ const raw = m[0];
1186
+ const trimmed = raw.trim();
1187
+ if (countWords(trimmed) === 0) continue;
1188
+ const lead = raw.length - raw.trimStart().length;
1189
+ out.push({ text: trimmed.replace(/\s+/g, " "), offset: block.offset + start + (m.index ?? 0) + lead });
1190
+ }
1191
+ }
1192
+ }
1193
+ return out;
1194
+ }
1195
+ var RATES = [
1196
+ { label: "rather than", pattern: /\brather\s+than\b/i, floor: 4, per1000: 0.9, why: "the document's one move" },
1197
+ { label: "instead of", pattern: /\binstead\s+of\b/i, floor: 4, per1000: 0.7, why: "the reflex swap once 'rather than' is edited out" },
1198
+ { label: "actually", pattern: /\bactually\b/i, floor: 2, per1000: 0.35, why: "filler, the sentence means the same without it" },
1199
+ { label: "properly", pattern: /\bproperly\b/i, floor: 3, per1000: 0.4, why: "filler, the sentence means the same without it" },
1200
+ { label: "genuinely", pattern: /\bgenuinely\b/i, floor: 1, per1000: 0.2, why: "reaching for sincerity: one is emphasis, two is a tic" },
1201
+ {
1202
+ label: "the single ...est",
1203
+ pattern: /\bthe\s+single\s+(?:\w+est|most|\w+\s+most)\b/i,
1204
+ floor: 1,
1205
+ per1000: 0.15,
1206
+ why: "if three things are the single most important, none is"
1207
+ },
1208
+ { 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" },
1209
+ { label: "Every opener", pattern: /^Every\b/, opener: true, floor: 4, per1000: 0.6, why: "the same sweeping opener, again" },
1210
+ {
1211
+ label: "Worth noting opener",
1212
+ pattern: /^(?:Worth\s+(?:knowing|noting|saying|mentioning|remembering)|It(?:['’]s|\s+is)\s+worth\s+(?:knowing|noting|saying|mentioning|remembering))\b/,
1213
+ opener: true,
1214
+ floor: 3,
1215
+ per1000: 0.5,
1216
+ why: "a hedge that lets a sentence start without committing"
1217
+ },
1218
+ {
1219
+ label: "negation-led opener",
1220
+ pattern: /^(?:None\s+of|Not\s+one|Not\s+a\s+single|Nothing|No\s+one|Nobody|Never)\b/,
1221
+ opener: true,
1222
+ floor: 5,
1223
+ per1000: 1,
1224
+ why: "each earns its place once or twice; at twenty the document has one move"
1225
+ },
1226
+ {
1227
+ label: "participle triad",
1228
+ pattern: /\b[a-z]{3,}ed,\s+[a-z]{3,}ed,?\s+and\s+[a-z]{3,}ed\b/i,
1229
+ floor: 3,
1230
+ per1000: 0.6,
1231
+ why: "'researched, written and referenced' is fine once and a cadence in bulk"
1232
+ }
1233
+ ];
1234
+ function rateHits(ctx) {
1235
+ const hits = [];
1236
+ for (const doc2 of documents(ctx)) {
1237
+ const list = sentences(doc2);
1238
+ for (const rate of RATES) {
1239
+ const found = [];
1240
+ const global = new RegExp(rate.pattern.source, rate.pattern.flags.replace("g", "") + "g");
1241
+ for (const s of list) {
1242
+ if (rate.opener) {
1243
+ if (rate.pattern.test(s.text)) found.push(s);
1244
+ } else {
1245
+ const n = (s.text.match(global) ?? []).length;
1246
+ for (let k = 0; k < n; k += 1) found.push(s);
1247
+ }
1248
+ }
1249
+ const limit = allowed(doc2, rate.floor, rate.per1000);
1250
+ if (found.length > limit) {
1251
+ hits.push({
1252
+ path: doc2.path,
1253
+ offset: found[limit].offset,
1254
+ message: `"${rate.label}" ${found.length} times in ${thousands(doc2.words)} words (allows ${limit}): ${rate.why}`,
1255
+ excerpt: found[limit].text.slice(0, 120)
1256
+ });
1257
+ }
1258
+ }
1259
+ }
1260
+ return hits;
1261
+ }
1262
+ function aphorismHits(ctx) {
1263
+ const hits = [];
1264
+ for (const doc2 of documents(ctx)) {
1265
+ const found = [];
1266
+ for (const block of doc2.blocks) {
1267
+ let at = 0;
1268
+ for (const line of withoutHeadings(block.text).split("\n")) {
1269
+ const text = line.trim().replace(/^[-*>]\s+/, "");
1270
+ const parts2 = text.match(/[^.!?]+[.!?]+/g) ?? [];
1271
+ if (parts2.length === 2 && parts2.join("").length >= text.length - 1 && parts2.every((p) => countWords(p) >= 2 && countWords(p) <= 7)) {
1272
+ found.push({ text, offset: block.offset + at + line.indexOf(text.charAt(0)) });
1273
+ }
1274
+ at += line.length + 1;
1275
+ }
1276
+ }
1277
+ const limit = allowed(doc2, 5, 1.2);
1278
+ if (found.length > limit) {
1279
+ hits.push({
1280
+ path: doc2.path,
1281
+ offset: found[limit].offset,
1282
+ message: `${found.length} two-line aphorisms in ${thousands(doc2.words)} words (allows ${limit}). Keep the one that lands hardest`,
1283
+ excerpt: found[limit].text
1284
+ });
1285
+ }
1286
+ }
1287
+ return hits;
1288
+ }
1289
+ 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;
1290
+ function contractionHits(ctx) {
1291
+ const hits = [];
1292
+ for (const doc2 of documents(ctx)) {
1293
+ if (doc2.words < 800) continue;
1294
+ const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
1295
+ const count2 = (text.match(CONTRACTION) ?? []).length;
1296
+ const rate = count2 * 1e3 / doc2.words;
1297
+ if (rate < 1) {
1298
+ const first = sentences(doc2)[0];
1299
+ hits.push({
1300
+ path: doc2.path,
1301
+ offset: first?.offset ?? 0,
1302
+ message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
1303
+ });
1304
+ }
1305
+ }
1306
+ return hits;
1307
+ }
1308
+ function rhythmHits(ctx) {
1309
+ const hits = [];
1310
+ for (const doc2 of documents(ctx)) {
1311
+ const list = sentences(doc2);
1312
+ if (list.length < 40) continue;
1313
+ const lengths = list.map((s) => countWords(s.text));
1314
+ const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
1315
+ const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
1316
+ const cv = sd / mean;
1317
+ if (cv < 0.42) {
1318
+ hits.push({
1319
+ path: doc2.path,
1320
+ offset: list[0].offset,
1321
+ message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
1322
+ });
1323
+ }
1324
+ }
1325
+ return hits;
1326
+ }
1327
+ var normal = (s) => s.toLowerCase().replace(/[’]/g, "'").match(/[a-z0-9£$€'%]+/g) ?? [];
1328
+ function repeatHits(ctx) {
1329
+ const hits = [];
1330
+ const RUN = 9;
1331
+ for (const doc2 of documents(ctx)) {
1332
+ const whole = /* @__PURE__ */ new Map();
1333
+ const runs = /* @__PURE__ */ new Map();
1334
+ for (const s of sentences(doc2)) {
1335
+ const words = normal(s.text);
1336
+ if (words.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
1337
+ const key = words.join(" ");
1338
+ const earlier = whole.get(key);
1339
+ if (earlier) {
1340
+ hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words.length} words)`, excerpt: s.text.slice(0, 120) });
1341
+ continue;
1342
+ }
1343
+ whole.set(key, s);
1344
+ let reported = false;
1345
+ for (let i = 0; i + RUN <= words.length && !reported; i += 1) {
1346
+ const run = words.slice(i, i + RUN).join(" ");
1347
+ const seen = runs.get(run);
1348
+ if (seen && seen.key !== key) {
1349
+ 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) });
1350
+ reported = true;
1351
+ } else if (!seen) {
1352
+ runs.set(run, { sentence: s, key });
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1357
+ return hits;
1358
+ }
1359
+ function headings(file) {
1360
+ const out = [];
1361
+ for (const m of file.text.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)) {
1362
+ out.push({ text: m[1], offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
1363
+ }
1364
+ for (const m of file.text.matchAll(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/gi)) {
1365
+ const text = m[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
1366
+ if (text) out.push({ text, offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
1367
+ }
1368
+ return out.sort((a, b) => a.offset - b.offset);
1369
+ }
1370
+ function longFiles(ctx) {
1371
+ const docs = new Map(documents(ctx).map((d) => [d.path, d]));
1372
+ return ctx.files.filter((f4) => docs.has(f4.path)).map((file) => ({ file, doc: docs.get(file.path) }));
1373
+ }
1374
+ var HEADING_SHAPES = [
1375
+ ["X, and Y", /^[^,]{2,}, and [^,]{2,}$/i],
1376
+ ["X, because Y", /^[^,]{2,}, because [^,]{2,}$/i],
1377
+ ["comma list", /^[A-Z][\w'’-]*(?:, [\w'’-]+){2,}(?:,? (?:and|or) [\w'’-]+)?\.?$/]
1378
+ ];
1379
+ function headingShapeHits(ctx) {
1380
+ const hits = [];
1381
+ for (const { file, doc: doc2 } of longFiles(ctx)) {
1382
+ const found = [];
1383
+ for (const heading of headings(file)) {
1384
+ const shape = HEADING_SHAPES.find(([, re]) => re.test(heading.text));
1385
+ if (shape) found.push({ heading, shape: shape[0] });
1386
+ }
1387
+ const limit = allowed(doc2, 3, 0.5);
1388
+ if (found.length > limit) {
1389
+ hits.push({
1390
+ path: file.path,
1391
+ offset: found[limit].heading.offset,
1392
+ message: `${found.length} headings share one shape (${[...new Set(found.map((f4) => f4.shape))].join(", ")}), allows ${limit}`,
1393
+ excerpt: found[limit].heading.text
1394
+ });
1395
+ }
1396
+ }
1397
+ return hits;
1398
+ }
1399
+ function headingEchoHits(ctx) {
1400
+ const hits = [];
1401
+ for (const { file } of longFiles(ctx)) {
1402
+ for (const heading of headings(file)) {
1403
+ const words = normal(heading.text);
1404
+ if (words.length < 4) continue;
1405
+ const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
1406
+ const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words.length);
1407
+ if (opening.join(" ") === words.join(" ")) {
1408
+ hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
1409
+ }
1410
+ }
1411
+ }
1412
+ return hits;
1413
+ }
1414
+ 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(" ");
1415
+ function filler(words, seed = 1, options = {}) {
1416
+ const { contractions = true, uniform } = options;
1417
+ const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
1418
+ let state = seed;
1419
+ const next = (n) => {
1420
+ state = (state * 1103515245 + 12345) % 2147483648;
1421
+ return state % n;
1422
+ };
1423
+ const out = [];
1424
+ let count2 = 0;
1425
+ let i = 0;
1426
+ while (count2 < words) {
1427
+ const length = uniform ?? shape[i % shape.length];
1428
+ const s = [];
1429
+ for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
1430
+ if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
1431
+ s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
1432
+ out.push(`${s.join(" ")}.`);
1433
+ count2 += s.length;
1434
+ i += 1;
1435
+ }
1436
+ const paragraphs = [];
1437
+ for (let p = 0; p < out.length; p += 4) paragraphs.push(out.slice(p, p + 4).join(" "));
1438
+ return paragraphs.join("\n\n");
1439
+ }
1440
+ var f2 = (path, text) => ({ path, text });
1441
+ var doc = (extra, seed = 1) => `${filler(520, seed)}
1442
+
1443
+ ${extra}
1444
+ `;
1445
+ var times = (n, sentence) => Array.from({ length: n }, (_, i) => sentence(i)).join(" ");
1446
+ var DENSITY_TELLS = [
1447
+ {
1448
+ id: "phrase-density",
1449
+ name: "Leaned-on phrase",
1450
+ generation: 2,
1451
+ severity: "warn",
1452
+ surface: "copy",
1453
+ 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.",
1454
+ 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.",
1455
+ detect: rateHits,
1456
+ fixtures: {
1457
+ flag: [
1458
+ f2("content/proposal.md", doc(times(6, (i) => `We fitted boiler ${i} rather than patching it.`))),
1459
+ f2("content/proposal.md", doc(times(6, (i) => `We sealed roof ${i} instead of patching it.`))),
1460
+ f2("content/proposal.md", doc(times(4, (i) => `Room ${i} actually needed a new valve.`))),
1461
+ f2("content/proposal.md", doc(times(5, (i) => `Gate ${i} was properly hung.`))),
1462
+ f2("content/proposal.md", doc(times(3, (i) => `Kitchen ${i} was genuinely tidy.`))),
1463
+ f2("content/proposal.md", doc(times(3, (i) => `Pipe ${i} is the single biggest risk.`))),
1464
+ f2("content/proposal.md", doc(times(6, (i) => `That is why tap ${i} leaks.`))),
1465
+ f2("content/proposal.md", doc(times(6, (i) => `Every tap ${i} gets checked.`))),
1466
+ f2("content/proposal.md", doc(times(5, (i) => `Worth noting: valve ${i} sticks.`))),
1467
+ f2("content/proposal.md", doc(times(7, (i) => `None of room ${i} needed paint.`))),
1468
+ f2("content/proposal.md", doc(times(5, (i) => `Wall ${i} was measured, sanded and sealed.`))),
1469
+ 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>`)
1470
+ ],
1471
+ pass: [
1472
+ f2("content/proposal.md", doc(times(3, (i) => `We fitted boiler ${i} rather than patching it.`))),
1473
+ f2("content/short.md", times(6, (i) => `We fitted boiler ${i} rather than patching it.`)),
1474
+ f2("app/page.tsx", `export const copy = [${Array.from({ length: 40 }, (_, i) => `"We fitted boiler ${i} rather than patching it."`).join(", ")}];`)
1475
+ ]
1476
+ }
1477
+ },
1478
+ {
1479
+ id: "aphorism-density",
1480
+ name: "Aphorism cadence",
1481
+ generation: 2,
1482
+ severity: "warn",
1483
+ surface: "copy",
1484
+ 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.",
1485
+ fix: "Keep the strongest one. Fold the rest into the paragraph above as plain statements.",
1486
+ detect: aphorismHits,
1487
+ fixtures: {
1488
+ flag: [f2("content/proposal.md", doc(Array.from({ length: 7 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))],
1489
+ pass: [f2("content/proposal.md", doc(Array.from({ length: 3 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))]
1490
+ }
1491
+ },
1492
+ {
1493
+ id: "contraction-scarcity",
1494
+ name: "No contractions",
1495
+ generation: 1,
1496
+ severity: "warn",
1497
+ surface: "copy",
1498
+ why: "A long document with almost no contractions reads stiff and machine-made. People write 'we're' and 'don't'.",
1499
+ fix: "Contract where you would in speech: 'we are' to 'we're', 'do not' to 'don't'. Formal legal pages are the deliberate exception.",
1500
+ detect: contractionHits,
1501
+ fixtures: {
1502
+ flag: [f2("content/proposal.md", filler(900, 5, { contractions: false }))],
1503
+ pass: [f2("content/proposal.md", filler(900, 5)), f2("content/short.md", filler(500, 5, { contractions: false }))]
1504
+ }
1505
+ },
1506
+ {
1507
+ id: "sentence-rhythm",
1508
+ name: "Metronomic rhythm",
1509
+ generation: 2,
1510
+ severity: "warn",
1511
+ surface: "copy",
1512
+ why: "Sentence after sentence of the same length reads generated. A person's sentences run long, then short, then long again.",
1513
+ fix: "Break one long sentence in two, join two short ones, and let some sentences be flat and plain.",
1514
+ detect: rhythmHits,
1515
+ fixtures: {
1516
+ flag: [f2("content/proposal.md", filler(600, 7, { uniform: 12 }))],
1517
+ pass: [f2("content/proposal.md", filler(600, 7))]
1518
+ }
1519
+ },
1520
+ {
1521
+ id: "repeated-sentence",
1522
+ name: "Repeated sentence",
1523
+ generation: 1,
1524
+ severity: "warn",
1525
+ surface: "copy",
1526
+ 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.",
1527
+ fix: "Say it once, where it matters most, and write the second place fresh.",
1528
+ detect: repeatHits,
1529
+ fixtures: {
1530
+ flag: [
1531
+ 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.")),
1532
+ 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."))
1533
+ ],
1534
+ 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."))]
1535
+ }
1536
+ },
1537
+ {
1538
+ id: "heading-shape",
1539
+ name: "One heading shape",
1540
+ generation: 2,
1541
+ severity: "warn",
1542
+ surface: "copy",
1543
+ 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.",
1544
+ fix: "Write each heading for its section. Most should be a plain noun phrase.",
1545
+ detect: headingShapeHits,
1546
+ fixtures: {
1547
+ flag: [
1548
+ f2("content/proposal.md", ["## Fast, and fairly priced", "## Local, and insured", "## Tidy, and quiet", "## Early, and on time", "", filler(520, 9)].join("\n\n")),
1549
+ 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>`),
1550
+ f2("content/proposal.md", ["## Keep, rewrite, retire", "## Fix, paint, seal", "## Book, quote, fit", "## Plan, build, check", "", filler(520, 9)].join("\n\n"))
1551
+ ],
1552
+ pass: [f2("content/proposal.md", ["## Pricing", "## Fast, and fairly priced", "## Who we are", "## Areas we cover", "", filler(520, 9)].join("\n\n"))]
1553
+ }
1554
+ },
1555
+ {
1556
+ id: "heading-echo",
1557
+ name: "Heading echoed",
1558
+ generation: 2,
1559
+ severity: "warn",
1560
+ surface: "copy",
1561
+ why: "A heading repeated word for word as the first line under it spends the reader's attention twice on the same words.",
1562
+ fix: "Let the first sentence say something the heading did not.",
1563
+ detect: headingEchoHits,
1564
+ fixtures: {
1565
+ flag: [
1566
+ f2("content/proposal.md", `## We fix boilers the same day
1567
+
1568
+ We fix boilers the same day across Brackley.
1569
+
1570
+ ${filler(520, 11)}`),
1571
+ f2("site/index.html", `<h2>We fix boilers the same day</h2>
1572
+ <p>We fix boilers the same day across Brackley.</p><p>${filler(520, 11)}</p>`)
1573
+ ],
1574
+ pass: [f2("content/proposal.md", `## We fix boilers the same day
1575
+
1576
+ Call before noon and an engineer is with you by six.
1577
+
1578
+ ${filler(520, 11)}`)]
1579
+ }
1580
+ }
1581
+ ];
856
1582
 
857
1583
  // src/color/oklch.ts
858
1584
  var clamp01 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
@@ -949,8 +1675,8 @@ function parts(fn) {
949
1675
  function hslToRgb(h, s, l) {
950
1676
  const k = (n) => (n + h / 30) % 12;
951
1677
  const a = s * Math.min(l, 1 - l);
952
- const f3 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
953
- return { r: f3(0), g: f3(8), b: f3(4) };
1678
+ const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
1679
+ return { r: f4(0), g: f4(8), b: f4(4) };
954
1680
  }
955
1681
  var clamp012 = (n) => Math.min(1, Math.max(0, n));
956
1682
  function parseColour(raw) {
@@ -1062,7 +1788,7 @@ function firstMatch(text, pattern) {
1062
1788
  return m ? m.index : -1;
1063
1789
  }
1064
1790
  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-/;
1065
- var f2 = (path, text) => ({ path, text });
1791
+ var f3 = (path, text) => ({ path, text });
1066
1792
  function familyNames(value) {
1067
1793
  return value.split(",").map((v) => v.trim().replace(/^["']|["']$/g, "").trim()).filter(Boolean);
1068
1794
  }
@@ -1527,14 +2253,14 @@ var SOURCE_TELLS = [
1527
2253
  detect: fontTell(REFLEX_FONTS_1),
1528
2254
  fixtures: {
1529
2255
  flag: [
1530
- f2("app/layout.tsx", `import { Inter } from "next/font/google";
2256
+ f3("app/layout.tsx", `import { Inter } from "next/font/google";
1531
2257
  const inter = Inter({ subsets: ["latin"] });`),
1532
- f2("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
1533
- f2("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
1534
- f2("main.ts", `import "@fontsource/montserrat";`),
1535
- f2("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
2258
+ f3("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
2259
+ f3("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
2260
+ f3("main.ts", `import "@fontsource/montserrat";`),
2261
+ f3("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
1536
2262
  ],
1537
- pass: [f2("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
2263
+ pass: [f3("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
1538
2264
  }
1539
2265
  },
1540
2266
  {
@@ -1547,10 +2273,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1547
2273
  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.",
1548
2274
  detect: fontTell(REFLEX_FONTS_2),
1549
2275
  fixtures: {
1550
- flag: [f2("src/styles.css", `@theme {
2276
+ flag: [f3("src/styles.css", `@theme {
1551
2277
  --font-sans: "DM Sans", system-ui, sans-serif;
1552
2278
  }`)],
1553
- pass: [f2("src/styles.css", `@theme {
2279
+ pass: [f3("src/styles.css", `@theme {
1554
2280
  --font-sans: "Hanken Grotesk", system-ui, sans-serif;
1555
2281
  }`)]
1556
2282
  }
@@ -1566,10 +2292,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1566
2292
  detect: violetHits,
1567
2293
  fixtures: {
1568
2294
  flag: [
1569
- f2("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
1570
- f2("app/globals.css", `:root { --accent: #5a35d1; }`)
2295
+ f3("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
2296
+ f3("app/globals.css", `:root { --accent: #5a35d1; }`)
1571
2297
  ],
1572
- pass: [f2("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
2298
+ pass: [f3("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
1573
2299
  }
1574
2300
  },
1575
2301
  {
@@ -1583,10 +2309,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1583
2309
  detect: gradientHits,
1584
2310
  fixtures: {
1585
2311
  flag: [
1586
- f2("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
1587
- f2("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
2312
+ f3("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
2313
+ f3("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
1588
2314
  ],
1589
- pass: [f2("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
2315
+ pass: [f3("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
1590
2316
  }
1591
2317
  },
1592
2318
  {
@@ -1600,10 +2326,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1600
2326
  detect: gradientTextHits,
1601
2327
  fixtures: {
1602
2328
  flag: [
1603
- f2("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
1604
- f2("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
2329
+ f3("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
2330
+ f3("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
1605
2331
  ],
1606
- pass: [f2("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
2332
+ pass: [f3("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
1607
2333
  }
1608
2334
  },
1609
2335
  {
@@ -1617,10 +2343,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1617
2343
  detect: glassHits,
1618
2344
  fixtures: {
1619
2345
  flag: [
1620
- f2("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
1621
- f2("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
2346
+ f3("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
2347
+ f3("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
1622
2348
  ],
1623
- pass: [f2("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
2349
+ pass: [f3("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
1624
2350
  }
1625
2351
  },
1626
2352
  {
@@ -1633,10 +2359,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1633
2359
  fix: "Follow the hero with the thing only this business has: the work, the place, the person. Put proof where a doubt arises.",
1634
2360
  detect: heroThenProofHits,
1635
2361
  fixtures: {
1636
- flag: [f2("app/page.tsx", `export default function Page() {
2362
+ flag: [f3("app/page.tsx", `export default function Page() {
1637
2363
  return (<main><Hero /><TrustStrip /><Services /></main>);
1638
2364
  }`)],
1639
- pass: [f2("app/page.tsx", `export default function Page() {
2365
+ pass: [f3("app/page.tsx", `export default function Page() {
1640
2366
  return (<main><Hero /><Workshop /><TrustStrip /></main>);
1641
2367
  }`)]
1642
2368
  }
@@ -1652,7 +2378,7 @@ const inter = Inter({ subsets: ["latin"] });`),
1652
2378
  detect: iconGridHits,
1653
2379
  fixtures: {
1654
2380
  flag: [
1655
- f2(
2381
+ f3(
1656
2382
  "components/Features.tsx",
1657
2383
  `import { Heart } from "lucide-react";
1658
2384
  <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
@@ -1660,7 +2386,7 @@ const inter = Inter({ subsets: ["latin"] });`),
1660
2386
  </div>`
1661
2387
  )
1662
2388
  ],
1663
- pass: [f2("components/Features.tsx", `<ul className="divide-y">
2389
+ pass: [f3("components/Features.tsx", `<ul className="divide-y">
1664
2390
  <li className="flex justify-between py-3">Gel manicure <span>\xA332</span></li>
1665
2391
  </ul>`)]
1666
2392
  }
@@ -1675,8 +2401,8 @@ const inter = Inter({ subsets: ["latin"] });`),
1675
2401
  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.`,
1676
2402
  detect: revealHits,
1677
2403
  fixtures: {
1678
- flag: [f2("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
1679
- pass: [f2("app/page.tsx", `<Reveal><Signature /></Reveal>
2404
+ flag: [f3("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
2405
+ pass: [f3("app/page.tsx", `<Reveal><Signature /></Reveal>
1680
2406
  <Block />
1681
2407
  <Block />`)]
1682
2408
  }
@@ -1691,8 +2417,8 @@ const inter = Inter({ subsets: ["latin"] });`),
1691
2417
  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.`,
1692
2418
  detect: pillHits,
1693
2419
  fixtures: {
1694
- flag: [f2("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
1695
- pass: [f2("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
2420
+ flag: [f3("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
2421
+ pass: [f3("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
1696
2422
  <a className="rounded-sm px-4">Book</a>`)]
1697
2423
  }
1698
2424
  },
@@ -1706,8 +2432,8 @@ const inter = Inter({ subsets: ["latin"] });`),
1706
2432
  fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
1707
2433
  detect: shadcnHits,
1708
2434
  fixtures: {
1709
- flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f2(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
1710
- pass: [f2("src/components/ui/button.tsx", `export function Button() { return null; }`)]
2435
+ flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f3(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
2436
+ pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
1711
2437
  }
1712
2438
  },
1713
2439
  {
@@ -1721,11 +2447,11 @@ const inter = Inter({ subsets: ["latin"] });`),
1721
2447
  detect: creamHits,
1722
2448
  fixtures: {
1723
2449
  flag: [
1724
- f2("app/globals.css", `body { background-color: #f5efe6; }`),
1725
- f2("app/page.tsx", `<main className="bg-cream text-ink">`),
1726
- f2("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
2450
+ f3("app/globals.css", `body { background-color: #f5efe6; }`),
2451
+ f3("app/page.tsx", `<main className="bg-cream text-ink">`),
2452
+ f3("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
1727
2453
  ],
1728
- pass: [f2("app/globals.css", `body { background-color: #ffffff; }`)]
2454
+ pass: [f3("app/globals.css", `body { background-color: #ffffff; }`)]
1729
2455
  }
1730
2456
  },
1731
2457
  {
@@ -1739,11 +2465,11 @@ const inter = Inter({ subsets: ["latin"] });`),
1739
2465
  detect: italicSerifHits,
1740
2466
  fixtures: {
1741
2467
  flag: [
1742
- f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
1743
- f2("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
1744
- f2("app/globals.css", `.hero h1 { font-style: italic; }`)
2468
+ f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
2469
+ f3("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
2470
+ f3("app/globals.css", `.hero h1 { font-style: italic; }`)
1745
2471
  ],
1746
- pass: [f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
2472
+ pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
1747
2473
  <p><em>Walk-ins</em> welcome.</p>`)]
1748
2474
  }
1749
2475
  },
@@ -1758,12 +2484,12 @@ const inter = Inter({ subsets: ["latin"] });`),
1758
2484
  detect: eyebrowChipHits,
1759
2485
  fixtures: {
1760
2486
  flag: [
1761
- f2("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
2487
+ f3("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
1762
2488
  <h1>Nails in Brackley</h1>`),
1763
- f2("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
2489
+ f3("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
1764
2490
  <h1>Nails</h1>`)
1765
2491
  ],
1766
- pass: [f2("components/Hero.tsx", `<h1>Nails in Brackley</h1>
2492
+ pass: [f3("components/Hero.tsx", `<h1>Nails in Brackley</h1>
1767
2493
  <span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
1768
2494
  }
1769
2495
  },
@@ -1777,9 +2503,9 @@ const inter = Inter({ subsets: ["latin"] });`),
1777
2503
  fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
1778
2504
  detect: iconTileStackHits,
1779
2505
  fixtures: {
1780
- flag: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
2506
+ flag: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
1781
2507
  <div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
1782
- pass: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
2508
+ pass: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
1783
2509
  <p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
1784
2510
  }
1785
2511
  },
@@ -1793,8 +2519,8 @@ const inter = Inter({ subsets: ["latin"] });`),
1793
2519
  fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
1794
2520
  detect: bentoHits,
1795
2521
  fixtures: {
1796
- flag: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
1797
- pass: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
2522
+ flag: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
2523
+ pass: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
1798
2524
  }
1799
2525
  },
1800
2526
  {
@@ -1808,10 +2534,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1808
2534
  detect: glowHits,
1809
2535
  fixtures: {
1810
2536
  flag: [
1811
- f2("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
1812
- f2("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
2537
+ f3("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
2538
+ f3("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
1813
2539
  ],
1814
- pass: [f2("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
2540
+ pass: [f3("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
1815
2541
  }
1816
2542
  },
1817
2543
  {
@@ -1825,13 +2551,13 @@ const inter = Inter({ subsets: ["latin"] });`),
1825
2551
  detect: gridBackgroundHits,
1826
2552
  fixtures: {
1827
2553
  flag: [
1828
- f2(
2554
+ f3(
1829
2555
  "app/globals.css",
1830
2556
  `.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
1831
2557
  ),
1832
- f2("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
2558
+ f3("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
1833
2559
  ],
1834
- pass: [f2("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
2560
+ pass: [f3("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
1835
2561
  }
1836
2562
  },
1837
2563
  {
@@ -1845,10 +2571,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1845
2571
  detect: marqueeHits,
1846
2572
  fixtures: {
1847
2573
  flag: [
1848
- f2("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
1849
- f2("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
2574
+ f3("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
2575
+ f3("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
1850
2576
  ],
1851
- pass: [f2("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
2577
+ pass: [f3("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
1852
2578
  }
1853
2579
  },
1854
2580
  {
@@ -1862,10 +2588,10 @@ const inter = Inter({ subsets: ["latin"] });`),
1862
2588
  detect: thinBorderWideShadowHits,
1863
2589
  fixtures: {
1864
2590
  flag: [
1865
- f2("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
1866
- f2("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
2591
+ f3("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
2592
+ f3("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
1867
2593
  ],
1868
- pass: [f2("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
2594
+ pass: [f3("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
1869
2595
  }
1870
2596
  },
1871
2597
  {
@@ -1879,15 +2605,61 @@ const inter = Inter({ subsets: ["latin"] });`),
1879
2605
  detect: introHits,
1880
2606
  fixtures: {
1881
2607
  flag: [
1882
- f2("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
2608
+ f3("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
1883
2609
  export function IntroCinematic() { return <AnimatePresence /> }`),
1884
- f2("app/page.tsx", `<><Preloader /><Hero /></>`)
2610
+ f3("app/page.tsx", `<><Preloader /><Hero /></>`)
1885
2611
  ],
1886
- pass: [f2("components/Introduction.md", `An introduction to the studio.`)]
2612
+ pass: [f3("components/Introduction.md", `An introduction to the studio.`)]
1887
2613
  }
1888
2614
  }
1889
2615
  ];
1890
2616
 
2617
+ // src/character/house.ts
2618
+ var HOUSE = {
2619
+ "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
2620
+ emoji: { tier: "block", label: "emoji" },
2621
+ "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
2622
+ "no-x-no-y": {
2623
+ tier: "block",
2624
+ label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
2625
+ },
2626
+ "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
2627
+ "ai-phrase": { tier: "block", label: "AI tell" },
2628
+ "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
2629
+ "plain-english": { tier: "block", label: 'use "properly explained"' },
2630
+ buzzword: { tier: "block", label: "forbidden phrase" },
2631
+ "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
2632
+ "review-phrase": { tier: "review", label: "review tier" },
2633
+ "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
2634
+ "stock-phrase": { tier: "review", label: "stock phrase" },
2635
+ "hollow-imperative": { tier: "review", label: "hollow imperative" },
2636
+ "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
2637
+ "staccato-triplet": { tier: "review", label: "staccato triplet" },
2638
+ "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
2639
+ "ing-tail": { tier: "review", label: "empty -ing tail" },
2640
+ "vague-attribution": { tier: "review", label: "unnamed source" },
2641
+ "closing-summary": { tier: "review", label: "closing summary" },
2642
+ "false-range": { tier: "review", label: "false range" },
2643
+ // The first candidate for block: evidence of a pasted chat reply, not a
2644
+ // matter of style. Blocks once its estate hits have been read (ROADMAP.md).
2645
+ "chatbot-residue": { tier: "review", label: "chatbot residue" },
2646
+ "placeholder": { tier: "review", label: "unfilled placeholder" },
2647
+ "question-reveal": { tier: "review", label: "staged reveal" },
2648
+ "inline-label-list": { tier: "review", label: "bold-label bullets" },
2649
+ "phrase-density": { tier: "review", label: "density" },
2650
+ "aphorism-density": { tier: "review", label: "density" },
2651
+ "contraction-scarcity": { tier: "review", label: "density" },
2652
+ "sentence-rhythm": { tier: "review", label: "density" },
2653
+ "repeated-sentence": { tier: "review", label: "repetition" },
2654
+ "heading-shape": { tier: "review", label: "headings" },
2655
+ "heading-echo": { tier: "review", label: "headings" },
2656
+ // Banned only "when vague", per the rules. Too noisy for a sweep.
2657
+ "vague-word": { tier: "explicit", label: "vague" }
2658
+ };
2659
+ function houseRule(id, name = id) {
2660
+ return HOUSE[id] ?? { tier: "review", label: name };
2661
+ }
2662
+
1891
2663
  // src/snapshot/fixture.ts
1892
2664
  function makeSnapshot(patch = {}) {
1893
2665
  const base = {
@@ -2018,8 +2790,8 @@ var oklchOf = (value) => parseColour(value)?.oklch ?? null;
2018
2790
  function reflexFonts(list) {
2019
2791
  const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
2020
2792
  return (s) => {
2021
- const total = s.fonts.reduce((n, f3) => n + f3.chars, 0) || 1;
2022
- const display = s.fonts.reduce((n, f3) => n + f3.displayChars, 0) || 1;
2793
+ const total = s.fonts.reduce((n, f4) => n + f4.chars, 0) || 1;
2794
+ const display = s.fonts.reduce((n, f4) => n + f4.displayChars, 0) || 1;
2023
2795
  const hits = [];
2024
2796
  const seen = /* @__PURE__ */ new Set();
2025
2797
  const h1 = s.headings.find((h) => h.level === 1);
@@ -2220,8 +2992,8 @@ var RENDERED_PATHS = {
2220
2992
  };
2221
2993
 
2222
2994
  // src/character/check.ts
2223
- var CATALOGUE_VERSION = "2026.09.3";
2224
- var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS].map(
2995
+ var CATALOGUE_VERSION = "2026.09.7";
2996
+ var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2225
2997
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2226
2998
  );
2227
2999
  function tellById(id) {
@@ -2256,7 +3028,8 @@ function toFinding(tell, hit2, texts) {
2256
3028
  line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
2257
3029
  excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
2258
3030
  message: hit2.message,
2259
- fix: tell.fix
3031
+ fix: tell.fix,
3032
+ ...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
2260
3033
  };
2261
3034
  }
2262
3035
  function report(tells, files, run, options) {
@@ -2309,6 +3082,10 @@ function report(tells, files, run, options) {
2309
3082
  }
2310
3083
  };
2311
3084
  }
3085
+ function applyHouseGate(result) {
3086
+ const findings = result.findings.map((f4) => f4.house === "block" ? { ...f4, severity: "block" } : f4);
3087
+ return { ...result, findings, summary: { ...result.summary, blocking: findings.filter((f4) => f4.severity === "block").length } };
3088
+ }
2312
3089
  function scanSource(files, options = {}) {
2313
3090
  const ctx = { files: files.map(parseFile) };
2314
3091
  const tells = CATALOGUE.filter((t) => t.surface === "source");
@@ -2339,13 +3116,16 @@ export {
2339
3116
  isCream,
2340
3117
  REFLEX_FONTS_1,
2341
3118
  REFLEX_FONTS_2,
3119
+ houseRule,
2342
3120
  fileKind,
3121
+ extractCopy,
2343
3122
  extractStrings,
2344
3123
  SNAPSHOT_VERSION,
2345
3124
  normaliseFamily,
2346
3125
  fontClass,
2347
3126
  CATALOGUE_VERSION,
2348
3127
  CATALOGUE,
3128
+ applyHouseGate,
2349
3129
  scanSource,
2350
3130
  checkCopy,
2351
3131
  auditSnapshot