@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/index.js CHANGED
@@ -827,9 +827,9 @@ function resolveValue(value, props, depth = 0) {
827
827
  if (depth > 8) return null;
828
828
  const match = SINGLE_VAR.exec(value.trim());
829
829
  if (match === null) return value.trim();
830
- const target = props.get(match[1].toLowerCase());
831
- if (target !== void 0 && target !== "initial")
832
- return resolveValue(target, props, depth + 1);
830
+ const target2 = props.get(match[1].toLowerCase());
831
+ if (target2 !== void 0 && target2 !== "initial")
832
+ return resolveValue(target2, props, depth + 1);
833
833
  const fallback = match[2]?.trim();
834
834
  return fallback === void 0 || fallback === "" ? null : resolveValue(fallback, props, depth + 1);
835
835
  }
@@ -930,7 +930,7 @@ function checkRestraint(input) {
930
930
  );
931
931
  }
932
932
  const families = distinctValues(css, "font-family", props, unresolved).filter(
933
- (f3) => !/\bmonospace\b|\bmono\b/.test(f3)
933
+ (f4) => !/\bmonospace\b|\bmono\b/.test(f4)
934
934
  );
935
935
  if (families.length > budget.maxFontFamilies) {
936
936
  push(
@@ -1254,15 +1254,22 @@ var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
1254
1254
  function allText(ctx) {
1255
1255
  return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
1256
1256
  }
1257
+ var LINK = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+|\]\([^)\s]+\)/gi;
1258
+ function linkSpans(text) {
1259
+ return [...text.matchAll(LINK)].map((m) => [m.index ?? 0, (m.index ?? 0) + m[0].length]);
1260
+ }
1261
+ var insideLink = (spans, at) => spans.some(([a, b]) => at >= a && at < b);
1257
1262
  function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
1258
1263
  return (ctx) => {
1259
1264
  const hits = [];
1260
1265
  for (const block of source(ctx)) {
1261
1266
  const spans = [];
1267
+ const links = linkSpans(block.text);
1262
1268
  for (const pattern of patterns) {
1263
1269
  const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
1264
1270
  for (const m of block.text.matchAll(global)) {
1265
1271
  if (skip(block, m[0], m.index ?? 0)) continue;
1272
+ if (insideLink(links, m.index ?? 0)) continue;
1266
1273
  spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
1267
1274
  }
1268
1275
  }
@@ -1303,7 +1310,14 @@ var AI_WORDS = [
1303
1310
  "moreover",
1304
1311
  "navigating",
1305
1312
  "ever-evolving",
1306
- "unparalleled"
1313
+ "unparalleled",
1314
+ // Measured: Kobak et al. 2024 (arXiv 2406.07016) found these 10 to 14 times
1315
+ // above their expected rate in post-ChatGPT abstracts. Academic, so warn only.
1316
+ "underscore",
1317
+ "underscores",
1318
+ "underscoring",
1319
+ "showcasing",
1320
+ "pivotal"
1307
1321
  ];
1308
1322
  var STOCK_PHRASES = [
1309
1323
  "in today's digital age",
@@ -1317,7 +1331,11 @@ var STOCK_PHRASES = [
1317
1331
  "curated",
1318
1332
  "crafted with care",
1319
1333
  "tailored to your",
1320
- "peace of mind"
1334
+ "peace of mind",
1335
+ "so here's",
1336
+ "in an era where",
1337
+ "the future looks bright",
1338
+ "only time will tell"
1321
1339
  ];
1322
1340
  var AI_PHRASES = [
1323
1341
  "let's dive in",
@@ -1450,7 +1468,7 @@ function lineAround(text, index) {
1450
1468
  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);
1451
1469
  var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
1452
1470
  var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
1453
- var EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}]+/u;
1471
+ var EMOJI = /(?:[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u2728\u2705\u274C\u274E\u2757\u2753\u26A1\u2B50\u2764]|[\u2600-\u27BF]\uFE0F)+/u;
1454
1472
  var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
1455
1473
  function badgeHits(ctx) {
1456
1474
  const hits = [];
@@ -1493,10 +1511,16 @@ var COPY_TELLS = [
1493
1511
  surface: "copy",
1494
1512
  why: "Phrases every generated services page uses. They fill space where a fact should be.",
1495
1513
  fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
1496
- detect: phrases([wordList(STOCK_PHRASES)], quoted),
1514
+ detect: phrases([wordList(STOCK_PHRASES), /\b(?:in|on|along)\s+(?:its|their|his|her)\s+journey\b/i], quoted),
1497
1515
  fixtures: {
1498
- flag: [f("content/home.md", "Take your plumbing to the next level with peace of mind.")],
1499
- pass: [f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley.")]
1516
+ flag: [
1517
+ f("content/home.md", "Take your plumbing to the next level with peace of mind."),
1518
+ f("content/home.md", "We meet each practice where it is in its journey.")
1519
+ ],
1520
+ pass: [
1521
+ f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley."),
1522
+ f("content/home.md", "We mapped the patient journey from booking to aftercare.")
1523
+ ]
1500
1524
  }
1501
1525
  },
1502
1526
  {
@@ -1521,7 +1545,11 @@ var COPY_TELLS = [
1521
1545
  f("content/home.md", "In today's rapidly evolving market, we help."),
1522
1546
  f("content/home.md", "Whether you're a landlord or a tenant, call us.")
1523
1547
  ],
1524
- pass: [f("content/home.md", "Book in three taps. We confirm by text within the hour.")]
1548
+ pass: [
1549
+ f("content/home.md", "Book in three taps. We confirm by text within the hour."),
1550
+ f("content/home.md", "Book at https://example.com/seamless-booking today."),
1551
+ f("content/home.md", "[Book a slot](/seamless-booking) today.")
1552
+ ]
1525
1553
  }
1526
1554
  },
1527
1555
  {
@@ -1670,8 +1698,14 @@ var COPY_TELLS = [
1670
1698
  fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
1671
1699
  detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
1672
1700
  fixtures: {
1673
- flag: [f("content/home.md", "New treatments this month \u2728\u{1F680}"), f("app/cta.tsx", `export const label = "Book \u{1F680}";`)],
1674
- pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192")]
1701
+ flag: [
1702
+ f("content/home.md", "New treatments this month \u2728\u{1F680}"),
1703
+ f("app/cta.tsx", `export const label = "Book \u{1F680}";`),
1704
+ f("content/home.md", "New treatments this month \u2728"),
1705
+ f("content/home.md", "Same-day callouts \u2705"),
1706
+ f("content/home.md", "Open Sundays \u2600\uFE0F")
1707
+ ],
1708
+ pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192"), f("content/home.md", "\u2713 Gas Safe registered")]
1675
1709
  }
1676
1710
  },
1677
1711
  {
@@ -1769,6 +1803,695 @@ var COPY_TELLS = [
1769
1803
  }
1770
1804
  }
1771
1805
  ];
1806
+ 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;
1807
+ var VAGUE_ATTRIBUTION = [
1808
+ /\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,
1809
+ /\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
1810
+ /\bit\s+is\s+(?:widely|generally|commonly)\s+(?:known|accepted|believed|agreed)\b/i
1811
+ ];
1812
+ function sourced(block, _match, index) {
1813
+ const before = block.text.slice(Math.max(0, index - 40), index);
1814
+ if (/(?:\b[A-Z][\w&]*[’']s|\b[A-Z]{2,}[a-z]*)\s+$/.test(before)) return true;
1815
+ const rest = block.text.slice(index, index + 400);
1816
+ const end = rest.search(/[.!?](?:\s|$)/);
1817
+ const sentence = rest.slice(0, end === -1 ? rest.length : end + 16);
1818
+ return /\[\^\w+\]|\]\(https?:|\(source|according to [A-Z]|\bper [A-Z]/.test(sentence);
1819
+ }
1820
+ var CLOSING_SUMMARY = /(?:^|[.!?]\s+|\n\s*|>\s*)(?:Overall|In conclusion|In summary|To sum up|All in all|In short|To summari[sz]e),/;
1821
+ var FALSE_RANGE = [
1822
+ /\bfrom\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+alike\b/i,
1823
+ /\b(?:everyone|anyone)\s+from\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s*){1,4}/i
1824
+ ];
1825
+ var RESIDUE = [
1826
+ /\bas an AI(?:\s+language)?\s+(?:model|assistant)\b/i,
1827
+ /(?:^|\n|>)\s*(?:Certainly|Sure|Absolutely|Of course|Great question)[!,.]\s+(?:Here(?:['’]s| is| are)|Below)\b/i,
1828
+ /\bhere(?:['’]s| is)\s+(?:a|an|the|your)\s+(?:revised|rewritten|updated|polished|refined|improved|draft|more concise)\s+(?:version|draft|copy)\b/i,
1829
+ /\blet me know if you(?:['’]d| would) like me to\b/i,
1830
+ /\b(?:my|the) (?:knowledge|training) cut-?off\b|\bas of my last (?:update|training)\b/i,
1831
+ /\bI hope this helps\b/i,
1832
+ // Citation tokens a chat tool leaves in copied text.
1833
+ // A run of them is one paste: "citeturn11search1turn10view0" is one finding.
1834
+ // ChatGPT wraps them in invisible private-use characters (U+E200 to U+E202),
1835
+ // which survive a copy and paste into a CMS: found live, 2026-09-23.
1836
+ /[\uE200-\uE2FF]*(?:cite)?[\uE200-\uE2FF]*(?:turn\d+(?:search|news|view|fetch|file|image)\d+[\uE200-\uE2FF]*)+/,
1837
+ /:?contentReference\[oaicite:\d+\](?:\{index=\d+\})?/,
1838
+ /【\d+(?:[:†][^】]{0,40})?】/
1839
+ ];
1840
+ var AI_UTM = /utm_source=(?:chatgpt\.com|chat\.openai\.com|openai|perplexity(?:\.ai)?|claude\.ai|copilot(?:\.microsoft\.com)?|gemini(?:\.google\.com)?)\b/gi;
1841
+ function residueHits(ctx) {
1842
+ const hits = phrases(RESIDUE, quoted, allText)(ctx);
1843
+ for (const file of ctx.files) {
1844
+ 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]}"` });
1845
+ }
1846
+ return hits;
1847
+ }
1848
+ var PLACEHOLDER = [
1849
+ /\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n]{0,40}\](?!\()/i,
1850
+ /\blorem ipsum\b/i,
1851
+ /\bTODO_PLACEHOLDER\b/
1852
+ ];
1853
+ var PLACEHOLDER_ELEMENT = />\s*(\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n<]{0,40}\])\s*</gi;
1854
+ function insideQuotes(block, _match, index) {
1855
+ const lineStart = block.text.lastIndexOf("\n", index - 1) + 1;
1856
+ const before = block.text.slice(lineStart, index);
1857
+ const straight = (before.match(/"/g) ?? []).length;
1858
+ const curly = (before.match(/“/g) ?? []).length - (before.match(/”/g) ?? []).length;
1859
+ return straight % 2 === 1 || curly > 0;
1860
+ }
1861
+ function placeholderHits(ctx) {
1862
+ const hits = phrases(PLACEHOLDER, quoted, allText, insideQuotes)(ctx);
1863
+ const seen = new Set(hits.map((h) => `${h.path}:${h.offset}`));
1864
+ for (const file of ctx.files) {
1865
+ if (isProsePath(file.path)) continue;
1866
+ for (const m of file.text.matchAll(PLACEHOLDER_ELEMENT)) {
1867
+ const offset = (m.index ?? 0) + m[0].indexOf(m[1]);
1868
+ if (!seen.has(`${file.path}:${offset}`)) hits.push({ path: file.path, offset, message: quoted(m[1]) });
1869
+ }
1870
+ }
1871
+ return hits;
1872
+ }
1873
+ var QUESTION_REVEAL = [
1874
+ /\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]/,
1875
+ /\?\s+It means\b/
1876
+ ];
1877
+ function inlineLabelHits(ctx) {
1878
+ const hits = [];
1879
+ for (const file of ctx.files) {
1880
+ if (!isProsePath(file.path)) continue;
1881
+ const lines = file.text.split("\n");
1882
+ let run = [];
1883
+ let offset = 0;
1884
+ const flush = () => {
1885
+ 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` });
1886
+ run = [];
1887
+ };
1888
+ for (const line of lines) {
1889
+ const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
1890
+ if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
1891
+ else flush();
1892
+ offset += line.length + 1;
1893
+ }
1894
+ flush();
1895
+ }
1896
+ return hits;
1897
+ }
1898
+ var RESEARCH_TELLS = [
1899
+ {
1900
+ id: "chatbot-residue",
1901
+ name: "Chatbot residue",
1902
+ generation: 1,
1903
+ severity: "warn",
1904
+ surface: "copy",
1905
+ 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.",
1906
+ fix: "Delete the residue. Then read the whole passage again, because the rest of it came from the same paste.",
1907
+ detect: residueHits,
1908
+ fixtures: {
1909
+ flag: [
1910
+ f("content/home.md", "Certainly! Here's a revised version of your home page."),
1911
+ f("content/home.md", "As an AI language model, I cannot visit your salon."),
1912
+ f("content/home.md", "Let me know if you'd like me to shorten it."),
1913
+ f("content/home.md", "Prices start at \xA340 citeturn0search3 for a full set."),
1914
+ f("content/home.md", "Prices start at \xA340. citeturn11search1turn10view0turn2view3"),
1915
+ f("content/home.md", "Prices start at \xA340. \uE200cite\uE202turn11search1\uE202turn10view0\uE201"),
1916
+ f("content/home.md", "Prices start at \xA340 :contentReference[oaicite:2]{index=2} for a full set."),
1917
+ f("content/home.md", "Prices start at \xA340\u30104\u2020source\u3011 for a full set."),
1918
+ f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=chatgpt.com">Read the guide</a>;`),
1919
+ f("content/home.md", "Here's a more concise version for the hero.")
1920
+ ],
1921
+ pass: [
1922
+ f("content/home.md", "Certainly the busiest week of the year: book early."),
1923
+ f("content/home.md", "Here's the price list for 2026."),
1924
+ f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=newsletter">Read the guide</a>;`)
1925
+ ]
1926
+ }
1927
+ },
1928
+ {
1929
+ id: "placeholder",
1930
+ name: "Unfilled placeholder",
1931
+ generation: 1,
1932
+ severity: "warn",
1933
+ surface: "copy",
1934
+ 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.",
1935
+ fix: "Fill it from the client or the proof source, or remove the element. Never invent the missing fact to close it.",
1936
+ detect: placeholderHits,
1937
+ fixtures: {
1938
+ flag: [
1939
+ f("content/home.md", "Rated 5 stars by [Insert client name] and others."),
1940
+ f("content/home.md", "Lorem ipsum dolor sit amet."),
1941
+ f("content/home.md", "Rated TODO_PLACEHOLDER: google_rating from our reviews."),
1942
+ f("app/card.tsx", `export const Quote = () => <blockquote>[Insert testimonial here]</blockquote>;`)
1943
+ ],
1944
+ pass: [
1945
+ f("content/home.md", "Manage bookings in [your account](/account)."),
1946
+ f("content/guide.md", 'After 90 days, check your ranking for "[your trade] [your town]" on Google.'),
1947
+ f("content/guide.md", "Send a short message: \u201CThanks for choosing [Business Name]! Leave us a review.\u201D"),
1948
+ f("content/home.md", "Rated 4.9 from 212 Google reviews.")
1949
+ ]
1950
+ }
1951
+ },
1952
+ {
1953
+ id: "question-reveal",
1954
+ name: "Staged reveal",
1955
+ generation: 2,
1956
+ severity: "warn",
1957
+ surface: "copy",
1958
+ 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.",
1959
+ fix: "State the answer: 'Most customers book again within six weeks.'",
1960
+ detect: phrases(QUESTION_REVEAL, quoted),
1961
+ fixtures: {
1962
+ 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.")],
1963
+ 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.")]
1964
+ }
1965
+ },
1966
+ {
1967
+ id: "inline-label-list",
1968
+ name: "Bold-label bullets",
1969
+ generation: 2,
1970
+ severity: "warn",
1971
+ surface: "copy",
1972
+ 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.",
1973
+ 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.",
1974
+ detect: inlineLabelHits,
1975
+ fixtures: {
1976
+ flag: [f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n- **Support:** Help when you need it\n")],
1977
+ pass: [
1978
+ f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
1979
+ f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
1980
+ 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")
1981
+ ]
1982
+ }
1983
+ },
1984
+ {
1985
+ id: "ing-tail",
1986
+ name: "Empty -ing tail",
1987
+ generation: 2,
1988
+ severity: "warn",
1989
+ surface: "copy",
1990
+ 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.",
1991
+ fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
1992
+ detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
1993
+ fixtures: {
1994
+ flag: [
1995
+ f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
1996
+ f("app/about.tsx", `export const About = () => <p>We have served Brackley since 2009, highlighting our commitment to the town.</p>;`)
1997
+ ],
1998
+ pass: [
1999
+ f("content/home.md", "We open at 7am, so you can drop the car off before work."),
2000
+ f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
2001
+ ]
2002
+ }
2003
+ },
2004
+ {
2005
+ id: "vague-attribution",
2006
+ name: "Unnamed source",
2007
+ generation: 1,
2008
+ severity: "warn",
2009
+ surface: "copy",
2010
+ 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.",
2011
+ fix: "Name the source and the number ('A 2023 Which? survey of 2,000 drivers found...') or cut the claim.",
2012
+ detect: phrases(VAGUE_ATTRIBUTION, quoted, (ctx) => ctx.blocks, sourced),
2013
+ fixtures: {
2014
+ flag: [
2015
+ f("content/home.md", "Studies show that regular servicing saves money."),
2016
+ f("content/home.md", "Most experts agree a boiler lasts fifteen years."),
2017
+ f("content/home.md", "It is widely known that gel damages nails.")
2018
+ ],
2019
+ pass: [
2020
+ f("content/home.md", "A 2023 Which? survey of 2,000 owners found serviced boilers failed half as often."),
2021
+ f("content/home.md", "BrightLocal's research shows that reviews drive 20% of local ranking."),
2022
+ f("content/home.md", "Research shows 62% of consumers avoid a business with wrong hours.[^7]"),
2023
+ f("content/home.md", "Our customers rate us 4.9 from 212 Google reviews.")
2024
+ ]
2025
+ }
2026
+ },
2027
+ {
2028
+ id: "closing-summary",
2029
+ name: "Closing summary",
2030
+ generation: 1,
2031
+ severity: "warn",
2032
+ surface: "copy",
2033
+ why: "'Overall,', 'In conclusion,', 'In short,': a paragraph that announces it is summing up, then restates what the reader has just read.",
2034
+ 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'.",
2035
+ detect: phrases([CLOSING_SUMMARY], (m) => quoted(m.replace(/^[.!?>\s]+/, ""))),
2036
+ fixtures: {
2037
+ flag: [
2038
+ f("content/home.md", "We rewire, test and certify.\n\nOverall, we are the right choice for your home."),
2039
+ f("content/home.md", "Prices start at \xA340. In conclusion, we offer great value.")
2040
+ ],
2041
+ pass: [f("content/home.md", "We rewire, test and certify.\n\nBook a survey and we'll quote within two days.")]
2042
+ }
2043
+ },
2044
+ {
2045
+ id: "false-range",
2046
+ name: "False range",
2047
+ generation: 1,
2048
+ severity: "warn",
2049
+ surface: "copy",
2050
+ 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.",
2051
+ fix: "Say who you actually serve, or cut it. A real range is fine: 'from 9am to 5pm', 'from small repairs to full rewires'.",
2052
+ detect: phrases(FALSE_RANGE, quoted),
2053
+ fixtures: {
2054
+ flag: [
2055
+ f("content/home.md", "We help clients from first-time buyers to seasoned investors alike."),
2056
+ f("content/home.md", "Everyone from students to retirees loves our cakes.")
2057
+ ],
2058
+ pass: [
2059
+ f("content/home.md", "Open from 9am to 5pm, Monday to Saturday."),
2060
+ f("content/home.md", "We take on everything from small repairs to full rewires.")
2061
+ ]
2062
+ }
2063
+ }
2064
+ ];
2065
+
2066
+ // src/character/tells/density.ts
2067
+ var DOCUMENT = /\.(md|markdown|mdx|txt|html?)$/i;
2068
+ var MIN_WORDS = 400;
2069
+ var WORD = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
2070
+ var countWords = (text) => (text.match(WORD) ?? []).length;
2071
+ function documents(ctx) {
2072
+ const byPath = /* @__PURE__ */ new Map();
2073
+ for (const block of ctx.blocks) {
2074
+ if (!DOCUMENT.test(block.path) || block.literal) continue;
2075
+ const list = byPath.get(block.path) ?? [];
2076
+ list.push(block);
2077
+ byPath.set(block.path, list);
2078
+ }
2079
+ const docs = [];
2080
+ for (const [path, blocks] of byPath) {
2081
+ const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
2082
+ const words2 = countWords(text);
2083
+ if (words2 >= MIN_WORDS) docs.push({ path, blocks, words: words2 });
2084
+ }
2085
+ return docs;
2086
+ }
2087
+ var withoutHeadings = (text) => text.replace(/^\s{0,3}#{1,6}\s.*$/gm, "");
2088
+ var allowed = (doc2, floor, per1000) => Math.max(floor, Math.floor(doc2.words * per1000 / 1e3));
2089
+ var thousands = (n) => n.toLocaleString("en-GB");
2090
+ function sentences(doc2) {
2091
+ const out = [];
2092
+ for (const block of doc2.blocks) {
2093
+ const text = withoutHeadings(block.text);
2094
+ for (const para of text.matchAll(/[^\n]+(?:\n(?!\s*\n)[^\n]+)*/g)) {
2095
+ const body = para[0];
2096
+ const start = para.index ?? 0;
2097
+ for (const m of body.matchAll(/[^.!?]+[.!?]+["'’”)]*|[^.!?]+$/g)) {
2098
+ const raw = m[0];
2099
+ const trimmed = raw.trim();
2100
+ if (countWords(trimmed) === 0) continue;
2101
+ const lead = raw.length - raw.trimStart().length;
2102
+ out.push({ text: trimmed.replace(/\s+/g, " "), offset: block.offset + start + (m.index ?? 0) + lead });
2103
+ }
2104
+ }
2105
+ }
2106
+ return out;
2107
+ }
2108
+ var RATES = [
2109
+ { label: "rather than", pattern: /\brather\s+than\b/i, floor: 4, per1000: 0.9, why: "the document's one move" },
2110
+ { label: "instead of", pattern: /\binstead\s+of\b/i, floor: 4, per1000: 0.7, why: "the reflex swap once 'rather than' is edited out" },
2111
+ { label: "actually", pattern: /\bactually\b/i, floor: 2, per1000: 0.35, why: "filler, the sentence means the same without it" },
2112
+ { label: "properly", pattern: /\bproperly\b/i, floor: 3, per1000: 0.4, why: "filler, the sentence means the same without it" },
2113
+ { label: "genuinely", pattern: /\bgenuinely\b/i, floor: 1, per1000: 0.2, why: "reaching for sincerity: one is emphasis, two is a tic" },
2114
+ {
2115
+ label: "the single ...est",
2116
+ pattern: /\bthe\s+single\s+(?:\w+est|most|\w+\s+most)\b/i,
2117
+ floor: 1,
2118
+ per1000: 0.15,
2119
+ why: "if three things are the single most important, none is"
2120
+ },
2121
+ { 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" },
2122
+ { label: "Every opener", pattern: /^Every\b/, opener: true, floor: 4, per1000: 0.6, why: "the same sweeping opener, again" },
2123
+ {
2124
+ label: "Worth noting opener",
2125
+ pattern: /^(?:Worth\s+(?:knowing|noting|saying|mentioning|remembering)|It(?:['’]s|\s+is)\s+worth\s+(?:knowing|noting|saying|mentioning|remembering))\b/,
2126
+ opener: true,
2127
+ floor: 3,
2128
+ per1000: 0.5,
2129
+ why: "a hedge that lets a sentence start without committing"
2130
+ },
2131
+ {
2132
+ label: "negation-led opener",
2133
+ pattern: /^(?:None\s+of|Not\s+one|Not\s+a\s+single|Nothing|No\s+one|Nobody|Never)\b/,
2134
+ opener: true,
2135
+ floor: 5,
2136
+ per1000: 1,
2137
+ why: "each earns its place once or twice; at twenty the document has one move"
2138
+ },
2139
+ {
2140
+ label: "participle triad",
2141
+ pattern: /\b[a-z]{3,}ed,\s+[a-z]{3,}ed,?\s+and\s+[a-z]{3,}ed\b/i,
2142
+ floor: 3,
2143
+ per1000: 0.6,
2144
+ why: "'researched, written and referenced' is fine once and a cadence in bulk"
2145
+ }
2146
+ ];
2147
+ function rateHits(ctx) {
2148
+ const hits = [];
2149
+ for (const doc2 of documents(ctx)) {
2150
+ const list = sentences(doc2);
2151
+ for (const rate of RATES) {
2152
+ const found = [];
2153
+ const global = new RegExp(rate.pattern.source, rate.pattern.flags.replace("g", "") + "g");
2154
+ for (const s of list) {
2155
+ if (rate.opener) {
2156
+ if (rate.pattern.test(s.text)) found.push(s);
2157
+ } else {
2158
+ const n = (s.text.match(global) ?? []).length;
2159
+ for (let k = 0; k < n; k += 1) found.push(s);
2160
+ }
2161
+ }
2162
+ const limit = allowed(doc2, rate.floor, rate.per1000);
2163
+ if (found.length > limit) {
2164
+ hits.push({
2165
+ path: doc2.path,
2166
+ offset: found[limit].offset,
2167
+ message: `"${rate.label}" ${found.length} times in ${thousands(doc2.words)} words (allows ${limit}): ${rate.why}`,
2168
+ excerpt: found[limit].text.slice(0, 120)
2169
+ });
2170
+ }
2171
+ }
2172
+ }
2173
+ return hits;
2174
+ }
2175
+ function aphorismHits(ctx) {
2176
+ const hits = [];
2177
+ for (const doc2 of documents(ctx)) {
2178
+ const found = [];
2179
+ for (const block of doc2.blocks) {
2180
+ let at = 0;
2181
+ for (const line of withoutHeadings(block.text).split("\n")) {
2182
+ const text = line.trim().replace(/^[-*>]\s+/, "");
2183
+ const parts2 = text.match(/[^.!?]+[.!?]+/g) ?? [];
2184
+ if (parts2.length === 2 && parts2.join("").length >= text.length - 1 && parts2.every((p) => countWords(p) >= 2 && countWords(p) <= 7)) {
2185
+ found.push({ text, offset: block.offset + at + line.indexOf(text.charAt(0)) });
2186
+ }
2187
+ at += line.length + 1;
2188
+ }
2189
+ }
2190
+ const limit = allowed(doc2, 5, 1.2);
2191
+ if (found.length > limit) {
2192
+ hits.push({
2193
+ path: doc2.path,
2194
+ offset: found[limit].offset,
2195
+ message: `${found.length} two-line aphorisms in ${thousands(doc2.words)} words (allows ${limit}). Keep the one that lands hardest`,
2196
+ excerpt: found[limit].text
2197
+ });
2198
+ }
2199
+ }
2200
+ return hits;
2201
+ }
2202
+ 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;
2203
+ function contractionHits(ctx) {
2204
+ const hits = [];
2205
+ for (const doc2 of documents(ctx)) {
2206
+ if (doc2.words < 800) continue;
2207
+ const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
2208
+ const count2 = (text.match(CONTRACTION) ?? []).length;
2209
+ const rate = count2 * 1e3 / doc2.words;
2210
+ if (rate < 1) {
2211
+ const first = sentences(doc2)[0];
2212
+ hits.push({
2213
+ path: doc2.path,
2214
+ offset: first?.offset ?? 0,
2215
+ message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
2216
+ });
2217
+ }
2218
+ }
2219
+ return hits;
2220
+ }
2221
+ function rhythmHits(ctx) {
2222
+ const hits = [];
2223
+ for (const doc2 of documents(ctx)) {
2224
+ const list = sentences(doc2);
2225
+ if (list.length < 40) continue;
2226
+ const lengths = list.map((s) => countWords(s.text));
2227
+ const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
2228
+ const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
2229
+ const cv = sd / mean;
2230
+ if (cv < 0.42) {
2231
+ hits.push({
2232
+ path: doc2.path,
2233
+ offset: list[0].offset,
2234
+ message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
2235
+ });
2236
+ }
2237
+ }
2238
+ return hits;
2239
+ }
2240
+ var normal = (s) => s.toLowerCase().replace(/[’]/g, "'").match(/[a-z0-9£$€'%]+/g) ?? [];
2241
+ function repeatHits(ctx) {
2242
+ const hits = [];
2243
+ const RUN = 9;
2244
+ for (const doc2 of documents(ctx)) {
2245
+ const whole = /* @__PURE__ */ new Map();
2246
+ const runs = /* @__PURE__ */ new Map();
2247
+ for (const s of sentences(doc2)) {
2248
+ const words2 = normal(s.text);
2249
+ if (words2.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
2250
+ const key = words2.join(" ");
2251
+ const earlier = whole.get(key);
2252
+ if (earlier) {
2253
+ hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words2.length} words)`, excerpt: s.text.slice(0, 120) });
2254
+ continue;
2255
+ }
2256
+ whole.set(key, s);
2257
+ let reported = false;
2258
+ for (let i = 0; i + RUN <= words2.length && !reported; i += 1) {
2259
+ const run = words2.slice(i, i + RUN).join(" ");
2260
+ const seen = runs.get(run);
2261
+ if (seen && seen.key !== key) {
2262
+ 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) });
2263
+ reported = true;
2264
+ } else if (!seen) {
2265
+ runs.set(run, { sentence: s, key });
2266
+ }
2267
+ }
2268
+ }
2269
+ }
2270
+ return hits;
2271
+ }
2272
+ function headings(file) {
2273
+ const out = [];
2274
+ for (const m of file.text.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)) {
2275
+ out.push({ text: m[1], offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
2276
+ }
2277
+ for (const m of file.text.matchAll(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/gi)) {
2278
+ const text = m[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
2279
+ if (text) out.push({ text, offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
2280
+ }
2281
+ return out.sort((a, b) => a.offset - b.offset);
2282
+ }
2283
+ function longFiles(ctx) {
2284
+ const docs = new Map(documents(ctx).map((d) => [d.path, d]));
2285
+ return ctx.files.filter((f4) => docs.has(f4.path)).map((file) => ({ file, doc: docs.get(file.path) }));
2286
+ }
2287
+ var HEADING_SHAPES = [
2288
+ ["X, and Y", /^[^,]{2,}, and [^,]{2,}$/i],
2289
+ ["X, because Y", /^[^,]{2,}, because [^,]{2,}$/i],
2290
+ ["comma list", /^[A-Z][\w'’-]*(?:, [\w'’-]+){2,}(?:,? (?:and|or) [\w'’-]+)?\.?$/]
2291
+ ];
2292
+ function headingShapeHits(ctx) {
2293
+ const hits = [];
2294
+ for (const { file, doc: doc2 } of longFiles(ctx)) {
2295
+ const found = [];
2296
+ for (const heading of headings(file)) {
2297
+ const shape = HEADING_SHAPES.find(([, re]) => re.test(heading.text));
2298
+ if (shape) found.push({ heading, shape: shape[0] });
2299
+ }
2300
+ const limit = allowed(doc2, 3, 0.5);
2301
+ if (found.length > limit) {
2302
+ hits.push({
2303
+ path: file.path,
2304
+ offset: found[limit].heading.offset,
2305
+ message: `${found.length} headings share one shape (${[...new Set(found.map((f4) => f4.shape))].join(", ")}), allows ${limit}`,
2306
+ excerpt: found[limit].heading.text
2307
+ });
2308
+ }
2309
+ }
2310
+ return hits;
2311
+ }
2312
+ function headingEchoHits(ctx) {
2313
+ const hits = [];
2314
+ for (const { file } of longFiles(ctx)) {
2315
+ for (const heading of headings(file)) {
2316
+ const words2 = normal(heading.text);
2317
+ if (words2.length < 4) continue;
2318
+ const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
2319
+ const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words2.length);
2320
+ if (opening.join(" ") === words2.join(" ")) {
2321
+ hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
2322
+ }
2323
+ }
2324
+ }
2325
+ return hits;
2326
+ }
2327
+ 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(" ");
2328
+ function filler(words2, seed = 1, options = {}) {
2329
+ const { contractions = true, uniform } = options;
2330
+ const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
2331
+ let state = seed;
2332
+ const next = (n) => {
2333
+ state = (state * 1103515245 + 12345) % 2147483648;
2334
+ return state % n;
2335
+ };
2336
+ const out = [];
2337
+ let count2 = 0;
2338
+ let i = 0;
2339
+ while (count2 < words2) {
2340
+ const length = uniform ?? shape[i % shape.length];
2341
+ const s = [];
2342
+ for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
2343
+ if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
2344
+ s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
2345
+ out.push(`${s.join(" ")}.`);
2346
+ count2 += s.length;
2347
+ i += 1;
2348
+ }
2349
+ const paragraphs = [];
2350
+ for (let p = 0; p < out.length; p += 4) paragraphs.push(out.slice(p, p + 4).join(" "));
2351
+ return paragraphs.join("\n\n");
2352
+ }
2353
+ var f2 = (path, text) => ({ path, text });
2354
+ var doc = (extra, seed = 1) => `${filler(520, seed)}
2355
+
2356
+ ${extra}
2357
+ `;
2358
+ var times = (n, sentence) => Array.from({ length: n }, (_, i) => sentence(i)).join(" ");
2359
+ var DENSITY_TELLS = [
2360
+ {
2361
+ id: "phrase-density",
2362
+ name: "Leaned-on phrase",
2363
+ generation: 2,
2364
+ severity: "warn",
2365
+ surface: "copy",
2366
+ 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.",
2367
+ 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.",
2368
+ detect: rateHits,
2369
+ fixtures: {
2370
+ flag: [
2371
+ f2("content/proposal.md", doc(times(6, (i) => `We fitted boiler ${i} rather than patching it.`))),
2372
+ f2("content/proposal.md", doc(times(6, (i) => `We sealed roof ${i} instead of patching it.`))),
2373
+ f2("content/proposal.md", doc(times(4, (i) => `Room ${i} actually needed a new valve.`))),
2374
+ f2("content/proposal.md", doc(times(5, (i) => `Gate ${i} was properly hung.`))),
2375
+ f2("content/proposal.md", doc(times(3, (i) => `Kitchen ${i} was genuinely tidy.`))),
2376
+ f2("content/proposal.md", doc(times(3, (i) => `Pipe ${i} is the single biggest risk.`))),
2377
+ f2("content/proposal.md", doc(times(6, (i) => `That is why tap ${i} leaks.`))),
2378
+ f2("content/proposal.md", doc(times(6, (i) => `Every tap ${i} gets checked.`))),
2379
+ f2("content/proposal.md", doc(times(5, (i) => `Worth noting: valve ${i} sticks.`))),
2380
+ f2("content/proposal.md", doc(times(7, (i) => `None of room ${i} needed paint.`))),
2381
+ f2("content/proposal.md", doc(times(5, (i) => `Wall ${i} was measured, sanded and sealed.`))),
2382
+ 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>`)
2383
+ ],
2384
+ pass: [
2385
+ f2("content/proposal.md", doc(times(3, (i) => `We fitted boiler ${i} rather than patching it.`))),
2386
+ f2("content/short.md", times(6, (i) => `We fitted boiler ${i} rather than patching it.`)),
2387
+ f2("app/page.tsx", `export const copy = [${Array.from({ length: 40 }, (_, i) => `"We fitted boiler ${i} rather than patching it."`).join(", ")}];`)
2388
+ ]
2389
+ }
2390
+ },
2391
+ {
2392
+ id: "aphorism-density",
2393
+ name: "Aphorism cadence",
2394
+ generation: 2,
2395
+ severity: "warn",
2396
+ surface: "copy",
2397
+ 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.",
2398
+ fix: "Keep the strongest one. Fold the rest into the paragraph above as plain statements.",
2399
+ detect: aphorismHits,
2400
+ fixtures: {
2401
+ flag: [f2("content/proposal.md", doc(Array.from({ length: 7 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))],
2402
+ pass: [f2("content/proposal.md", doc(Array.from({ length: 3 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))]
2403
+ }
2404
+ },
2405
+ {
2406
+ id: "contraction-scarcity",
2407
+ name: "No contractions",
2408
+ generation: 1,
2409
+ severity: "warn",
2410
+ surface: "copy",
2411
+ why: "A long document with almost no contractions reads stiff and machine-made. People write 'we're' and 'don't'.",
2412
+ fix: "Contract where you would in speech: 'we are' to 'we're', 'do not' to 'don't'. Formal legal pages are the deliberate exception.",
2413
+ detect: contractionHits,
2414
+ fixtures: {
2415
+ flag: [f2("content/proposal.md", filler(900, 5, { contractions: false }))],
2416
+ pass: [f2("content/proposal.md", filler(900, 5)), f2("content/short.md", filler(500, 5, { contractions: false }))]
2417
+ }
2418
+ },
2419
+ {
2420
+ id: "sentence-rhythm",
2421
+ name: "Metronomic rhythm",
2422
+ generation: 2,
2423
+ severity: "warn",
2424
+ surface: "copy",
2425
+ why: "Sentence after sentence of the same length reads generated. A person's sentences run long, then short, then long again.",
2426
+ fix: "Break one long sentence in two, join two short ones, and let some sentences be flat and plain.",
2427
+ detect: rhythmHits,
2428
+ fixtures: {
2429
+ flag: [f2("content/proposal.md", filler(600, 7, { uniform: 12 }))],
2430
+ pass: [f2("content/proposal.md", filler(600, 7))]
2431
+ }
2432
+ },
2433
+ {
2434
+ id: "repeated-sentence",
2435
+ name: "Repeated sentence",
2436
+ generation: 1,
2437
+ severity: "warn",
2438
+ surface: "copy",
2439
+ 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.",
2440
+ fix: "Say it once, where it matters most, and write the second place fresh.",
2441
+ detect: repeatHits,
2442
+ fixtures: {
2443
+ flag: [
2444
+ 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.")),
2445
+ 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."))
2446
+ ],
2447
+ 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."))]
2448
+ }
2449
+ },
2450
+ {
2451
+ id: "heading-shape",
2452
+ name: "One heading shape",
2453
+ generation: 2,
2454
+ severity: "warn",
2455
+ surface: "copy",
2456
+ 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.",
2457
+ fix: "Write each heading for its section. Most should be a plain noun phrase.",
2458
+ detect: headingShapeHits,
2459
+ fixtures: {
2460
+ flag: [
2461
+ f2("content/proposal.md", ["## Fast, and fairly priced", "## Local, and insured", "## Tidy, and quiet", "## Early, and on time", "", filler(520, 9)].join("\n\n")),
2462
+ 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>`),
2463
+ f2("content/proposal.md", ["## Keep, rewrite, retire", "## Fix, paint, seal", "## Book, quote, fit", "## Plan, build, check", "", filler(520, 9)].join("\n\n"))
2464
+ ],
2465
+ pass: [f2("content/proposal.md", ["## Pricing", "## Fast, and fairly priced", "## Who we are", "## Areas we cover", "", filler(520, 9)].join("\n\n"))]
2466
+ }
2467
+ },
2468
+ {
2469
+ id: "heading-echo",
2470
+ name: "Heading echoed",
2471
+ generation: 2,
2472
+ severity: "warn",
2473
+ surface: "copy",
2474
+ why: "A heading repeated word for word as the first line under it spends the reader's attention twice on the same words.",
2475
+ fix: "Let the first sentence say something the heading did not.",
2476
+ detect: headingEchoHits,
2477
+ fixtures: {
2478
+ flag: [
2479
+ f2("content/proposal.md", `## We fix boilers the same day
2480
+
2481
+ We fix boilers the same day across Brackley.
2482
+
2483
+ ${filler(520, 11)}`),
2484
+ f2("site/index.html", `<h2>We fix boilers the same day</h2>
2485
+ <p>We fix boilers the same day across Brackley.</p><p>${filler(520, 11)}</p>`)
2486
+ ],
2487
+ pass: [f2("content/proposal.md", `## We fix boilers the same day
2488
+
2489
+ Call before noon and an engineer is with you by six.
2490
+
2491
+ ${filler(520, 11)}`)]
2492
+ }
2493
+ }
2494
+ ];
1772
2495
 
1773
2496
  // src/character/color.ts
1774
2497
  var COLOUR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3,4}\b|\b(?:rgba?|hsla?|oklch|oklab)\([^)]*\)|\bcolor\(\s*srgb\b[^)]*\)/g;
@@ -1789,8 +2512,8 @@ function parts(fn) {
1789
2512
  function hslToRgb(h, s, l) {
1790
2513
  const k = (n) => (n + h / 30) % 12;
1791
2514
  const a = s * Math.min(l, 1 - l);
1792
- const f3 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
1793
- return { r: f3(0), g: f3(8), b: f3(4) };
2515
+ const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
2516
+ return { r: f4(0), g: f4(8), b: f4(4) };
1794
2517
  }
1795
2518
  var clamp012 = (n) => Math.min(1, Math.max(0, n));
1796
2519
  function parseColour(raw) {
@@ -1902,7 +2625,7 @@ function firstMatch(text, pattern) {
1902
2625
  return m ? m.index : -1;
1903
2626
  }
1904
2627
  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-/;
1905
- var f2 = (path, text) => ({ path, text });
2628
+ var f3 = (path, text) => ({ path, text });
1906
2629
  function familyNames(value) {
1907
2630
  return value.split(",").map((v) => v.trim().replace(/^["']|["']$/g, "").trim()).filter(Boolean);
1908
2631
  }
@@ -2367,14 +3090,14 @@ var SOURCE_TELLS = [
2367
3090
  detect: fontTell(REFLEX_FONTS_1),
2368
3091
  fixtures: {
2369
3092
  flag: [
2370
- f2("app/layout.tsx", `import { Inter } from "next/font/google";
3093
+ f3("app/layout.tsx", `import { Inter } from "next/font/google";
2371
3094
  const inter = Inter({ subsets: ["latin"] });`),
2372
- f2("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
2373
- f2("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
2374
- f2("main.ts", `import "@fontsource/montserrat";`),
2375
- f2("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
3095
+ f3("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
3096
+ f3("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
3097
+ f3("main.ts", `import "@fontsource/montserrat";`),
3098
+ f3("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
2376
3099
  ],
2377
- pass: [f2("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
3100
+ pass: [f3("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
2378
3101
  }
2379
3102
  },
2380
3103
  {
@@ -2384,13 +3107,13 @@ const inter = Inter({ subsets: ["latin"] });`),
2384
3107
  severity: "warn",
2385
3108
  surface: "source",
2386
3109
  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.",
2387
- fix: "Same as reflex-font. If this face really is the right one, declare an exception with the reason and the evidence.",
3110
+ 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.",
2388
3111
  detect: fontTell(REFLEX_FONTS_2),
2389
3112
  fixtures: {
2390
- flag: [f2("src/styles.css", `@theme {
3113
+ flag: [f3("src/styles.css", `@theme {
2391
3114
  --font-sans: "DM Sans", system-ui, sans-serif;
2392
3115
  }`)],
2393
- pass: [f2("src/styles.css", `@theme {
3116
+ pass: [f3("src/styles.css", `@theme {
2394
3117
  --font-sans: "Hanken Grotesk", system-ui, sans-serif;
2395
3118
  }`)]
2396
3119
  }
@@ -2406,10 +3129,10 @@ const inter = Inter({ subsets: ["latin"] });`),
2406
3129
  detect: violetHits,
2407
3130
  fixtures: {
2408
3131
  flag: [
2409
- f2("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
2410
- f2("app/globals.css", `:root { --accent: #5a35d1; }`)
3132
+ f3("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
3133
+ f3("app/globals.css", `:root { --accent: #5a35d1; }`)
2411
3134
  ],
2412
- pass: [f2("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
3135
+ pass: [f3("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
2413
3136
  }
2414
3137
  },
2415
3138
  {
@@ -2423,10 +3146,10 @@ const inter = Inter({ subsets: ["latin"] });`),
2423
3146
  detect: gradientHits,
2424
3147
  fixtures: {
2425
3148
  flag: [
2426
- f2("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
2427
- f2("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
3149
+ f3("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
3150
+ f3("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
2428
3151
  ],
2429
- pass: [f2("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
3152
+ pass: [f3("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
2430
3153
  }
2431
3154
  },
2432
3155
  {
@@ -2440,10 +3163,10 @@ const inter = Inter({ subsets: ["latin"] });`),
2440
3163
  detect: gradientTextHits,
2441
3164
  fixtures: {
2442
3165
  flag: [
2443
- f2("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
2444
- f2("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
3166
+ f3("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
3167
+ f3("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
2445
3168
  ],
2446
- pass: [f2("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
3169
+ pass: [f3("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
2447
3170
  }
2448
3171
  },
2449
3172
  {
@@ -2457,10 +3180,10 @@ const inter = Inter({ subsets: ["latin"] });`),
2457
3180
  detect: glassHits,
2458
3181
  fixtures: {
2459
3182
  flag: [
2460
- f2("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
2461
- f2("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
3183
+ f3("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
3184
+ f3("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
2462
3185
  ],
2463
- pass: [f2("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
3186
+ pass: [f3("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
2464
3187
  }
2465
3188
  },
2466
3189
  {
@@ -2473,10 +3196,10 @@ const inter = Inter({ subsets: ["latin"] });`),
2473
3196
  fix: "Follow the hero with the thing only this business has: the work, the place, the person. Put proof where a doubt arises.",
2474
3197
  detect: heroThenProofHits,
2475
3198
  fixtures: {
2476
- flag: [f2("app/page.tsx", `export default function Page() {
3199
+ flag: [f3("app/page.tsx", `export default function Page() {
2477
3200
  return (<main><Hero /><TrustStrip /><Services /></main>);
2478
3201
  }`)],
2479
- pass: [f2("app/page.tsx", `export default function Page() {
3202
+ pass: [f3("app/page.tsx", `export default function Page() {
2480
3203
  return (<main><Hero /><Workshop /><TrustStrip /></main>);
2481
3204
  }`)]
2482
3205
  }
@@ -2492,7 +3215,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2492
3215
  detect: iconGridHits,
2493
3216
  fixtures: {
2494
3217
  flag: [
2495
- f2(
3218
+ f3(
2496
3219
  "components/Features.tsx",
2497
3220
  `import { Heart } from "lucide-react";
2498
3221
  <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
@@ -2500,7 +3223,7 @@ const inter = Inter({ subsets: ["latin"] });`),
2500
3223
  </div>`
2501
3224
  )
2502
3225
  ],
2503
- pass: [f2("components/Features.tsx", `<ul className="divide-y">
3226
+ pass: [f3("components/Features.tsx", `<ul className="divide-y">
2504
3227
  <li className="flex justify-between py-3">Gel manicure <span>\xA332</span></li>
2505
3228
  </ul>`)]
2506
3229
  }
@@ -2515,8 +3238,8 @@ const inter = Inter({ subsets: ["latin"] });`),
2515
3238
  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.`,
2516
3239
  detect: revealHits,
2517
3240
  fixtures: {
2518
- flag: [f2("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
2519
- pass: [f2("app/page.tsx", `<Reveal><Signature /></Reveal>
3241
+ flag: [f3("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
3242
+ pass: [f3("app/page.tsx", `<Reveal><Signature /></Reveal>
2520
3243
  <Block />
2521
3244
  <Block />`)]
2522
3245
  }
@@ -2531,8 +3254,8 @@ const inter = Inter({ subsets: ["latin"] });`),
2531
3254
  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.`,
2532
3255
  detect: pillHits,
2533
3256
  fixtures: {
2534
- flag: [f2("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
2535
- pass: [f2("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
3257
+ flag: [f3("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
3258
+ pass: [f3("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
2536
3259
  <a className="rounded-sm px-4">Book</a>`)]
2537
3260
  }
2538
3261
  },
@@ -2546,8 +3269,8 @@ const inter = Inter({ subsets: ["latin"] });`),
2546
3269
  fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
2547
3270
  detect: shadcnHits,
2548
3271
  fixtures: {
2549
- flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f2(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
2550
- pass: [f2("src/components/ui/button.tsx", `export function Button() { return null; }`)]
3272
+ flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f3(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
3273
+ pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
2551
3274
  }
2552
3275
  },
2553
3276
  {
@@ -2561,11 +3284,11 @@ const inter = Inter({ subsets: ["latin"] });`),
2561
3284
  detect: creamHits,
2562
3285
  fixtures: {
2563
3286
  flag: [
2564
- f2("app/globals.css", `body { background-color: #f5efe6; }`),
2565
- f2("app/page.tsx", `<main className="bg-cream text-ink">`),
2566
- f2("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
3287
+ f3("app/globals.css", `body { background-color: #f5efe6; }`),
3288
+ f3("app/page.tsx", `<main className="bg-cream text-ink">`),
3289
+ f3("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
2567
3290
  ],
2568
- pass: [f2("app/globals.css", `body { background-color: #ffffff; }`)]
3291
+ pass: [f3("app/globals.css", `body { background-color: #ffffff; }`)]
2569
3292
  }
2570
3293
  },
2571
3294
  {
@@ -2579,11 +3302,11 @@ const inter = Inter({ subsets: ["latin"] });`),
2579
3302
  detect: italicSerifHits,
2580
3303
  fixtures: {
2581
3304
  flag: [
2582
- f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
2583
- f2("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
2584
- f2("app/globals.css", `.hero h1 { font-style: italic; }`)
3305
+ f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
3306
+ f3("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
3307
+ f3("app/globals.css", `.hero h1 { font-style: italic; }`)
2585
3308
  ],
2586
- pass: [f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
3309
+ pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
2587
3310
  <p><em>Walk-ins</em> welcome.</p>`)]
2588
3311
  }
2589
3312
  },
@@ -2598,12 +3321,12 @@ const inter = Inter({ subsets: ["latin"] });`),
2598
3321
  detect: eyebrowChipHits,
2599
3322
  fixtures: {
2600
3323
  flag: [
2601
- f2("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
3324
+ f3("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
2602
3325
  <h1>Nails in Brackley</h1>`),
2603
- f2("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
3326
+ f3("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
2604
3327
  <h1>Nails</h1>`)
2605
3328
  ],
2606
- pass: [f2("components/Hero.tsx", `<h1>Nails in Brackley</h1>
3329
+ pass: [f3("components/Hero.tsx", `<h1>Nails in Brackley</h1>
2607
3330
  <span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
2608
3331
  }
2609
3332
  },
@@ -2617,9 +3340,9 @@ const inter = Inter({ subsets: ["latin"] });`),
2617
3340
  fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
2618
3341
  detect: iconTileStackHits,
2619
3342
  fixtures: {
2620
- flag: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
3343
+ flag: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
2621
3344
  <div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
2622
- pass: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
3345
+ pass: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
2623
3346
  <p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
2624
3347
  }
2625
3348
  },
@@ -2633,8 +3356,8 @@ const inter = Inter({ subsets: ["latin"] });`),
2633
3356
  fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
2634
3357
  detect: bentoHits,
2635
3358
  fixtures: {
2636
- flag: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
2637
- pass: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
3359
+ flag: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
3360
+ pass: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
2638
3361
  }
2639
3362
  },
2640
3363
  {
@@ -2648,10 +3371,10 @@ const inter = Inter({ subsets: ["latin"] });`),
2648
3371
  detect: glowHits,
2649
3372
  fixtures: {
2650
3373
  flag: [
2651
- f2("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
2652
- f2("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
3374
+ f3("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
3375
+ f3("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
2653
3376
  ],
2654
- pass: [f2("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
3377
+ pass: [f3("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
2655
3378
  }
2656
3379
  },
2657
3380
  {
@@ -2665,13 +3388,13 @@ const inter = Inter({ subsets: ["latin"] });`),
2665
3388
  detect: gridBackgroundHits,
2666
3389
  fixtures: {
2667
3390
  flag: [
2668
- f2(
3391
+ f3(
2669
3392
  "app/globals.css",
2670
3393
  `.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
2671
3394
  ),
2672
- f2("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
3395
+ f3("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
2673
3396
  ],
2674
- pass: [f2("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
3397
+ pass: [f3("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
2675
3398
  }
2676
3399
  },
2677
3400
  {
@@ -2685,10 +3408,10 @@ const inter = Inter({ subsets: ["latin"] });`),
2685
3408
  detect: marqueeHits,
2686
3409
  fixtures: {
2687
3410
  flag: [
2688
- f2("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
2689
- f2("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
3411
+ f3("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
3412
+ f3("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
2690
3413
  ],
2691
- pass: [f2("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
3414
+ pass: [f3("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
2692
3415
  }
2693
3416
  },
2694
3417
  {
@@ -2702,10 +3425,10 @@ const inter = Inter({ subsets: ["latin"] });`),
2702
3425
  detect: thinBorderWideShadowHits,
2703
3426
  fixtures: {
2704
3427
  flag: [
2705
- f2("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
2706
- f2("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
3428
+ f3("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
3429
+ f3("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
2707
3430
  ],
2708
- pass: [f2("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
3431
+ pass: [f3("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
2709
3432
  }
2710
3433
  },
2711
3434
  {
@@ -2719,15 +3442,61 @@ const inter = Inter({ subsets: ["latin"] });`),
2719
3442
  detect: introHits,
2720
3443
  fixtures: {
2721
3444
  flag: [
2722
- f2("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
3445
+ f3("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
2723
3446
  export function IntroCinematic() { return <AnimatePresence /> }`),
2724
- f2("app/page.tsx", `<><Preloader /><Hero /></>`)
3447
+ f3("app/page.tsx", `<><Preloader /><Hero /></>`)
2725
3448
  ],
2726
- pass: [f2("components/Introduction.md", `An introduction to the studio.`)]
3449
+ pass: [f3("components/Introduction.md", `An introduction to the studio.`)]
2727
3450
  }
2728
3451
  }
2729
3452
  ];
2730
3453
 
3454
+ // src/character/house.ts
3455
+ var HOUSE = {
3456
+ "em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
3457
+ emoji: { tier: "block", label: "emoji" },
3458
+ "not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
3459
+ "no-x-no-y": {
3460
+ tier: "block",
3461
+ label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
3462
+ },
3463
+ "no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
3464
+ "ai-phrase": { tier: "block", label: "AI tell" },
3465
+ "plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
3466
+ "plain-english": { tier: "block", label: 'use "properly explained"' },
3467
+ buzzword: { tier: "block", label: "forbidden phrase" },
3468
+ "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
3469
+ "review-phrase": { tier: "review", label: "review tier" },
3470
+ "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
3471
+ "stock-phrase": { tier: "review", label: "stock phrase" },
3472
+ "hollow-imperative": { tier: "review", label: "hollow imperative" },
3473
+ "rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
3474
+ "staccato-triplet": { tier: "review", label: "staccato triplet" },
3475
+ "where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
3476
+ "ing-tail": { tier: "review", label: "empty -ing tail" },
3477
+ "vague-attribution": { tier: "review", label: "unnamed source" },
3478
+ "closing-summary": { tier: "review", label: "closing summary" },
3479
+ "false-range": { tier: "review", label: "false range" },
3480
+ // The first candidate for block: evidence of a pasted chat reply, not a
3481
+ // matter of style. Blocks once its estate hits have been read (ROADMAP.md).
3482
+ "chatbot-residue": { tier: "review", label: "chatbot residue" },
3483
+ "placeholder": { tier: "review", label: "unfilled placeholder" },
3484
+ "question-reveal": { tier: "review", label: "staged reveal" },
3485
+ "inline-label-list": { tier: "review", label: "bold-label bullets" },
3486
+ "phrase-density": { tier: "review", label: "density" },
3487
+ "aphorism-density": { tier: "review", label: "density" },
3488
+ "contraction-scarcity": { tier: "review", label: "density" },
3489
+ "sentence-rhythm": { tier: "review", label: "density" },
3490
+ "repeated-sentence": { tier: "review", label: "repetition" },
3491
+ "heading-shape": { tier: "review", label: "headings" },
3492
+ "heading-echo": { tier: "review", label: "headings" },
3493
+ // Banned only "when vague", per the rules. Too noisy for a sweep.
3494
+ "vague-word": { tier: "explicit", label: "vague" }
3495
+ };
3496
+ function houseRule(id, name = id) {
3497
+ return HOUSE[id] ?? { tier: "review", label: name };
3498
+ }
3499
+
2731
3500
  // src/character/parse.ts
2732
3501
  var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
2733
3502
  var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
@@ -3132,15 +3901,16 @@ function makeSnapshot(patch = {}) {
3132
3901
  }
3133
3902
 
3134
3903
  // src/snapshot/fonts.ts
3904
+ var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
3135
3905
  function normaliseFamily(raw) {
3136
3906
  let name = raw.replace(/["']/g, "").trim();
3137
3907
  const next = name.match(/^__(.+?)(?:_Fallback)?_[0-9a-f]{5,8}$/i);
3138
3908
  if (next) name = next[1].replace(/_/g, " ");
3139
3909
  name = name.replace(/\s+Fallback$/i, "").replace(/\s+Variable$/i, "");
3910
+ if (SYSTEM.test(name)) return "system-ui";
3140
3911
  if (!/\s/.test(name) && (/[a-z][A-Z]/.test(name) || /^[a-z]+(?:-[a-z]+)+$/.test(name))) {
3141
3912
  name = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/-/g, " ").replace(/\b([a-z])/g, (c) => c.toUpperCase()).replace(/\bDm\b/, "DM");
3142
3913
  }
3143
- if (/^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|sans-serif|serif|monospace)$/i.test(name)) return "system-ui";
3144
3914
  return name;
3145
3915
  }
3146
3916
  var KNOWN = {
@@ -3191,8 +3961,8 @@ var oklchOf = (value) => parseColour(value)?.oklch ?? null;
3191
3961
  function reflexFonts(list) {
3192
3962
  const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
3193
3963
  return (s) => {
3194
- const total = s.fonts.reduce((n, f3) => n + f3.chars, 0) || 1;
3195
- const display = s.fonts.reduce((n, f3) => n + f3.displayChars, 0) || 1;
3964
+ const total = s.fonts.reduce((n, f4) => n + f4.chars, 0) || 1;
3965
+ const display = s.fonts.reduce((n, f4) => n + f4.displayChars, 0) || 1;
3196
3966
  const hits = [];
3197
3967
  const seen = /* @__PURE__ */ new Set();
3198
3968
  const h1 = s.headings.find((h) => h.level === 1);
@@ -3393,8 +4163,8 @@ var RENDERED_PATHS = {
3393
4163
  };
3394
4164
 
3395
4165
  // src/character/check.ts
3396
- var CATALOGUE_VERSION = "2026.09.3";
3397
- var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS].map(
4166
+ var CATALOGUE_VERSION = "2026.09.6";
4167
+ var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
3398
4168
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
3399
4169
  );
3400
4170
  function tellById(id) {
@@ -3429,7 +4199,8 @@ function toFinding(tell, hit2, texts) {
3429
4199
  line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
3430
4200
  excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
3431
4201
  message: hit2.message,
3432
- fix: tell.fix
4202
+ fix: tell.fix,
4203
+ ...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
3433
4204
  };
3434
4205
  }
3435
4206
  function report(tells, files, run, options) {
@@ -3482,6 +4253,10 @@ function report(tells, files, run, options) {
3482
4253
  }
3483
4254
  };
3484
4255
  }
4256
+ function applyHouseGate(result) {
4257
+ const findings = result.findings.map((f4) => f4.house === "block" ? { ...f4, severity: "block" } : f4);
4258
+ return { ...result, findings, summary: { ...result.summary, blocking: findings.filter((f4) => f4.severity === "block").length } };
4259
+ }
3485
4260
  function scanSource(files, options = {}) {
3486
4261
  const ctx = { files: files.map(parseFile) };
3487
4262
  const tells = CATALOGUE.filter((t) => t.surface === "source");
@@ -3517,20 +4292,39 @@ function catalogueTable() {
3517
4292
  ...rows
3518
4293
  ].join("\n");
3519
4294
  }
4295
+ function wordListsJson() {
4296
+ return JSON.stringify(
4297
+ {
4298
+ catalogueVersion: CATALOGUE_VERSION,
4299
+ lists: {
4300
+ aiWords: AI_WORDS,
4301
+ stockPhrases: STOCK_PHRASES,
4302
+ aiPhrases: AI_PHRASES,
4303
+ plainerWords: PLAINER_WORDS,
4304
+ buzzwords: BUZZWORDS,
4305
+ negativeReassurance: NEGATIVE_REASSURANCE,
4306
+ vagueWords: VAGUE_WORDS,
4307
+ reviewPhrases: REVIEW_PHRASES
4308
+ }
4309
+ },
4310
+ null,
4311
+ 2
4312
+ ) + "\n";
4313
+ }
3520
4314
 
3521
4315
  // src/character/format.ts
3522
4316
  function formatReport(report2, title) {
3523
4317
  const lines = [];
3524
4318
  let current = "";
3525
- for (const f3 of report2.findings) {
3526
- if (f3.path !== current) {
3527
- current = f3.path;
3528
- lines.push("", f3.path);
4319
+ for (const f4 of report2.findings) {
4320
+ if (f4.path !== current) {
4321
+ current = f4.path;
4322
+ lines.push("", f4.path);
3529
4323
  }
3530
- lines.push(` ${String(f3.line).padStart(4)} ${f3.tell} (gen ${f3.generation}, ${f3.severity}) ${f3.message}`);
3531
- lines.push(` ${f3.excerpt}`);
4324
+ lines.push(` ${String(f4.line).padStart(4)} ${f4.tell} (gen ${f4.generation}, ${f4.severity}) ${f4.message}`);
4325
+ lines.push(` ${f4.excerpt}`);
3532
4326
  }
3533
- const fixes = new Map(report2.findings.map((f3) => [f3.tell, f3.fix]));
4327
+ const fixes = new Map(report2.findings.map((f4) => [f4.tell, f4.fix]));
3534
4328
  if (fixes.size > 0) {
3535
4329
  lines.push("", "What to do instead:");
3536
4330
  for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
@@ -3553,9 +4347,91 @@ function formatReport(report2, title) {
3553
4347
  return lines.join("\n").replace(/^\n/, "");
3554
4348
  }
3555
4349
 
4350
+ // src/character/facts.ts
4351
+ var MONTH = "(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)";
4352
+ var PATTERNS = [
4353
+ ["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
4354
+ ["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
4355
+ ["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
4356
+ ["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
4357
+ ["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
4358
+ ["date", new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH}\\b(?:\\s+\\d{4})?|\\b${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?\\b(?:,?\\s+\\d{4})?|\\b${MONTH}\\s+\\d{4}\\b|\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b|\\b(?:19|20)\\d{2}\\b`, "g")],
4359
+ ["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
4360
+ ["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
4361
+ ];
4362
+ var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
4363
+ var normalise = (kind, value) => {
4364
+ const v = value.trim();
4365
+ if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
4366
+ if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
4367
+ if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
4368
+ if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
4369
+ return v.replace(/\s+/g, " ").toLowerCase();
4370
+ };
4371
+ function visibleText(file) {
4372
+ const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
4373
+ return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
4374
+ }
4375
+ function protectedFacts(text) {
4376
+ const facts = [];
4377
+ let rest = text;
4378
+ const mask = (start, length) => {
4379
+ rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
4380
+ };
4381
+ for (const [kind, pattern] of PATTERNS) {
4382
+ for (const m of [...rest.matchAll(pattern)]) {
4383
+ const value = m[0].replace(/[.,;:]+$/, "");
4384
+ facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
4385
+ mask(m.index ?? 0, m[0].length);
4386
+ }
4387
+ }
4388
+ const lines = rest.split("\n");
4389
+ for (const line of lines) {
4390
+ if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
4391
+ for (const m of line.matchAll(NAME)) {
4392
+ const at = m.index ?? 0;
4393
+ const before = line.slice(0, at).trimEnd();
4394
+ const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
4395
+ const word = m[0];
4396
+ const acronym = /^[A-Z]{2,}$/.test(word);
4397
+ if (opensSentence && !acronym && !/\s/.test(word)) continue;
4398
+ if (word === "I") continue;
4399
+ facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
4400
+ }
4401
+ }
4402
+ return facts;
4403
+ }
4404
+ var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
4405
+ function compareFacts(before, after) {
4406
+ const a = unique(protectedFacts(before));
4407
+ const b = unique(protectedFacts(after));
4408
+ const inA = new Set(a.map((f4) => f4.key));
4409
+ const inB = new Set(b.map((f4) => f4.key));
4410
+ return {
4411
+ before: a.length,
4412
+ after: b.length,
4413
+ lost: a.filter((f4) => !inB.has(f4.key)),
4414
+ added: b.filter((f4) => !inA.has(f4.key))
4415
+ };
4416
+ }
4417
+ function formatComparison(result, beforePath, afterPath) {
4418
+ const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
4419
+ if (result.lost.length) {
4420
+ lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
4421
+ for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
4422
+ }
4423
+ if (result.added.length) {
4424
+ lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
4425
+ for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
4426
+ }
4427
+ if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
4428
+ return lines.join("\n");
4429
+ }
4430
+
3556
4431
  // src/fingerprint/index.ts
3557
4432
  var FINGERPRINT_VERSION = 1;
3558
4433
  var CHROMATIC = 0.03;
4434
+ var MIN_ACCENT_AREA = 2e4;
3559
4435
  var oklch = (value) => {
3560
4436
  const parsed = parseColour(value);
3561
4437
  return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
@@ -3568,7 +4444,7 @@ function accentOf(s) {
3568
4444
  }
3569
4445
  const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
3570
4446
  if (topButton) return oklch(topButton[0]);
3571
- 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];
4447
+ 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];
3572
4448
  if (bg) return bg.o;
3573
4449
  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];
3574
4450
  return text ? text.o : null;
@@ -3645,8 +4521,8 @@ function fingerprintDistance(a, b) {
3645
4521
  const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts2[k], 0);
3646
4522
  return { total: Math.round(total * 1e3) / 1e3, parts: parts2 };
3647
4523
  }
3648
- function nearest(target, set, k = set.length) {
3649
- return set.map((item) => ({ id: item.id, distance: fingerprintDistance(target, item.fingerprint) })).sort((x, y) => x.distance.total - y.distance.total).slice(0, k);
4524
+ function nearest(target2, set, k = set.length) {
4525
+ return set.map((item) => ({ id: item.id, distance: fingerprintDistance(target2, item.fingerprint) })).sort((x, y) => x.distance.total - y.distance.total).slice(0, k);
3650
4526
  }
3651
4527
 
3652
4528
  // src/direction/types.ts
@@ -3687,8 +4563,8 @@ function tellFor(key, value) {
3687
4563
  }
3688
4564
  if (key === "display" || key === "body") {
3689
4565
  const name = normaliseFamily(value).toLowerCase();
3690
- if (REFLEX_FONTS_1.some((f3) => f3.toLowerCase() === name)) return "reflex-font";
3691
- if (REFLEX_FONTS_2.some((f3) => f3.toLowerCase() === name)) return "reflex-font-2";
4566
+ if (REFLEX_FONTS_1.some((f4) => f4.toLowerCase() === name)) return "reflex-font";
4567
+ if (REFLEX_FONTS_2.some((f4) => f4.toLowerCase() === name)) return "reflex-font-2";
3692
4568
  }
3693
4569
  return null;
3694
4570
  }
@@ -3953,6 +4829,495 @@ function propose(input) {
3953
4829
  direction: { version: DIRECTION_VERSION, client: input.client, brief: input.brief, sources: input.sources, choices }
3954
4830
  };
3955
4831
  }
4832
+
4833
+ // src/null/index.ts
4834
+ var NULL_VERSION = 1;
4835
+ function nullPrompt(brief) {
4836
+ return [
4837
+ `Build the home page for this business: ${brief.trim()}`,
4838
+ "",
4839
+ "Write it as one complete, self-contained HTML file. You may load fonts and libraries from a CDN. Use real copy, not placeholder text. Reply with the HTML only."
4840
+ ].join("\n");
4841
+ }
4842
+ function extractHtml(reply) {
4843
+ const start = reply.search(/<!doctype html|<html[\s>]/i);
4844
+ if (start < 0) return null;
4845
+ const end = reply.toLowerCase().lastIndexOf("</html>");
4846
+ const html = end > start ? reply.slice(start, end + "</html>".length) : reply.slice(start).replace(/\s*```\s*$/, "");
4847
+ return html.length > 200 ? html : null;
4848
+ }
4849
+ var MIN_RUNS = 5;
4850
+ var TYPICAL_AT = 0.1;
4851
+ function meanNearest(distances, k) {
4852
+ const sorted = [...distances].sort((a, b) => a - b).slice(0, k);
4853
+ return sorted.reduce((s, d) => s + d, 0) / sorted.length;
4854
+ }
4855
+ function median2(values) {
4856
+ const sorted = [...values].sort((a, b) => a - b);
4857
+ const mid = Math.floor(sorted.length / 2);
4858
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
4859
+ }
4860
+ function typicality(target2, runs, k = 3) {
4861
+ if (runs.length < MIN_RUNS) throw new Error(`a null model needs at least ${MIN_RUNS} runs; this one has ${runs.length}`);
4862
+ const kk = Math.min(k, runs.length - 1);
4863
+ const toTarget = runs.map((r) => ({ id: r.id, distance: fingerprintDistance(target2, r.fingerprint).total }));
4864
+ const distance = meanNearest(
4865
+ toTarget.map((t) => t.distance),
4866
+ kk
4867
+ );
4868
+ const loo = runs.map(
4869
+ (r, i) => meanNearest(
4870
+ runs.filter((_, j) => j !== i).map((o) => fingerprintDistance(r.fingerprint, o.fingerprint).total),
4871
+ kk
4872
+ )
4873
+ );
4874
+ const score = loo.filter((d) => d >= distance).length / loo.length;
4875
+ return {
4876
+ score: Math.round(score * 100) / 100,
4877
+ typical: score >= TYPICAL_AT,
4878
+ distance: Math.round(distance * 1e3) / 1e3,
4879
+ baseline: Math.round(median2(loo) * 1e3) / 1e3,
4880
+ nearest: toTarget.sort((a, b) => a.distance - b.distance).slice(0, kk),
4881
+ shared: sharedChoices(target2, runs)
4882
+ };
4883
+ }
4884
+ var HUES = [
4885
+ ["red", 0, 45],
4886
+ ["orange", 45, 75],
4887
+ ["yellow", 75, 110],
4888
+ ["green", 110, 165],
4889
+ ["teal", 165, 215],
4890
+ ["blue", 215, 265],
4891
+ ["violet", 265, 315],
4892
+ ["pink", 315, 360]
4893
+ ];
4894
+ function hueName(o) {
4895
+ if (!o) return "none";
4896
+ if (o.c < 0.03) return "neutral";
4897
+ return HUES.find(([, lo, hi]) => o.h >= lo && o.h < hi)?.[0] ?? "red";
4898
+ }
4899
+ function groundName(o) {
4900
+ if (o.l < 0.3) return "dark";
4901
+ if (isCream(o)) return "cream";
4902
+ if (o.l >= 0.97 && o.c < 0.012) return "white";
4903
+ return o.c < 0.02 ? "grey" : "tinted";
4904
+ }
4905
+ function shapeName(roundness) {
4906
+ if (roundness === null) return "no buttons";
4907
+ if (roundness >= 0.45) return "pill";
4908
+ return roundness >= 0.15 ? "soft" : "square";
4909
+ }
4910
+ function choicesOf(fp) {
4911
+ return {
4912
+ display: [fp.display.family],
4913
+ body: [fp.body.family],
4914
+ accent: [hueName(fp.accent)],
4915
+ ground: [groundName(fp.ground)],
4916
+ shape: [shapeName(fp.roundness)],
4917
+ motion: [fp.motion >= 0.6 ? "reveals most sections" : fp.motion > 0 ? "reveals some sections" : "no scroll reveals"],
4918
+ effects: fp.effects,
4919
+ opening: [fp.layout.slice(0, 3).join(" > ") || "none"]
4920
+ };
4921
+ }
4922
+ function sharedChoices(target2, runs) {
4923
+ const mine = choicesOf(target2);
4924
+ const all = runs.map((r) => choicesOf(r.fingerprint));
4925
+ const out = [];
4926
+ for (const [part, values] of Object.entries(mine)) {
4927
+ for (const value of values) {
4928
+ const count2 = all.filter((c) => c[part].includes(value)).length;
4929
+ if (count2 / runs.length >= 0.25) out.push({ part, value, runs: count2, of: runs.length });
4930
+ }
4931
+ }
4932
+ return out.sort((a, b) => b.runs - a.runs);
4933
+ }
4934
+ var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f4) => f4.toLowerCase()));
4935
+ var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f4) => f4.toLowerCase()));
4936
+ function knownFor(kind, value, sample) {
4937
+ if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
4938
+ if (kind === "accent" && value === "violet") return sample.some((f4) => f4.accent && isAiViolet(f4.accent)) ? "ai-violet" : null;
4939
+ if (kind === "ground" && value === "cream") return "cream-palette";
4940
+ if (kind === "shape" && value === "pill") return "pill-everything";
4941
+ if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
4942
+ if (kind === "tell") return value;
4943
+ if (kind === "opening" && /^hero > (?:logos|stats)/.test(value)) return "hero-then-proof";
4944
+ return null;
4945
+ }
4946
+ var STOP2 = new Set(
4947
+ "a an the and or but of to in on for with at by from as is are be we our you your us it its this that these those will can all more get has have not no any".split(" ")
4948
+ );
4949
+ function grams(text) {
4950
+ const out = /* @__PURE__ */ new Set();
4951
+ for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
4952
+ const words2 = clause.match(/[a-z][a-z'-]*/g) ?? [];
4953
+ for (let n = 2; n <= 4; n += 1) {
4954
+ for (let i = 0; i + n <= words2.length; i += 1) {
4955
+ const g = words2.slice(i, i + n);
4956
+ if (STOP2.has(g[0]) || STOP2.has(g[n - 1])) continue;
4957
+ if (g.filter((w) => !STOP2.has(w)).length < 2) continue;
4958
+ out.add(g.join(" "));
4959
+ }
4960
+ }
4961
+ }
4962
+ return out;
4963
+ }
4964
+ var GRAMMAR = /* @__PURE__ */ new Set([
4965
+ "privacy policy",
4966
+ "cookie policy",
4967
+ "rights reserved",
4968
+ "all rights reserved",
4969
+ "terms of service",
4970
+ "terms and conditions",
4971
+ "terms conditions",
4972
+ "how it works",
4973
+ "what we do",
4974
+ "about us",
4975
+ "contact us",
4976
+ "get in touch",
4977
+ "opening hours",
4978
+ "read more",
4979
+ "learn more",
4980
+ "book now",
4981
+ "free quote",
4982
+ "our services",
4983
+ "quick links",
4984
+ "email address",
4985
+ "phone number",
4986
+ "full name",
4987
+ "first name",
4988
+ "last name",
4989
+ "your name",
4990
+ "your email",
4991
+ "send message"
4992
+ ]);
4993
+ var LEGAL = /©|&copy;|\bregistered (?:in|office|number)\b|\bcompany (?:no|number|reg)|\bvat (?:no|number|reg)/i;
4994
+ var NOISE = /\S+@\S+|https?:\/\/\S+|\b[\w-]+(?:\.[\w-]+)*\.(?:co\.uk|com|org|net|uk|io)\b|\b\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm)\b|\d+/gi;
4995
+ function textGrams(text) {
4996
+ if (LEGAL.test(text)) return /* @__PURE__ */ new Set();
4997
+ const out = grams(text.replace(NOISE, " | "));
4998
+ for (const g of out) if (GRAMMAR.has(g)) out.delete(g);
4999
+ return out;
5000
+ }
5001
+ function properNouns(brief) {
5002
+ return new Set((brief.match(/\b[A-Z][a-z]+/g) ?? []).map((w) => w.toLowerCase()).filter((w) => !STOP2.has(w)));
5003
+ }
5004
+ function harvest(models, options = {}) {
5005
+ const minShare = options.minShare ?? 0.25;
5006
+ const runs = models.flatMap((m, b) => m.runs.map((r) => ({ ...r, brief: b })));
5007
+ const of = runs.length;
5008
+ if (of === 0) return [];
5009
+ const multiBrief = models.length > 1;
5010
+ const sample = runs.map((r) => r.fingerprint);
5011
+ const tally = /* @__PURE__ */ new Map();
5012
+ const add = (kind, value, run, brief, example) => {
5013
+ const key = `${kind}\0${value}`;
5014
+ const t = tally.get(key) ?? { kind, value, runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example };
5015
+ t.runs.add(run);
5016
+ t.briefs.add(brief);
5017
+ tally.set(key, t);
5018
+ };
5019
+ runs.forEach((r, i) => {
5020
+ const c = choicesOf(r.fingerprint);
5021
+ for (const f4 of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f4, i, r.brief);
5022
+ add("accent", c.accent[0], i, r.brief);
5023
+ add("ground", c.ground[0], i, r.brief);
5024
+ add("shape", c.shape[0], i, r.brief);
5025
+ add("motion", c.motion[0], i, r.brief);
5026
+ add("opening", c.opening[0], i, r.brief);
5027
+ for (const t of r.tells) add("tell", t, i, r.brief);
5028
+ });
5029
+ const out = [];
5030
+ for (const t of tally.values()) {
5031
+ if (t.runs.size / of < minShare) continue;
5032
+ if (t.kind === "accent" && (t.value === "none" || t.value === "neutral")) continue;
5033
+ if (t.kind === "shape" && t.value === "no buttons") continue;
5034
+ if (t.kind === "ground" && (t.value === "white" || t.value === "grey")) continue;
5035
+ if (t.kind === "motion" && t.value === "no scroll reveals") continue;
5036
+ out.push({ kind: t.kind, value: t.value, runs: t.runs.size, of, briefs: t.briefs.size, known: knownFor(t.kind, t.value, sample) });
5037
+ }
5038
+ const phraseMin = Math.max(3, Math.ceil(of * minShare * 0.4));
5039
+ const names = models.map((m) => properNouns(m.brief));
5040
+ const phrases2 = /* @__PURE__ */ new Map();
5041
+ runs.forEach((r, i) => {
5042
+ for (const text of r.copy) {
5043
+ for (const g of textGrams(text)) {
5044
+ if (g.split(" ").some((w) => names[r.brief].has(w))) continue;
5045
+ const p = phrases2.get(g) ?? { runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example: text.trim().slice(0, 140) };
5046
+ p.runs.add(i);
5047
+ p.briefs.add(r.brief);
5048
+ phrases2.set(g, p);
5049
+ }
5050
+ }
5051
+ });
5052
+ const kept = [...phrases2].filter(([, p]) => p.runs.size >= phraseMin && (!multiBrief || p.briefs.size >= 2)).sort((a, b) => b[1].runs.size - a[1].runs.size || b[0].length - a[0].length);
5053
+ const maximal = kept.filter(([g, p]) => !kept.some(([h, q]) => h !== g && h.includes(g) && q.runs.size >= 0.8 * p.runs.size));
5054
+ for (const [g, p] of maximal.slice(0, 40)) {
5055
+ out.push({ kind: "phrase", value: g, runs: p.runs.size, of, briefs: p.briefs.size, known: options.copyTell?.(g) ?? null, example: p.example });
5056
+ }
5057
+ const order = ["face", "accent", "ground", "shape", "motion", "opening", "tell", "phrase"];
5058
+ return out.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.runs - a.runs);
5059
+ }
5060
+ function hueSwatch(runs, hue) {
5061
+ const hits = runs.map((r) => r.fingerprint.accent).filter((o) => !!o && hueName(o) === hue);
5062
+ if (hits.length === 0) return null;
5063
+ const centre = hits.reduce((best, o) => hits.reduce((s, p) => s + deltaEOk(o, p), 0) < hits.reduce((s, p) => s + deltaEOk(best, p), 0) ? o : best);
5064
+ return formatHex(oklchToRgb(centre));
5065
+ }
5066
+
5067
+ // src/estate/index.ts
5068
+ var ESTATE_VERSION = 1;
5069
+ var SIBLING_AT = 0.31;
5070
+ function emptyEstate() {
5071
+ return { version: ESTATE_VERSION, sites: [] };
5072
+ }
5073
+ function addSite(register, site) {
5074
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(site.id)) throw new Error(`site id "${site.id}" must be short kebab-case`);
5075
+ const sites = [...register.sites.filter((s) => s.id !== site.id), site].sort((a, b) => a.id.localeCompare(b.id));
5076
+ return { version: ESTATE_VERSION, sites };
5077
+ }
5078
+ function sharedParts(a, b, d = fingerprintDistance(a, b)) {
5079
+ const out = [];
5080
+ const close = (k) => d.parts[k] <= 0.2;
5081
+ if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
5082
+ if (close("type")) {
5083
+ if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
5084
+ if (a.body.family === b.body.family) out.push(`${a.body.family} body`);
5085
+ }
5086
+ if (close("ground")) out.push(`${groundName(a.ground)} ground`);
5087
+ if (close("shape")) out.push(`${shapeName(a.roundness)} buttons`);
5088
+ if (close("motion")) out.push(a.motion > 0 || b.motion > 0 ? "scroll reveals" : "no reveals");
5089
+ if (close("effects") && a.effects.length) out.push(a.effects.filter((e) => b.effects.includes(e)).join(", "));
5090
+ if (close("layout")) out.push("running order");
5091
+ return out;
5092
+ }
5093
+ function compareToEstate(target2, register, options = {}) {
5094
+ const at = options.siblingAt ?? SIBLING_AT;
5095
+ return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
5096
+ const d = fingerprintDistance(target2, s.fingerprint);
5097
+ return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target2, s.fingerprint, d) };
5098
+ }).sort((a, b) => a.distance - b.distance);
5099
+ }
5100
+ function estatePairs(register, siblingAt = SIBLING_AT) {
5101
+ const out = [];
5102
+ const s = register.sites;
5103
+ for (let i = 0; i < s.length; i += 1) {
5104
+ for (let j = i + 1; j < s.length; j += 1) {
5105
+ const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
5106
+ out.push({ a: s[i].id, b: s[j].id, distance: d.total, sibling: d.total < siblingAt, shared: sharedParts(s[i].fingerprint, s[j].fingerprint, d) });
5107
+ }
5108
+ }
5109
+ return out.sort((x, y) => x.distance - y.distance);
5110
+ }
5111
+ function siblingLine(briefs) {
5112
+ const d = [];
5113
+ for (const runs of briefs) {
5114
+ for (let i = 0; i < runs.length; i += 1) for (let j = i + 1; j < runs.length; j += 1) d.push(fingerprintDistance(runs[i], runs[j]).total);
5115
+ }
5116
+ if (d.length === 0) throw new Error("no null pages to measure the sibling line from");
5117
+ d.sort((a, b) => a - b);
5118
+ const mid = Math.floor(d.length / 2);
5119
+ const m = d.length % 2 ? d[mid] : (d[mid - 1] + d[mid]) / 2;
5120
+ return Math.round(m * 1e3) / 1e3;
5121
+ }
5122
+
5123
+ // src/report/index.ts
5124
+ var TELL_HEAVY = 3;
5125
+ var MIN_DECIDED = 5;
5126
+ var TELL_AREA = {
5127
+ "reflex-font": { area: "type", choice: "display" },
5128
+ "reflex-font-2": { area: "type", choice: "display" },
5129
+ "italic-serif-display": { area: "type", choice: "display" },
5130
+ "ai-violet": { area: "colour", choice: "accent" },
5131
+ "blue-purple-gradient": { area: "colour", choice: "accent" },
5132
+ "gradient-text": { area: "colour", choice: "accent" },
5133
+ "cream-palette": { area: "colour", choice: "ground" },
5134
+ "pill-everything": { area: "shape", choice: "shape" },
5135
+ "shadcn-dump": { area: "shape", choice: "shape" },
5136
+ "glass-panel": { area: "effects", choice: "motif" },
5137
+ "radial-spotlight-glow": { area: "effects", choice: "motif" },
5138
+ "grid-background": { area: "effects", choice: "motif" },
5139
+ "thin-border-wide-shadow": { area: "effects", choice: "shape" },
5140
+ "icon-tile-stack": { area: "effects", choice: "motif" },
5141
+ "reveal-everywhere": { area: "motion", choice: "signature" },
5142
+ "intro-cinematic": { area: "motion", choice: "signature" },
5143
+ marquee: { area: "motion", choice: "signature" },
5144
+ "hero-then-proof": { area: "layout" },
5145
+ "icon-tile-grid": { area: "layout" },
5146
+ "hero-eyebrow-chip": { area: "layout" },
5147
+ "bento-grid": { area: "layout" }
5148
+ };
5149
+ var PART_AREA = {
5150
+ display: { area: "type", choice: "display" },
5151
+ body: { area: "type", choice: "body" },
5152
+ accent: { area: "colour", choice: "accent" },
5153
+ ground: { area: "colour", choice: "ground" },
5154
+ shape: { area: "shape", choice: "shape" },
5155
+ effects: { area: "effects", choice: "motif" },
5156
+ motion: { area: "motion", choice: "signature" }
5157
+ };
5158
+ var DEFAULTS = /^(?:ground )?(?:white|grey)(?: ground)?$|no scroll reveals|no reveals|accent (?:none|neutral)|^no buttons|shape no buttons/;
5159
+ function designFindings(findings, copyTells) {
5160
+ return (findings?.findings ?? []).filter((f4) => !copyTells.has(f4.tell));
5161
+ }
5162
+ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
5163
+ const actions = [];
5164
+ const signals = [];
5165
+ if (input.findings) {
5166
+ const design = designFindings(input.findings, copyTells);
5167
+ const distinct = [...new Set(design.map((f4) => f4.tell))];
5168
+ signals.push({
5169
+ id: "tells",
5170
+ raised: distinct.length >= TELL_HEAVY,
5171
+ detail: `${distinct.length} distinct design tell${distinct.length === 1 ? "" : "s"}${distinct.length ? `: ${distinct.join(", ")}` : ""}`
5172
+ });
5173
+ const seen = /* @__PURE__ */ new Set();
5174
+ for (const f4 of design) {
5175
+ if (seen.has(f4.tell)) continue;
5176
+ seen.add(f4.tell);
5177
+ const map = TELL_AREA[f4.tell] ?? { area: "effects" };
5178
+ const both = design.some((g) => g.tell === f4.tell && g.line === 0) && design.some((g) => g.tell === f4.tell && g.line > 0);
5179
+ actions.push({ area: map.area, what: f4.fix, why: `${f4.name} (${f4.tell}, gen ${f4.generation})${both ? ", in source and on the page" : ""}`, priority: both ? 1 : 2, choice: map.choice });
5180
+ }
5181
+ const copy = (input.findings.findings ?? []).filter((f4) => copyTells.has(f4.tell));
5182
+ const byTell = /* @__PURE__ */ new Map();
5183
+ for (const f4 of copy) byTell.set(f4.tell, [...byTell.get(f4.tell) ?? [], f4]);
5184
+ for (const [tell, list] of byTell) {
5185
+ actions.push({ area: "copy", what: list[0].fix, why: `${list[0].name} (${tell}), ${list.length} place${list.length === 1 ? "" : "s"}, first at ${list[0].path}${list[0].line ? `:${list[0].line}` : ""}`, priority: 3 });
5186
+ }
5187
+ } else {
5188
+ signals.push({ id: "tells", raised: null, detail: "not measured" });
5189
+ }
5190
+ if (input.typicality) {
5191
+ const t = input.typicality;
5192
+ signals.push({ id: "typicality", raised: t.typical, detail: `score ${t.score.toFixed(2)}, ${t.typical ? "typical" : "not typical"} of what Claude builds for this brief` });
5193
+ if (t.typical) {
5194
+ for (const s of t.shared) {
5195
+ const map = PART_AREA[s.part];
5196
+ if (!map || DEFAULTS.test(`${s.part} ${s.value}`)) continue;
5197
+ actions.push({ area: map.area, what: `Move off the model's pick for this brief: ${s.part} ${s.value}.`, why: `${s.runs} of ${s.of} null pages chose it`, priority: 1, choice: map.choice });
5198
+ }
5199
+ }
5200
+ } else {
5201
+ signals.push({ id: "typicality", raised: null, detail: "not measured: no null model for this brief" });
5202
+ }
5203
+ if (input.estate) {
5204
+ const siblings = input.estate.filter((m) => m.sibling);
5205
+ const nearest2 = input.estate[0];
5206
+ signals.push({
5207
+ id: "estate",
5208
+ raised: siblings.length > 0,
5209
+ detail: siblings.length ? `sibling of ${siblings.map((s) => `${s.id} (${s.distance.toFixed(2)})`).join(", ")}` : nearest2 ? `nearest ${nearest2.id} at ${nearest2.distance.toFixed(2)}, no sibling` : "the register is empty"
5210
+ });
5211
+ for (const s of siblings) {
5212
+ for (const part of s.shared) {
5213
+ if (DEFAULTS.test(part)) continue;
5214
+ actions.push({ area: areaOfShared(part), what: `Differ from ${s.id}: both have ${part}.`, why: `sibling at ${s.distance.toFixed(2)}`, priority: 1, choice: choiceOfShared(part) });
5215
+ }
5216
+ }
5217
+ } else {
5218
+ signals.push({ id: "estate", raised: null, detail: "not measured: no estate register" });
5219
+ }
5220
+ if (input.direction) {
5221
+ const d = input.direction;
5222
+ const raised2 = !d.valid || d.decided < MIN_DECIDED;
5223
+ signals.push({ id: "reasons", raised: raised2, detail: `${d.decided} of 7 choices decided with a reason${d.valid ? "" : ", art-direction.json has errors"}` });
5224
+ for (const p of d.problems) {
5225
+ const key = p.at.match(/^choices\.(\w+)/)?.[1];
5226
+ if (p.severity === "error" || p.message === "not decided yet") {
5227
+ actions.push({ area: "direction", what: key && p.message === "not decided yet" ? `Decide ${key}, with a source from the client's world.` : `${p.at || "art-direction.json"}: ${p.message}`, why: "the reason rule", priority: 1, choice: key });
5228
+ }
5229
+ }
5230
+ } else {
5231
+ signals.push({ id: "reasons", raised: null, detail: "not measured: no art-direction.json" });
5232
+ actions.push({ area: "direction", what: "Write art-direction.json: run craft direction init, then give every choice a reason from the client's world.", why: "the reason rule", priority: 1 });
5233
+ }
5234
+ const raised = signals.filter((s) => s.raised === true).length;
5235
+ const unmeasured = signals.filter((s) => s.raised === null).map((s) => s.id);
5236
+ const verdict = raised >= 2 ? "default" : raised === 1 ? "mixed" : unmeasured.length ? "unproven" : "decided";
5237
+ const summary = verdict === "default" ? `${raised} of 4 signals raised: nobody chose how this looks.` : verdict === "mixed" ? `1 of 4 signals raised: ${signals.find((s) => s.raised).id}.` : verdict === "decided" ? "No signal raised, and every choice carries a reason." : `No signal raised, but ${unmeasured.join(", ")} not measured. Unverified is not a pass.`;
5238
+ const order = ["direction", "type", "colour", "shape", "effects", "motion", "layout", "copy"];
5239
+ const merged = [];
5240
+ for (const a of actions) {
5241
+ const same = a.choice && a.area !== "direction" && a.area !== "copy" ? merged.find((m) => m.choice === a.choice && m.area === a.area) : void 0;
5242
+ if (same) {
5243
+ const fromTell = /\(\S+, gen \d\)/.test(a.why);
5244
+ same.why = `${same.why}; ${fromTell ? a.why : `${a.what.replace(/\.$/, "")} (${a.why})`}`;
5245
+ same.priority = Math.min(same.priority, a.priority);
5246
+ } else merged.push({ ...a });
5247
+ }
5248
+ const unique2 = merged.filter((a, i) => merged.findIndex((b) => b.area === a.area && b.what === a.what) === i);
5249
+ unique2.sort((a, b) => a.priority - b.priority || order.indexOf(a.area) - order.indexOf(b.area));
5250
+ return { site: input.site, verdict, summary, signals, actions: unique2 };
5251
+ }
5252
+ function areaOfShared(part) {
5253
+ if (/headline|body$/.test(part)) return "type";
5254
+ if (/accent|ground/.test(part)) return "colour";
5255
+ if (/buttons/.test(part)) return "shape";
5256
+ if (/reveal/.test(part)) return "motion";
5257
+ if (/running order/.test(part)) return "layout";
5258
+ return "effects";
5259
+ }
5260
+ function choiceOfShared(part) {
5261
+ if (/headline/.test(part)) return "display";
5262
+ if (/body$/.test(part)) return "body";
5263
+ if (/accent/.test(part)) return "accent";
5264
+ if (/ground/.test(part)) return "ground";
5265
+ if (/buttons/.test(part)) return "shape";
5266
+ if (/reveal/.test(part)) return "signature";
5267
+ return /running order/.test(part) ? void 0 : "motif";
5268
+ }
5269
+
5270
+ // src/report/retrofit.ts
5271
+ var HEADINGS = {
5272
+ direction: "Decide first",
5273
+ type: "Type",
5274
+ colour: "Colour",
5275
+ shape: "Shape",
5276
+ effects: "Effects",
5277
+ motion: "Motion",
5278
+ layout: "How sections look (not where they sit)",
5279
+ copy: "Copy"
5280
+ };
5281
+ var ORDER2 = ["direction", "type", "colour", "shape", "effects", "motion", "layout", "copy"];
5282
+ function target(a, direction) {
5283
+ if (!a.choice || a.area === "direction") return "";
5284
+ const c = direction?.choices?.[a.choice];
5285
+ if (c && c.because && !c.because.startsWith("PROPOSED:")) return `
5286
+ - Use: **${c.value}** for ${a.choice}. ${c.because}`;
5287
+ return `
5288
+ - Waits on: deciding **${a.choice}** in art-direction.json.`;
5289
+ }
5290
+ function retrofitPlan(report2, direction) {
5291
+ const lines = [
5292
+ `# Retrofit: ${report2.site}`,
5293
+ "",
5294
+ `**Now:** ${report2.verdict}. ${report2.summary}`,
5295
+ "",
5296
+ "| Signal | Raised | Reading |",
5297
+ "|---|---|---|",
5298
+ ...report2.signals.map((s) => `| ${s.id} | ${s.raised === null ? "not measured" : s.raised ? "yes" : "no"} | ${s.detail} |`),
5299
+ "",
5300
+ "**Done when** `craft report` says *decided*: fewer than three design tells, typicality under 0.10 against this brief's null, no sibling in the estate, and at least five of the seven choices decided with a reason.",
5301
+ ""
5302
+ ];
5303
+ let n = 0;
5304
+ for (const area of ORDER2) {
5305
+ const list = report2.actions.filter((a) => a.area === area);
5306
+ if (list.length === 0) continue;
5307
+ n += 1;
5308
+ lines.push(`## ${n}. ${HEADINGS[area]}`, "");
5309
+ for (const a of list) lines.push(`- [ ] ${a.what} *(${a.why})*${target(a, direction)}`);
5310
+ lines.push("");
5311
+ }
5312
+ if (n === 0) lines.push("Nothing to change.", "");
5313
+ lines.push(
5314
+ "## Leave alone",
5315
+ "",
5316
+ "Navigation, the order of the sections, where the call to action sits, reading order and legibility. Change the look, never the page grammar. A layout tell above is about how a section looks: the icon tiles, the chip, the strip. The section stays where it is.",
5317
+ ""
5318
+ );
5319
+ return lines.join("\n");
5320
+ }
3956
5321
  export {
3957
5322
  AI_PHRASES,
3958
5323
  AI_VIOLET,
@@ -3969,12 +5334,17 @@ export {
3969
5334
  DURATION_S,
3970
5335
  EASE,
3971
5336
  EASE_TUPLE,
5337
+ ESTATE_VERSION,
3972
5338
  EXIT_RATIO,
3973
5339
  FINGERPRINT_VERSION,
5340
+ HOUSE,
3974
5341
  HOUSE_BUDGET,
3975
5342
  HOUSE_TYPE,
3976
5343
  LIGHTNESS_CURVE,
5344
+ MIN_DECIDED,
5345
+ MIN_RUNS,
3977
5346
  NEGATIVE_REASSURANCE,
5347
+ NULL_VERSION,
3978
5348
  PILL_LIMIT,
3979
5349
  PLAINER_WORDS,
3980
5350
  PROPOSED,
@@ -3987,27 +5357,39 @@ export {
3987
5357
  SCALE,
3988
5358
  SHADCN_LIMIT,
3989
5359
  SHADCN_PRIMITIVES,
5360
+ SIBLING_AT,
3990
5361
  SNAPSHOT_VERSION,
3991
5362
  SOURCE_KINDS,
3992
5363
  SPACE_STEPS,
3993
5364
  SPRING,
3994
5365
  STOCK_PHRASES,
5366
+ TELL_HEAVY,
3995
5367
  TYPE_STEPS,
5368
+ TYPICAL_AT,
3996
5369
  VAGUE_WORDS,
3997
5370
  accentFork,
5371
+ addSite,
3998
5372
  apcaContrast,
5373
+ applyHouseGate,
3999
5374
  auditSnapshot,
4000
5375
  catalogueTable,
5376
+ characterReport,
4001
5377
  checkCopy,
4002
5378
  checkPair,
4003
5379
  checkRestraint,
5380
+ choicesOf,
5381
+ compareFacts,
5382
+ compareToEstate,
4004
5383
  craftTokens,
4005
5384
  deltaEOk,
4006
5385
  deltaEOkHex,
4007
5386
  densityCss,
4008
5387
  densityTokens,
4009
5388
  emitCss,
5389
+ emptyEstate,
5390
+ estatePairs,
4010
5391
  exitDuration,
5392
+ extractHtml,
4011
5393
  findColours,
4012
5394
  fingerprint,
4013
5395
  fingerprintDistance,
@@ -4015,9 +5397,15 @@ export {
4015
5397
  fluidSpace,
4016
5398
  fluidType,
4017
5399
  fontClass,
5400
+ formatComparison,
4018
5401
  formatHex,
4019
5402
  formatReport,
5403
+ groundName,
5404
+ harvest,
4020
5405
  hexToOklch,
5406
+ houseRule,
5407
+ hueName,
5408
+ hueSwatch,
4021
5409
  inP3Gamut,
4022
5410
  inSrgbGamut,
4023
5411
  initDirection,
@@ -4027,24 +5415,33 @@ export {
4027
5415
  motionTokens,
4028
5416
  nearest,
4029
5417
  normaliseFamily,
5418
+ nullPrompt,
4030
5419
  oklchToHex,
4031
5420
  oklchToRgb,
4032
5421
  paletteFromPixels,
4033
5422
  parseColour,
4034
5423
  parseHex,
4035
5424
  propose,
5425
+ protectedFacts,
4036
5426
  ramp,
4037
5427
  rampFromAnchors,
5428
+ retrofitPlan,
4038
5429
  runTell,
4039
5430
  scanSource,
4040
5431
  sectionRhythm,
4041
5432
  semantic,
5433
+ shapeName,
5434
+ sharedParts,
4042
5435
  shouldAnimate,
5436
+ siblingLine,
4043
5437
  tailwindV3Preset,
4044
5438
  tailwindV4Theme,
4045
5439
  tellById,
4046
5440
  toGamut,
4047
5441
  typeFeatureTokens,
5442
+ typicality,
4048
5443
  validateDirection,
4049
- wcagContrast
5444
+ visibleText,
5445
+ wcagContrast,
5446
+ wordListsJson
4050
5447
  };