@domandigital/craft 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/CHARACTER.md +26 -9
- package/COPY.md +280 -0
- package/README.md +12 -0
- package/dist/{audit-YNODYB3U.js → audit-C62GULQR.js} +3 -3
- package/dist/audit.cjs +859 -86
- package/dist/audit.d.cts +2 -0
- package/dist/audit.d.ts +2 -0
- package/dist/audit.js +859 -86
- package/dist/{chunk-CU5TZHOV.js → chunk-2VLN6JLN.js} +5 -5
- package/dist/{chunk-IQTZWNEK.js → chunk-6LPMM752.js} +5 -5
- package/dist/{chunk-Q54JSNC5.js → chunk-DWZY5MWD.js} +1 -1
- package/dist/{chunk-J2BOAIHJ.js → chunk-JNFHQGKR.js} +1 -1
- package/dist/{chunk-3AP4PJMX.js → chunk-OBVWDPQQ.js} +857 -77
- package/dist/{chunk-OA7SGPN4.js → chunk-UVBVYM5D.js} +2 -2
- package/dist/{chunk-YYC4EGER.js → chunk-X6PHP22P.js} +126 -11
- package/dist/{chunk-GV3QOTQU.js → chunk-XEH4GK5K.js} +4 -4
- package/dist/{cli-6Y5E32FS.js → cli-BWERSVPW.js} +4 -4
- package/dist/{cli-MW3JAGX3.js → cli-C5RLL7AW.js} +4 -4
- package/dist/cli-D6VYGCOE.js +16 -0
- package/dist/cli-I4TQMCUP.js +15 -0
- package/dist/{cli-TM2HWYN7.js → cli-UMQNCHAQ.js} +9 -9
- package/dist/cli.js +2 -2
- package/dist/index.cjs +977 -105
- package/dist/index.d.cts +80 -4
- package/dist/index.d.ts +80 -4
- package/dist/index.js +970 -105
- package/package.json +2 -1
- package/word-lists.json +12 -3
- package/dist/cli-IQT6QZ6U.js +0 -16
- package/dist/cli-PD43TWX7.js +0 -15
package/dist/index.cjs
CHANGED
|
@@ -38,6 +38,7 @@ __export(index_exports, {
|
|
|
38
38
|
ESTATE_VERSION: () => ESTATE_VERSION,
|
|
39
39
|
EXIT_RATIO: () => EXIT_RATIO,
|
|
40
40
|
FINGERPRINT_VERSION: () => FINGERPRINT_VERSION,
|
|
41
|
+
HOUSE: () => HOUSE,
|
|
41
42
|
HOUSE_BUDGET: () => HOUSE_BUDGET,
|
|
42
43
|
HOUSE_TYPE: () => HOUSE_TYPE,
|
|
43
44
|
LIGHTNESS_CURVE: () => LIGHTNESS_CURVE,
|
|
@@ -70,6 +71,7 @@ __export(index_exports, {
|
|
|
70
71
|
accentFork: () => accentFork,
|
|
71
72
|
addSite: () => addSite,
|
|
72
73
|
apcaContrast: () => apcaContrast,
|
|
74
|
+
applyHouseGate: () => applyHouseGate,
|
|
73
75
|
auditSnapshot: () => auditSnapshot,
|
|
74
76
|
catalogueTable: () => catalogueTable,
|
|
75
77
|
characterReport: () => characterReport,
|
|
@@ -77,6 +79,7 @@ __export(index_exports, {
|
|
|
77
79
|
checkPair: () => checkPair,
|
|
78
80
|
checkRestraint: () => checkRestraint,
|
|
79
81
|
choicesOf: () => choicesOf,
|
|
82
|
+
compareFacts: () => compareFacts,
|
|
80
83
|
compareToEstate: () => compareToEstate,
|
|
81
84
|
craftTokens: () => craftTokens,
|
|
82
85
|
deltaEOk: () => deltaEOk,
|
|
@@ -95,11 +98,13 @@ __export(index_exports, {
|
|
|
95
98
|
fluidSpace: () => fluidSpace,
|
|
96
99
|
fluidType: () => fluidType,
|
|
97
100
|
fontClass: () => fontClass,
|
|
101
|
+
formatComparison: () => formatComparison,
|
|
98
102
|
formatHex: () => formatHex,
|
|
99
103
|
formatReport: () => formatReport,
|
|
100
104
|
groundName: () => groundName,
|
|
101
105
|
harvest: () => harvest,
|
|
102
106
|
hexToOklch: () => hexToOklch,
|
|
107
|
+
houseRule: () => houseRule,
|
|
103
108
|
hueName: () => hueName,
|
|
104
109
|
hueSwatch: () => hueSwatch,
|
|
105
110
|
inP3Gamut: () => inP3Gamut,
|
|
@@ -118,6 +123,7 @@ __export(index_exports, {
|
|
|
118
123
|
parseColour: () => parseColour,
|
|
119
124
|
parseHex: () => parseHex,
|
|
120
125
|
propose: () => propose,
|
|
126
|
+
protectedFacts: () => protectedFacts,
|
|
121
127
|
ramp: () => ramp,
|
|
122
128
|
rampFromAnchors: () => rampFromAnchors,
|
|
123
129
|
retrofitPlan: () => retrofitPlan,
|
|
@@ -136,6 +142,7 @@ __export(index_exports, {
|
|
|
136
142
|
typeFeatureTokens: () => typeFeatureTokens,
|
|
137
143
|
typicality: () => typicality,
|
|
138
144
|
validateDirection: () => validateDirection,
|
|
145
|
+
visibleText: () => visibleText,
|
|
139
146
|
wcagContrast: () => wcagContrast,
|
|
140
147
|
wordListsJson: () => wordListsJson
|
|
141
148
|
});
|
|
@@ -1073,7 +1080,7 @@ function checkRestraint(input) {
|
|
|
1073
1080
|
);
|
|
1074
1081
|
}
|
|
1075
1082
|
const families = distinctValues(css, "font-family", props, unresolved).filter(
|
|
1076
|
-
(
|
|
1083
|
+
(f4) => !/\bmonospace\b|\bmono\b/.test(f4)
|
|
1077
1084
|
);
|
|
1078
1085
|
if (families.length > budget.maxFontFamilies) {
|
|
1079
1086
|
push(
|
|
@@ -1397,15 +1404,22 @@ var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
|
|
|
1397
1404
|
function allText(ctx) {
|
|
1398
1405
|
return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
|
|
1399
1406
|
}
|
|
1407
|
+
var LINK = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+|\]\([^)\s]+\)/gi;
|
|
1408
|
+
function linkSpans(text) {
|
|
1409
|
+
return [...text.matchAll(LINK)].map((m) => [m.index ?? 0, (m.index ?? 0) + m[0].length]);
|
|
1410
|
+
}
|
|
1411
|
+
var insideLink = (spans, at) => spans.some(([a, b]) => at >= a && at < b);
|
|
1400
1412
|
function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
|
|
1401
1413
|
return (ctx) => {
|
|
1402
1414
|
const hits = [];
|
|
1403
1415
|
for (const block of source(ctx)) {
|
|
1404
1416
|
const spans = [];
|
|
1417
|
+
const links = linkSpans(block.text);
|
|
1405
1418
|
for (const pattern of patterns) {
|
|
1406
1419
|
const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
1407
1420
|
for (const m of block.text.matchAll(global)) {
|
|
1408
1421
|
if (skip(block, m[0], m.index ?? 0)) continue;
|
|
1422
|
+
if (insideLink(links, m.index ?? 0)) continue;
|
|
1409
1423
|
spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
|
|
1410
1424
|
}
|
|
1411
1425
|
}
|
|
@@ -1446,7 +1460,14 @@ var AI_WORDS = [
|
|
|
1446
1460
|
"moreover",
|
|
1447
1461
|
"navigating",
|
|
1448
1462
|
"ever-evolving",
|
|
1449
|
-
"unparalleled"
|
|
1463
|
+
"unparalleled",
|
|
1464
|
+
// Measured: Kobak et al. 2024 (arXiv 2406.07016) found these 10 to 14 times
|
|
1465
|
+
// above their expected rate in post-ChatGPT abstracts. Academic, so warn only.
|
|
1466
|
+
"underscore",
|
|
1467
|
+
"underscores",
|
|
1468
|
+
"underscoring",
|
|
1469
|
+
"showcasing",
|
|
1470
|
+
"pivotal"
|
|
1450
1471
|
];
|
|
1451
1472
|
var STOCK_PHRASES = [
|
|
1452
1473
|
"in today's digital age",
|
|
@@ -1460,7 +1481,11 @@ var STOCK_PHRASES = [
|
|
|
1460
1481
|
"curated",
|
|
1461
1482
|
"crafted with care",
|
|
1462
1483
|
"tailored to your",
|
|
1463
|
-
"peace of mind"
|
|
1484
|
+
"peace of mind",
|
|
1485
|
+
"so here's",
|
|
1486
|
+
"in an era where",
|
|
1487
|
+
"the future looks bright",
|
|
1488
|
+
"only time will tell"
|
|
1464
1489
|
];
|
|
1465
1490
|
var AI_PHRASES = [
|
|
1466
1491
|
"let's dive in",
|
|
@@ -1593,7 +1618,7 @@ function lineAround(text, index) {
|
|
|
1593
1618
|
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);
|
|
1594
1619
|
var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
|
|
1595
1620
|
var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
|
|
1596
|
-
var EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}]+/u;
|
|
1621
|
+
var EMOJI = /(?:[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u2728\u2705\u274C\u274E\u2757\u2753\u26A1\u2B50\u2764]|[\u2600-\u27BF]\uFE0F)+/u;
|
|
1597
1622
|
var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
|
|
1598
1623
|
function badgeHits(ctx) {
|
|
1599
1624
|
const hits = [];
|
|
@@ -1636,10 +1661,16 @@ var COPY_TELLS = [
|
|
|
1636
1661
|
surface: "copy",
|
|
1637
1662
|
why: "Phrases every generated services page uses. They fill space where a fact should be.",
|
|
1638
1663
|
fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
|
|
1639
|
-
detect: phrases([wordList(STOCK_PHRASES)], quoted),
|
|
1664
|
+
detect: phrases([wordList(STOCK_PHRASES), /\b(?:in|on|along)\s+(?:its|their|his|her)\s+journey\b/i], quoted),
|
|
1640
1665
|
fixtures: {
|
|
1641
|
-
flag: [
|
|
1642
|
-
|
|
1666
|
+
flag: [
|
|
1667
|
+
f("content/home.md", "Take your plumbing to the next level with peace of mind."),
|
|
1668
|
+
f("content/home.md", "We meet each practice where it is in its journey.")
|
|
1669
|
+
],
|
|
1670
|
+
pass: [
|
|
1671
|
+
f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley."),
|
|
1672
|
+
f("content/home.md", "We mapped the patient journey from booking to aftercare.")
|
|
1673
|
+
]
|
|
1643
1674
|
}
|
|
1644
1675
|
},
|
|
1645
1676
|
{
|
|
@@ -1664,7 +1695,11 @@ var COPY_TELLS = [
|
|
|
1664
1695
|
f("content/home.md", "In today's rapidly evolving market, we help."),
|
|
1665
1696
|
f("content/home.md", "Whether you're a landlord or a tenant, call us.")
|
|
1666
1697
|
],
|
|
1667
|
-
pass: [
|
|
1698
|
+
pass: [
|
|
1699
|
+
f("content/home.md", "Book in three taps. We confirm by text within the hour."),
|
|
1700
|
+
f("content/home.md", "Book at https://example.com/seamless-booking today."),
|
|
1701
|
+
f("content/home.md", "[Book a slot](/seamless-booking) today.")
|
|
1702
|
+
]
|
|
1668
1703
|
}
|
|
1669
1704
|
},
|
|
1670
1705
|
{
|
|
@@ -1813,8 +1848,14 @@ var COPY_TELLS = [
|
|
|
1813
1848
|
fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
|
|
1814
1849
|
detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
|
|
1815
1850
|
fixtures: {
|
|
1816
|
-
flag: [
|
|
1817
|
-
|
|
1851
|
+
flag: [
|
|
1852
|
+
f("content/home.md", "New treatments this month \u2728\u{1F680}"),
|
|
1853
|
+
f("app/cta.tsx", `export const label = "Book \u{1F680}";`),
|
|
1854
|
+
f("content/home.md", "New treatments this month \u2728"),
|
|
1855
|
+
f("content/home.md", "Same-day callouts \u2705"),
|
|
1856
|
+
f("content/home.md", "Open Sundays \u2600\uFE0F")
|
|
1857
|
+
],
|
|
1858
|
+
pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192"), f("content/home.md", "\u2713 Gas Safe registered")]
|
|
1818
1859
|
}
|
|
1819
1860
|
},
|
|
1820
1861
|
{
|
|
@@ -1912,6 +1953,698 @@ var COPY_TELLS = [
|
|
|
1912
1953
|
}
|
|
1913
1954
|
}
|
|
1914
1955
|
];
|
|
1956
|
+
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;
|
|
1957
|
+
var VAGUE_ATTRIBUTION = [
|
|
1958
|
+
/\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,
|
|
1959
|
+
/\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
|
|
1960
|
+
/\bit\s+is\s+(?:widely|generally|commonly)\s+(?:known|accepted|believed|agreed)\b/i
|
|
1961
|
+
];
|
|
1962
|
+
function sourced(block, _match, index) {
|
|
1963
|
+
const before = block.text.slice(Math.max(0, index - 40), index);
|
|
1964
|
+
if (/(?:\b[A-Z][\w&]*[’']s|\b[A-Z]{2,}[a-z]*)\s+$/.test(before)) return true;
|
|
1965
|
+
const rest = block.text.slice(index, index + 400);
|
|
1966
|
+
const end = rest.search(/[.!?](?:\s|$)/);
|
|
1967
|
+
const sentence = rest.slice(0, end === -1 ? rest.length : end + 16);
|
|
1968
|
+
return /\[\^\w+\]|\]\(https?:|\(source|according to [A-Z]|\bper [A-Z]/.test(sentence);
|
|
1969
|
+
}
|
|
1970
|
+
var CLOSING_SUMMARY = /(?:^|[.!?]\s+|\n\s*|>\s*)(?:Overall|In conclusion|In summary|To sum up|All in all|In short|To summari[sz]e),/;
|
|
1971
|
+
var FALSE_RANGE = [
|
|
1972
|
+
/\bfrom\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+alike\b/i,
|
|
1973
|
+
/\b(?:everyone|anyone)\s+from\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s*){1,4}/i
|
|
1974
|
+
];
|
|
1975
|
+
var RESIDUE = [
|
|
1976
|
+
/\bas an AI(?:\s+language)?\s+(?:model|assistant)\b/i,
|
|
1977
|
+
/(?:^|\n|>)\s*(?:Certainly|Sure|Absolutely|Of course|Great question)[!,.]\s+(?:Here(?:['’]s| is| are)|Below)\b/i,
|
|
1978
|
+
/\bhere(?:['’]s| is)\s+(?:a|an|the|your)\s+(?:revised|rewritten|updated|polished|refined|improved|draft|more concise)\s+(?:version|draft|copy)\b/i,
|
|
1979
|
+
/\blet me know if you(?:['’]d| would) like me to\b/i,
|
|
1980
|
+
/\b(?:my|the) (?:knowledge|training) cut-?off\b|\bas of my last (?:update|training)\b/i,
|
|
1981
|
+
/\bI hope this helps\b/i,
|
|
1982
|
+
// Citation tokens a chat tool leaves in copied text.
|
|
1983
|
+
// A run of them is one paste: "citeturn11search1turn10view0" is one finding.
|
|
1984
|
+
// ChatGPT wraps them in invisible private-use characters (U+E200 to U+E202),
|
|
1985
|
+
// which survive a copy and paste into a CMS: found live, 2026-09-23.
|
|
1986
|
+
/[\uE200-\uE2FF]*(?:cite)?[\uE200-\uE2FF]*(?:turn\d+(?:search|news|view|fetch|file|image)\d+[\uE200-\uE2FF]*)+/,
|
|
1987
|
+
/:?contentReference\[oaicite:\d+\](?:\{index=\d+\})?/,
|
|
1988
|
+
// Entity markers from the same paste: entity["company","Bark","services
|
|
1989
|
+
// marketplace"] shows on the page where the plain name "Bark" belongs.
|
|
1990
|
+
/[\uE200-\uE2FF]*entity[\uE200-\uE2FF]*\["[a-z_]+"\s*,\s*"[^"\n]{1,80}"(?:\s*,\s*"[^"\n]{0,160}")*\][\uE200-\uE2FF]*/,
|
|
1991
|
+
/【\d+(?:[:†][^】]{0,40})?】/
|
|
1992
|
+
];
|
|
1993
|
+
var AI_UTM = /utm_source=(?:chatgpt\.com|chat\.openai\.com|openai|perplexity(?:\.ai)?|claude\.ai|copilot(?:\.microsoft\.com)?|gemini(?:\.google\.com)?)\b/gi;
|
|
1994
|
+
function residueHits(ctx) {
|
|
1995
|
+
const hits = phrases(RESIDUE, quoted, allText)(ctx);
|
|
1996
|
+
for (const file of ctx.files) {
|
|
1997
|
+
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]}"` });
|
|
1998
|
+
}
|
|
1999
|
+
return hits;
|
|
2000
|
+
}
|
|
2001
|
+
var PLACEHOLDER = [
|
|
2002
|
+
/\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n]{0,40}\](?!\()/i,
|
|
2003
|
+
/\blorem ipsum\b/i,
|
|
2004
|
+
/\bTODO_PLACEHOLDER\b/
|
|
2005
|
+
];
|
|
2006
|
+
var PLACEHOLDER_ELEMENT = />\s*(\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n<]{0,40}\])\s*</gi;
|
|
2007
|
+
function insideQuotes(block, _match, index) {
|
|
2008
|
+
const lineStart = block.text.lastIndexOf("\n", index - 1) + 1;
|
|
2009
|
+
const before = block.text.slice(lineStart, index);
|
|
2010
|
+
const straight = (before.match(/"/g) ?? []).length;
|
|
2011
|
+
const curly = (before.match(/“/g) ?? []).length - (before.match(/”/g) ?? []).length;
|
|
2012
|
+
return straight % 2 === 1 || curly > 0;
|
|
2013
|
+
}
|
|
2014
|
+
function placeholderHits(ctx) {
|
|
2015
|
+
const hits = phrases(PLACEHOLDER, quoted, allText, insideQuotes)(ctx);
|
|
2016
|
+
const seen = new Set(hits.map((h) => `${h.path}:${h.offset}`));
|
|
2017
|
+
for (const file of ctx.files) {
|
|
2018
|
+
if (isProsePath(file.path)) continue;
|
|
2019
|
+
for (const m of file.text.matchAll(PLACEHOLDER_ELEMENT)) {
|
|
2020
|
+
const offset = (m.index ?? 0) + m[0].indexOf(m[1]);
|
|
2021
|
+
if (!seen.has(`${file.path}:${offset}`)) hits.push({ path: file.path, offset, message: quoted(m[1]) });
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
return hits;
|
|
2025
|
+
}
|
|
2026
|
+
var QUESTION_REVEAL = [
|
|
2027
|
+
/\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]/,
|
|
2028
|
+
/\?\s+It means\b/
|
|
2029
|
+
];
|
|
2030
|
+
function inlineLabelHits(ctx) {
|
|
2031
|
+
const hits = [];
|
|
2032
|
+
for (const file of ctx.files) {
|
|
2033
|
+
if (!isProsePath(file.path)) continue;
|
|
2034
|
+
const lines = file.text.split("\n");
|
|
2035
|
+
let run = [];
|
|
2036
|
+
let offset = 0;
|
|
2037
|
+
const flush = () => {
|
|
2038
|
+
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` });
|
|
2039
|
+
run = [];
|
|
2040
|
+
};
|
|
2041
|
+
for (const line of lines) {
|
|
2042
|
+
const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
|
|
2043
|
+
if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
|
|
2044
|
+
else flush();
|
|
2045
|
+
offset += line.length + 1;
|
|
2046
|
+
}
|
|
2047
|
+
flush();
|
|
2048
|
+
}
|
|
2049
|
+
return hits;
|
|
2050
|
+
}
|
|
2051
|
+
var RESEARCH_TELLS = [
|
|
2052
|
+
{
|
|
2053
|
+
id: "chatbot-residue",
|
|
2054
|
+
name: "Chatbot residue",
|
|
2055
|
+
generation: 1,
|
|
2056
|
+
severity: "warn",
|
|
2057
|
+
surface: "copy",
|
|
2058
|
+
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.",
|
|
2059
|
+
fix: "Delete the residue. Then read the whole passage again, because the rest of it came from the same paste.",
|
|
2060
|
+
detect: residueHits,
|
|
2061
|
+
fixtures: {
|
|
2062
|
+
flag: [
|
|
2063
|
+
f("content/home.md", "Certainly! Here's a revised version of your home page."),
|
|
2064
|
+
f("content/home.md", "As an AI language model, I cannot visit your salon."),
|
|
2065
|
+
f("content/home.md", "Let me know if you'd like me to shorten it."),
|
|
2066
|
+
f("content/home.md", "Prices start at \xA340 citeturn0search3 for a full set."),
|
|
2067
|
+
f("content/home.md", "Prices start at \xA340. citeturn11search1turn10view0turn2view3"),
|
|
2068
|
+
f("content/home.md", "Prices start at \xA340. \uE200cite\uE202turn11search1\uE202turn10view0\uE201"),
|
|
2069
|
+
f("content/home.md", "Prices start at \xA340 :contentReference[oaicite:2]{index=2} for a full set."),
|
|
2070
|
+
f("content/home.md", "Prices start at \xA340\u30104\u2020source\u3011 for a full set."),
|
|
2071
|
+
f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=chatgpt.com">Read the guide</a>;`),
|
|
2072
|
+
f("content/home.md", "Here's a more concise version for the hero.")
|
|
2073
|
+
],
|
|
2074
|
+
pass: [
|
|
2075
|
+
f("content/home.md", "Certainly the busiest week of the year: book early."),
|
|
2076
|
+
f("content/home.md", "Here's the price list for 2026."),
|
|
2077
|
+
f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=newsletter">Read the guide</a>;`)
|
|
2078
|
+
]
|
|
2079
|
+
}
|
|
2080
|
+
},
|
|
2081
|
+
{
|
|
2082
|
+
id: "placeholder",
|
|
2083
|
+
name: "Unfilled placeholder",
|
|
2084
|
+
generation: 1,
|
|
2085
|
+
severity: "warn",
|
|
2086
|
+
surface: "copy",
|
|
2087
|
+
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.",
|
|
2088
|
+
fix: "Fill it from the client or the proof source, or remove the element. Never invent the missing fact to close it.",
|
|
2089
|
+
detect: placeholderHits,
|
|
2090
|
+
fixtures: {
|
|
2091
|
+
flag: [
|
|
2092
|
+
f("content/home.md", "Rated 5 stars by [Insert client name] and others."),
|
|
2093
|
+
f("content/home.md", "Lorem ipsum dolor sit amet."),
|
|
2094
|
+
f("content/home.md", "Rated TODO_PLACEHOLDER: google_rating from our reviews."),
|
|
2095
|
+
f("app/card.tsx", `export const Quote = () => <blockquote>[Insert testimonial here]</blockquote>;`)
|
|
2096
|
+
],
|
|
2097
|
+
pass: [
|
|
2098
|
+
f("content/home.md", "Manage bookings in [your account](/account)."),
|
|
2099
|
+
f("content/guide.md", 'After 90 days, check your ranking for "[your trade] [your town]" on Google.'),
|
|
2100
|
+
f("content/guide.md", "Send a short message: \u201CThanks for choosing [Business Name]! Leave us a review.\u201D"),
|
|
2101
|
+
f("content/home.md", "Rated 4.9 from 212 Google reviews.")
|
|
2102
|
+
]
|
|
2103
|
+
}
|
|
2104
|
+
},
|
|
2105
|
+
{
|
|
2106
|
+
id: "question-reveal",
|
|
2107
|
+
name: "Staged reveal",
|
|
2108
|
+
generation: 2,
|
|
2109
|
+
severity: "warn",
|
|
2110
|
+
surface: "copy",
|
|
2111
|
+
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.",
|
|
2112
|
+
fix: "State the answer: 'Most customers book again within six weeks.'",
|
|
2113
|
+
detect: phrases(QUESTION_REVEAL, quoted),
|
|
2114
|
+
fixtures: {
|
|
2115
|
+
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.")],
|
|
2116
|
+
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.")]
|
|
2117
|
+
}
|
|
2118
|
+
},
|
|
2119
|
+
{
|
|
2120
|
+
id: "inline-label-list",
|
|
2121
|
+
name: "Bold-label bullets",
|
|
2122
|
+
generation: 2,
|
|
2123
|
+
severity: "warn",
|
|
2124
|
+
surface: "copy",
|
|
2125
|
+
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.",
|
|
2126
|
+
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.",
|
|
2127
|
+
detect: inlineLabelHits,
|
|
2128
|
+
fixtures: {
|
|
2129
|
+
flag: [f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n- **Support:** Help when you need it\n")],
|
|
2130
|
+
pass: [
|
|
2131
|
+
f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
|
|
2132
|
+
f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
|
|
2133
|
+
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")
|
|
2134
|
+
]
|
|
2135
|
+
}
|
|
2136
|
+
},
|
|
2137
|
+
{
|
|
2138
|
+
id: "ing-tail",
|
|
2139
|
+
name: "Empty -ing tail",
|
|
2140
|
+
generation: 2,
|
|
2141
|
+
severity: "warn",
|
|
2142
|
+
surface: "copy",
|
|
2143
|
+
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.",
|
|
2144
|
+
fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
|
|
2145
|
+
detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
|
|
2146
|
+
fixtures: {
|
|
2147
|
+
flag: [
|
|
2148
|
+
f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
|
|
2149
|
+
f("app/about.tsx", `export const About = () => <p>We have served Brackley since 2009, highlighting our commitment to the town.</p>;`)
|
|
2150
|
+
],
|
|
2151
|
+
pass: [
|
|
2152
|
+
f("content/home.md", "We open at 7am, so you can drop the car off before work."),
|
|
2153
|
+
f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
|
|
2154
|
+
]
|
|
2155
|
+
}
|
|
2156
|
+
},
|
|
2157
|
+
{
|
|
2158
|
+
id: "vague-attribution",
|
|
2159
|
+
name: "Unnamed source",
|
|
2160
|
+
generation: 1,
|
|
2161
|
+
severity: "warn",
|
|
2162
|
+
surface: "copy",
|
|
2163
|
+
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.",
|
|
2164
|
+
fix: "Name the source and the number ('A 2023 Which? survey of 2,000 drivers found...') or cut the claim.",
|
|
2165
|
+
detect: phrases(VAGUE_ATTRIBUTION, quoted, (ctx) => ctx.blocks, sourced),
|
|
2166
|
+
fixtures: {
|
|
2167
|
+
flag: [
|
|
2168
|
+
f("content/home.md", "Studies show that regular servicing saves money."),
|
|
2169
|
+
f("content/home.md", "Most experts agree a boiler lasts fifteen years."),
|
|
2170
|
+
f("content/home.md", "It is widely known that gel damages nails.")
|
|
2171
|
+
],
|
|
2172
|
+
pass: [
|
|
2173
|
+
f("content/home.md", "A 2023 Which? survey of 2,000 owners found serviced boilers failed half as often."),
|
|
2174
|
+
f("content/home.md", "BrightLocal's research shows that reviews drive 20% of local ranking."),
|
|
2175
|
+
f("content/home.md", "Research shows 62% of consumers avoid a business with wrong hours.[^7]"),
|
|
2176
|
+
f("content/home.md", "Our customers rate us 4.9 from 212 Google reviews.")
|
|
2177
|
+
]
|
|
2178
|
+
}
|
|
2179
|
+
},
|
|
2180
|
+
{
|
|
2181
|
+
id: "closing-summary",
|
|
2182
|
+
name: "Closing summary",
|
|
2183
|
+
generation: 1,
|
|
2184
|
+
severity: "warn",
|
|
2185
|
+
surface: "copy",
|
|
2186
|
+
why: "'Overall,', 'In conclusion,', 'In short,': a paragraph that announces it is summing up, then restates what the reader has just read.",
|
|
2187
|
+
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'.",
|
|
2188
|
+
detect: phrases([CLOSING_SUMMARY], (m) => quoted(m.replace(/^[.!?>\s]+/, ""))),
|
|
2189
|
+
fixtures: {
|
|
2190
|
+
flag: [
|
|
2191
|
+
f("content/home.md", "We rewire, test and certify.\n\nOverall, we are the right choice for your home."),
|
|
2192
|
+
f("content/home.md", "Prices start at \xA340. In conclusion, we offer great value.")
|
|
2193
|
+
],
|
|
2194
|
+
pass: [f("content/home.md", "We rewire, test and certify.\n\nBook a survey and we'll quote within two days.")]
|
|
2195
|
+
}
|
|
2196
|
+
},
|
|
2197
|
+
{
|
|
2198
|
+
id: "false-range",
|
|
2199
|
+
name: "False range",
|
|
2200
|
+
generation: 1,
|
|
2201
|
+
severity: "warn",
|
|
2202
|
+
surface: "copy",
|
|
2203
|
+
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.",
|
|
2204
|
+
fix: "Say who you actually serve, or cut it. A real range is fine: 'from 9am to 5pm', 'from small repairs to full rewires'.",
|
|
2205
|
+
detect: phrases(FALSE_RANGE, quoted),
|
|
2206
|
+
fixtures: {
|
|
2207
|
+
flag: [
|
|
2208
|
+
f("content/home.md", "We help clients from first-time buyers to seasoned investors alike."),
|
|
2209
|
+
f("content/home.md", "Everyone from students to retirees loves our cakes.")
|
|
2210
|
+
],
|
|
2211
|
+
pass: [
|
|
2212
|
+
f("content/home.md", "Open from 9am to 5pm, Monday to Saturday."),
|
|
2213
|
+
f("content/home.md", "We take on everything from small repairs to full rewires.")
|
|
2214
|
+
]
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
];
|
|
2218
|
+
|
|
2219
|
+
// src/character/tells/density.ts
|
|
2220
|
+
var DOCUMENT = /\.(md|markdown|mdx|txt|html?)$/i;
|
|
2221
|
+
var MIN_WORDS = 400;
|
|
2222
|
+
var WORD = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
2223
|
+
var countWords = (text) => (text.match(WORD) ?? []).length;
|
|
2224
|
+
function documents(ctx) {
|
|
2225
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
2226
|
+
for (const block of ctx.blocks) {
|
|
2227
|
+
if (!DOCUMENT.test(block.path) || block.literal) continue;
|
|
2228
|
+
const list = byPath.get(block.path) ?? [];
|
|
2229
|
+
list.push(block);
|
|
2230
|
+
byPath.set(block.path, list);
|
|
2231
|
+
}
|
|
2232
|
+
const docs = [];
|
|
2233
|
+
for (const [path, blocks] of byPath) {
|
|
2234
|
+
const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2235
|
+
const words2 = countWords(text);
|
|
2236
|
+
if (words2 >= MIN_WORDS) docs.push({ path, blocks, words: words2 });
|
|
2237
|
+
}
|
|
2238
|
+
return docs;
|
|
2239
|
+
}
|
|
2240
|
+
var withoutHeadings = (text) => text.replace(/^\s{0,3}#{1,6}\s.*$/gm, "");
|
|
2241
|
+
var allowed = (doc2, floor, per1000) => Math.max(floor, Math.floor(doc2.words * per1000 / 1e3));
|
|
2242
|
+
var thousands = (n) => n.toLocaleString("en-GB");
|
|
2243
|
+
function sentences(doc2) {
|
|
2244
|
+
const out = [];
|
|
2245
|
+
for (const block of doc2.blocks) {
|
|
2246
|
+
const text = withoutHeadings(block.text);
|
|
2247
|
+
for (const para of text.matchAll(/[^\n]+(?:\n(?!\s*\n)[^\n]+)*/g)) {
|
|
2248
|
+
const body = para[0];
|
|
2249
|
+
const start = para.index ?? 0;
|
|
2250
|
+
for (const m of body.matchAll(/[^.!?]+[.!?]+["'’”)]*|[^.!?]+$/g)) {
|
|
2251
|
+
const raw = m[0];
|
|
2252
|
+
const trimmed = raw.trim();
|
|
2253
|
+
if (countWords(trimmed) === 0) continue;
|
|
2254
|
+
const lead = raw.length - raw.trimStart().length;
|
|
2255
|
+
out.push({ text: trimmed.replace(/\s+/g, " "), offset: block.offset + start + (m.index ?? 0) + lead });
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
return out;
|
|
2260
|
+
}
|
|
2261
|
+
var RATES = [
|
|
2262
|
+
{ label: "rather than", pattern: /\brather\s+than\b/i, floor: 4, per1000: 0.9, why: "the document's one move" },
|
|
2263
|
+
{ label: "instead of", pattern: /\binstead\s+of\b/i, floor: 4, per1000: 0.7, why: "the reflex swap once 'rather than' is edited out" },
|
|
2264
|
+
{ label: "actually", pattern: /\bactually\b/i, floor: 2, per1000: 0.35, why: "filler, the sentence means the same without it" },
|
|
2265
|
+
{ label: "properly", pattern: /\bproperly\b/i, floor: 3, per1000: 0.4, why: "filler, the sentence means the same without it" },
|
|
2266
|
+
{ label: "genuinely", pattern: /\bgenuinely\b/i, floor: 1, per1000: 0.2, why: "reaching for sincerity: one is emphasis, two is a tic" },
|
|
2267
|
+
{
|
|
2268
|
+
label: "the single ...est",
|
|
2269
|
+
pattern: /\bthe\s+single\s+(?:\w+est|most|\w+\s+most)\b/i,
|
|
2270
|
+
floor: 1,
|
|
2271
|
+
per1000: 0.15,
|
|
2272
|
+
why: "if three things are the single most important, none is"
|
|
2273
|
+
},
|
|
2274
|
+
{ 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" },
|
|
2275
|
+
{ label: "Every opener", pattern: /^Every\b/, opener: true, floor: 4, per1000: 0.6, why: "the same sweeping opener, again" },
|
|
2276
|
+
{
|
|
2277
|
+
label: "Worth noting opener",
|
|
2278
|
+
pattern: /^(?:Worth\s+(?:knowing|noting|saying|mentioning|remembering)|It(?:['’]s|\s+is)\s+worth\s+(?:knowing|noting|saying|mentioning|remembering))\b/,
|
|
2279
|
+
opener: true,
|
|
2280
|
+
floor: 3,
|
|
2281
|
+
per1000: 0.5,
|
|
2282
|
+
why: "a hedge that lets a sentence start without committing"
|
|
2283
|
+
},
|
|
2284
|
+
{
|
|
2285
|
+
label: "negation-led opener",
|
|
2286
|
+
pattern: /^(?:None\s+of|Not\s+one|Not\s+a\s+single|Nothing|No\s+one|Nobody|Never)\b/,
|
|
2287
|
+
opener: true,
|
|
2288
|
+
floor: 5,
|
|
2289
|
+
per1000: 1,
|
|
2290
|
+
why: "each earns its place once or twice; at twenty the document has one move"
|
|
2291
|
+
},
|
|
2292
|
+
{
|
|
2293
|
+
label: "participle triad",
|
|
2294
|
+
pattern: /\b[a-z]{3,}ed,\s+[a-z]{3,}ed,?\s+and\s+[a-z]{3,}ed\b/i,
|
|
2295
|
+
floor: 3,
|
|
2296
|
+
per1000: 0.6,
|
|
2297
|
+
why: "'researched, written and referenced' is fine once and a cadence in bulk"
|
|
2298
|
+
}
|
|
2299
|
+
];
|
|
2300
|
+
function rateHits(ctx) {
|
|
2301
|
+
const hits = [];
|
|
2302
|
+
for (const doc2 of documents(ctx)) {
|
|
2303
|
+
const list = sentences(doc2);
|
|
2304
|
+
for (const rate of RATES) {
|
|
2305
|
+
const found = [];
|
|
2306
|
+
const global = new RegExp(rate.pattern.source, rate.pattern.flags.replace("g", "") + "g");
|
|
2307
|
+
for (const s of list) {
|
|
2308
|
+
if (rate.opener) {
|
|
2309
|
+
if (rate.pattern.test(s.text)) found.push(s);
|
|
2310
|
+
} else {
|
|
2311
|
+
const n = (s.text.match(global) ?? []).length;
|
|
2312
|
+
for (let k = 0; k < n; k += 1) found.push(s);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
const limit = allowed(doc2, rate.floor, rate.per1000);
|
|
2316
|
+
if (found.length > limit) {
|
|
2317
|
+
hits.push({
|
|
2318
|
+
path: doc2.path,
|
|
2319
|
+
offset: found[limit].offset,
|
|
2320
|
+
message: `"${rate.label}" ${found.length} times in ${thousands(doc2.words)} words (allows ${limit}): ${rate.why}`,
|
|
2321
|
+
excerpt: found[limit].text.slice(0, 120)
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
return hits;
|
|
2327
|
+
}
|
|
2328
|
+
function aphorismHits(ctx) {
|
|
2329
|
+
const hits = [];
|
|
2330
|
+
for (const doc2 of documents(ctx)) {
|
|
2331
|
+
const found = [];
|
|
2332
|
+
for (const block of doc2.blocks) {
|
|
2333
|
+
let at = 0;
|
|
2334
|
+
for (const line of withoutHeadings(block.text).split("\n")) {
|
|
2335
|
+
const text = line.trim().replace(/^[-*>]\s+/, "");
|
|
2336
|
+
const parts2 = text.match(/[^.!?]+[.!?]+/g) ?? [];
|
|
2337
|
+
if (parts2.length === 2 && parts2.join("").length >= text.length - 1 && parts2.every((p) => countWords(p) >= 2 && countWords(p) <= 7)) {
|
|
2338
|
+
found.push({ text, offset: block.offset + at + line.indexOf(text.charAt(0)) });
|
|
2339
|
+
}
|
|
2340
|
+
at += line.length + 1;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
const limit = allowed(doc2, 5, 1.2);
|
|
2344
|
+
if (found.length > limit) {
|
|
2345
|
+
hits.push({
|
|
2346
|
+
path: doc2.path,
|
|
2347
|
+
offset: found[limit].offset,
|
|
2348
|
+
message: `${found.length} two-line aphorisms in ${thousands(doc2.words)} words (allows ${limit}). Keep the one that lands hardest`,
|
|
2349
|
+
excerpt: found[limit].text
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
return hits;
|
|
2354
|
+
}
|
|
2355
|
+
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;
|
|
2356
|
+
function contractionHits(ctx) {
|
|
2357
|
+
const hits = [];
|
|
2358
|
+
for (const doc2 of documents(ctx)) {
|
|
2359
|
+
if (doc2.words < 800) continue;
|
|
2360
|
+
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2361
|
+
const count2 = (text.match(CONTRACTION) ?? []).length;
|
|
2362
|
+
const rate = count2 * 1e3 / doc2.words;
|
|
2363
|
+
if (rate < 1) {
|
|
2364
|
+
const first = sentences(doc2)[0];
|
|
2365
|
+
hits.push({
|
|
2366
|
+
path: doc2.path,
|
|
2367
|
+
offset: first?.offset ?? 0,
|
|
2368
|
+
message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
return hits;
|
|
2373
|
+
}
|
|
2374
|
+
function rhythmHits(ctx) {
|
|
2375
|
+
const hits = [];
|
|
2376
|
+
for (const doc2 of documents(ctx)) {
|
|
2377
|
+
const list = sentences(doc2);
|
|
2378
|
+
if (list.length < 40) continue;
|
|
2379
|
+
const lengths = list.map((s) => countWords(s.text));
|
|
2380
|
+
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
|
2381
|
+
const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
|
|
2382
|
+
const cv = sd / mean;
|
|
2383
|
+
if (cv < 0.42) {
|
|
2384
|
+
hits.push({
|
|
2385
|
+
path: doc2.path,
|
|
2386
|
+
offset: list[0].offset,
|
|
2387
|
+
message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
return hits;
|
|
2392
|
+
}
|
|
2393
|
+
var normal = (s) => s.toLowerCase().replace(/[’]/g, "'").match(/[a-z0-9£$€'%]+/g) ?? [];
|
|
2394
|
+
function repeatHits(ctx) {
|
|
2395
|
+
const hits = [];
|
|
2396
|
+
const RUN = 9;
|
|
2397
|
+
for (const doc2 of documents(ctx)) {
|
|
2398
|
+
const whole = /* @__PURE__ */ new Map();
|
|
2399
|
+
const runs = /* @__PURE__ */ new Map();
|
|
2400
|
+
for (const s of sentences(doc2)) {
|
|
2401
|
+
const words2 = normal(s.text);
|
|
2402
|
+
if (words2.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
|
|
2403
|
+
const key = words2.join(" ");
|
|
2404
|
+
const earlier = whole.get(key);
|
|
2405
|
+
if (earlier) {
|
|
2406
|
+
hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words2.length} words)`, excerpt: s.text.slice(0, 120) });
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
whole.set(key, s);
|
|
2410
|
+
let reported = false;
|
|
2411
|
+
for (let i = 0; i + RUN <= words2.length && !reported; i += 1) {
|
|
2412
|
+
const run = words2.slice(i, i + RUN).join(" ");
|
|
2413
|
+
const seen = runs.get(run);
|
|
2414
|
+
if (seen && seen.key !== key) {
|
|
2415
|
+
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) });
|
|
2416
|
+
reported = true;
|
|
2417
|
+
} else if (!seen) {
|
|
2418
|
+
runs.set(run, { sentence: s, key });
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
return hits;
|
|
2424
|
+
}
|
|
2425
|
+
function headings(file) {
|
|
2426
|
+
const out = [];
|
|
2427
|
+
for (const m of file.text.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)) {
|
|
2428
|
+
out.push({ text: m[1], offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
|
|
2429
|
+
}
|
|
2430
|
+
for (const m of file.text.matchAll(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/gi)) {
|
|
2431
|
+
const text = m[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
2432
|
+
if (text) out.push({ text, offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
|
|
2433
|
+
}
|
|
2434
|
+
return out.sort((a, b) => a.offset - b.offset);
|
|
2435
|
+
}
|
|
2436
|
+
function longFiles(ctx) {
|
|
2437
|
+
const docs = new Map(documents(ctx).map((d) => [d.path, d]));
|
|
2438
|
+
return ctx.files.filter((f4) => docs.has(f4.path)).map((file) => ({ file, doc: docs.get(file.path) }));
|
|
2439
|
+
}
|
|
2440
|
+
var HEADING_SHAPES = [
|
|
2441
|
+
["X, and Y", /^[^,]{2,}, and [^,]{2,}$/i],
|
|
2442
|
+
["X, because Y", /^[^,]{2,}, because [^,]{2,}$/i],
|
|
2443
|
+
["comma list", /^[A-Z][\w'’-]*(?:, [\w'’-]+){2,}(?:,? (?:and|or) [\w'’-]+)?\.?$/]
|
|
2444
|
+
];
|
|
2445
|
+
function headingShapeHits(ctx) {
|
|
2446
|
+
const hits = [];
|
|
2447
|
+
for (const { file, doc: doc2 } of longFiles(ctx)) {
|
|
2448
|
+
const found = [];
|
|
2449
|
+
for (const heading of headings(file)) {
|
|
2450
|
+
const shape = HEADING_SHAPES.find(([, re]) => re.test(heading.text));
|
|
2451
|
+
if (shape) found.push({ heading, shape: shape[0] });
|
|
2452
|
+
}
|
|
2453
|
+
const limit = allowed(doc2, 3, 0.5);
|
|
2454
|
+
if (found.length > limit) {
|
|
2455
|
+
hits.push({
|
|
2456
|
+
path: file.path,
|
|
2457
|
+
offset: found[limit].heading.offset,
|
|
2458
|
+
message: `${found.length} headings share one shape (${[...new Set(found.map((f4) => f4.shape))].join(", ")}), allows ${limit}`,
|
|
2459
|
+
excerpt: found[limit].heading.text
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
return hits;
|
|
2464
|
+
}
|
|
2465
|
+
function headingEchoHits(ctx) {
|
|
2466
|
+
const hits = [];
|
|
2467
|
+
for (const { file } of longFiles(ctx)) {
|
|
2468
|
+
for (const heading of headings(file)) {
|
|
2469
|
+
const words2 = normal(heading.text);
|
|
2470
|
+
if (words2.length < 4) continue;
|
|
2471
|
+
const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
|
|
2472
|
+
const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words2.length);
|
|
2473
|
+
if (opening.join(" ") === words2.join(" ")) {
|
|
2474
|
+
hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
return hits;
|
|
2479
|
+
}
|
|
2480
|
+
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(" ");
|
|
2481
|
+
function filler(words2, seed = 1, options = {}) {
|
|
2482
|
+
const { contractions = true, uniform } = options;
|
|
2483
|
+
const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
|
|
2484
|
+
let state = seed;
|
|
2485
|
+
const next = (n) => {
|
|
2486
|
+
state = (state * 1103515245 + 12345) % 2147483648;
|
|
2487
|
+
return state % n;
|
|
2488
|
+
};
|
|
2489
|
+
const out = [];
|
|
2490
|
+
let count2 = 0;
|
|
2491
|
+
let i = 0;
|
|
2492
|
+
while (count2 < words2) {
|
|
2493
|
+
const length = uniform ?? shape[i % shape.length];
|
|
2494
|
+
const s = [];
|
|
2495
|
+
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
2496
|
+
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
2497
|
+
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
2498
|
+
out.push(`${s.join(" ")}.`);
|
|
2499
|
+
count2 += s.length;
|
|
2500
|
+
i += 1;
|
|
2501
|
+
}
|
|
2502
|
+
const paragraphs = [];
|
|
2503
|
+
for (let p = 0; p < out.length; p += 4) paragraphs.push(out.slice(p, p + 4).join(" "));
|
|
2504
|
+
return paragraphs.join("\n\n");
|
|
2505
|
+
}
|
|
2506
|
+
var f2 = (path, text) => ({ path, text });
|
|
2507
|
+
var doc = (extra, seed = 1) => `${filler(520, seed)}
|
|
2508
|
+
|
|
2509
|
+
${extra}
|
|
2510
|
+
`;
|
|
2511
|
+
var times = (n, sentence) => Array.from({ length: n }, (_, i) => sentence(i)).join(" ");
|
|
2512
|
+
var DENSITY_TELLS = [
|
|
2513
|
+
{
|
|
2514
|
+
id: "phrase-density",
|
|
2515
|
+
name: "Leaned-on phrase",
|
|
2516
|
+
generation: 2,
|
|
2517
|
+
severity: "warn",
|
|
2518
|
+
surface: "copy",
|
|
2519
|
+
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.",
|
|
2520
|
+
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.",
|
|
2521
|
+
detect: rateHits,
|
|
2522
|
+
fixtures: {
|
|
2523
|
+
flag: [
|
|
2524
|
+
f2("content/proposal.md", doc(times(6, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
2525
|
+
f2("content/proposal.md", doc(times(6, (i) => `We sealed roof ${i} instead of patching it.`))),
|
|
2526
|
+
f2("content/proposal.md", doc(times(4, (i) => `Room ${i} actually needed a new valve.`))),
|
|
2527
|
+
f2("content/proposal.md", doc(times(5, (i) => `Gate ${i} was properly hung.`))),
|
|
2528
|
+
f2("content/proposal.md", doc(times(3, (i) => `Kitchen ${i} was genuinely tidy.`))),
|
|
2529
|
+
f2("content/proposal.md", doc(times(3, (i) => `Pipe ${i} is the single biggest risk.`))),
|
|
2530
|
+
f2("content/proposal.md", doc(times(6, (i) => `That is why tap ${i} leaks.`))),
|
|
2531
|
+
f2("content/proposal.md", doc(times(6, (i) => `Every tap ${i} gets checked.`))),
|
|
2532
|
+
f2("content/proposal.md", doc(times(5, (i) => `Worth noting: valve ${i} sticks.`))),
|
|
2533
|
+
f2("content/proposal.md", doc(times(7, (i) => `None of room ${i} needed paint.`))),
|
|
2534
|
+
f2("content/proposal.md", doc(times(5, (i) => `Wall ${i} was measured, sanded and sealed.`))),
|
|
2535
|
+
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>`)
|
|
2536
|
+
],
|
|
2537
|
+
pass: [
|
|
2538
|
+
f2("content/proposal.md", doc(times(3, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
2539
|
+
f2("content/short.md", times(6, (i) => `We fitted boiler ${i} rather than patching it.`)),
|
|
2540
|
+
f2("app/page.tsx", `export const copy = [${Array.from({ length: 40 }, (_, i) => `"We fitted boiler ${i} rather than patching it."`).join(", ")}];`)
|
|
2541
|
+
]
|
|
2542
|
+
}
|
|
2543
|
+
},
|
|
2544
|
+
{
|
|
2545
|
+
id: "aphorism-density",
|
|
2546
|
+
name: "Aphorism cadence",
|
|
2547
|
+
generation: 2,
|
|
2548
|
+
severity: "warn",
|
|
2549
|
+
surface: "copy",
|
|
2550
|
+
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.",
|
|
2551
|
+
fix: "Keep the strongest one. Fold the rest into the paragraph above as plain statements.",
|
|
2552
|
+
detect: aphorismHits,
|
|
2553
|
+
fixtures: {
|
|
2554
|
+
flag: [f2("content/proposal.md", doc(Array.from({ length: 7 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))],
|
|
2555
|
+
pass: [f2("content/proposal.md", doc(Array.from({ length: 3 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))]
|
|
2556
|
+
}
|
|
2557
|
+
},
|
|
2558
|
+
{
|
|
2559
|
+
id: "contraction-scarcity",
|
|
2560
|
+
name: "No contractions",
|
|
2561
|
+
generation: 1,
|
|
2562
|
+
severity: "warn",
|
|
2563
|
+
surface: "copy",
|
|
2564
|
+
why: "A long document with almost no contractions reads stiff and machine-made. People write 'we're' and 'don't'.",
|
|
2565
|
+
fix: "Contract where you would in speech: 'we are' to 'we're', 'do not' to 'don't'. Formal legal pages are the deliberate exception.",
|
|
2566
|
+
detect: contractionHits,
|
|
2567
|
+
fixtures: {
|
|
2568
|
+
flag: [f2("content/proposal.md", filler(900, 5, { contractions: false }))],
|
|
2569
|
+
pass: [f2("content/proposal.md", filler(900, 5)), f2("content/short.md", filler(500, 5, { contractions: false }))]
|
|
2570
|
+
}
|
|
2571
|
+
},
|
|
2572
|
+
{
|
|
2573
|
+
id: "sentence-rhythm",
|
|
2574
|
+
name: "Metronomic rhythm",
|
|
2575
|
+
generation: 2,
|
|
2576
|
+
severity: "warn",
|
|
2577
|
+
surface: "copy",
|
|
2578
|
+
why: "Sentence after sentence of the same length reads generated. A person's sentences run long, then short, then long again.",
|
|
2579
|
+
fix: "Break one long sentence in two, join two short ones, and let some sentences be flat and plain.",
|
|
2580
|
+
detect: rhythmHits,
|
|
2581
|
+
fixtures: {
|
|
2582
|
+
flag: [f2("content/proposal.md", filler(600, 7, { uniform: 12 }))],
|
|
2583
|
+
pass: [f2("content/proposal.md", filler(600, 7))]
|
|
2584
|
+
}
|
|
2585
|
+
},
|
|
2586
|
+
{
|
|
2587
|
+
id: "repeated-sentence",
|
|
2588
|
+
name: "Repeated sentence",
|
|
2589
|
+
generation: 1,
|
|
2590
|
+
severity: "warn",
|
|
2591
|
+
surface: "copy",
|
|
2592
|
+
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.",
|
|
2593
|
+
fix: "Say it once, where it matters most, and write the second place fresh.",
|
|
2594
|
+
detect: repeatHits,
|
|
2595
|
+
fixtures: {
|
|
2596
|
+
flag: [
|
|
2597
|
+
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.")),
|
|
2598
|
+
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."))
|
|
2599
|
+
],
|
|
2600
|
+
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."))]
|
|
2601
|
+
}
|
|
2602
|
+
},
|
|
2603
|
+
{
|
|
2604
|
+
id: "heading-shape",
|
|
2605
|
+
name: "One heading shape",
|
|
2606
|
+
generation: 2,
|
|
2607
|
+
severity: "warn",
|
|
2608
|
+
surface: "copy",
|
|
2609
|
+
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.",
|
|
2610
|
+
fix: "Write each heading for its section. Most should be a plain noun phrase.",
|
|
2611
|
+
detect: headingShapeHits,
|
|
2612
|
+
fixtures: {
|
|
2613
|
+
flag: [
|
|
2614
|
+
f2("content/proposal.md", ["## Fast, and fairly priced", "## Local, and insured", "## Tidy, and quiet", "## Early, and on time", "", filler(520, 9)].join("\n\n")),
|
|
2615
|
+
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>`),
|
|
2616
|
+
f2("content/proposal.md", ["## Keep, rewrite, retire", "## Fix, paint, seal", "## Book, quote, fit", "## Plan, build, check", "", filler(520, 9)].join("\n\n"))
|
|
2617
|
+
],
|
|
2618
|
+
pass: [f2("content/proposal.md", ["## Pricing", "## Fast, and fairly priced", "## Who we are", "## Areas we cover", "", filler(520, 9)].join("\n\n"))]
|
|
2619
|
+
}
|
|
2620
|
+
},
|
|
2621
|
+
{
|
|
2622
|
+
id: "heading-echo",
|
|
2623
|
+
name: "Heading echoed",
|
|
2624
|
+
generation: 2,
|
|
2625
|
+
severity: "warn",
|
|
2626
|
+
surface: "copy",
|
|
2627
|
+
why: "A heading repeated word for word as the first line under it spends the reader's attention twice on the same words.",
|
|
2628
|
+
fix: "Let the first sentence say something the heading did not.",
|
|
2629
|
+
detect: headingEchoHits,
|
|
2630
|
+
fixtures: {
|
|
2631
|
+
flag: [
|
|
2632
|
+
f2("content/proposal.md", `## We fix boilers the same day
|
|
2633
|
+
|
|
2634
|
+
We fix boilers the same day across Brackley.
|
|
2635
|
+
|
|
2636
|
+
${filler(520, 11)}`),
|
|
2637
|
+
f2("site/index.html", `<h2>We fix boilers the same day</h2>
|
|
2638
|
+
<p>We fix boilers the same day across Brackley.</p><p>${filler(520, 11)}</p>`)
|
|
2639
|
+
],
|
|
2640
|
+
pass: [f2("content/proposal.md", `## We fix boilers the same day
|
|
2641
|
+
|
|
2642
|
+
Call before noon and an engineer is with you by six.
|
|
2643
|
+
|
|
2644
|
+
${filler(520, 11)}`)]
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
];
|
|
1915
2648
|
|
|
1916
2649
|
// src/character/color.ts
|
|
1917
2650
|
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;
|
|
@@ -1932,8 +2665,8 @@ function parts(fn) {
|
|
|
1932
2665
|
function hslToRgb(h, s, l) {
|
|
1933
2666
|
const k = (n) => (n + h / 30) % 12;
|
|
1934
2667
|
const a = s * Math.min(l, 1 - l);
|
|
1935
|
-
const
|
|
1936
|
-
return { r:
|
|
2668
|
+
const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
2669
|
+
return { r: f4(0), g: f4(8), b: f4(4) };
|
|
1937
2670
|
}
|
|
1938
2671
|
var clamp012 = (n) => Math.min(1, Math.max(0, n));
|
|
1939
2672
|
function parseColour(raw) {
|
|
@@ -2045,7 +2778,7 @@ function firstMatch(text, pattern) {
|
|
|
2045
2778
|
return m ? m.index : -1;
|
|
2046
2779
|
}
|
|
2047
2780
|
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-/;
|
|
2048
|
-
var
|
|
2781
|
+
var f3 = (path, text) => ({ path, text });
|
|
2049
2782
|
function familyNames(value) {
|
|
2050
2783
|
return value.split(",").map((v) => v.trim().replace(/^["']|["']$/g, "").trim()).filter(Boolean);
|
|
2051
2784
|
}
|
|
@@ -2510,14 +3243,14 @@ var SOURCE_TELLS = [
|
|
|
2510
3243
|
detect: fontTell(REFLEX_FONTS_1),
|
|
2511
3244
|
fixtures: {
|
|
2512
3245
|
flag: [
|
|
2513
|
-
|
|
3246
|
+
f3("app/layout.tsx", `import { Inter } from "next/font/google";
|
|
2514
3247
|
const inter = Inter({ subsets: ["latin"] });`),
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
3248
|
+
f3("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
|
|
3249
|
+
f3("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
|
|
3250
|
+
f3("main.ts", `import "@fontsource/montserrat";`),
|
|
3251
|
+
f3("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
|
|
2519
3252
|
],
|
|
2520
|
-
pass: [
|
|
3253
|
+
pass: [f3("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
|
|
2521
3254
|
}
|
|
2522
3255
|
},
|
|
2523
3256
|
{
|
|
@@ -2530,10 +3263,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2530
3263
|
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.",
|
|
2531
3264
|
detect: fontTell(REFLEX_FONTS_2),
|
|
2532
3265
|
fixtures: {
|
|
2533
|
-
flag: [
|
|
3266
|
+
flag: [f3("src/styles.css", `@theme {
|
|
2534
3267
|
--font-sans: "DM Sans", system-ui, sans-serif;
|
|
2535
3268
|
}`)],
|
|
2536
|
-
pass: [
|
|
3269
|
+
pass: [f3("src/styles.css", `@theme {
|
|
2537
3270
|
--font-sans: "Hanken Grotesk", system-ui, sans-serif;
|
|
2538
3271
|
}`)]
|
|
2539
3272
|
}
|
|
@@ -2549,10 +3282,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2549
3282
|
detect: violetHits,
|
|
2550
3283
|
fixtures: {
|
|
2551
3284
|
flag: [
|
|
2552
|
-
|
|
2553
|
-
|
|
3285
|
+
f3("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
|
|
3286
|
+
f3("app/globals.css", `:root { --accent: #5a35d1; }`)
|
|
2554
3287
|
],
|
|
2555
|
-
pass: [
|
|
3288
|
+
pass: [f3("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
|
|
2556
3289
|
}
|
|
2557
3290
|
},
|
|
2558
3291
|
{
|
|
@@ -2566,10 +3299,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2566
3299
|
detect: gradientHits,
|
|
2567
3300
|
fixtures: {
|
|
2568
3301
|
flag: [
|
|
2569
|
-
|
|
2570
|
-
|
|
3302
|
+
f3("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
|
|
3303
|
+
f3("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
|
|
2571
3304
|
],
|
|
2572
|
-
pass: [
|
|
3305
|
+
pass: [f3("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
|
|
2573
3306
|
}
|
|
2574
3307
|
},
|
|
2575
3308
|
{
|
|
@@ -2583,10 +3316,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2583
3316
|
detect: gradientTextHits,
|
|
2584
3317
|
fixtures: {
|
|
2585
3318
|
flag: [
|
|
2586
|
-
|
|
2587
|
-
|
|
3319
|
+
f3("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
|
|
3320
|
+
f3("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
|
|
2588
3321
|
],
|
|
2589
|
-
pass: [
|
|
3322
|
+
pass: [f3("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
|
|
2590
3323
|
}
|
|
2591
3324
|
},
|
|
2592
3325
|
{
|
|
@@ -2600,10 +3333,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2600
3333
|
detect: glassHits,
|
|
2601
3334
|
fixtures: {
|
|
2602
3335
|
flag: [
|
|
2603
|
-
|
|
2604
|
-
|
|
3336
|
+
f3("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
|
|
3337
|
+
f3("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
|
|
2605
3338
|
],
|
|
2606
|
-
pass: [
|
|
3339
|
+
pass: [f3("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
|
|
2607
3340
|
}
|
|
2608
3341
|
},
|
|
2609
3342
|
{
|
|
@@ -2616,10 +3349,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2616
3349
|
fix: "Follow the hero with the thing only this business has: the work, the place, the person. Put proof where a doubt arises.",
|
|
2617
3350
|
detect: heroThenProofHits,
|
|
2618
3351
|
fixtures: {
|
|
2619
|
-
flag: [
|
|
3352
|
+
flag: [f3("app/page.tsx", `export default function Page() {
|
|
2620
3353
|
return (<main><Hero /><TrustStrip /><Services /></main>);
|
|
2621
3354
|
}`)],
|
|
2622
|
-
pass: [
|
|
3355
|
+
pass: [f3("app/page.tsx", `export default function Page() {
|
|
2623
3356
|
return (<main><Hero /><Workshop /><TrustStrip /></main>);
|
|
2624
3357
|
}`)]
|
|
2625
3358
|
}
|
|
@@ -2635,7 +3368,7 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2635
3368
|
detect: iconGridHits,
|
|
2636
3369
|
fixtures: {
|
|
2637
3370
|
flag: [
|
|
2638
|
-
|
|
3371
|
+
f3(
|
|
2639
3372
|
"components/Features.tsx",
|
|
2640
3373
|
`import { Heart } from "lucide-react";
|
|
2641
3374
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
@@ -2643,7 +3376,7 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2643
3376
|
</div>`
|
|
2644
3377
|
)
|
|
2645
3378
|
],
|
|
2646
|
-
pass: [
|
|
3379
|
+
pass: [f3("components/Features.tsx", `<ul className="divide-y">
|
|
2647
3380
|
<li className="flex justify-between py-3">Gel manicure <span>\xA332</span></li>
|
|
2648
3381
|
</ul>`)]
|
|
2649
3382
|
}
|
|
@@ -2658,8 +3391,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2658
3391
|
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.`,
|
|
2659
3392
|
detect: revealHits,
|
|
2660
3393
|
fixtures: {
|
|
2661
|
-
flag: [
|
|
2662
|
-
pass: [
|
|
3394
|
+
flag: [f3("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
|
|
3395
|
+
pass: [f3("app/page.tsx", `<Reveal><Signature /></Reveal>
|
|
2663
3396
|
<Block />
|
|
2664
3397
|
<Block />`)]
|
|
2665
3398
|
}
|
|
@@ -2674,8 +3407,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2674
3407
|
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.`,
|
|
2675
3408
|
detect: pillHits,
|
|
2676
3409
|
fixtures: {
|
|
2677
|
-
flag: [
|
|
2678
|
-
pass: [
|
|
3410
|
+
flag: [f3("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
|
|
3411
|
+
pass: [f3("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
|
|
2679
3412
|
<a className="rounded-sm px-4">Book</a>`)]
|
|
2680
3413
|
}
|
|
2681
3414
|
},
|
|
@@ -2689,8 +3422,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2689
3422
|
fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
|
|
2690
3423
|
detect: shadcnHits,
|
|
2691
3424
|
fixtures: {
|
|
2692
|
-
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) =>
|
|
2693
|
-
pass: [
|
|
3425
|
+
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f3(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
|
|
3426
|
+
pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
|
|
2694
3427
|
}
|
|
2695
3428
|
},
|
|
2696
3429
|
{
|
|
@@ -2704,11 +3437,11 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2704
3437
|
detect: creamHits,
|
|
2705
3438
|
fixtures: {
|
|
2706
3439
|
flag: [
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
3440
|
+
f3("app/globals.css", `body { background-color: #f5efe6; }`),
|
|
3441
|
+
f3("app/page.tsx", `<main className="bg-cream text-ink">`),
|
|
3442
|
+
f3("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
|
|
2710
3443
|
],
|
|
2711
|
-
pass: [
|
|
3444
|
+
pass: [f3("app/globals.css", `body { background-color: #ffffff; }`)]
|
|
2712
3445
|
}
|
|
2713
3446
|
},
|
|
2714
3447
|
{
|
|
@@ -2722,11 +3455,11 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2722
3455
|
detect: italicSerifHits,
|
|
2723
3456
|
fixtures: {
|
|
2724
3457
|
flag: [
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
3458
|
+
f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
|
|
3459
|
+
f3("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
|
|
3460
|
+
f3("app/globals.css", `.hero h1 { font-style: italic; }`)
|
|
2728
3461
|
],
|
|
2729
|
-
pass: [
|
|
3462
|
+
pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
|
|
2730
3463
|
<p><em>Walk-ins</em> welcome.</p>`)]
|
|
2731
3464
|
}
|
|
2732
3465
|
},
|
|
@@ -2741,12 +3474,12 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2741
3474
|
detect: eyebrowChipHits,
|
|
2742
3475
|
fixtures: {
|
|
2743
3476
|
flag: [
|
|
2744
|
-
|
|
3477
|
+
f3("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
|
|
2745
3478
|
<h1>Nails in Brackley</h1>`),
|
|
2746
|
-
|
|
3479
|
+
f3("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
|
|
2747
3480
|
<h1>Nails</h1>`)
|
|
2748
3481
|
],
|
|
2749
|
-
pass: [
|
|
3482
|
+
pass: [f3("components/Hero.tsx", `<h1>Nails in Brackley</h1>
|
|
2750
3483
|
<span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
|
|
2751
3484
|
}
|
|
2752
3485
|
},
|
|
@@ -2760,9 +3493,9 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2760
3493
|
fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
|
|
2761
3494
|
detect: iconTileStackHits,
|
|
2762
3495
|
fixtures: {
|
|
2763
|
-
flag: [
|
|
3496
|
+
flag: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
2764
3497
|
<div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
|
|
2765
|
-
pass: [
|
|
3498
|
+
pass: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
2766
3499
|
<p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
|
|
2767
3500
|
}
|
|
2768
3501
|
},
|
|
@@ -2776,8 +3509,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2776
3509
|
fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
|
|
2777
3510
|
detect: bentoHits,
|
|
2778
3511
|
fixtures: {
|
|
2779
|
-
flag: [
|
|
2780
|
-
pass: [
|
|
3512
|
+
flag: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
|
|
3513
|
+
pass: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
|
|
2781
3514
|
}
|
|
2782
3515
|
},
|
|
2783
3516
|
{
|
|
@@ -2791,10 +3524,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2791
3524
|
detect: glowHits,
|
|
2792
3525
|
fixtures: {
|
|
2793
3526
|
flag: [
|
|
2794
|
-
|
|
2795
|
-
|
|
3527
|
+
f3("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
|
|
3528
|
+
f3("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
|
|
2796
3529
|
],
|
|
2797
|
-
pass: [
|
|
3530
|
+
pass: [f3("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
|
|
2798
3531
|
}
|
|
2799
3532
|
},
|
|
2800
3533
|
{
|
|
@@ -2808,13 +3541,13 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2808
3541
|
detect: gridBackgroundHits,
|
|
2809
3542
|
fixtures: {
|
|
2810
3543
|
flag: [
|
|
2811
|
-
|
|
3544
|
+
f3(
|
|
2812
3545
|
"app/globals.css",
|
|
2813
3546
|
`.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
|
|
2814
3547
|
),
|
|
2815
|
-
|
|
3548
|
+
f3("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
|
|
2816
3549
|
],
|
|
2817
|
-
pass: [
|
|
3550
|
+
pass: [f3("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
|
|
2818
3551
|
}
|
|
2819
3552
|
},
|
|
2820
3553
|
{
|
|
@@ -2828,10 +3561,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2828
3561
|
detect: marqueeHits,
|
|
2829
3562
|
fixtures: {
|
|
2830
3563
|
flag: [
|
|
2831
|
-
|
|
2832
|
-
|
|
3564
|
+
f3("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
|
|
3565
|
+
f3("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
|
|
2833
3566
|
],
|
|
2834
|
-
pass: [
|
|
3567
|
+
pass: [f3("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
|
|
2835
3568
|
}
|
|
2836
3569
|
},
|
|
2837
3570
|
{
|
|
@@ -2845,10 +3578,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2845
3578
|
detect: thinBorderWideShadowHits,
|
|
2846
3579
|
fixtures: {
|
|
2847
3580
|
flag: [
|
|
2848
|
-
|
|
2849
|
-
|
|
3581
|
+
f3("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
|
|
3582
|
+
f3("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
|
|
2850
3583
|
],
|
|
2851
|
-
pass: [
|
|
3584
|
+
pass: [f3("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
|
|
2852
3585
|
}
|
|
2853
3586
|
},
|
|
2854
3587
|
{
|
|
@@ -2862,15 +3595,61 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2862
3595
|
detect: introHits,
|
|
2863
3596
|
fixtures: {
|
|
2864
3597
|
flag: [
|
|
2865
|
-
|
|
3598
|
+
f3("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
|
|
2866
3599
|
export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
2867
|
-
|
|
3600
|
+
f3("app/page.tsx", `<><Preloader /><Hero /></>`)
|
|
2868
3601
|
],
|
|
2869
|
-
pass: [
|
|
3602
|
+
pass: [f3("components/Introduction.md", `An introduction to the studio.`)]
|
|
2870
3603
|
}
|
|
2871
3604
|
}
|
|
2872
3605
|
];
|
|
2873
3606
|
|
|
3607
|
+
// src/character/house.ts
|
|
3608
|
+
var HOUSE = {
|
|
3609
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
3610
|
+
emoji: { tier: "block", label: "emoji" },
|
|
3611
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
3612
|
+
"no-x-no-y": {
|
|
3613
|
+
tier: "block",
|
|
3614
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
3615
|
+
},
|
|
3616
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
3617
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
3618
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
3619
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
3620
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
3621
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
3622
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
3623
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
3624
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
3625
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
3626
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
3627
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
3628
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
3629
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
3630
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
3631
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
3632
|
+
"false-range": { tier: "review", label: "false range" },
|
|
3633
|
+
// The first candidate for block: evidence of a pasted chat reply, not a
|
|
3634
|
+
// matter of style. Blocks once its estate hits have been read (ROADMAP.md).
|
|
3635
|
+
"chatbot-residue": { tier: "review", label: "chatbot residue" },
|
|
3636
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
3637
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
3638
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
3639
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
3640
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
3641
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
3642
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
3643
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
3644
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
3645
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
3646
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
3647
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
3648
|
+
};
|
|
3649
|
+
function houseRule(id, name = id) {
|
|
3650
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
3651
|
+
}
|
|
3652
|
+
|
|
2874
3653
|
// src/character/parse.ts
|
|
2875
3654
|
var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
|
|
2876
3655
|
var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
|
|
@@ -3335,8 +4114,8 @@ var oklchOf = (value) => parseColour(value)?.oklch ?? null;
|
|
|
3335
4114
|
function reflexFonts(list) {
|
|
3336
4115
|
const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
|
|
3337
4116
|
return (s) => {
|
|
3338
|
-
const total = s.fonts.reduce((n,
|
|
3339
|
-
const display = s.fonts.reduce((n,
|
|
4117
|
+
const total = s.fonts.reduce((n, f4) => n + f4.chars, 0) || 1;
|
|
4118
|
+
const display = s.fonts.reduce((n, f4) => n + f4.displayChars, 0) || 1;
|
|
3340
4119
|
const hits = [];
|
|
3341
4120
|
const seen = /* @__PURE__ */ new Set();
|
|
3342
4121
|
const h1 = s.headings.find((h) => h.level === 1);
|
|
@@ -3537,8 +4316,8 @@ var RENDERED_PATHS = {
|
|
|
3537
4316
|
};
|
|
3538
4317
|
|
|
3539
4318
|
// src/character/check.ts
|
|
3540
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
3541
|
-
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS].map(
|
|
4319
|
+
var CATALOGUE_VERSION = "2026.09.7";
|
|
4320
|
+
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
|
|
3542
4321
|
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
3543
4322
|
);
|
|
3544
4323
|
function tellById(id) {
|
|
@@ -3573,7 +4352,8 @@ function toFinding(tell, hit2, texts) {
|
|
|
3573
4352
|
line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
|
|
3574
4353
|
excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
|
|
3575
4354
|
message: hit2.message,
|
|
3576
|
-
fix: tell.fix
|
|
4355
|
+
fix: tell.fix,
|
|
4356
|
+
...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
|
|
3577
4357
|
};
|
|
3578
4358
|
}
|
|
3579
4359
|
function report(tells, files, run, options) {
|
|
@@ -3626,6 +4406,10 @@ function report(tells, files, run, options) {
|
|
|
3626
4406
|
}
|
|
3627
4407
|
};
|
|
3628
4408
|
}
|
|
4409
|
+
function applyHouseGate(result) {
|
|
4410
|
+
const findings = result.findings.map((f4) => f4.house === "block" ? { ...f4, severity: "block" } : f4);
|
|
4411
|
+
return { ...result, findings, summary: { ...result.summary, blocking: findings.filter((f4) => f4.severity === "block").length } };
|
|
4412
|
+
}
|
|
3629
4413
|
function scanSource(files, options = {}) {
|
|
3630
4414
|
const ctx = { files: files.map(parseFile) };
|
|
3631
4415
|
const tells = CATALOGUE.filter((t) => t.surface === "source");
|
|
@@ -3685,15 +4469,15 @@ function wordListsJson() {
|
|
|
3685
4469
|
function formatReport(report2, title) {
|
|
3686
4470
|
const lines = [];
|
|
3687
4471
|
let current = "";
|
|
3688
|
-
for (const
|
|
3689
|
-
if (
|
|
3690
|
-
current =
|
|
3691
|
-
lines.push("",
|
|
4472
|
+
for (const f4 of report2.findings) {
|
|
4473
|
+
if (f4.path !== current) {
|
|
4474
|
+
current = f4.path;
|
|
4475
|
+
lines.push("", f4.path);
|
|
3692
4476
|
}
|
|
3693
|
-
lines.push(` ${String(
|
|
3694
|
-
lines.push(` ${
|
|
4477
|
+
lines.push(` ${String(f4.line).padStart(4)} ${f4.tell} (gen ${f4.generation}, ${f4.severity}) ${f4.message}`);
|
|
4478
|
+
lines.push(` ${f4.excerpt}`);
|
|
3695
4479
|
}
|
|
3696
|
-
const fixes = new Map(report2.findings.map((
|
|
4480
|
+
const fixes = new Map(report2.findings.map((f4) => [f4.tell, f4.fix]));
|
|
3697
4481
|
if (fixes.size > 0) {
|
|
3698
4482
|
lines.push("", "What to do instead:");
|
|
3699
4483
|
for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
|
|
@@ -3716,6 +4500,87 @@ function formatReport(report2, title) {
|
|
|
3716
4500
|
return lines.join("\n").replace(/^\n/, "");
|
|
3717
4501
|
}
|
|
3718
4502
|
|
|
4503
|
+
// src/character/facts.ts
|
|
4504
|
+
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)?)";
|
|
4505
|
+
var PATTERNS = [
|
|
4506
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
4507
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
4508
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
4509
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
4510
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
4511
|
+
["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")],
|
|
4512
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
4513
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
4514
|
+
];
|
|
4515
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
4516
|
+
var normalise = (kind, value) => {
|
|
4517
|
+
const v = value.trim();
|
|
4518
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
4519
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
4520
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
4521
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
4522
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
4523
|
+
};
|
|
4524
|
+
function visibleText(file) {
|
|
4525
|
+
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
4526
|
+
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
4527
|
+
}
|
|
4528
|
+
function protectedFacts(text) {
|
|
4529
|
+
const facts = [];
|
|
4530
|
+
let rest = text;
|
|
4531
|
+
const mask = (start, length) => {
|
|
4532
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
4533
|
+
};
|
|
4534
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
4535
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
4536
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
4537
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
4538
|
+
mask(m.index ?? 0, m[0].length);
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
const lines = rest.split("\n");
|
|
4542
|
+
for (const line of lines) {
|
|
4543
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
4544
|
+
for (const m of line.matchAll(NAME)) {
|
|
4545
|
+
const at = m.index ?? 0;
|
|
4546
|
+
const before = line.slice(0, at).trimEnd();
|
|
4547
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
4548
|
+
const word = m[0];
|
|
4549
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
4550
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
4551
|
+
if (word === "I") continue;
|
|
4552
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
return facts;
|
|
4556
|
+
}
|
|
4557
|
+
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
4558
|
+
function compareFacts(before, after) {
|
|
4559
|
+
const a = unique(protectedFacts(before));
|
|
4560
|
+
const b = unique(protectedFacts(after));
|
|
4561
|
+
const inA = new Set(a.map((f4) => f4.key));
|
|
4562
|
+
const inB = new Set(b.map((f4) => f4.key));
|
|
4563
|
+
return {
|
|
4564
|
+
before: a.length,
|
|
4565
|
+
after: b.length,
|
|
4566
|
+
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
4567
|
+
added: b.filter((f4) => !inA.has(f4.key))
|
|
4568
|
+
};
|
|
4569
|
+
}
|
|
4570
|
+
function formatComparison(result, beforePath, afterPath) {
|
|
4571
|
+
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
4572
|
+
if (result.lost.length) {
|
|
4573
|
+
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
4574
|
+
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4575
|
+
}
|
|
4576
|
+
if (result.added.length) {
|
|
4577
|
+
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
4578
|
+
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4579
|
+
}
|
|
4580
|
+
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
4581
|
+
return lines.join("\n");
|
|
4582
|
+
}
|
|
4583
|
+
|
|
3719
4584
|
// src/fingerprint/index.ts
|
|
3720
4585
|
var FINGERPRINT_VERSION = 1;
|
|
3721
4586
|
var CHROMATIC = 0.03;
|
|
@@ -3851,8 +4716,8 @@ function tellFor(key, value) {
|
|
|
3851
4716
|
}
|
|
3852
4717
|
if (key === "display" || key === "body") {
|
|
3853
4718
|
const name = normaliseFamily(value).toLowerCase();
|
|
3854
|
-
if (REFLEX_FONTS_1.some((
|
|
3855
|
-
if (REFLEX_FONTS_2.some((
|
|
4719
|
+
if (REFLEX_FONTS_1.some((f4) => f4.toLowerCase() === name)) return "reflex-font";
|
|
4720
|
+
if (REFLEX_FONTS_2.some((f4) => f4.toLowerCase() === name)) return "reflex-font-2";
|
|
3856
4721
|
}
|
|
3857
4722
|
return null;
|
|
3858
4723
|
}
|
|
@@ -4219,11 +5084,11 @@ function sharedChoices(target2, runs) {
|
|
|
4219
5084
|
}
|
|
4220
5085
|
return out.sort((a, b) => b.runs - a.runs);
|
|
4221
5086
|
}
|
|
4222
|
-
var REFLEX_1 = new Set(REFLEX_FONTS_1.map((
|
|
4223
|
-
var REFLEX_2 = new Set(REFLEX_FONTS_2.map((
|
|
5087
|
+
var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f4) => f4.toLowerCase()));
|
|
5088
|
+
var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f4) => f4.toLowerCase()));
|
|
4224
5089
|
function knownFor(kind, value, sample) {
|
|
4225
5090
|
if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
|
|
4226
|
-
if (kind === "accent" && value === "violet") return sample.some((
|
|
5091
|
+
if (kind === "accent" && value === "violet") return sample.some((f4) => f4.accent && isAiViolet(f4.accent)) ? "ai-violet" : null;
|
|
4227
5092
|
if (kind === "ground" && value === "cream") return "cream-palette";
|
|
4228
5093
|
if (kind === "shape" && value === "pill") return "pill-everything";
|
|
4229
5094
|
if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
|
|
@@ -4306,7 +5171,7 @@ function harvest(models, options = {}) {
|
|
|
4306
5171
|
};
|
|
4307
5172
|
runs.forEach((r, i) => {
|
|
4308
5173
|
const c = choicesOf(r.fingerprint);
|
|
4309
|
-
for (const
|
|
5174
|
+
for (const f4 of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f4, i, r.brief);
|
|
4310
5175
|
add("accent", c.accent[0], i, r.brief);
|
|
4311
5176
|
add("ground", c.ground[0], i, r.brief);
|
|
4312
5177
|
add("shape", c.shape[0], i, r.brief);
|
|
@@ -4445,30 +5310,30 @@ var PART_AREA = {
|
|
|
4445
5310
|
};
|
|
4446
5311
|
var DEFAULTS = /^(?:ground )?(?:white|grey)(?: ground)?$|no scroll reveals|no reveals|accent (?:none|neutral)|^no buttons|shape no buttons/;
|
|
4447
5312
|
function designFindings(findings, copyTells) {
|
|
4448
|
-
return (findings?.findings ?? []).filter((
|
|
5313
|
+
return (findings?.findings ?? []).filter((f4) => !copyTells.has(f4.tell));
|
|
4449
5314
|
}
|
|
4450
5315
|
function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
|
|
4451
5316
|
const actions = [];
|
|
4452
5317
|
const signals = [];
|
|
4453
5318
|
if (input.findings) {
|
|
4454
5319
|
const design = designFindings(input.findings, copyTells);
|
|
4455
|
-
const distinct = [...new Set(design.map((
|
|
5320
|
+
const distinct = [...new Set(design.map((f4) => f4.tell))];
|
|
4456
5321
|
signals.push({
|
|
4457
5322
|
id: "tells",
|
|
4458
5323
|
raised: distinct.length >= TELL_HEAVY,
|
|
4459
5324
|
detail: `${distinct.length} distinct design tell${distinct.length === 1 ? "" : "s"}${distinct.length ? `: ${distinct.join(", ")}` : ""}`
|
|
4460
5325
|
});
|
|
4461
5326
|
const seen = /* @__PURE__ */ new Set();
|
|
4462
|
-
for (const
|
|
4463
|
-
if (seen.has(
|
|
4464
|
-
seen.add(
|
|
4465
|
-
const map = TELL_AREA[
|
|
4466
|
-
const both = design.some((g) => g.tell ===
|
|
4467
|
-
actions.push({ area: map.area, what:
|
|
4468
|
-
}
|
|
4469
|
-
const copy = (input.findings.findings ?? []).filter((
|
|
5327
|
+
for (const f4 of design) {
|
|
5328
|
+
if (seen.has(f4.tell)) continue;
|
|
5329
|
+
seen.add(f4.tell);
|
|
5330
|
+
const map = TELL_AREA[f4.tell] ?? { area: "effects" };
|
|
5331
|
+
const both = design.some((g) => g.tell === f4.tell && g.line === 0) && design.some((g) => g.tell === f4.tell && g.line > 0);
|
|
5332
|
+
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 });
|
|
5333
|
+
}
|
|
5334
|
+
const copy = (input.findings.findings ?? []).filter((f4) => copyTells.has(f4.tell));
|
|
4470
5335
|
const byTell = /* @__PURE__ */ new Map();
|
|
4471
|
-
for (const
|
|
5336
|
+
for (const f4 of copy) byTell.set(f4.tell, [...byTell.get(f4.tell) ?? [], f4]);
|
|
4472
5337
|
for (const [tell, list] of byTell) {
|
|
4473
5338
|
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 });
|
|
4474
5339
|
}
|
|
@@ -4533,9 +5398,9 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
|
|
|
4533
5398
|
same.priority = Math.min(same.priority, a.priority);
|
|
4534
5399
|
} else merged.push({ ...a });
|
|
4535
5400
|
}
|
|
4536
|
-
const
|
|
4537
|
-
|
|
4538
|
-
return { site: input.site, verdict, summary, signals, actions:
|
|
5401
|
+
const unique2 = merged.filter((a, i) => merged.findIndex((b) => b.area === a.area && b.what === a.what) === i);
|
|
5402
|
+
unique2.sort((a, b) => a.priority - b.priority || order.indexOf(a.area) - order.indexOf(b.area));
|
|
5403
|
+
return { site: input.site, verdict, summary, signals, actions: unique2 };
|
|
4539
5404
|
}
|
|
4540
5405
|
function areaOfShared(part) {
|
|
4541
5406
|
if (/headline|body$/.test(part)) return "type";
|
|
@@ -4626,6 +5491,7 @@ function retrofitPlan(report2, direction) {
|
|
|
4626
5491
|
ESTATE_VERSION,
|
|
4627
5492
|
EXIT_RATIO,
|
|
4628
5493
|
FINGERPRINT_VERSION,
|
|
5494
|
+
HOUSE,
|
|
4629
5495
|
HOUSE_BUDGET,
|
|
4630
5496
|
HOUSE_TYPE,
|
|
4631
5497
|
LIGHTNESS_CURVE,
|
|
@@ -4658,6 +5524,7 @@ function retrofitPlan(report2, direction) {
|
|
|
4658
5524
|
accentFork,
|
|
4659
5525
|
addSite,
|
|
4660
5526
|
apcaContrast,
|
|
5527
|
+
applyHouseGate,
|
|
4661
5528
|
auditSnapshot,
|
|
4662
5529
|
catalogueTable,
|
|
4663
5530
|
characterReport,
|
|
@@ -4665,6 +5532,7 @@ function retrofitPlan(report2, direction) {
|
|
|
4665
5532
|
checkPair,
|
|
4666
5533
|
checkRestraint,
|
|
4667
5534
|
choicesOf,
|
|
5535
|
+
compareFacts,
|
|
4668
5536
|
compareToEstate,
|
|
4669
5537
|
craftTokens,
|
|
4670
5538
|
deltaEOk,
|
|
@@ -4683,11 +5551,13 @@ function retrofitPlan(report2, direction) {
|
|
|
4683
5551
|
fluidSpace,
|
|
4684
5552
|
fluidType,
|
|
4685
5553
|
fontClass,
|
|
5554
|
+
formatComparison,
|
|
4686
5555
|
formatHex,
|
|
4687
5556
|
formatReport,
|
|
4688
5557
|
groundName,
|
|
4689
5558
|
harvest,
|
|
4690
5559
|
hexToOklch,
|
|
5560
|
+
houseRule,
|
|
4691
5561
|
hueName,
|
|
4692
5562
|
hueSwatch,
|
|
4693
5563
|
inP3Gamut,
|
|
@@ -4706,6 +5576,7 @@ function retrofitPlan(report2, direction) {
|
|
|
4706
5576
|
parseColour,
|
|
4707
5577
|
parseHex,
|
|
4708
5578
|
propose,
|
|
5579
|
+
protectedFacts,
|
|
4709
5580
|
ramp,
|
|
4710
5581
|
rampFromAnchors,
|
|
4711
5582
|
retrofitPlan,
|
|
@@ -4724,6 +5595,7 @@ function retrofitPlan(report2, direction) {
|
|
|
4724
5595
|
typeFeatureTokens,
|
|
4725
5596
|
typicality,
|
|
4726
5597
|
validateDirection,
|
|
5598
|
+
visibleText,
|
|
4727
5599
|
wcagContrast,
|
|
4728
5600
|
wordListsJson
|
|
4729
5601
|
});
|