@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/CHANGELOG.md +36 -0
- package/CHARACTER.md +280 -20
- package/COPY.md +280 -0
- package/README.md +12 -0
- package/dist/audit-W57YDZ2K.js +20 -0
- package/dist/audit.cjs +901 -103
- package/dist/audit.d.cts +16 -1
- package/dist/audit.d.ts +16 -1
- package/dist/audit.js +899 -102
- package/dist/chunk-35OX6C4H.js +112 -0
- package/dist/{chunk-GWMSSRIQ.js → chunk-CZHRNPDD.js} +1521 -934
- package/dist/chunk-K253ZXB3.js +458 -0
- package/dist/{chunk-HYGFP3LB.js → chunk-OGSYVCMU.js} +40 -3
- package/dist/chunk-OVOH7KZY.js +163 -0
- package/dist/{cli-7ME4VIRG.js → chunk-Q4MN3OXF.js} +50 -101
- package/dist/chunk-ULYKASLA.js +175 -0
- package/dist/chunk-X65RDW65.js +364 -0
- package/dist/cli-F7ONSKZS.js +21 -0
- package/dist/cli-FKJOMOOF.js +15 -0
- package/dist/{cli-F7W4F3ER.js → cli-KA3JDNDH.js} +14 -168
- package/dist/cli-LZ7R6KJM.js +312 -0
- package/dist/cli-SHLNRHK6.js +16 -0
- package/dist/cli.js +2 -1
- package/dist/index.cjs +1528 -99
- package/dist/index.d.cts +365 -4
- package/dist/index.d.ts +365 -4
- package/dist/index.js +1493 -96
- package/package.json +4 -2
- package/word-lists.json +164 -0
package/dist/index.cjs
CHANGED
|
@@ -3,9 +3,9 @@ var __defProp = Object.defineProperty;
|
|
|
3
3
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
5
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
-
var __export = (
|
|
6
|
+
var __export = (target2, all) => {
|
|
7
7
|
for (var name in all)
|
|
8
|
-
__defProp(
|
|
8
|
+
__defProp(target2, name, { get: all[name], enumerable: true });
|
|
9
9
|
};
|
|
10
10
|
var __copyProps = (to, from, except, desc) => {
|
|
11
11
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
@@ -35,12 +35,17 @@ __export(index_exports, {
|
|
|
35
35
|
DURATION_S: () => DURATION_S,
|
|
36
36
|
EASE: () => EASE,
|
|
37
37
|
EASE_TUPLE: () => EASE_TUPLE,
|
|
38
|
+
ESTATE_VERSION: () => ESTATE_VERSION,
|
|
38
39
|
EXIT_RATIO: () => EXIT_RATIO,
|
|
39
40
|
FINGERPRINT_VERSION: () => FINGERPRINT_VERSION,
|
|
41
|
+
HOUSE: () => HOUSE,
|
|
40
42
|
HOUSE_BUDGET: () => HOUSE_BUDGET,
|
|
41
43
|
HOUSE_TYPE: () => HOUSE_TYPE,
|
|
42
44
|
LIGHTNESS_CURVE: () => LIGHTNESS_CURVE,
|
|
45
|
+
MIN_DECIDED: () => MIN_DECIDED,
|
|
46
|
+
MIN_RUNS: () => MIN_RUNS,
|
|
43
47
|
NEGATIVE_REASSURANCE: () => NEGATIVE_REASSURANCE,
|
|
48
|
+
NULL_VERSION: () => NULL_VERSION,
|
|
44
49
|
PILL_LIMIT: () => PILL_LIMIT,
|
|
45
50
|
PLAINER_WORDS: () => PLAINER_WORDS,
|
|
46
51
|
PROPOSED: () => PROPOSED,
|
|
@@ -53,27 +58,39 @@ __export(index_exports, {
|
|
|
53
58
|
SCALE: () => SCALE,
|
|
54
59
|
SHADCN_LIMIT: () => SHADCN_LIMIT,
|
|
55
60
|
SHADCN_PRIMITIVES: () => SHADCN_PRIMITIVES,
|
|
61
|
+
SIBLING_AT: () => SIBLING_AT,
|
|
56
62
|
SNAPSHOT_VERSION: () => SNAPSHOT_VERSION,
|
|
57
63
|
SOURCE_KINDS: () => SOURCE_KINDS,
|
|
58
64
|
SPACE_STEPS: () => SPACE_STEPS,
|
|
59
65
|
SPRING: () => SPRING,
|
|
60
66
|
STOCK_PHRASES: () => STOCK_PHRASES,
|
|
67
|
+
TELL_HEAVY: () => TELL_HEAVY,
|
|
61
68
|
TYPE_STEPS: () => TYPE_STEPS,
|
|
69
|
+
TYPICAL_AT: () => TYPICAL_AT,
|
|
62
70
|
VAGUE_WORDS: () => VAGUE_WORDS,
|
|
63
71
|
accentFork: () => accentFork,
|
|
72
|
+
addSite: () => addSite,
|
|
64
73
|
apcaContrast: () => apcaContrast,
|
|
74
|
+
applyHouseGate: () => applyHouseGate,
|
|
65
75
|
auditSnapshot: () => auditSnapshot,
|
|
66
76
|
catalogueTable: () => catalogueTable,
|
|
77
|
+
characterReport: () => characterReport,
|
|
67
78
|
checkCopy: () => checkCopy,
|
|
68
79
|
checkPair: () => checkPair,
|
|
69
80
|
checkRestraint: () => checkRestraint,
|
|
81
|
+
choicesOf: () => choicesOf,
|
|
82
|
+
compareFacts: () => compareFacts,
|
|
83
|
+
compareToEstate: () => compareToEstate,
|
|
70
84
|
craftTokens: () => craftTokens,
|
|
71
85
|
deltaEOk: () => deltaEOk,
|
|
72
86
|
deltaEOkHex: () => deltaEOkHex,
|
|
73
87
|
densityCss: () => densityCss,
|
|
74
88
|
densityTokens: () => densityTokens,
|
|
75
89
|
emitCss: () => emitCss,
|
|
90
|
+
emptyEstate: () => emptyEstate,
|
|
91
|
+
estatePairs: () => estatePairs,
|
|
76
92
|
exitDuration: () => exitDuration,
|
|
93
|
+
extractHtml: () => extractHtml,
|
|
77
94
|
findColours: () => findColours,
|
|
78
95
|
fingerprint: () => fingerprint,
|
|
79
96
|
fingerprintDistance: () => fingerprintDistance,
|
|
@@ -81,9 +98,15 @@ __export(index_exports, {
|
|
|
81
98
|
fluidSpace: () => fluidSpace,
|
|
82
99
|
fluidType: () => fluidType,
|
|
83
100
|
fontClass: () => fontClass,
|
|
101
|
+
formatComparison: () => formatComparison,
|
|
84
102
|
formatHex: () => formatHex,
|
|
85
103
|
formatReport: () => formatReport,
|
|
104
|
+
groundName: () => groundName,
|
|
105
|
+
harvest: () => harvest,
|
|
86
106
|
hexToOklch: () => hexToOklch,
|
|
107
|
+
houseRule: () => houseRule,
|
|
108
|
+
hueName: () => hueName,
|
|
109
|
+
hueSwatch: () => hueSwatch,
|
|
87
110
|
inP3Gamut: () => inP3Gamut,
|
|
88
111
|
inSrgbGamut: () => inSrgbGamut,
|
|
89
112
|
initDirection: () => initDirection,
|
|
@@ -93,26 +116,35 @@ __export(index_exports, {
|
|
|
93
116
|
motionTokens: () => motionTokens,
|
|
94
117
|
nearest: () => nearest,
|
|
95
118
|
normaliseFamily: () => normaliseFamily,
|
|
119
|
+
nullPrompt: () => nullPrompt,
|
|
96
120
|
oklchToHex: () => oklchToHex,
|
|
97
121
|
oklchToRgb: () => oklchToRgb,
|
|
98
122
|
paletteFromPixels: () => paletteFromPixels,
|
|
99
123
|
parseColour: () => parseColour,
|
|
100
124
|
parseHex: () => parseHex,
|
|
101
125
|
propose: () => propose,
|
|
126
|
+
protectedFacts: () => protectedFacts,
|
|
102
127
|
ramp: () => ramp,
|
|
103
128
|
rampFromAnchors: () => rampFromAnchors,
|
|
129
|
+
retrofitPlan: () => retrofitPlan,
|
|
104
130
|
runTell: () => runTell,
|
|
105
131
|
scanSource: () => scanSource,
|
|
106
132
|
sectionRhythm: () => sectionRhythm,
|
|
107
133
|
semantic: () => semantic,
|
|
134
|
+
shapeName: () => shapeName,
|
|
135
|
+
sharedParts: () => sharedParts,
|
|
108
136
|
shouldAnimate: () => shouldAnimate,
|
|
137
|
+
siblingLine: () => siblingLine,
|
|
109
138
|
tailwindV3Preset: () => tailwindV3Preset,
|
|
110
139
|
tailwindV4Theme: () => tailwindV4Theme,
|
|
111
140
|
tellById: () => tellById,
|
|
112
141
|
toGamut: () => toGamut,
|
|
113
142
|
typeFeatureTokens: () => typeFeatureTokens,
|
|
143
|
+
typicality: () => typicality,
|
|
114
144
|
validateDirection: () => validateDirection,
|
|
115
|
-
|
|
145
|
+
visibleText: () => visibleText,
|
|
146
|
+
wcagContrast: () => wcagContrast,
|
|
147
|
+
wordListsJson: () => wordListsJson
|
|
116
148
|
});
|
|
117
149
|
module.exports = __toCommonJS(index_exports);
|
|
118
150
|
|
|
@@ -945,9 +977,9 @@ function resolveValue(value, props, depth = 0) {
|
|
|
945
977
|
if (depth > 8) return null;
|
|
946
978
|
const match = SINGLE_VAR.exec(value.trim());
|
|
947
979
|
if (match === null) return value.trim();
|
|
948
|
-
const
|
|
949
|
-
if (
|
|
950
|
-
return resolveValue(
|
|
980
|
+
const target2 = props.get(match[1].toLowerCase());
|
|
981
|
+
if (target2 !== void 0 && target2 !== "initial")
|
|
982
|
+
return resolveValue(target2, props, depth + 1);
|
|
951
983
|
const fallback = match[2]?.trim();
|
|
952
984
|
return fallback === void 0 || fallback === "" ? null : resolveValue(fallback, props, depth + 1);
|
|
953
985
|
}
|
|
@@ -1048,7 +1080,7 @@ function checkRestraint(input) {
|
|
|
1048
1080
|
);
|
|
1049
1081
|
}
|
|
1050
1082
|
const families = distinctValues(css, "font-family", props, unresolved).filter(
|
|
1051
|
-
(
|
|
1083
|
+
(f4) => !/\bmonospace\b|\bmono\b/.test(f4)
|
|
1052
1084
|
);
|
|
1053
1085
|
if (families.length > budget.maxFontFamilies) {
|
|
1054
1086
|
push(
|
|
@@ -1372,15 +1404,22 @@ var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
|
|
|
1372
1404
|
function allText(ctx) {
|
|
1373
1405
|
return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
|
|
1374
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);
|
|
1375
1412
|
function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
|
|
1376
1413
|
return (ctx) => {
|
|
1377
1414
|
const hits = [];
|
|
1378
1415
|
for (const block of source(ctx)) {
|
|
1379
1416
|
const spans = [];
|
|
1417
|
+
const links = linkSpans(block.text);
|
|
1380
1418
|
for (const pattern of patterns) {
|
|
1381
1419
|
const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
1382
1420
|
for (const m of block.text.matchAll(global)) {
|
|
1383
1421
|
if (skip(block, m[0], m.index ?? 0)) continue;
|
|
1422
|
+
if (insideLink(links, m.index ?? 0)) continue;
|
|
1384
1423
|
spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
|
|
1385
1424
|
}
|
|
1386
1425
|
}
|
|
@@ -1421,7 +1460,14 @@ var AI_WORDS = [
|
|
|
1421
1460
|
"moreover",
|
|
1422
1461
|
"navigating",
|
|
1423
1462
|
"ever-evolving",
|
|
1424
|
-
"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"
|
|
1425
1471
|
];
|
|
1426
1472
|
var STOCK_PHRASES = [
|
|
1427
1473
|
"in today's digital age",
|
|
@@ -1435,7 +1481,11 @@ var STOCK_PHRASES = [
|
|
|
1435
1481
|
"curated",
|
|
1436
1482
|
"crafted with care",
|
|
1437
1483
|
"tailored to your",
|
|
1438
|
-
"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"
|
|
1439
1489
|
];
|
|
1440
1490
|
var AI_PHRASES = [
|
|
1441
1491
|
"let's dive in",
|
|
@@ -1568,7 +1618,7 @@ function lineAround(text, index) {
|
|
|
1568
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);
|
|
1569
1619
|
var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
|
|
1570
1620
|
var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
|
|
1571
|
-
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;
|
|
1572
1622
|
var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
|
|
1573
1623
|
function badgeHits(ctx) {
|
|
1574
1624
|
const hits = [];
|
|
@@ -1611,10 +1661,16 @@ var COPY_TELLS = [
|
|
|
1611
1661
|
surface: "copy",
|
|
1612
1662
|
why: "Phrases every generated services page uses. They fill space where a fact should be.",
|
|
1613
1663
|
fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
|
|
1614
|
-
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),
|
|
1615
1665
|
fixtures: {
|
|
1616
|
-
flag: [
|
|
1617
|
-
|
|
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
|
+
]
|
|
1618
1674
|
}
|
|
1619
1675
|
},
|
|
1620
1676
|
{
|
|
@@ -1639,7 +1695,11 @@ var COPY_TELLS = [
|
|
|
1639
1695
|
f("content/home.md", "In today's rapidly evolving market, we help."),
|
|
1640
1696
|
f("content/home.md", "Whether you're a landlord or a tenant, call us.")
|
|
1641
1697
|
],
|
|
1642
|
-
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
|
+
]
|
|
1643
1703
|
}
|
|
1644
1704
|
},
|
|
1645
1705
|
{
|
|
@@ -1788,8 +1848,14 @@ var COPY_TELLS = [
|
|
|
1788
1848
|
fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
|
|
1789
1849
|
detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
|
|
1790
1850
|
fixtures: {
|
|
1791
|
-
flag: [
|
|
1792
|
-
|
|
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")]
|
|
1793
1859
|
}
|
|
1794
1860
|
},
|
|
1795
1861
|
{
|
|
@@ -1887,6 +1953,695 @@ var COPY_TELLS = [
|
|
|
1887
1953
|
}
|
|
1888
1954
|
}
|
|
1889
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
|
+
/【\d+(?:[:†][^】]{0,40})?】/
|
|
1989
|
+
];
|
|
1990
|
+
var AI_UTM = /utm_source=(?:chatgpt\.com|chat\.openai\.com|openai|perplexity(?:\.ai)?|claude\.ai|copilot(?:\.microsoft\.com)?|gemini(?:\.google\.com)?)\b/gi;
|
|
1991
|
+
function residueHits(ctx) {
|
|
1992
|
+
const hits = phrases(RESIDUE, quoted, allText)(ctx);
|
|
1993
|
+
for (const file of ctx.files) {
|
|
1994
|
+
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]}"` });
|
|
1995
|
+
}
|
|
1996
|
+
return hits;
|
|
1997
|
+
}
|
|
1998
|
+
var PLACEHOLDER = [
|
|
1999
|
+
/\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n]{0,40}\](?!\()/i,
|
|
2000
|
+
/\blorem ipsum\b/i,
|
|
2001
|
+
/\bTODO_PLACEHOLDER\b/
|
|
2002
|
+
];
|
|
2003
|
+
var PLACEHOLDER_ELEMENT = />\s*(\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n<]{0,40}\])\s*</gi;
|
|
2004
|
+
function insideQuotes(block, _match, index) {
|
|
2005
|
+
const lineStart = block.text.lastIndexOf("\n", index - 1) + 1;
|
|
2006
|
+
const before = block.text.slice(lineStart, index);
|
|
2007
|
+
const straight = (before.match(/"/g) ?? []).length;
|
|
2008
|
+
const curly = (before.match(/“/g) ?? []).length - (before.match(/”/g) ?? []).length;
|
|
2009
|
+
return straight % 2 === 1 || curly > 0;
|
|
2010
|
+
}
|
|
2011
|
+
function placeholderHits(ctx) {
|
|
2012
|
+
const hits = phrases(PLACEHOLDER, quoted, allText, insideQuotes)(ctx);
|
|
2013
|
+
const seen = new Set(hits.map((h) => `${h.path}:${h.offset}`));
|
|
2014
|
+
for (const file of ctx.files) {
|
|
2015
|
+
if (isProsePath(file.path)) continue;
|
|
2016
|
+
for (const m of file.text.matchAll(PLACEHOLDER_ELEMENT)) {
|
|
2017
|
+
const offset = (m.index ?? 0) + m[0].indexOf(m[1]);
|
|
2018
|
+
if (!seen.has(`${file.path}:${offset}`)) hits.push({ path: file.path, offset, message: quoted(m[1]) });
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
return hits;
|
|
2022
|
+
}
|
|
2023
|
+
var QUESTION_REVEAL = [
|
|
2024
|
+
/\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]/,
|
|
2025
|
+
/\?\s+It means\b/
|
|
2026
|
+
];
|
|
2027
|
+
function inlineLabelHits(ctx) {
|
|
2028
|
+
const hits = [];
|
|
2029
|
+
for (const file of ctx.files) {
|
|
2030
|
+
if (!isProsePath(file.path)) continue;
|
|
2031
|
+
const lines = file.text.split("\n");
|
|
2032
|
+
let run = [];
|
|
2033
|
+
let offset = 0;
|
|
2034
|
+
const flush = () => {
|
|
2035
|
+
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` });
|
|
2036
|
+
run = [];
|
|
2037
|
+
};
|
|
2038
|
+
for (const line of lines) {
|
|
2039
|
+
const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
|
|
2040
|
+
if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
|
|
2041
|
+
else flush();
|
|
2042
|
+
offset += line.length + 1;
|
|
2043
|
+
}
|
|
2044
|
+
flush();
|
|
2045
|
+
}
|
|
2046
|
+
return hits;
|
|
2047
|
+
}
|
|
2048
|
+
var RESEARCH_TELLS = [
|
|
2049
|
+
{
|
|
2050
|
+
id: "chatbot-residue",
|
|
2051
|
+
name: "Chatbot residue",
|
|
2052
|
+
generation: 1,
|
|
2053
|
+
severity: "warn",
|
|
2054
|
+
surface: "copy",
|
|
2055
|
+
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.",
|
|
2056
|
+
fix: "Delete the residue. Then read the whole passage again, because the rest of it came from the same paste.",
|
|
2057
|
+
detect: residueHits,
|
|
2058
|
+
fixtures: {
|
|
2059
|
+
flag: [
|
|
2060
|
+
f("content/home.md", "Certainly! Here's a revised version of your home page."),
|
|
2061
|
+
f("content/home.md", "As an AI language model, I cannot visit your salon."),
|
|
2062
|
+
f("content/home.md", "Let me know if you'd like me to shorten it."),
|
|
2063
|
+
f("content/home.md", "Prices start at \xA340 citeturn0search3 for a full set."),
|
|
2064
|
+
f("content/home.md", "Prices start at \xA340. citeturn11search1turn10view0turn2view3"),
|
|
2065
|
+
f("content/home.md", "Prices start at \xA340. \uE200cite\uE202turn11search1\uE202turn10view0\uE201"),
|
|
2066
|
+
f("content/home.md", "Prices start at \xA340 :contentReference[oaicite:2]{index=2} for a full set."),
|
|
2067
|
+
f("content/home.md", "Prices start at \xA340\u30104\u2020source\u3011 for a full set."),
|
|
2068
|
+
f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=chatgpt.com">Read the guide</a>;`),
|
|
2069
|
+
f("content/home.md", "Here's a more concise version for the hero.")
|
|
2070
|
+
],
|
|
2071
|
+
pass: [
|
|
2072
|
+
f("content/home.md", "Certainly the busiest week of the year: book early."),
|
|
2073
|
+
f("content/home.md", "Here's the price list for 2026."),
|
|
2074
|
+
f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=newsletter">Read the guide</a>;`)
|
|
2075
|
+
]
|
|
2076
|
+
}
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
id: "placeholder",
|
|
2080
|
+
name: "Unfilled placeholder",
|
|
2081
|
+
generation: 1,
|
|
2082
|
+
severity: "warn",
|
|
2083
|
+
surface: "copy",
|
|
2084
|
+
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.",
|
|
2085
|
+
fix: "Fill it from the client or the proof source, or remove the element. Never invent the missing fact to close it.",
|
|
2086
|
+
detect: placeholderHits,
|
|
2087
|
+
fixtures: {
|
|
2088
|
+
flag: [
|
|
2089
|
+
f("content/home.md", "Rated 5 stars by [Insert client name] and others."),
|
|
2090
|
+
f("content/home.md", "Lorem ipsum dolor sit amet."),
|
|
2091
|
+
f("content/home.md", "Rated TODO_PLACEHOLDER: google_rating from our reviews."),
|
|
2092
|
+
f("app/card.tsx", `export const Quote = () => <blockquote>[Insert testimonial here]</blockquote>;`)
|
|
2093
|
+
],
|
|
2094
|
+
pass: [
|
|
2095
|
+
f("content/home.md", "Manage bookings in [your account](/account)."),
|
|
2096
|
+
f("content/guide.md", 'After 90 days, check your ranking for "[your trade] [your town]" on Google.'),
|
|
2097
|
+
f("content/guide.md", "Send a short message: \u201CThanks for choosing [Business Name]! Leave us a review.\u201D"),
|
|
2098
|
+
f("content/home.md", "Rated 4.9 from 212 Google reviews.")
|
|
2099
|
+
]
|
|
2100
|
+
}
|
|
2101
|
+
},
|
|
2102
|
+
{
|
|
2103
|
+
id: "question-reveal",
|
|
2104
|
+
name: "Staged reveal",
|
|
2105
|
+
generation: 2,
|
|
2106
|
+
severity: "warn",
|
|
2107
|
+
surface: "copy",
|
|
2108
|
+
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.",
|
|
2109
|
+
fix: "State the answer: 'Most customers book again within six weeks.'",
|
|
2110
|
+
detect: phrases(QUESTION_REVEAL, quoted),
|
|
2111
|
+
fixtures: {
|
|
2112
|
+
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.")],
|
|
2113
|
+
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.")]
|
|
2114
|
+
}
|
|
2115
|
+
},
|
|
2116
|
+
{
|
|
2117
|
+
id: "inline-label-list",
|
|
2118
|
+
name: "Bold-label bullets",
|
|
2119
|
+
generation: 2,
|
|
2120
|
+
severity: "warn",
|
|
2121
|
+
surface: "copy",
|
|
2122
|
+
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.",
|
|
2123
|
+
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.",
|
|
2124
|
+
detect: inlineLabelHits,
|
|
2125
|
+
fixtures: {
|
|
2126
|
+
flag: [f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n- **Support:** Help when you need it\n")],
|
|
2127
|
+
pass: [
|
|
2128
|
+
f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
|
|
2129
|
+
f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
|
|
2130
|
+
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")
|
|
2131
|
+
]
|
|
2132
|
+
}
|
|
2133
|
+
},
|
|
2134
|
+
{
|
|
2135
|
+
id: "ing-tail",
|
|
2136
|
+
name: "Empty -ing tail",
|
|
2137
|
+
generation: 2,
|
|
2138
|
+
severity: "warn",
|
|
2139
|
+
surface: "copy",
|
|
2140
|
+
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.",
|
|
2141
|
+
fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
|
|
2142
|
+
detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
|
|
2143
|
+
fixtures: {
|
|
2144
|
+
flag: [
|
|
2145
|
+
f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
|
|
2146
|
+
f("app/about.tsx", `export const About = () => <p>We have served Brackley since 2009, highlighting our commitment to the town.</p>;`)
|
|
2147
|
+
],
|
|
2148
|
+
pass: [
|
|
2149
|
+
f("content/home.md", "We open at 7am, so you can drop the car off before work."),
|
|
2150
|
+
f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
|
|
2151
|
+
]
|
|
2152
|
+
}
|
|
2153
|
+
},
|
|
2154
|
+
{
|
|
2155
|
+
id: "vague-attribution",
|
|
2156
|
+
name: "Unnamed source",
|
|
2157
|
+
generation: 1,
|
|
2158
|
+
severity: "warn",
|
|
2159
|
+
surface: "copy",
|
|
2160
|
+
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.",
|
|
2161
|
+
fix: "Name the source and the number ('A 2023 Which? survey of 2,000 drivers found...') or cut the claim.",
|
|
2162
|
+
detect: phrases(VAGUE_ATTRIBUTION, quoted, (ctx) => ctx.blocks, sourced),
|
|
2163
|
+
fixtures: {
|
|
2164
|
+
flag: [
|
|
2165
|
+
f("content/home.md", "Studies show that regular servicing saves money."),
|
|
2166
|
+
f("content/home.md", "Most experts agree a boiler lasts fifteen years."),
|
|
2167
|
+
f("content/home.md", "It is widely known that gel damages nails.")
|
|
2168
|
+
],
|
|
2169
|
+
pass: [
|
|
2170
|
+
f("content/home.md", "A 2023 Which? survey of 2,000 owners found serviced boilers failed half as often."),
|
|
2171
|
+
f("content/home.md", "BrightLocal's research shows that reviews drive 20% of local ranking."),
|
|
2172
|
+
f("content/home.md", "Research shows 62% of consumers avoid a business with wrong hours.[^7]"),
|
|
2173
|
+
f("content/home.md", "Our customers rate us 4.9 from 212 Google reviews.")
|
|
2174
|
+
]
|
|
2175
|
+
}
|
|
2176
|
+
},
|
|
2177
|
+
{
|
|
2178
|
+
id: "closing-summary",
|
|
2179
|
+
name: "Closing summary",
|
|
2180
|
+
generation: 1,
|
|
2181
|
+
severity: "warn",
|
|
2182
|
+
surface: "copy",
|
|
2183
|
+
why: "'Overall,', 'In conclusion,', 'In short,': a paragraph that announces it is summing up, then restates what the reader has just read.",
|
|
2184
|
+
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'.",
|
|
2185
|
+
detect: phrases([CLOSING_SUMMARY], (m) => quoted(m.replace(/^[.!?>\s]+/, ""))),
|
|
2186
|
+
fixtures: {
|
|
2187
|
+
flag: [
|
|
2188
|
+
f("content/home.md", "We rewire, test and certify.\n\nOverall, we are the right choice for your home."),
|
|
2189
|
+
f("content/home.md", "Prices start at \xA340. In conclusion, we offer great value.")
|
|
2190
|
+
],
|
|
2191
|
+
pass: [f("content/home.md", "We rewire, test and certify.\n\nBook a survey and we'll quote within two days.")]
|
|
2192
|
+
}
|
|
2193
|
+
},
|
|
2194
|
+
{
|
|
2195
|
+
id: "false-range",
|
|
2196
|
+
name: "False range",
|
|
2197
|
+
generation: 1,
|
|
2198
|
+
severity: "warn",
|
|
2199
|
+
surface: "copy",
|
|
2200
|
+
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.",
|
|
2201
|
+
fix: "Say who you actually serve, or cut it. A real range is fine: 'from 9am to 5pm', 'from small repairs to full rewires'.",
|
|
2202
|
+
detect: phrases(FALSE_RANGE, quoted),
|
|
2203
|
+
fixtures: {
|
|
2204
|
+
flag: [
|
|
2205
|
+
f("content/home.md", "We help clients from first-time buyers to seasoned investors alike."),
|
|
2206
|
+
f("content/home.md", "Everyone from students to retirees loves our cakes.")
|
|
2207
|
+
],
|
|
2208
|
+
pass: [
|
|
2209
|
+
f("content/home.md", "Open from 9am to 5pm, Monday to Saturday."),
|
|
2210
|
+
f("content/home.md", "We take on everything from small repairs to full rewires.")
|
|
2211
|
+
]
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
];
|
|
2215
|
+
|
|
2216
|
+
// src/character/tells/density.ts
|
|
2217
|
+
var DOCUMENT = /\.(md|markdown|mdx|txt|html?)$/i;
|
|
2218
|
+
var MIN_WORDS = 400;
|
|
2219
|
+
var WORD = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
2220
|
+
var countWords = (text) => (text.match(WORD) ?? []).length;
|
|
2221
|
+
function documents(ctx) {
|
|
2222
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
2223
|
+
for (const block of ctx.blocks) {
|
|
2224
|
+
if (!DOCUMENT.test(block.path) || block.literal) continue;
|
|
2225
|
+
const list = byPath.get(block.path) ?? [];
|
|
2226
|
+
list.push(block);
|
|
2227
|
+
byPath.set(block.path, list);
|
|
2228
|
+
}
|
|
2229
|
+
const docs = [];
|
|
2230
|
+
for (const [path, blocks] of byPath) {
|
|
2231
|
+
const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2232
|
+
const words2 = countWords(text);
|
|
2233
|
+
if (words2 >= MIN_WORDS) docs.push({ path, blocks, words: words2 });
|
|
2234
|
+
}
|
|
2235
|
+
return docs;
|
|
2236
|
+
}
|
|
2237
|
+
var withoutHeadings = (text) => text.replace(/^\s{0,3}#{1,6}\s.*$/gm, "");
|
|
2238
|
+
var allowed = (doc2, floor, per1000) => Math.max(floor, Math.floor(doc2.words * per1000 / 1e3));
|
|
2239
|
+
var thousands = (n) => n.toLocaleString("en-GB");
|
|
2240
|
+
function sentences(doc2) {
|
|
2241
|
+
const out = [];
|
|
2242
|
+
for (const block of doc2.blocks) {
|
|
2243
|
+
const text = withoutHeadings(block.text);
|
|
2244
|
+
for (const para of text.matchAll(/[^\n]+(?:\n(?!\s*\n)[^\n]+)*/g)) {
|
|
2245
|
+
const body = para[0];
|
|
2246
|
+
const start = para.index ?? 0;
|
|
2247
|
+
for (const m of body.matchAll(/[^.!?]+[.!?]+["'’”)]*|[^.!?]+$/g)) {
|
|
2248
|
+
const raw = m[0];
|
|
2249
|
+
const trimmed = raw.trim();
|
|
2250
|
+
if (countWords(trimmed) === 0) continue;
|
|
2251
|
+
const lead = raw.length - raw.trimStart().length;
|
|
2252
|
+
out.push({ text: trimmed.replace(/\s+/g, " "), offset: block.offset + start + (m.index ?? 0) + lead });
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return out;
|
|
2257
|
+
}
|
|
2258
|
+
var RATES = [
|
|
2259
|
+
{ label: "rather than", pattern: /\brather\s+than\b/i, floor: 4, per1000: 0.9, why: "the document's one move" },
|
|
2260
|
+
{ label: "instead of", pattern: /\binstead\s+of\b/i, floor: 4, per1000: 0.7, why: "the reflex swap once 'rather than' is edited out" },
|
|
2261
|
+
{ label: "actually", pattern: /\bactually\b/i, floor: 2, per1000: 0.35, why: "filler, the sentence means the same without it" },
|
|
2262
|
+
{ label: "properly", pattern: /\bproperly\b/i, floor: 3, per1000: 0.4, why: "filler, the sentence means the same without it" },
|
|
2263
|
+
{ label: "genuinely", pattern: /\bgenuinely\b/i, floor: 1, per1000: 0.2, why: "reaching for sincerity: one is emphasis, two is a tic" },
|
|
2264
|
+
{
|
|
2265
|
+
label: "the single ...est",
|
|
2266
|
+
pattern: /\bthe\s+single\s+(?:\w+est|most|\w+\s+most)\b/i,
|
|
2267
|
+
floor: 1,
|
|
2268
|
+
per1000: 0.15,
|
|
2269
|
+
why: "if three things are the single most important, none is"
|
|
2270
|
+
},
|
|
2271
|
+
{ 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" },
|
|
2272
|
+
{ label: "Every opener", pattern: /^Every\b/, opener: true, floor: 4, per1000: 0.6, why: "the same sweeping opener, again" },
|
|
2273
|
+
{
|
|
2274
|
+
label: "Worth noting opener",
|
|
2275
|
+
pattern: /^(?:Worth\s+(?:knowing|noting|saying|mentioning|remembering)|It(?:['’]s|\s+is)\s+worth\s+(?:knowing|noting|saying|mentioning|remembering))\b/,
|
|
2276
|
+
opener: true,
|
|
2277
|
+
floor: 3,
|
|
2278
|
+
per1000: 0.5,
|
|
2279
|
+
why: "a hedge that lets a sentence start without committing"
|
|
2280
|
+
},
|
|
2281
|
+
{
|
|
2282
|
+
label: "negation-led opener",
|
|
2283
|
+
pattern: /^(?:None\s+of|Not\s+one|Not\s+a\s+single|Nothing|No\s+one|Nobody|Never)\b/,
|
|
2284
|
+
opener: true,
|
|
2285
|
+
floor: 5,
|
|
2286
|
+
per1000: 1,
|
|
2287
|
+
why: "each earns its place once or twice; at twenty the document has one move"
|
|
2288
|
+
},
|
|
2289
|
+
{
|
|
2290
|
+
label: "participle triad",
|
|
2291
|
+
pattern: /\b[a-z]{3,}ed,\s+[a-z]{3,}ed,?\s+and\s+[a-z]{3,}ed\b/i,
|
|
2292
|
+
floor: 3,
|
|
2293
|
+
per1000: 0.6,
|
|
2294
|
+
why: "'researched, written and referenced' is fine once and a cadence in bulk"
|
|
2295
|
+
}
|
|
2296
|
+
];
|
|
2297
|
+
function rateHits(ctx) {
|
|
2298
|
+
const hits = [];
|
|
2299
|
+
for (const doc2 of documents(ctx)) {
|
|
2300
|
+
const list = sentences(doc2);
|
|
2301
|
+
for (const rate of RATES) {
|
|
2302
|
+
const found = [];
|
|
2303
|
+
const global = new RegExp(rate.pattern.source, rate.pattern.flags.replace("g", "") + "g");
|
|
2304
|
+
for (const s of list) {
|
|
2305
|
+
if (rate.opener) {
|
|
2306
|
+
if (rate.pattern.test(s.text)) found.push(s);
|
|
2307
|
+
} else {
|
|
2308
|
+
const n = (s.text.match(global) ?? []).length;
|
|
2309
|
+
for (let k = 0; k < n; k += 1) found.push(s);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
const limit = allowed(doc2, rate.floor, rate.per1000);
|
|
2313
|
+
if (found.length > limit) {
|
|
2314
|
+
hits.push({
|
|
2315
|
+
path: doc2.path,
|
|
2316
|
+
offset: found[limit].offset,
|
|
2317
|
+
message: `"${rate.label}" ${found.length} times in ${thousands(doc2.words)} words (allows ${limit}): ${rate.why}`,
|
|
2318
|
+
excerpt: found[limit].text.slice(0, 120)
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
return hits;
|
|
2324
|
+
}
|
|
2325
|
+
function aphorismHits(ctx) {
|
|
2326
|
+
const hits = [];
|
|
2327
|
+
for (const doc2 of documents(ctx)) {
|
|
2328
|
+
const found = [];
|
|
2329
|
+
for (const block of doc2.blocks) {
|
|
2330
|
+
let at = 0;
|
|
2331
|
+
for (const line of withoutHeadings(block.text).split("\n")) {
|
|
2332
|
+
const text = line.trim().replace(/^[-*>]\s+/, "");
|
|
2333
|
+
const parts2 = text.match(/[^.!?]+[.!?]+/g) ?? [];
|
|
2334
|
+
if (parts2.length === 2 && parts2.join("").length >= text.length - 1 && parts2.every((p) => countWords(p) >= 2 && countWords(p) <= 7)) {
|
|
2335
|
+
found.push({ text, offset: block.offset + at + line.indexOf(text.charAt(0)) });
|
|
2336
|
+
}
|
|
2337
|
+
at += line.length + 1;
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
const limit = allowed(doc2, 5, 1.2);
|
|
2341
|
+
if (found.length > limit) {
|
|
2342
|
+
hits.push({
|
|
2343
|
+
path: doc2.path,
|
|
2344
|
+
offset: found[limit].offset,
|
|
2345
|
+
message: `${found.length} two-line aphorisms in ${thousands(doc2.words)} words (allows ${limit}). Keep the one that lands hardest`,
|
|
2346
|
+
excerpt: found[limit].text
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
return hits;
|
|
2351
|
+
}
|
|
2352
|
+
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;
|
|
2353
|
+
function contractionHits(ctx) {
|
|
2354
|
+
const hits = [];
|
|
2355
|
+
for (const doc2 of documents(ctx)) {
|
|
2356
|
+
if (doc2.words < 800) continue;
|
|
2357
|
+
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2358
|
+
const count2 = (text.match(CONTRACTION) ?? []).length;
|
|
2359
|
+
const rate = count2 * 1e3 / doc2.words;
|
|
2360
|
+
if (rate < 1) {
|
|
2361
|
+
const first = sentences(doc2)[0];
|
|
2362
|
+
hits.push({
|
|
2363
|
+
path: doc2.path,
|
|
2364
|
+
offset: first?.offset ?? 0,
|
|
2365
|
+
message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
return hits;
|
|
2370
|
+
}
|
|
2371
|
+
function rhythmHits(ctx) {
|
|
2372
|
+
const hits = [];
|
|
2373
|
+
for (const doc2 of documents(ctx)) {
|
|
2374
|
+
const list = sentences(doc2);
|
|
2375
|
+
if (list.length < 40) continue;
|
|
2376
|
+
const lengths = list.map((s) => countWords(s.text));
|
|
2377
|
+
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
|
2378
|
+
const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
|
|
2379
|
+
const cv = sd / mean;
|
|
2380
|
+
if (cv < 0.42) {
|
|
2381
|
+
hits.push({
|
|
2382
|
+
path: doc2.path,
|
|
2383
|
+
offset: list[0].offset,
|
|
2384
|
+
message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
return hits;
|
|
2389
|
+
}
|
|
2390
|
+
var normal = (s) => s.toLowerCase().replace(/[’]/g, "'").match(/[a-z0-9£$€'%]+/g) ?? [];
|
|
2391
|
+
function repeatHits(ctx) {
|
|
2392
|
+
const hits = [];
|
|
2393
|
+
const RUN = 9;
|
|
2394
|
+
for (const doc2 of documents(ctx)) {
|
|
2395
|
+
const whole = /* @__PURE__ */ new Map();
|
|
2396
|
+
const runs = /* @__PURE__ */ new Map();
|
|
2397
|
+
for (const s of sentences(doc2)) {
|
|
2398
|
+
const words2 = normal(s.text);
|
|
2399
|
+
if (words2.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
|
|
2400
|
+
const key = words2.join(" ");
|
|
2401
|
+
const earlier = whole.get(key);
|
|
2402
|
+
if (earlier) {
|
|
2403
|
+
hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words2.length} words)`, excerpt: s.text.slice(0, 120) });
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
whole.set(key, s);
|
|
2407
|
+
let reported = false;
|
|
2408
|
+
for (let i = 0; i + RUN <= words2.length && !reported; i += 1) {
|
|
2409
|
+
const run = words2.slice(i, i + RUN).join(" ");
|
|
2410
|
+
const seen = runs.get(run);
|
|
2411
|
+
if (seen && seen.key !== key) {
|
|
2412
|
+
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) });
|
|
2413
|
+
reported = true;
|
|
2414
|
+
} else if (!seen) {
|
|
2415
|
+
runs.set(run, { sentence: s, key });
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
return hits;
|
|
2421
|
+
}
|
|
2422
|
+
function headings(file) {
|
|
2423
|
+
const out = [];
|
|
2424
|
+
for (const m of file.text.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)) {
|
|
2425
|
+
out.push({ text: m[1], offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
|
|
2426
|
+
}
|
|
2427
|
+
for (const m of file.text.matchAll(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/gi)) {
|
|
2428
|
+
const text = m[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
2429
|
+
if (text) out.push({ text, offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
|
|
2430
|
+
}
|
|
2431
|
+
return out.sort((a, b) => a.offset - b.offset);
|
|
2432
|
+
}
|
|
2433
|
+
function longFiles(ctx) {
|
|
2434
|
+
const docs = new Map(documents(ctx).map((d) => [d.path, d]));
|
|
2435
|
+
return ctx.files.filter((f4) => docs.has(f4.path)).map((file) => ({ file, doc: docs.get(file.path) }));
|
|
2436
|
+
}
|
|
2437
|
+
var HEADING_SHAPES = [
|
|
2438
|
+
["X, and Y", /^[^,]{2,}, and [^,]{2,}$/i],
|
|
2439
|
+
["X, because Y", /^[^,]{2,}, because [^,]{2,}$/i],
|
|
2440
|
+
["comma list", /^[A-Z][\w'’-]*(?:, [\w'’-]+){2,}(?:,? (?:and|or) [\w'’-]+)?\.?$/]
|
|
2441
|
+
];
|
|
2442
|
+
function headingShapeHits(ctx) {
|
|
2443
|
+
const hits = [];
|
|
2444
|
+
for (const { file, doc: doc2 } of longFiles(ctx)) {
|
|
2445
|
+
const found = [];
|
|
2446
|
+
for (const heading of headings(file)) {
|
|
2447
|
+
const shape = HEADING_SHAPES.find(([, re]) => re.test(heading.text));
|
|
2448
|
+
if (shape) found.push({ heading, shape: shape[0] });
|
|
2449
|
+
}
|
|
2450
|
+
const limit = allowed(doc2, 3, 0.5);
|
|
2451
|
+
if (found.length > limit) {
|
|
2452
|
+
hits.push({
|
|
2453
|
+
path: file.path,
|
|
2454
|
+
offset: found[limit].heading.offset,
|
|
2455
|
+
message: `${found.length} headings share one shape (${[...new Set(found.map((f4) => f4.shape))].join(", ")}), allows ${limit}`,
|
|
2456
|
+
excerpt: found[limit].heading.text
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
return hits;
|
|
2461
|
+
}
|
|
2462
|
+
function headingEchoHits(ctx) {
|
|
2463
|
+
const hits = [];
|
|
2464
|
+
for (const { file } of longFiles(ctx)) {
|
|
2465
|
+
for (const heading of headings(file)) {
|
|
2466
|
+
const words2 = normal(heading.text);
|
|
2467
|
+
if (words2.length < 4) continue;
|
|
2468
|
+
const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
|
|
2469
|
+
const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words2.length);
|
|
2470
|
+
if (opening.join(" ") === words2.join(" ")) {
|
|
2471
|
+
hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
return hits;
|
|
2476
|
+
}
|
|
2477
|
+
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(" ");
|
|
2478
|
+
function filler(words2, seed = 1, options = {}) {
|
|
2479
|
+
const { contractions = true, uniform } = options;
|
|
2480
|
+
const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
|
|
2481
|
+
let state = seed;
|
|
2482
|
+
const next = (n) => {
|
|
2483
|
+
state = (state * 1103515245 + 12345) % 2147483648;
|
|
2484
|
+
return state % n;
|
|
2485
|
+
};
|
|
2486
|
+
const out = [];
|
|
2487
|
+
let count2 = 0;
|
|
2488
|
+
let i = 0;
|
|
2489
|
+
while (count2 < words2) {
|
|
2490
|
+
const length = uniform ?? shape[i % shape.length];
|
|
2491
|
+
const s = [];
|
|
2492
|
+
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
2493
|
+
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
2494
|
+
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
2495
|
+
out.push(`${s.join(" ")}.`);
|
|
2496
|
+
count2 += s.length;
|
|
2497
|
+
i += 1;
|
|
2498
|
+
}
|
|
2499
|
+
const paragraphs = [];
|
|
2500
|
+
for (let p = 0; p < out.length; p += 4) paragraphs.push(out.slice(p, p + 4).join(" "));
|
|
2501
|
+
return paragraphs.join("\n\n");
|
|
2502
|
+
}
|
|
2503
|
+
var f2 = (path, text) => ({ path, text });
|
|
2504
|
+
var doc = (extra, seed = 1) => `${filler(520, seed)}
|
|
2505
|
+
|
|
2506
|
+
${extra}
|
|
2507
|
+
`;
|
|
2508
|
+
var times = (n, sentence) => Array.from({ length: n }, (_, i) => sentence(i)).join(" ");
|
|
2509
|
+
var DENSITY_TELLS = [
|
|
2510
|
+
{
|
|
2511
|
+
id: "phrase-density",
|
|
2512
|
+
name: "Leaned-on phrase",
|
|
2513
|
+
generation: 2,
|
|
2514
|
+
severity: "warn",
|
|
2515
|
+
surface: "copy",
|
|
2516
|
+
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.",
|
|
2517
|
+
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.",
|
|
2518
|
+
detect: rateHits,
|
|
2519
|
+
fixtures: {
|
|
2520
|
+
flag: [
|
|
2521
|
+
f2("content/proposal.md", doc(times(6, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
2522
|
+
f2("content/proposal.md", doc(times(6, (i) => `We sealed roof ${i} instead of patching it.`))),
|
|
2523
|
+
f2("content/proposal.md", doc(times(4, (i) => `Room ${i} actually needed a new valve.`))),
|
|
2524
|
+
f2("content/proposal.md", doc(times(5, (i) => `Gate ${i} was properly hung.`))),
|
|
2525
|
+
f2("content/proposal.md", doc(times(3, (i) => `Kitchen ${i} was genuinely tidy.`))),
|
|
2526
|
+
f2("content/proposal.md", doc(times(3, (i) => `Pipe ${i} is the single biggest risk.`))),
|
|
2527
|
+
f2("content/proposal.md", doc(times(6, (i) => `That is why tap ${i} leaks.`))),
|
|
2528
|
+
f2("content/proposal.md", doc(times(6, (i) => `Every tap ${i} gets checked.`))),
|
|
2529
|
+
f2("content/proposal.md", doc(times(5, (i) => `Worth noting: valve ${i} sticks.`))),
|
|
2530
|
+
f2("content/proposal.md", doc(times(7, (i) => `None of room ${i} needed paint.`))),
|
|
2531
|
+
f2("content/proposal.md", doc(times(5, (i) => `Wall ${i} was measured, sanded and sealed.`))),
|
|
2532
|
+
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>`)
|
|
2533
|
+
],
|
|
2534
|
+
pass: [
|
|
2535
|
+
f2("content/proposal.md", doc(times(3, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
2536
|
+
f2("content/short.md", times(6, (i) => `We fitted boiler ${i} rather than patching it.`)),
|
|
2537
|
+
f2("app/page.tsx", `export const copy = [${Array.from({ length: 40 }, (_, i) => `"We fitted boiler ${i} rather than patching it."`).join(", ")}];`)
|
|
2538
|
+
]
|
|
2539
|
+
}
|
|
2540
|
+
},
|
|
2541
|
+
{
|
|
2542
|
+
id: "aphorism-density",
|
|
2543
|
+
name: "Aphorism cadence",
|
|
2544
|
+
generation: 2,
|
|
2545
|
+
severity: "warn",
|
|
2546
|
+
surface: "copy",
|
|
2547
|
+
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.",
|
|
2548
|
+
fix: "Keep the strongest one. Fold the rest into the paragraph above as plain statements.",
|
|
2549
|
+
detect: aphorismHits,
|
|
2550
|
+
fixtures: {
|
|
2551
|
+
flag: [f2("content/proposal.md", doc(Array.from({ length: 7 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))],
|
|
2552
|
+
pass: [f2("content/proposal.md", doc(Array.from({ length: 3 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))]
|
|
2553
|
+
}
|
|
2554
|
+
},
|
|
2555
|
+
{
|
|
2556
|
+
id: "contraction-scarcity",
|
|
2557
|
+
name: "No contractions",
|
|
2558
|
+
generation: 1,
|
|
2559
|
+
severity: "warn",
|
|
2560
|
+
surface: "copy",
|
|
2561
|
+
why: "A long document with almost no contractions reads stiff and machine-made. People write 'we're' and 'don't'.",
|
|
2562
|
+
fix: "Contract where you would in speech: 'we are' to 'we're', 'do not' to 'don't'. Formal legal pages are the deliberate exception.",
|
|
2563
|
+
detect: contractionHits,
|
|
2564
|
+
fixtures: {
|
|
2565
|
+
flag: [f2("content/proposal.md", filler(900, 5, { contractions: false }))],
|
|
2566
|
+
pass: [f2("content/proposal.md", filler(900, 5)), f2("content/short.md", filler(500, 5, { contractions: false }))]
|
|
2567
|
+
}
|
|
2568
|
+
},
|
|
2569
|
+
{
|
|
2570
|
+
id: "sentence-rhythm",
|
|
2571
|
+
name: "Metronomic rhythm",
|
|
2572
|
+
generation: 2,
|
|
2573
|
+
severity: "warn",
|
|
2574
|
+
surface: "copy",
|
|
2575
|
+
why: "Sentence after sentence of the same length reads generated. A person's sentences run long, then short, then long again.",
|
|
2576
|
+
fix: "Break one long sentence in two, join two short ones, and let some sentences be flat and plain.",
|
|
2577
|
+
detect: rhythmHits,
|
|
2578
|
+
fixtures: {
|
|
2579
|
+
flag: [f2("content/proposal.md", filler(600, 7, { uniform: 12 }))],
|
|
2580
|
+
pass: [f2("content/proposal.md", filler(600, 7))]
|
|
2581
|
+
}
|
|
2582
|
+
},
|
|
2583
|
+
{
|
|
2584
|
+
id: "repeated-sentence",
|
|
2585
|
+
name: "Repeated sentence",
|
|
2586
|
+
generation: 1,
|
|
2587
|
+
severity: "warn",
|
|
2588
|
+
surface: "copy",
|
|
2589
|
+
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.",
|
|
2590
|
+
fix: "Say it once, where it matters most, and write the second place fresh.",
|
|
2591
|
+
detect: repeatHits,
|
|
2592
|
+
fixtures: {
|
|
2593
|
+
flag: [
|
|
2594
|
+
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.")),
|
|
2595
|
+
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."))
|
|
2596
|
+
],
|
|
2597
|
+
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."))]
|
|
2598
|
+
}
|
|
2599
|
+
},
|
|
2600
|
+
{
|
|
2601
|
+
id: "heading-shape",
|
|
2602
|
+
name: "One heading shape",
|
|
2603
|
+
generation: 2,
|
|
2604
|
+
severity: "warn",
|
|
2605
|
+
surface: "copy",
|
|
2606
|
+
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.",
|
|
2607
|
+
fix: "Write each heading for its section. Most should be a plain noun phrase.",
|
|
2608
|
+
detect: headingShapeHits,
|
|
2609
|
+
fixtures: {
|
|
2610
|
+
flag: [
|
|
2611
|
+
f2("content/proposal.md", ["## Fast, and fairly priced", "## Local, and insured", "## Tidy, and quiet", "## Early, and on time", "", filler(520, 9)].join("\n\n")),
|
|
2612
|
+
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>`),
|
|
2613
|
+
f2("content/proposal.md", ["## Keep, rewrite, retire", "## Fix, paint, seal", "## Book, quote, fit", "## Plan, build, check", "", filler(520, 9)].join("\n\n"))
|
|
2614
|
+
],
|
|
2615
|
+
pass: [f2("content/proposal.md", ["## Pricing", "## Fast, and fairly priced", "## Who we are", "## Areas we cover", "", filler(520, 9)].join("\n\n"))]
|
|
2616
|
+
}
|
|
2617
|
+
},
|
|
2618
|
+
{
|
|
2619
|
+
id: "heading-echo",
|
|
2620
|
+
name: "Heading echoed",
|
|
2621
|
+
generation: 2,
|
|
2622
|
+
severity: "warn",
|
|
2623
|
+
surface: "copy",
|
|
2624
|
+
why: "A heading repeated word for word as the first line under it spends the reader's attention twice on the same words.",
|
|
2625
|
+
fix: "Let the first sentence say something the heading did not.",
|
|
2626
|
+
detect: headingEchoHits,
|
|
2627
|
+
fixtures: {
|
|
2628
|
+
flag: [
|
|
2629
|
+
f2("content/proposal.md", `## We fix boilers the same day
|
|
2630
|
+
|
|
2631
|
+
We fix boilers the same day across Brackley.
|
|
2632
|
+
|
|
2633
|
+
${filler(520, 11)}`),
|
|
2634
|
+
f2("site/index.html", `<h2>We fix boilers the same day</h2>
|
|
2635
|
+
<p>We fix boilers the same day across Brackley.</p><p>${filler(520, 11)}</p>`)
|
|
2636
|
+
],
|
|
2637
|
+
pass: [f2("content/proposal.md", `## We fix boilers the same day
|
|
2638
|
+
|
|
2639
|
+
Call before noon and an engineer is with you by six.
|
|
2640
|
+
|
|
2641
|
+
${filler(520, 11)}`)]
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
];
|
|
1890
2645
|
|
|
1891
2646
|
// src/character/color.ts
|
|
1892
2647
|
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;
|
|
@@ -1907,8 +2662,8 @@ function parts(fn) {
|
|
|
1907
2662
|
function hslToRgb(h, s, l) {
|
|
1908
2663
|
const k = (n) => (n + h / 30) % 12;
|
|
1909
2664
|
const a = s * Math.min(l, 1 - l);
|
|
1910
|
-
const
|
|
1911
|
-
return { r:
|
|
2665
|
+
const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
2666
|
+
return { r: f4(0), g: f4(8), b: f4(4) };
|
|
1912
2667
|
}
|
|
1913
2668
|
var clamp012 = (n) => Math.min(1, Math.max(0, n));
|
|
1914
2669
|
function parseColour(raw) {
|
|
@@ -2020,7 +2775,7 @@ function firstMatch(text, pattern) {
|
|
|
2020
2775
|
return m ? m.index : -1;
|
|
2021
2776
|
}
|
|
2022
2777
|
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-/;
|
|
2023
|
-
var
|
|
2778
|
+
var f3 = (path, text) => ({ path, text });
|
|
2024
2779
|
function familyNames(value) {
|
|
2025
2780
|
return value.split(",").map((v) => v.trim().replace(/^["']|["']$/g, "").trim()).filter(Boolean);
|
|
2026
2781
|
}
|
|
@@ -2485,14 +3240,14 @@ var SOURCE_TELLS = [
|
|
|
2485
3240
|
detect: fontTell(REFLEX_FONTS_1),
|
|
2486
3241
|
fixtures: {
|
|
2487
3242
|
flag: [
|
|
2488
|
-
|
|
3243
|
+
f3("app/layout.tsx", `import { Inter } from "next/font/google";
|
|
2489
3244
|
const inter = Inter({ subsets: ["latin"] });`),
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
3245
|
+
f3("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
|
|
3246
|
+
f3("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
|
|
3247
|
+
f3("main.ts", `import "@fontsource/montserrat";`),
|
|
3248
|
+
f3("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
|
|
2494
3249
|
],
|
|
2495
|
-
pass: [
|
|
3250
|
+
pass: [f3("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
|
|
2496
3251
|
}
|
|
2497
3252
|
},
|
|
2498
3253
|
{
|
|
@@ -2502,13 +3257,13 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2502
3257
|
severity: "warn",
|
|
2503
3258
|
surface: "source",
|
|
2504
3259
|
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.",
|
|
2505
|
-
fix: "
|
|
3260
|
+
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.",
|
|
2506
3261
|
detect: fontTell(REFLEX_FONTS_2),
|
|
2507
3262
|
fixtures: {
|
|
2508
|
-
flag: [
|
|
3263
|
+
flag: [f3("src/styles.css", `@theme {
|
|
2509
3264
|
--font-sans: "DM Sans", system-ui, sans-serif;
|
|
2510
3265
|
}`)],
|
|
2511
|
-
pass: [
|
|
3266
|
+
pass: [f3("src/styles.css", `@theme {
|
|
2512
3267
|
--font-sans: "Hanken Grotesk", system-ui, sans-serif;
|
|
2513
3268
|
}`)]
|
|
2514
3269
|
}
|
|
@@ -2524,10 +3279,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2524
3279
|
detect: violetHits,
|
|
2525
3280
|
fixtures: {
|
|
2526
3281
|
flag: [
|
|
2527
|
-
|
|
2528
|
-
|
|
3282
|
+
f3("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
|
|
3283
|
+
f3("app/globals.css", `:root { --accent: #5a35d1; }`)
|
|
2529
3284
|
],
|
|
2530
|
-
pass: [
|
|
3285
|
+
pass: [f3("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
|
|
2531
3286
|
}
|
|
2532
3287
|
},
|
|
2533
3288
|
{
|
|
@@ -2541,10 +3296,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2541
3296
|
detect: gradientHits,
|
|
2542
3297
|
fixtures: {
|
|
2543
3298
|
flag: [
|
|
2544
|
-
|
|
2545
|
-
|
|
3299
|
+
f3("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
|
|
3300
|
+
f3("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
|
|
2546
3301
|
],
|
|
2547
|
-
pass: [
|
|
3302
|
+
pass: [f3("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
|
|
2548
3303
|
}
|
|
2549
3304
|
},
|
|
2550
3305
|
{
|
|
@@ -2558,10 +3313,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2558
3313
|
detect: gradientTextHits,
|
|
2559
3314
|
fixtures: {
|
|
2560
3315
|
flag: [
|
|
2561
|
-
|
|
2562
|
-
|
|
3316
|
+
f3("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
|
|
3317
|
+
f3("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
|
|
2563
3318
|
],
|
|
2564
|
-
pass: [
|
|
3319
|
+
pass: [f3("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
|
|
2565
3320
|
}
|
|
2566
3321
|
},
|
|
2567
3322
|
{
|
|
@@ -2575,10 +3330,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2575
3330
|
detect: glassHits,
|
|
2576
3331
|
fixtures: {
|
|
2577
3332
|
flag: [
|
|
2578
|
-
|
|
2579
|
-
|
|
3333
|
+
f3("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
|
|
3334
|
+
f3("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
|
|
2580
3335
|
],
|
|
2581
|
-
pass: [
|
|
3336
|
+
pass: [f3("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
|
|
2582
3337
|
}
|
|
2583
3338
|
},
|
|
2584
3339
|
{
|
|
@@ -2591,10 +3346,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2591
3346
|
fix: "Follow the hero with the thing only this business has: the work, the place, the person. Put proof where a doubt arises.",
|
|
2592
3347
|
detect: heroThenProofHits,
|
|
2593
3348
|
fixtures: {
|
|
2594
|
-
flag: [
|
|
3349
|
+
flag: [f3("app/page.tsx", `export default function Page() {
|
|
2595
3350
|
return (<main><Hero /><TrustStrip /><Services /></main>);
|
|
2596
3351
|
}`)],
|
|
2597
|
-
pass: [
|
|
3352
|
+
pass: [f3("app/page.tsx", `export default function Page() {
|
|
2598
3353
|
return (<main><Hero /><Workshop /><TrustStrip /></main>);
|
|
2599
3354
|
}`)]
|
|
2600
3355
|
}
|
|
@@ -2610,7 +3365,7 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2610
3365
|
detect: iconGridHits,
|
|
2611
3366
|
fixtures: {
|
|
2612
3367
|
flag: [
|
|
2613
|
-
|
|
3368
|
+
f3(
|
|
2614
3369
|
"components/Features.tsx",
|
|
2615
3370
|
`import { Heart } from "lucide-react";
|
|
2616
3371
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
@@ -2618,7 +3373,7 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2618
3373
|
</div>`
|
|
2619
3374
|
)
|
|
2620
3375
|
],
|
|
2621
|
-
pass: [
|
|
3376
|
+
pass: [f3("components/Features.tsx", `<ul className="divide-y">
|
|
2622
3377
|
<li className="flex justify-between py-3">Gel manicure <span>\xA332</span></li>
|
|
2623
3378
|
</ul>`)]
|
|
2624
3379
|
}
|
|
@@ -2633,8 +3388,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2633
3388
|
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.`,
|
|
2634
3389
|
detect: revealHits,
|
|
2635
3390
|
fixtures: {
|
|
2636
|
-
flag: [
|
|
2637
|
-
pass: [
|
|
3391
|
+
flag: [f3("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
|
|
3392
|
+
pass: [f3("app/page.tsx", `<Reveal><Signature /></Reveal>
|
|
2638
3393
|
<Block />
|
|
2639
3394
|
<Block />`)]
|
|
2640
3395
|
}
|
|
@@ -2649,8 +3404,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2649
3404
|
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.`,
|
|
2650
3405
|
detect: pillHits,
|
|
2651
3406
|
fixtures: {
|
|
2652
|
-
flag: [
|
|
2653
|
-
pass: [
|
|
3407
|
+
flag: [f3("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
|
|
3408
|
+
pass: [f3("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
|
|
2654
3409
|
<a className="rounded-sm px-4">Book</a>`)]
|
|
2655
3410
|
}
|
|
2656
3411
|
},
|
|
@@ -2664,8 +3419,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2664
3419
|
fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
|
|
2665
3420
|
detect: shadcnHits,
|
|
2666
3421
|
fixtures: {
|
|
2667
|
-
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) =>
|
|
2668
|
-
pass: [
|
|
3422
|
+
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f3(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
|
|
3423
|
+
pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
|
|
2669
3424
|
}
|
|
2670
3425
|
},
|
|
2671
3426
|
{
|
|
@@ -2679,11 +3434,11 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2679
3434
|
detect: creamHits,
|
|
2680
3435
|
fixtures: {
|
|
2681
3436
|
flag: [
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
3437
|
+
f3("app/globals.css", `body { background-color: #f5efe6; }`),
|
|
3438
|
+
f3("app/page.tsx", `<main className="bg-cream text-ink">`),
|
|
3439
|
+
f3("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
|
|
2685
3440
|
],
|
|
2686
|
-
pass: [
|
|
3441
|
+
pass: [f3("app/globals.css", `body { background-color: #ffffff; }`)]
|
|
2687
3442
|
}
|
|
2688
3443
|
},
|
|
2689
3444
|
{
|
|
@@ -2697,11 +3452,11 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2697
3452
|
detect: italicSerifHits,
|
|
2698
3453
|
fixtures: {
|
|
2699
3454
|
flag: [
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
3455
|
+
f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
|
|
3456
|
+
f3("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
|
|
3457
|
+
f3("app/globals.css", `.hero h1 { font-style: italic; }`)
|
|
2703
3458
|
],
|
|
2704
|
-
pass: [
|
|
3459
|
+
pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
|
|
2705
3460
|
<p><em>Walk-ins</em> welcome.</p>`)]
|
|
2706
3461
|
}
|
|
2707
3462
|
},
|
|
@@ -2716,12 +3471,12 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2716
3471
|
detect: eyebrowChipHits,
|
|
2717
3472
|
fixtures: {
|
|
2718
3473
|
flag: [
|
|
2719
|
-
|
|
3474
|
+
f3("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
|
|
2720
3475
|
<h1>Nails in Brackley</h1>`),
|
|
2721
|
-
|
|
3476
|
+
f3("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
|
|
2722
3477
|
<h1>Nails</h1>`)
|
|
2723
3478
|
],
|
|
2724
|
-
pass: [
|
|
3479
|
+
pass: [f3("components/Hero.tsx", `<h1>Nails in Brackley</h1>
|
|
2725
3480
|
<span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
|
|
2726
3481
|
}
|
|
2727
3482
|
},
|
|
@@ -2735,9 +3490,9 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2735
3490
|
fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
|
|
2736
3491
|
detect: iconTileStackHits,
|
|
2737
3492
|
fixtures: {
|
|
2738
|
-
flag: [
|
|
3493
|
+
flag: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
2739
3494
|
<div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
|
|
2740
|
-
pass: [
|
|
3495
|
+
pass: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
2741
3496
|
<p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
|
|
2742
3497
|
}
|
|
2743
3498
|
},
|
|
@@ -2751,8 +3506,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2751
3506
|
fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
|
|
2752
3507
|
detect: bentoHits,
|
|
2753
3508
|
fixtures: {
|
|
2754
|
-
flag: [
|
|
2755
|
-
pass: [
|
|
3509
|
+
flag: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
|
|
3510
|
+
pass: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
|
|
2756
3511
|
}
|
|
2757
3512
|
},
|
|
2758
3513
|
{
|
|
@@ -2766,10 +3521,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2766
3521
|
detect: glowHits,
|
|
2767
3522
|
fixtures: {
|
|
2768
3523
|
flag: [
|
|
2769
|
-
|
|
2770
|
-
|
|
3524
|
+
f3("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
|
|
3525
|
+
f3("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
|
|
2771
3526
|
],
|
|
2772
|
-
pass: [
|
|
3527
|
+
pass: [f3("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
|
|
2773
3528
|
}
|
|
2774
3529
|
},
|
|
2775
3530
|
{
|
|
@@ -2783,13 +3538,13 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2783
3538
|
detect: gridBackgroundHits,
|
|
2784
3539
|
fixtures: {
|
|
2785
3540
|
flag: [
|
|
2786
|
-
|
|
3541
|
+
f3(
|
|
2787
3542
|
"app/globals.css",
|
|
2788
3543
|
`.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
|
|
2789
3544
|
),
|
|
2790
|
-
|
|
3545
|
+
f3("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
|
|
2791
3546
|
],
|
|
2792
|
-
pass: [
|
|
3547
|
+
pass: [f3("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
|
|
2793
3548
|
}
|
|
2794
3549
|
},
|
|
2795
3550
|
{
|
|
@@ -2803,10 +3558,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2803
3558
|
detect: marqueeHits,
|
|
2804
3559
|
fixtures: {
|
|
2805
3560
|
flag: [
|
|
2806
|
-
|
|
2807
|
-
|
|
3561
|
+
f3("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
|
|
3562
|
+
f3("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
|
|
2808
3563
|
],
|
|
2809
|
-
pass: [
|
|
3564
|
+
pass: [f3("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
|
|
2810
3565
|
}
|
|
2811
3566
|
},
|
|
2812
3567
|
{
|
|
@@ -2820,10 +3575,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2820
3575
|
detect: thinBorderWideShadowHits,
|
|
2821
3576
|
fixtures: {
|
|
2822
3577
|
flag: [
|
|
2823
|
-
|
|
2824
|
-
|
|
3578
|
+
f3("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
|
|
3579
|
+
f3("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
|
|
2825
3580
|
],
|
|
2826
|
-
pass: [
|
|
3581
|
+
pass: [f3("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
|
|
2827
3582
|
}
|
|
2828
3583
|
},
|
|
2829
3584
|
{
|
|
@@ -2837,15 +3592,61 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2837
3592
|
detect: introHits,
|
|
2838
3593
|
fixtures: {
|
|
2839
3594
|
flag: [
|
|
2840
|
-
|
|
3595
|
+
f3("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
|
|
2841
3596
|
export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
2842
|
-
|
|
3597
|
+
f3("app/page.tsx", `<><Preloader /><Hero /></>`)
|
|
2843
3598
|
],
|
|
2844
|
-
pass: [
|
|
3599
|
+
pass: [f3("components/Introduction.md", `An introduction to the studio.`)]
|
|
2845
3600
|
}
|
|
2846
3601
|
}
|
|
2847
3602
|
];
|
|
2848
3603
|
|
|
3604
|
+
// src/character/house.ts
|
|
3605
|
+
var HOUSE = {
|
|
3606
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
3607
|
+
emoji: { tier: "block", label: "emoji" },
|
|
3608
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
3609
|
+
"no-x-no-y": {
|
|
3610
|
+
tier: "block",
|
|
3611
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
3612
|
+
},
|
|
3613
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
3614
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
3615
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
3616
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
3617
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
3618
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
3619
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
3620
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
3621
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
3622
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
3623
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
3624
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
3625
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
3626
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
3627
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
3628
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
3629
|
+
"false-range": { tier: "review", label: "false range" },
|
|
3630
|
+
// The first candidate for block: evidence of a pasted chat reply, not a
|
|
3631
|
+
// matter of style. Blocks once its estate hits have been read (ROADMAP.md).
|
|
3632
|
+
"chatbot-residue": { tier: "review", label: "chatbot residue" },
|
|
3633
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
3634
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
3635
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
3636
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
3637
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
3638
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
3639
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
3640
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
3641
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
3642
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
3643
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
3644
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
3645
|
+
};
|
|
3646
|
+
function houseRule(id, name = id) {
|
|
3647
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
3648
|
+
}
|
|
3649
|
+
|
|
2849
3650
|
// src/character/parse.ts
|
|
2850
3651
|
var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
|
|
2851
3652
|
var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
|
|
@@ -3250,15 +4051,16 @@ function makeSnapshot(patch = {}) {
|
|
|
3250
4051
|
}
|
|
3251
4052
|
|
|
3252
4053
|
// src/snapshot/fonts.ts
|
|
4054
|
+
var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
|
|
3253
4055
|
function normaliseFamily(raw) {
|
|
3254
4056
|
let name = raw.replace(/["']/g, "").trim();
|
|
3255
4057
|
const next = name.match(/^__(.+?)(?:_Fallback)?_[0-9a-f]{5,8}$/i);
|
|
3256
4058
|
if (next) name = next[1].replace(/_/g, " ");
|
|
3257
4059
|
name = name.replace(/\s+Fallback$/i, "").replace(/\s+Variable$/i, "");
|
|
4060
|
+
if (SYSTEM.test(name)) return "system-ui";
|
|
3258
4061
|
if (!/\s/.test(name) && (/[a-z][A-Z]/.test(name) || /^[a-z]+(?:-[a-z]+)+$/.test(name))) {
|
|
3259
4062
|
name = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/-/g, " ").replace(/\b([a-z])/g, (c) => c.toUpperCase()).replace(/\bDm\b/, "DM");
|
|
3260
4063
|
}
|
|
3261
|
-
if (/^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|sans-serif|serif|monospace)$/i.test(name)) return "system-ui";
|
|
3262
4064
|
return name;
|
|
3263
4065
|
}
|
|
3264
4066
|
var KNOWN = {
|
|
@@ -3309,8 +4111,8 @@ var oklchOf = (value) => parseColour(value)?.oklch ?? null;
|
|
|
3309
4111
|
function reflexFonts(list) {
|
|
3310
4112
|
const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
|
|
3311
4113
|
return (s) => {
|
|
3312
|
-
const total = s.fonts.reduce((n,
|
|
3313
|
-
const display = s.fonts.reduce((n,
|
|
4114
|
+
const total = s.fonts.reduce((n, f4) => n + f4.chars, 0) || 1;
|
|
4115
|
+
const display = s.fonts.reduce((n, f4) => n + f4.displayChars, 0) || 1;
|
|
3314
4116
|
const hits = [];
|
|
3315
4117
|
const seen = /* @__PURE__ */ new Set();
|
|
3316
4118
|
const h1 = s.headings.find((h) => h.level === 1);
|
|
@@ -3511,8 +4313,8 @@ var RENDERED_PATHS = {
|
|
|
3511
4313
|
};
|
|
3512
4314
|
|
|
3513
4315
|
// src/character/check.ts
|
|
3514
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
3515
|
-
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS].map(
|
|
4316
|
+
var CATALOGUE_VERSION = "2026.09.6";
|
|
4317
|
+
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
|
|
3516
4318
|
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
3517
4319
|
);
|
|
3518
4320
|
function tellById(id) {
|
|
@@ -3547,7 +4349,8 @@ function toFinding(tell, hit2, texts) {
|
|
|
3547
4349
|
line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
|
|
3548
4350
|
excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
|
|
3549
4351
|
message: hit2.message,
|
|
3550
|
-
fix: tell.fix
|
|
4352
|
+
fix: tell.fix,
|
|
4353
|
+
...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
|
|
3551
4354
|
};
|
|
3552
4355
|
}
|
|
3553
4356
|
function report(tells, files, run, options) {
|
|
@@ -3600,6 +4403,10 @@ function report(tells, files, run, options) {
|
|
|
3600
4403
|
}
|
|
3601
4404
|
};
|
|
3602
4405
|
}
|
|
4406
|
+
function applyHouseGate(result) {
|
|
4407
|
+
const findings = result.findings.map((f4) => f4.house === "block" ? { ...f4, severity: "block" } : f4);
|
|
4408
|
+
return { ...result, findings, summary: { ...result.summary, blocking: findings.filter((f4) => f4.severity === "block").length } };
|
|
4409
|
+
}
|
|
3603
4410
|
function scanSource(files, options = {}) {
|
|
3604
4411
|
const ctx = { files: files.map(parseFile) };
|
|
3605
4412
|
const tells = CATALOGUE.filter((t) => t.surface === "source");
|
|
@@ -3635,20 +4442,39 @@ function catalogueTable() {
|
|
|
3635
4442
|
...rows
|
|
3636
4443
|
].join("\n");
|
|
3637
4444
|
}
|
|
4445
|
+
function wordListsJson() {
|
|
4446
|
+
return JSON.stringify(
|
|
4447
|
+
{
|
|
4448
|
+
catalogueVersion: CATALOGUE_VERSION,
|
|
4449
|
+
lists: {
|
|
4450
|
+
aiWords: AI_WORDS,
|
|
4451
|
+
stockPhrases: STOCK_PHRASES,
|
|
4452
|
+
aiPhrases: AI_PHRASES,
|
|
4453
|
+
plainerWords: PLAINER_WORDS,
|
|
4454
|
+
buzzwords: BUZZWORDS,
|
|
4455
|
+
negativeReassurance: NEGATIVE_REASSURANCE,
|
|
4456
|
+
vagueWords: VAGUE_WORDS,
|
|
4457
|
+
reviewPhrases: REVIEW_PHRASES
|
|
4458
|
+
}
|
|
4459
|
+
},
|
|
4460
|
+
null,
|
|
4461
|
+
2
|
|
4462
|
+
) + "\n";
|
|
4463
|
+
}
|
|
3638
4464
|
|
|
3639
4465
|
// src/character/format.ts
|
|
3640
4466
|
function formatReport(report2, title) {
|
|
3641
4467
|
const lines = [];
|
|
3642
4468
|
let current = "";
|
|
3643
|
-
for (const
|
|
3644
|
-
if (
|
|
3645
|
-
current =
|
|
3646
|
-
lines.push("",
|
|
4469
|
+
for (const f4 of report2.findings) {
|
|
4470
|
+
if (f4.path !== current) {
|
|
4471
|
+
current = f4.path;
|
|
4472
|
+
lines.push("", f4.path);
|
|
3647
4473
|
}
|
|
3648
|
-
lines.push(` ${String(
|
|
3649
|
-
lines.push(` ${
|
|
4474
|
+
lines.push(` ${String(f4.line).padStart(4)} ${f4.tell} (gen ${f4.generation}, ${f4.severity}) ${f4.message}`);
|
|
4475
|
+
lines.push(` ${f4.excerpt}`);
|
|
3650
4476
|
}
|
|
3651
|
-
const fixes = new Map(report2.findings.map((
|
|
4477
|
+
const fixes = new Map(report2.findings.map((f4) => [f4.tell, f4.fix]));
|
|
3652
4478
|
if (fixes.size > 0) {
|
|
3653
4479
|
lines.push("", "What to do instead:");
|
|
3654
4480
|
for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
|
|
@@ -3671,9 +4497,91 @@ function formatReport(report2, title) {
|
|
|
3671
4497
|
return lines.join("\n").replace(/^\n/, "");
|
|
3672
4498
|
}
|
|
3673
4499
|
|
|
4500
|
+
// src/character/facts.ts
|
|
4501
|
+
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)?)";
|
|
4502
|
+
var PATTERNS = [
|
|
4503
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
4504
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
4505
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
4506
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
4507
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
4508
|
+
["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")],
|
|
4509
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
4510
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
4511
|
+
];
|
|
4512
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
4513
|
+
var normalise = (kind, value) => {
|
|
4514
|
+
const v = value.trim();
|
|
4515
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
4516
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
4517
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
4518
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
4519
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
4520
|
+
};
|
|
4521
|
+
function visibleText(file) {
|
|
4522
|
+
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
4523
|
+
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
4524
|
+
}
|
|
4525
|
+
function protectedFacts(text) {
|
|
4526
|
+
const facts = [];
|
|
4527
|
+
let rest = text;
|
|
4528
|
+
const mask = (start, length) => {
|
|
4529
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
4530
|
+
};
|
|
4531
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
4532
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
4533
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
4534
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
4535
|
+
mask(m.index ?? 0, m[0].length);
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
const lines = rest.split("\n");
|
|
4539
|
+
for (const line of lines) {
|
|
4540
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
4541
|
+
for (const m of line.matchAll(NAME)) {
|
|
4542
|
+
const at = m.index ?? 0;
|
|
4543
|
+
const before = line.slice(0, at).trimEnd();
|
|
4544
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
4545
|
+
const word = m[0];
|
|
4546
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
4547
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
4548
|
+
if (word === "I") continue;
|
|
4549
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
return facts;
|
|
4553
|
+
}
|
|
4554
|
+
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
4555
|
+
function compareFacts(before, after) {
|
|
4556
|
+
const a = unique(protectedFacts(before));
|
|
4557
|
+
const b = unique(protectedFacts(after));
|
|
4558
|
+
const inA = new Set(a.map((f4) => f4.key));
|
|
4559
|
+
const inB = new Set(b.map((f4) => f4.key));
|
|
4560
|
+
return {
|
|
4561
|
+
before: a.length,
|
|
4562
|
+
after: b.length,
|
|
4563
|
+
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
4564
|
+
added: b.filter((f4) => !inA.has(f4.key))
|
|
4565
|
+
};
|
|
4566
|
+
}
|
|
4567
|
+
function formatComparison(result, beforePath, afterPath) {
|
|
4568
|
+
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
4569
|
+
if (result.lost.length) {
|
|
4570
|
+
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
4571
|
+
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4572
|
+
}
|
|
4573
|
+
if (result.added.length) {
|
|
4574
|
+
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
4575
|
+
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4576
|
+
}
|
|
4577
|
+
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
4578
|
+
return lines.join("\n");
|
|
4579
|
+
}
|
|
4580
|
+
|
|
3674
4581
|
// src/fingerprint/index.ts
|
|
3675
4582
|
var FINGERPRINT_VERSION = 1;
|
|
3676
4583
|
var CHROMATIC = 0.03;
|
|
4584
|
+
var MIN_ACCENT_AREA = 2e4;
|
|
3677
4585
|
var oklch = (value) => {
|
|
3678
4586
|
const parsed = parseColour(value);
|
|
3679
4587
|
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
@@ -3686,7 +4594,7 @@ function accentOf(s) {
|
|
|
3686
4594
|
}
|
|
3687
4595
|
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
3688
4596
|
if (topButton) return oklch(topButton[0]);
|
|
3689
|
-
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];
|
|
4597
|
+
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];
|
|
3690
4598
|
if (bg) return bg.o;
|
|
3691
4599
|
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];
|
|
3692
4600
|
return text ? text.o : null;
|
|
@@ -3763,8 +4671,8 @@ function fingerprintDistance(a, b) {
|
|
|
3763
4671
|
const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts2[k], 0);
|
|
3764
4672
|
return { total: Math.round(total * 1e3) / 1e3, parts: parts2 };
|
|
3765
4673
|
}
|
|
3766
|
-
function nearest(
|
|
3767
|
-
return set.map((item) => ({ id: item.id, distance: fingerprintDistance(
|
|
4674
|
+
function nearest(target2, set, k = set.length) {
|
|
4675
|
+
return set.map((item) => ({ id: item.id, distance: fingerprintDistance(target2, item.fingerprint) })).sort((x, y) => x.distance.total - y.distance.total).slice(0, k);
|
|
3768
4676
|
}
|
|
3769
4677
|
|
|
3770
4678
|
// src/direction/types.ts
|
|
@@ -3805,8 +4713,8 @@ function tellFor(key, value) {
|
|
|
3805
4713
|
}
|
|
3806
4714
|
if (key === "display" || key === "body") {
|
|
3807
4715
|
const name = normaliseFamily(value).toLowerCase();
|
|
3808
|
-
if (REFLEX_FONTS_1.some((
|
|
3809
|
-
if (REFLEX_FONTS_2.some((
|
|
4716
|
+
if (REFLEX_FONTS_1.some((f4) => f4.toLowerCase() === name)) return "reflex-font";
|
|
4717
|
+
if (REFLEX_FONTS_2.some((f4) => f4.toLowerCase() === name)) return "reflex-font-2";
|
|
3810
4718
|
}
|
|
3811
4719
|
return null;
|
|
3812
4720
|
}
|
|
@@ -4071,6 +4979,495 @@ function propose(input) {
|
|
|
4071
4979
|
direction: { version: DIRECTION_VERSION, client: input.client, brief: input.brief, sources: input.sources, choices }
|
|
4072
4980
|
};
|
|
4073
4981
|
}
|
|
4982
|
+
|
|
4983
|
+
// src/null/index.ts
|
|
4984
|
+
var NULL_VERSION = 1;
|
|
4985
|
+
function nullPrompt(brief) {
|
|
4986
|
+
return [
|
|
4987
|
+
`Build the home page for this business: ${brief.trim()}`,
|
|
4988
|
+
"",
|
|
4989
|
+
"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."
|
|
4990
|
+
].join("\n");
|
|
4991
|
+
}
|
|
4992
|
+
function extractHtml(reply) {
|
|
4993
|
+
const start = reply.search(/<!doctype html|<html[\s>]/i);
|
|
4994
|
+
if (start < 0) return null;
|
|
4995
|
+
const end = reply.toLowerCase().lastIndexOf("</html>");
|
|
4996
|
+
const html = end > start ? reply.slice(start, end + "</html>".length) : reply.slice(start).replace(/\s*```\s*$/, "");
|
|
4997
|
+
return html.length > 200 ? html : null;
|
|
4998
|
+
}
|
|
4999
|
+
var MIN_RUNS = 5;
|
|
5000
|
+
var TYPICAL_AT = 0.1;
|
|
5001
|
+
function meanNearest(distances, k) {
|
|
5002
|
+
const sorted = [...distances].sort((a, b) => a - b).slice(0, k);
|
|
5003
|
+
return sorted.reduce((s, d) => s + d, 0) / sorted.length;
|
|
5004
|
+
}
|
|
5005
|
+
function median2(values) {
|
|
5006
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
5007
|
+
const mid = Math.floor(sorted.length / 2);
|
|
5008
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
5009
|
+
}
|
|
5010
|
+
function typicality(target2, runs, k = 3) {
|
|
5011
|
+
if (runs.length < MIN_RUNS) throw new Error(`a null model needs at least ${MIN_RUNS} runs; this one has ${runs.length}`);
|
|
5012
|
+
const kk = Math.min(k, runs.length - 1);
|
|
5013
|
+
const toTarget = runs.map((r) => ({ id: r.id, distance: fingerprintDistance(target2, r.fingerprint).total }));
|
|
5014
|
+
const distance = meanNearest(
|
|
5015
|
+
toTarget.map((t) => t.distance),
|
|
5016
|
+
kk
|
|
5017
|
+
);
|
|
5018
|
+
const loo = runs.map(
|
|
5019
|
+
(r, i) => meanNearest(
|
|
5020
|
+
runs.filter((_, j) => j !== i).map((o) => fingerprintDistance(r.fingerprint, o.fingerprint).total),
|
|
5021
|
+
kk
|
|
5022
|
+
)
|
|
5023
|
+
);
|
|
5024
|
+
const score = loo.filter((d) => d >= distance).length / loo.length;
|
|
5025
|
+
return {
|
|
5026
|
+
score: Math.round(score * 100) / 100,
|
|
5027
|
+
typical: score >= TYPICAL_AT,
|
|
5028
|
+
distance: Math.round(distance * 1e3) / 1e3,
|
|
5029
|
+
baseline: Math.round(median2(loo) * 1e3) / 1e3,
|
|
5030
|
+
nearest: toTarget.sort((a, b) => a.distance - b.distance).slice(0, kk),
|
|
5031
|
+
shared: sharedChoices(target2, runs)
|
|
5032
|
+
};
|
|
5033
|
+
}
|
|
5034
|
+
var HUES = [
|
|
5035
|
+
["red", 0, 45],
|
|
5036
|
+
["orange", 45, 75],
|
|
5037
|
+
["yellow", 75, 110],
|
|
5038
|
+
["green", 110, 165],
|
|
5039
|
+
["teal", 165, 215],
|
|
5040
|
+
["blue", 215, 265],
|
|
5041
|
+
["violet", 265, 315],
|
|
5042
|
+
["pink", 315, 360]
|
|
5043
|
+
];
|
|
5044
|
+
function hueName(o) {
|
|
5045
|
+
if (!o) return "none";
|
|
5046
|
+
if (o.c < 0.03) return "neutral";
|
|
5047
|
+
return HUES.find(([, lo, hi]) => o.h >= lo && o.h < hi)?.[0] ?? "red";
|
|
5048
|
+
}
|
|
5049
|
+
function groundName(o) {
|
|
5050
|
+
if (o.l < 0.3) return "dark";
|
|
5051
|
+
if (isCream(o)) return "cream";
|
|
5052
|
+
if (o.l >= 0.97 && o.c < 0.012) return "white";
|
|
5053
|
+
return o.c < 0.02 ? "grey" : "tinted";
|
|
5054
|
+
}
|
|
5055
|
+
function shapeName(roundness) {
|
|
5056
|
+
if (roundness === null) return "no buttons";
|
|
5057
|
+
if (roundness >= 0.45) return "pill";
|
|
5058
|
+
return roundness >= 0.15 ? "soft" : "square";
|
|
5059
|
+
}
|
|
5060
|
+
function choicesOf(fp) {
|
|
5061
|
+
return {
|
|
5062
|
+
display: [fp.display.family],
|
|
5063
|
+
body: [fp.body.family],
|
|
5064
|
+
accent: [hueName(fp.accent)],
|
|
5065
|
+
ground: [groundName(fp.ground)],
|
|
5066
|
+
shape: [shapeName(fp.roundness)],
|
|
5067
|
+
motion: [fp.motion >= 0.6 ? "reveals most sections" : fp.motion > 0 ? "reveals some sections" : "no scroll reveals"],
|
|
5068
|
+
effects: fp.effects,
|
|
5069
|
+
opening: [fp.layout.slice(0, 3).join(" > ") || "none"]
|
|
5070
|
+
};
|
|
5071
|
+
}
|
|
5072
|
+
function sharedChoices(target2, runs) {
|
|
5073
|
+
const mine = choicesOf(target2);
|
|
5074
|
+
const all = runs.map((r) => choicesOf(r.fingerprint));
|
|
5075
|
+
const out = [];
|
|
5076
|
+
for (const [part, values] of Object.entries(mine)) {
|
|
5077
|
+
for (const value of values) {
|
|
5078
|
+
const count2 = all.filter((c) => c[part].includes(value)).length;
|
|
5079
|
+
if (count2 / runs.length >= 0.25) out.push({ part, value, runs: count2, of: runs.length });
|
|
5080
|
+
}
|
|
5081
|
+
}
|
|
5082
|
+
return out.sort((a, b) => b.runs - a.runs);
|
|
5083
|
+
}
|
|
5084
|
+
var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f4) => f4.toLowerCase()));
|
|
5085
|
+
var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f4) => f4.toLowerCase()));
|
|
5086
|
+
function knownFor(kind, value, sample) {
|
|
5087
|
+
if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
|
|
5088
|
+
if (kind === "accent" && value === "violet") return sample.some((f4) => f4.accent && isAiViolet(f4.accent)) ? "ai-violet" : null;
|
|
5089
|
+
if (kind === "ground" && value === "cream") return "cream-palette";
|
|
5090
|
+
if (kind === "shape" && value === "pill") return "pill-everything";
|
|
5091
|
+
if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
|
|
5092
|
+
if (kind === "tell") return value;
|
|
5093
|
+
if (kind === "opening" && /^hero > (?:logos|stats)/.test(value)) return "hero-then-proof";
|
|
5094
|
+
return null;
|
|
5095
|
+
}
|
|
5096
|
+
var STOP2 = new Set(
|
|
5097
|
+
"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(" ")
|
|
5098
|
+
);
|
|
5099
|
+
function grams(text) {
|
|
5100
|
+
const out = /* @__PURE__ */ new Set();
|
|
5101
|
+
for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
|
|
5102
|
+
const words2 = clause.match(/[a-z][a-z'-]*/g) ?? [];
|
|
5103
|
+
for (let n = 2; n <= 4; n += 1) {
|
|
5104
|
+
for (let i = 0; i + n <= words2.length; i += 1) {
|
|
5105
|
+
const g = words2.slice(i, i + n);
|
|
5106
|
+
if (STOP2.has(g[0]) || STOP2.has(g[n - 1])) continue;
|
|
5107
|
+
if (g.filter((w) => !STOP2.has(w)).length < 2) continue;
|
|
5108
|
+
out.add(g.join(" "));
|
|
5109
|
+
}
|
|
5110
|
+
}
|
|
5111
|
+
}
|
|
5112
|
+
return out;
|
|
5113
|
+
}
|
|
5114
|
+
var GRAMMAR = /* @__PURE__ */ new Set([
|
|
5115
|
+
"privacy policy",
|
|
5116
|
+
"cookie policy",
|
|
5117
|
+
"rights reserved",
|
|
5118
|
+
"all rights reserved",
|
|
5119
|
+
"terms of service",
|
|
5120
|
+
"terms and conditions",
|
|
5121
|
+
"terms conditions",
|
|
5122
|
+
"how it works",
|
|
5123
|
+
"what we do",
|
|
5124
|
+
"about us",
|
|
5125
|
+
"contact us",
|
|
5126
|
+
"get in touch",
|
|
5127
|
+
"opening hours",
|
|
5128
|
+
"read more",
|
|
5129
|
+
"learn more",
|
|
5130
|
+
"book now",
|
|
5131
|
+
"free quote",
|
|
5132
|
+
"our services",
|
|
5133
|
+
"quick links",
|
|
5134
|
+
"email address",
|
|
5135
|
+
"phone number",
|
|
5136
|
+
"full name",
|
|
5137
|
+
"first name",
|
|
5138
|
+
"last name",
|
|
5139
|
+
"your name",
|
|
5140
|
+
"your email",
|
|
5141
|
+
"send message"
|
|
5142
|
+
]);
|
|
5143
|
+
var LEGAL = /©|©|\bregistered (?:in|office|number)\b|\bcompany (?:no|number|reg)|\bvat (?:no|number|reg)/i;
|
|
5144
|
+
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;
|
|
5145
|
+
function textGrams(text) {
|
|
5146
|
+
if (LEGAL.test(text)) return /* @__PURE__ */ new Set();
|
|
5147
|
+
const out = grams(text.replace(NOISE, " | "));
|
|
5148
|
+
for (const g of out) if (GRAMMAR.has(g)) out.delete(g);
|
|
5149
|
+
return out;
|
|
5150
|
+
}
|
|
5151
|
+
function properNouns(brief) {
|
|
5152
|
+
return new Set((brief.match(/\b[A-Z][a-z]+/g) ?? []).map((w) => w.toLowerCase()).filter((w) => !STOP2.has(w)));
|
|
5153
|
+
}
|
|
5154
|
+
function harvest(models, options = {}) {
|
|
5155
|
+
const minShare = options.minShare ?? 0.25;
|
|
5156
|
+
const runs = models.flatMap((m, b) => m.runs.map((r) => ({ ...r, brief: b })));
|
|
5157
|
+
const of = runs.length;
|
|
5158
|
+
if (of === 0) return [];
|
|
5159
|
+
const multiBrief = models.length > 1;
|
|
5160
|
+
const sample = runs.map((r) => r.fingerprint);
|
|
5161
|
+
const tally = /* @__PURE__ */ new Map();
|
|
5162
|
+
const add = (kind, value, run, brief, example) => {
|
|
5163
|
+
const key = `${kind}\0${value}`;
|
|
5164
|
+
const t = tally.get(key) ?? { kind, value, runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example };
|
|
5165
|
+
t.runs.add(run);
|
|
5166
|
+
t.briefs.add(brief);
|
|
5167
|
+
tally.set(key, t);
|
|
5168
|
+
};
|
|
5169
|
+
runs.forEach((r, i) => {
|
|
5170
|
+
const c = choicesOf(r.fingerprint);
|
|
5171
|
+
for (const f4 of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f4, i, r.brief);
|
|
5172
|
+
add("accent", c.accent[0], i, r.brief);
|
|
5173
|
+
add("ground", c.ground[0], i, r.brief);
|
|
5174
|
+
add("shape", c.shape[0], i, r.brief);
|
|
5175
|
+
add("motion", c.motion[0], i, r.brief);
|
|
5176
|
+
add("opening", c.opening[0], i, r.brief);
|
|
5177
|
+
for (const t of r.tells) add("tell", t, i, r.brief);
|
|
5178
|
+
});
|
|
5179
|
+
const out = [];
|
|
5180
|
+
for (const t of tally.values()) {
|
|
5181
|
+
if (t.runs.size / of < minShare) continue;
|
|
5182
|
+
if (t.kind === "accent" && (t.value === "none" || t.value === "neutral")) continue;
|
|
5183
|
+
if (t.kind === "shape" && t.value === "no buttons") continue;
|
|
5184
|
+
if (t.kind === "ground" && (t.value === "white" || t.value === "grey")) continue;
|
|
5185
|
+
if (t.kind === "motion" && t.value === "no scroll reveals") continue;
|
|
5186
|
+
out.push({ kind: t.kind, value: t.value, runs: t.runs.size, of, briefs: t.briefs.size, known: knownFor(t.kind, t.value, sample) });
|
|
5187
|
+
}
|
|
5188
|
+
const phraseMin = Math.max(3, Math.ceil(of * minShare * 0.4));
|
|
5189
|
+
const names = models.map((m) => properNouns(m.brief));
|
|
5190
|
+
const phrases2 = /* @__PURE__ */ new Map();
|
|
5191
|
+
runs.forEach((r, i) => {
|
|
5192
|
+
for (const text of r.copy) {
|
|
5193
|
+
for (const g of textGrams(text)) {
|
|
5194
|
+
if (g.split(" ").some((w) => names[r.brief].has(w))) continue;
|
|
5195
|
+
const p = phrases2.get(g) ?? { runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example: text.trim().slice(0, 140) };
|
|
5196
|
+
p.runs.add(i);
|
|
5197
|
+
p.briefs.add(r.brief);
|
|
5198
|
+
phrases2.set(g, p);
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5201
|
+
});
|
|
5202
|
+
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);
|
|
5203
|
+
const maximal = kept.filter(([g, p]) => !kept.some(([h, q]) => h !== g && h.includes(g) && q.runs.size >= 0.8 * p.runs.size));
|
|
5204
|
+
for (const [g, p] of maximal.slice(0, 40)) {
|
|
5205
|
+
out.push({ kind: "phrase", value: g, runs: p.runs.size, of, briefs: p.briefs.size, known: options.copyTell?.(g) ?? null, example: p.example });
|
|
5206
|
+
}
|
|
5207
|
+
const order = ["face", "accent", "ground", "shape", "motion", "opening", "tell", "phrase"];
|
|
5208
|
+
return out.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.runs - a.runs);
|
|
5209
|
+
}
|
|
5210
|
+
function hueSwatch(runs, hue) {
|
|
5211
|
+
const hits = runs.map((r) => r.fingerprint.accent).filter((o) => !!o && hueName(o) === hue);
|
|
5212
|
+
if (hits.length === 0) return null;
|
|
5213
|
+
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);
|
|
5214
|
+
return formatHex(oklchToRgb(centre));
|
|
5215
|
+
}
|
|
5216
|
+
|
|
5217
|
+
// src/estate/index.ts
|
|
5218
|
+
var ESTATE_VERSION = 1;
|
|
5219
|
+
var SIBLING_AT = 0.31;
|
|
5220
|
+
function emptyEstate() {
|
|
5221
|
+
return { version: ESTATE_VERSION, sites: [] };
|
|
5222
|
+
}
|
|
5223
|
+
function addSite(register, site) {
|
|
5224
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(site.id)) throw new Error(`site id "${site.id}" must be short kebab-case`);
|
|
5225
|
+
const sites = [...register.sites.filter((s) => s.id !== site.id), site].sort((a, b) => a.id.localeCompare(b.id));
|
|
5226
|
+
return { version: ESTATE_VERSION, sites };
|
|
5227
|
+
}
|
|
5228
|
+
function sharedParts(a, b, d = fingerprintDistance(a, b)) {
|
|
5229
|
+
const out = [];
|
|
5230
|
+
const close = (k) => d.parts[k] <= 0.2;
|
|
5231
|
+
if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
|
|
5232
|
+
if (close("type")) {
|
|
5233
|
+
if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
|
|
5234
|
+
if (a.body.family === b.body.family) out.push(`${a.body.family} body`);
|
|
5235
|
+
}
|
|
5236
|
+
if (close("ground")) out.push(`${groundName(a.ground)} ground`);
|
|
5237
|
+
if (close("shape")) out.push(`${shapeName(a.roundness)} buttons`);
|
|
5238
|
+
if (close("motion")) out.push(a.motion > 0 || b.motion > 0 ? "scroll reveals" : "no reveals");
|
|
5239
|
+
if (close("effects") && a.effects.length) out.push(a.effects.filter((e) => b.effects.includes(e)).join(", "));
|
|
5240
|
+
if (close("layout")) out.push("running order");
|
|
5241
|
+
return out;
|
|
5242
|
+
}
|
|
5243
|
+
function compareToEstate(target2, register, options = {}) {
|
|
5244
|
+
const at = options.siblingAt ?? SIBLING_AT;
|
|
5245
|
+
return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
|
|
5246
|
+
const d = fingerprintDistance(target2, s.fingerprint);
|
|
5247
|
+
return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target2, s.fingerprint, d) };
|
|
5248
|
+
}).sort((a, b) => a.distance - b.distance);
|
|
5249
|
+
}
|
|
5250
|
+
function estatePairs(register, siblingAt = SIBLING_AT) {
|
|
5251
|
+
const out = [];
|
|
5252
|
+
const s = register.sites;
|
|
5253
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
5254
|
+
for (let j = i + 1; j < s.length; j += 1) {
|
|
5255
|
+
const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
|
|
5256
|
+
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) });
|
|
5257
|
+
}
|
|
5258
|
+
}
|
|
5259
|
+
return out.sort((x, y) => x.distance - y.distance);
|
|
5260
|
+
}
|
|
5261
|
+
function siblingLine(briefs) {
|
|
5262
|
+
const d = [];
|
|
5263
|
+
for (const runs of briefs) {
|
|
5264
|
+
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);
|
|
5265
|
+
}
|
|
5266
|
+
if (d.length === 0) throw new Error("no null pages to measure the sibling line from");
|
|
5267
|
+
d.sort((a, b) => a - b);
|
|
5268
|
+
const mid = Math.floor(d.length / 2);
|
|
5269
|
+
const m = d.length % 2 ? d[mid] : (d[mid - 1] + d[mid]) / 2;
|
|
5270
|
+
return Math.round(m * 1e3) / 1e3;
|
|
5271
|
+
}
|
|
5272
|
+
|
|
5273
|
+
// src/report/index.ts
|
|
5274
|
+
var TELL_HEAVY = 3;
|
|
5275
|
+
var MIN_DECIDED = 5;
|
|
5276
|
+
var TELL_AREA = {
|
|
5277
|
+
"reflex-font": { area: "type", choice: "display" },
|
|
5278
|
+
"reflex-font-2": { area: "type", choice: "display" },
|
|
5279
|
+
"italic-serif-display": { area: "type", choice: "display" },
|
|
5280
|
+
"ai-violet": { area: "colour", choice: "accent" },
|
|
5281
|
+
"blue-purple-gradient": { area: "colour", choice: "accent" },
|
|
5282
|
+
"gradient-text": { area: "colour", choice: "accent" },
|
|
5283
|
+
"cream-palette": { area: "colour", choice: "ground" },
|
|
5284
|
+
"pill-everything": { area: "shape", choice: "shape" },
|
|
5285
|
+
"shadcn-dump": { area: "shape", choice: "shape" },
|
|
5286
|
+
"glass-panel": { area: "effects", choice: "motif" },
|
|
5287
|
+
"radial-spotlight-glow": { area: "effects", choice: "motif" },
|
|
5288
|
+
"grid-background": { area: "effects", choice: "motif" },
|
|
5289
|
+
"thin-border-wide-shadow": { area: "effects", choice: "shape" },
|
|
5290
|
+
"icon-tile-stack": { area: "effects", choice: "motif" },
|
|
5291
|
+
"reveal-everywhere": { area: "motion", choice: "signature" },
|
|
5292
|
+
"intro-cinematic": { area: "motion", choice: "signature" },
|
|
5293
|
+
marquee: { area: "motion", choice: "signature" },
|
|
5294
|
+
"hero-then-proof": { area: "layout" },
|
|
5295
|
+
"icon-tile-grid": { area: "layout" },
|
|
5296
|
+
"hero-eyebrow-chip": { area: "layout" },
|
|
5297
|
+
"bento-grid": { area: "layout" }
|
|
5298
|
+
};
|
|
5299
|
+
var PART_AREA = {
|
|
5300
|
+
display: { area: "type", choice: "display" },
|
|
5301
|
+
body: { area: "type", choice: "body" },
|
|
5302
|
+
accent: { area: "colour", choice: "accent" },
|
|
5303
|
+
ground: { area: "colour", choice: "ground" },
|
|
5304
|
+
shape: { area: "shape", choice: "shape" },
|
|
5305
|
+
effects: { area: "effects", choice: "motif" },
|
|
5306
|
+
motion: { area: "motion", choice: "signature" }
|
|
5307
|
+
};
|
|
5308
|
+
var DEFAULTS = /^(?:ground )?(?:white|grey)(?: ground)?$|no scroll reveals|no reveals|accent (?:none|neutral)|^no buttons|shape no buttons/;
|
|
5309
|
+
function designFindings(findings, copyTells) {
|
|
5310
|
+
return (findings?.findings ?? []).filter((f4) => !copyTells.has(f4.tell));
|
|
5311
|
+
}
|
|
5312
|
+
function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
|
|
5313
|
+
const actions = [];
|
|
5314
|
+
const signals = [];
|
|
5315
|
+
if (input.findings) {
|
|
5316
|
+
const design = designFindings(input.findings, copyTells);
|
|
5317
|
+
const distinct = [...new Set(design.map((f4) => f4.tell))];
|
|
5318
|
+
signals.push({
|
|
5319
|
+
id: "tells",
|
|
5320
|
+
raised: distinct.length >= TELL_HEAVY,
|
|
5321
|
+
detail: `${distinct.length} distinct design tell${distinct.length === 1 ? "" : "s"}${distinct.length ? `: ${distinct.join(", ")}` : ""}`
|
|
5322
|
+
});
|
|
5323
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5324
|
+
for (const f4 of design) {
|
|
5325
|
+
if (seen.has(f4.tell)) continue;
|
|
5326
|
+
seen.add(f4.tell);
|
|
5327
|
+
const map = TELL_AREA[f4.tell] ?? { area: "effects" };
|
|
5328
|
+
const both = design.some((g) => g.tell === f4.tell && g.line === 0) && design.some((g) => g.tell === f4.tell && g.line > 0);
|
|
5329
|
+
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 });
|
|
5330
|
+
}
|
|
5331
|
+
const copy = (input.findings.findings ?? []).filter((f4) => copyTells.has(f4.tell));
|
|
5332
|
+
const byTell = /* @__PURE__ */ new Map();
|
|
5333
|
+
for (const f4 of copy) byTell.set(f4.tell, [...byTell.get(f4.tell) ?? [], f4]);
|
|
5334
|
+
for (const [tell, list] of byTell) {
|
|
5335
|
+
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 });
|
|
5336
|
+
}
|
|
5337
|
+
} else {
|
|
5338
|
+
signals.push({ id: "tells", raised: null, detail: "not measured" });
|
|
5339
|
+
}
|
|
5340
|
+
if (input.typicality) {
|
|
5341
|
+
const t = input.typicality;
|
|
5342
|
+
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` });
|
|
5343
|
+
if (t.typical) {
|
|
5344
|
+
for (const s of t.shared) {
|
|
5345
|
+
const map = PART_AREA[s.part];
|
|
5346
|
+
if (!map || DEFAULTS.test(`${s.part} ${s.value}`)) continue;
|
|
5347
|
+
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 });
|
|
5348
|
+
}
|
|
5349
|
+
}
|
|
5350
|
+
} else {
|
|
5351
|
+
signals.push({ id: "typicality", raised: null, detail: "not measured: no null model for this brief" });
|
|
5352
|
+
}
|
|
5353
|
+
if (input.estate) {
|
|
5354
|
+
const siblings = input.estate.filter((m) => m.sibling);
|
|
5355
|
+
const nearest2 = input.estate[0];
|
|
5356
|
+
signals.push({
|
|
5357
|
+
id: "estate",
|
|
5358
|
+
raised: siblings.length > 0,
|
|
5359
|
+
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"
|
|
5360
|
+
});
|
|
5361
|
+
for (const s of siblings) {
|
|
5362
|
+
for (const part of s.shared) {
|
|
5363
|
+
if (DEFAULTS.test(part)) continue;
|
|
5364
|
+
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) });
|
|
5365
|
+
}
|
|
5366
|
+
}
|
|
5367
|
+
} else {
|
|
5368
|
+
signals.push({ id: "estate", raised: null, detail: "not measured: no estate register" });
|
|
5369
|
+
}
|
|
5370
|
+
if (input.direction) {
|
|
5371
|
+
const d = input.direction;
|
|
5372
|
+
const raised2 = !d.valid || d.decided < MIN_DECIDED;
|
|
5373
|
+
signals.push({ id: "reasons", raised: raised2, detail: `${d.decided} of 7 choices decided with a reason${d.valid ? "" : ", art-direction.json has errors"}` });
|
|
5374
|
+
for (const p of d.problems) {
|
|
5375
|
+
const key = p.at.match(/^choices\.(\w+)/)?.[1];
|
|
5376
|
+
if (p.severity === "error" || p.message === "not decided yet") {
|
|
5377
|
+
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 });
|
|
5378
|
+
}
|
|
5379
|
+
}
|
|
5380
|
+
} else {
|
|
5381
|
+
signals.push({ id: "reasons", raised: null, detail: "not measured: no art-direction.json" });
|
|
5382
|
+
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 });
|
|
5383
|
+
}
|
|
5384
|
+
const raised = signals.filter((s) => s.raised === true).length;
|
|
5385
|
+
const unmeasured = signals.filter((s) => s.raised === null).map((s) => s.id);
|
|
5386
|
+
const verdict = raised >= 2 ? "default" : raised === 1 ? "mixed" : unmeasured.length ? "unproven" : "decided";
|
|
5387
|
+
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.`;
|
|
5388
|
+
const order = ["direction", "type", "colour", "shape", "effects", "motion", "layout", "copy"];
|
|
5389
|
+
const merged = [];
|
|
5390
|
+
for (const a of actions) {
|
|
5391
|
+
const same = a.choice && a.area !== "direction" && a.area !== "copy" ? merged.find((m) => m.choice === a.choice && m.area === a.area) : void 0;
|
|
5392
|
+
if (same) {
|
|
5393
|
+
const fromTell = /\(\S+, gen \d\)/.test(a.why);
|
|
5394
|
+
same.why = `${same.why}; ${fromTell ? a.why : `${a.what.replace(/\.$/, "")} (${a.why})`}`;
|
|
5395
|
+
same.priority = Math.min(same.priority, a.priority);
|
|
5396
|
+
} else merged.push({ ...a });
|
|
5397
|
+
}
|
|
5398
|
+
const unique2 = merged.filter((a, i) => merged.findIndex((b) => b.area === a.area && b.what === a.what) === i);
|
|
5399
|
+
unique2.sort((a, b) => a.priority - b.priority || order.indexOf(a.area) - order.indexOf(b.area));
|
|
5400
|
+
return { site: input.site, verdict, summary, signals, actions: unique2 };
|
|
5401
|
+
}
|
|
5402
|
+
function areaOfShared(part) {
|
|
5403
|
+
if (/headline|body$/.test(part)) return "type";
|
|
5404
|
+
if (/accent|ground/.test(part)) return "colour";
|
|
5405
|
+
if (/buttons/.test(part)) return "shape";
|
|
5406
|
+
if (/reveal/.test(part)) return "motion";
|
|
5407
|
+
if (/running order/.test(part)) return "layout";
|
|
5408
|
+
return "effects";
|
|
5409
|
+
}
|
|
5410
|
+
function choiceOfShared(part) {
|
|
5411
|
+
if (/headline/.test(part)) return "display";
|
|
5412
|
+
if (/body$/.test(part)) return "body";
|
|
5413
|
+
if (/accent/.test(part)) return "accent";
|
|
5414
|
+
if (/ground/.test(part)) return "ground";
|
|
5415
|
+
if (/buttons/.test(part)) return "shape";
|
|
5416
|
+
if (/reveal/.test(part)) return "signature";
|
|
5417
|
+
return /running order/.test(part) ? void 0 : "motif";
|
|
5418
|
+
}
|
|
5419
|
+
|
|
5420
|
+
// src/report/retrofit.ts
|
|
5421
|
+
var HEADINGS = {
|
|
5422
|
+
direction: "Decide first",
|
|
5423
|
+
type: "Type",
|
|
5424
|
+
colour: "Colour",
|
|
5425
|
+
shape: "Shape",
|
|
5426
|
+
effects: "Effects",
|
|
5427
|
+
motion: "Motion",
|
|
5428
|
+
layout: "How sections look (not where they sit)",
|
|
5429
|
+
copy: "Copy"
|
|
5430
|
+
};
|
|
5431
|
+
var ORDER2 = ["direction", "type", "colour", "shape", "effects", "motion", "layout", "copy"];
|
|
5432
|
+
function target(a, direction) {
|
|
5433
|
+
if (!a.choice || a.area === "direction") return "";
|
|
5434
|
+
const c = direction?.choices?.[a.choice];
|
|
5435
|
+
if (c && c.because && !c.because.startsWith("PROPOSED:")) return `
|
|
5436
|
+
- Use: **${c.value}** for ${a.choice}. ${c.because}`;
|
|
5437
|
+
return `
|
|
5438
|
+
- Waits on: deciding **${a.choice}** in art-direction.json.`;
|
|
5439
|
+
}
|
|
5440
|
+
function retrofitPlan(report2, direction) {
|
|
5441
|
+
const lines = [
|
|
5442
|
+
`# Retrofit: ${report2.site}`,
|
|
5443
|
+
"",
|
|
5444
|
+
`**Now:** ${report2.verdict}. ${report2.summary}`,
|
|
5445
|
+
"",
|
|
5446
|
+
"| Signal | Raised | Reading |",
|
|
5447
|
+
"|---|---|---|",
|
|
5448
|
+
...report2.signals.map((s) => `| ${s.id} | ${s.raised === null ? "not measured" : s.raised ? "yes" : "no"} | ${s.detail} |`),
|
|
5449
|
+
"",
|
|
5450
|
+
"**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.",
|
|
5451
|
+
""
|
|
5452
|
+
];
|
|
5453
|
+
let n = 0;
|
|
5454
|
+
for (const area of ORDER2) {
|
|
5455
|
+
const list = report2.actions.filter((a) => a.area === area);
|
|
5456
|
+
if (list.length === 0) continue;
|
|
5457
|
+
n += 1;
|
|
5458
|
+
lines.push(`## ${n}. ${HEADINGS[area]}`, "");
|
|
5459
|
+
for (const a of list) lines.push(`- [ ] ${a.what} *(${a.why})*${target(a, direction)}`);
|
|
5460
|
+
lines.push("");
|
|
5461
|
+
}
|
|
5462
|
+
if (n === 0) lines.push("Nothing to change.", "");
|
|
5463
|
+
lines.push(
|
|
5464
|
+
"## Leave alone",
|
|
5465
|
+
"",
|
|
5466
|
+
"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.",
|
|
5467
|
+
""
|
|
5468
|
+
);
|
|
5469
|
+
return lines.join("\n");
|
|
5470
|
+
}
|
|
4074
5471
|
// Annotate the CommonJS export names for ESM import in node:
|
|
4075
5472
|
0 && (module.exports = {
|
|
4076
5473
|
AI_PHRASES,
|
|
@@ -4088,12 +5485,17 @@ function propose(input) {
|
|
|
4088
5485
|
DURATION_S,
|
|
4089
5486
|
EASE,
|
|
4090
5487
|
EASE_TUPLE,
|
|
5488
|
+
ESTATE_VERSION,
|
|
4091
5489
|
EXIT_RATIO,
|
|
4092
5490
|
FINGERPRINT_VERSION,
|
|
5491
|
+
HOUSE,
|
|
4093
5492
|
HOUSE_BUDGET,
|
|
4094
5493
|
HOUSE_TYPE,
|
|
4095
5494
|
LIGHTNESS_CURVE,
|
|
5495
|
+
MIN_DECIDED,
|
|
5496
|
+
MIN_RUNS,
|
|
4096
5497
|
NEGATIVE_REASSURANCE,
|
|
5498
|
+
NULL_VERSION,
|
|
4097
5499
|
PILL_LIMIT,
|
|
4098
5500
|
PLAINER_WORDS,
|
|
4099
5501
|
PROPOSED,
|
|
@@ -4106,27 +5508,39 @@ function propose(input) {
|
|
|
4106
5508
|
SCALE,
|
|
4107
5509
|
SHADCN_LIMIT,
|
|
4108
5510
|
SHADCN_PRIMITIVES,
|
|
5511
|
+
SIBLING_AT,
|
|
4109
5512
|
SNAPSHOT_VERSION,
|
|
4110
5513
|
SOURCE_KINDS,
|
|
4111
5514
|
SPACE_STEPS,
|
|
4112
5515
|
SPRING,
|
|
4113
5516
|
STOCK_PHRASES,
|
|
5517
|
+
TELL_HEAVY,
|
|
4114
5518
|
TYPE_STEPS,
|
|
5519
|
+
TYPICAL_AT,
|
|
4115
5520
|
VAGUE_WORDS,
|
|
4116
5521
|
accentFork,
|
|
5522
|
+
addSite,
|
|
4117
5523
|
apcaContrast,
|
|
5524
|
+
applyHouseGate,
|
|
4118
5525
|
auditSnapshot,
|
|
4119
5526
|
catalogueTable,
|
|
5527
|
+
characterReport,
|
|
4120
5528
|
checkCopy,
|
|
4121
5529
|
checkPair,
|
|
4122
5530
|
checkRestraint,
|
|
5531
|
+
choicesOf,
|
|
5532
|
+
compareFacts,
|
|
5533
|
+
compareToEstate,
|
|
4123
5534
|
craftTokens,
|
|
4124
5535
|
deltaEOk,
|
|
4125
5536
|
deltaEOkHex,
|
|
4126
5537
|
densityCss,
|
|
4127
5538
|
densityTokens,
|
|
4128
5539
|
emitCss,
|
|
5540
|
+
emptyEstate,
|
|
5541
|
+
estatePairs,
|
|
4129
5542
|
exitDuration,
|
|
5543
|
+
extractHtml,
|
|
4130
5544
|
findColours,
|
|
4131
5545
|
fingerprint,
|
|
4132
5546
|
fingerprintDistance,
|
|
@@ -4134,9 +5548,15 @@ function propose(input) {
|
|
|
4134
5548
|
fluidSpace,
|
|
4135
5549
|
fluidType,
|
|
4136
5550
|
fontClass,
|
|
5551
|
+
formatComparison,
|
|
4137
5552
|
formatHex,
|
|
4138
5553
|
formatReport,
|
|
5554
|
+
groundName,
|
|
5555
|
+
harvest,
|
|
4139
5556
|
hexToOklch,
|
|
5557
|
+
houseRule,
|
|
5558
|
+
hueName,
|
|
5559
|
+
hueSwatch,
|
|
4140
5560
|
inP3Gamut,
|
|
4141
5561
|
inSrgbGamut,
|
|
4142
5562
|
initDirection,
|
|
@@ -4146,24 +5566,33 @@ function propose(input) {
|
|
|
4146
5566
|
motionTokens,
|
|
4147
5567
|
nearest,
|
|
4148
5568
|
normaliseFamily,
|
|
5569
|
+
nullPrompt,
|
|
4149
5570
|
oklchToHex,
|
|
4150
5571
|
oklchToRgb,
|
|
4151
5572
|
paletteFromPixels,
|
|
4152
5573
|
parseColour,
|
|
4153
5574
|
parseHex,
|
|
4154
5575
|
propose,
|
|
5576
|
+
protectedFacts,
|
|
4155
5577
|
ramp,
|
|
4156
5578
|
rampFromAnchors,
|
|
5579
|
+
retrofitPlan,
|
|
4157
5580
|
runTell,
|
|
4158
5581
|
scanSource,
|
|
4159
5582
|
sectionRhythm,
|
|
4160
5583
|
semantic,
|
|
5584
|
+
shapeName,
|
|
5585
|
+
sharedParts,
|
|
4161
5586
|
shouldAnimate,
|
|
5587
|
+
siblingLine,
|
|
4162
5588
|
tailwindV3Preset,
|
|
4163
5589
|
tailwindV4Theme,
|
|
4164
5590
|
tellById,
|
|
4165
5591
|
toGamut,
|
|
4166
5592
|
typeFeatureTokens,
|
|
5593
|
+
typicality,
|
|
4167
5594
|
validateDirection,
|
|
4168
|
-
|
|
5595
|
+
visibleText,
|
|
5596
|
+
wcagContrast,
|
|
5597
|
+
wordListsJson
|
|
4169
5598
|
});
|