@domandigital/craft 0.12.0 → 0.14.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 +26 -0
- package/CHARACTER.md +140 -1
- package/COPY.md +5 -0
- package/dist/{audit-5QWFLZ6L.js → audit-6WHR7ZTV.js} +7 -3
- package/dist/audit.cjs +1267 -165
- package/dist/audit.d.cts +95 -1
- package/dist/audit.d.ts +95 -1
- package/dist/audit.js +1265 -165
- package/dist/{chunk-J3QUROJ5.js → chunk-3OTKMKJQ.js} +66 -91
- package/dist/{chunk-PU5G5DUC.js → chunk-7T5WR74C.js} +27 -14
- package/dist/{chunk-AL6MRG6V.js → chunk-GQVEYTBY.js} +3592 -2173
- package/dist/{chunk-S3NZAVU6.js → chunk-OOZOEWGD.js} +1 -1
- package/dist/{chunk-CDAJUS5U.js → chunk-PBK6SYH5.js} +51 -16
- package/dist/{chunk-ULPI335S.js → chunk-Q7ILL2HV.js} +211 -10
- package/dist/chunk-QYBAVU3P.js +6 -0
- package/dist/{chunk-ZKVJ7K6X.js → chunk-WUACK2HX.js} +2 -1
- package/dist/chunk-XVBR2UPH.js +217 -0
- package/dist/cli-3CGUKXY4.js +19 -0
- package/dist/{cli-ZPJIWVEP.js → cli-O3CDSONO.js} +7 -8
- package/dist/{cli-ZB2VJ7CY.js → cli-WG3UJNUF.js} +15 -16
- package/dist/cli-WSHPSWVU.js +21 -0
- package/dist/{cli-C3JLFXMG.js → cli-Z2IQG3LC.js} +5 -5
- package/dist/cli.js +3 -2
- package/dist/index.cjs +1719 -636
- package/dist/index.d.cts +570 -196
- package/dist/index.d.ts +570 -196
- package/dist/index.js +1684 -636
- package/dist/migrate-VIFAYIBM.js +9 -0
- package/package.json +1 -1
- package/word-lists.json +1 -1
- package/dist/chunk-LHGY43LL.js +0 -462
- package/dist/chunk-PZ4MF2VV.js +0 -117
- package/dist/cli-2DY3V3SF.js +0 -17
- package/dist/cli-QM2AQQMF.js +0 -19
package/dist/index.cjs
CHANGED
|
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
AI_PHRASES: () => AI_PHRASES,
|
|
24
|
+
AI_SOURCE_TYPES: () => AI_SOURCE_TYPES,
|
|
24
25
|
AI_VIOLET: () => AI_VIOLET,
|
|
25
26
|
AI_WORDS: () => AI_WORDS,
|
|
26
27
|
BASELINE_VERSION: () => BASELINE_VERSION,
|
|
@@ -28,6 +29,7 @@ __export(index_exports, {
|
|
|
28
29
|
CATALOGUE: () => CATALOGUE,
|
|
29
30
|
CATALOGUE_VERSION: () => CATALOGUE_VERSION,
|
|
30
31
|
CHOICE_KEYS: () => CHOICE_KEYS,
|
|
32
|
+
COMPONENT_ROLES: () => COMPONENT_ROLES,
|
|
31
33
|
DEFAULT_IGNORE: () => DEFAULT_IGNORE,
|
|
32
34
|
DENSITIES: () => DENSITIES,
|
|
33
35
|
DENSITY_NAMES: () => DENSITY_NAMES,
|
|
@@ -41,10 +43,13 @@ __export(index_exports, {
|
|
|
41
43
|
ESTATE_VERSION: () => ESTATE_VERSION,
|
|
42
44
|
EXIT_RATIO: () => EXIT_RATIO,
|
|
43
45
|
FINGERPRINT_VERSION: () => FINGERPRINT_VERSION,
|
|
46
|
+
HOME_FILE: () => HOME_FILE,
|
|
47
|
+
HOME_URL: () => HOME_URL,
|
|
44
48
|
HOUSE: () => HOUSE,
|
|
45
49
|
HOUSE_BUDGET: () => HOUSE_BUDGET,
|
|
46
50
|
HOUSE_LOCKED: () => HOUSE_LOCKED,
|
|
47
51
|
HOUSE_TYPE: () => HOUSE_TYPE,
|
|
52
|
+
IMAGERY_TELLS: () => IMAGERY_TELLS,
|
|
48
53
|
JSON_SCHEMA_VERSION: () => JSON_SCHEMA_VERSION,
|
|
49
54
|
LICENCES: () => LICENCES,
|
|
50
55
|
LIGHTNESS_CURVE: () => LIGHTNESS_CURVE,
|
|
@@ -54,12 +59,16 @@ __export(index_exports, {
|
|
|
54
59
|
NULL_VERSION: () => NULL_VERSION,
|
|
55
60
|
PILL_LIMIT: () => PILL_LIMIT,
|
|
56
61
|
PLAINER_WORDS: () => PLAINER_WORDS,
|
|
62
|
+
PROMO_BADGE: () => PROMO_BADGE,
|
|
63
|
+
PROOF_NAMES: () => PROOF_NAMES,
|
|
57
64
|
PROPOSED: () => PROPOSED,
|
|
65
|
+
PROVENANCE_SCAN_BYTES: () => PROVENANCE_SCAN_BYTES,
|
|
58
66
|
RAMP_STEPS: () => RAMP_STEPS,
|
|
59
67
|
READABLE_SNAPSHOT_VERSIONS: () => READABLE_SNAPSHOT_VERSIONS,
|
|
60
68
|
REFLEX_FONTS_1: () => REFLEX_FONTS_1,
|
|
61
69
|
REFLEX_FONTS_2: () => REFLEX_FONTS_2,
|
|
62
70
|
RENDERED_PATHS: () => RENDERED_PATHS,
|
|
71
|
+
RENDERED_TELLS: () => RENDERED_TELLS,
|
|
63
72
|
REVEAL_LIMIT: () => REVEAL_LIMIT,
|
|
64
73
|
REVIEW_PHRASES: () => REVIEW_PHRASES,
|
|
65
74
|
SCALE: () => SCALE,
|
|
@@ -69,9 +78,16 @@ __export(index_exports, {
|
|
|
69
78
|
SNAPSHOT_VERSION: () => SNAPSHOT_VERSION,
|
|
70
79
|
SOURCE_KINDS: () => SOURCE_KINDS,
|
|
71
80
|
SPACE_STEPS: () => SPACE_STEPS,
|
|
81
|
+
SPECIFICITY_COPY_TELLS: () => SPECIFICITY_COPY_TELLS,
|
|
82
|
+
SPECIFICITY_RENDERED_TELLS: () => SPECIFICITY_RENDERED_TELLS,
|
|
72
83
|
SPRING: () => SPRING,
|
|
84
|
+
STANDING_CLAIM: () => STANDING_CLAIM,
|
|
85
|
+
STOCK_AVATAR_HOSTS: () => STOCK_AVATAR_HOSTS,
|
|
86
|
+
STOCK_FIGURES: () => STOCK_FIGURES,
|
|
87
|
+
STOCK_PHOTO_HOSTS: () => STOCK_PHOTO_HOSTS,
|
|
73
88
|
STOCK_PHRASES: () => STOCK_PHRASES,
|
|
74
89
|
TELL_HEAVY: () => TELL_HEAVY,
|
|
90
|
+
TRADE_NOUNS: () => TRADE_NOUNS,
|
|
75
91
|
TYPE_STEPS: () => TYPE_STEPS,
|
|
76
92
|
TYPICAL_AT: () => TYPICAL_AT,
|
|
77
93
|
VAGUE_WORDS: () => VAGUE_WORDS,
|
|
@@ -82,14 +98,23 @@ __export(index_exports, {
|
|
|
82
98
|
applyHouseGate: () => applyHouseGate,
|
|
83
99
|
applySeverity: () => applySeverity,
|
|
84
100
|
auditSnapshot: () => auditSnapshot,
|
|
101
|
+
briefTerms: () => briefTerms,
|
|
85
102
|
catalogueTable: () => catalogueTable,
|
|
86
103
|
characterReport: () => characterReport,
|
|
87
104
|
checkCopy: () => checkCopy,
|
|
88
105
|
checkPair: () => checkPair,
|
|
89
106
|
checkRestraint: () => checkRestraint,
|
|
90
107
|
choicesOf: () => choicesOf,
|
|
108
|
+
closestComponents: () => closestComponents,
|
|
109
|
+
compareCompetitor: () => compareCompetitor,
|
|
91
110
|
compareFacts: () => compareFacts,
|
|
92
111
|
compareToEstate: () => compareToEstate,
|
|
112
|
+
componentDistance: () => componentDistance,
|
|
113
|
+
componentShared: () => componentShared,
|
|
114
|
+
componentTypicalities: () => componentTypicalities,
|
|
115
|
+
componentTypicality: () => componentTypicality,
|
|
116
|
+
componentsOf: () => componentsOf,
|
|
117
|
+
countWords: () => countWords2,
|
|
93
118
|
craftTokens: () => craftTokens,
|
|
94
119
|
createBaseline: () => createBaseline,
|
|
95
120
|
deltaEOk: () => deltaEOk,
|
|
@@ -98,6 +123,7 @@ __export(index_exports, {
|
|
|
98
123
|
densityTokens: () => densityTokens,
|
|
99
124
|
emitCss: () => emitCss,
|
|
100
125
|
emptyEstate: () => emptyEstate,
|
|
126
|
+
estateComponentPairs: () => estateComponentPairs,
|
|
101
127
|
estatePairs: () => estatePairs,
|
|
102
128
|
exitDuration: () => exitDuration,
|
|
103
129
|
extractHtml: () => extractHtml,
|
|
@@ -113,6 +139,7 @@ __export(index_exports, {
|
|
|
113
139
|
fontClass: () => fontClass,
|
|
114
140
|
formatClaims: () => formatClaims,
|
|
115
141
|
formatComparison: () => formatComparison,
|
|
142
|
+
formatCompetitor: () => formatCompetitor,
|
|
116
143
|
formatHex: () => formatHex,
|
|
117
144
|
formatReport: () => formatReport,
|
|
118
145
|
globToRegExp: () => globToRegExp,
|
|
@@ -139,6 +166,7 @@ __export(index_exports, {
|
|
|
139
166
|
nullPrompt: () => nullPrompt,
|
|
140
167
|
oklchToHex: () => oklchToHex,
|
|
141
168
|
oklchToRgb: () => oklchToRgb,
|
|
169
|
+
pageCopy: () => pageCopy,
|
|
142
170
|
paletteFromPixels: () => paletteFromPixels,
|
|
143
171
|
parseBaseline: () => parseBaseline,
|
|
144
172
|
parseColour: () => parseColour,
|
|
@@ -151,14 +179,20 @@ __export(index_exports, {
|
|
|
151
179
|
readSnapshot: () => readSnapshot,
|
|
152
180
|
retrofitPlan: () => retrofitPlan,
|
|
153
181
|
runTell: () => runTell,
|
|
182
|
+
scanProvenance: () => scanProvenance,
|
|
154
183
|
scanSource: () => scanSource,
|
|
155
184
|
sectionRhythm: () => sectionRhythm,
|
|
156
185
|
semantic: () => semantic,
|
|
186
|
+
sentencesOf: () => sentencesOf,
|
|
157
187
|
severityExceptions: () => severityExceptions,
|
|
158
188
|
shapeName: () => shapeName,
|
|
159
189
|
sharedParts: () => sharedParts,
|
|
160
190
|
shouldAnimate: () => shouldAnimate,
|
|
161
191
|
siblingLine: () => siblingLine,
|
|
192
|
+
specificity: () => specificity,
|
|
193
|
+
specifics: () => specifics,
|
|
194
|
+
stockAvatarSource: () => stockAvatarSource,
|
|
195
|
+
stockSource: () => stockSource,
|
|
162
196
|
tailwindV3Preset: () => tailwindV3Preset,
|
|
163
197
|
tailwindV4Theme: () => tailwindV4Theme,
|
|
164
198
|
tellById: () => tellById,
|
|
@@ -167,6 +201,7 @@ __export(index_exports, {
|
|
|
167
201
|
toSarif: () => toSarif,
|
|
168
202
|
typeFeatureTokens: () => typeFeatureTokens,
|
|
169
203
|
typicality: () => typicality,
|
|
204
|
+
unprovenClaims: () => unprovenClaims,
|
|
170
205
|
validateDirection: () => validateDirection,
|
|
171
206
|
visibleText: () => visibleText,
|
|
172
207
|
visualMeasures: () => visualMeasures,
|
|
@@ -2427,14 +2462,14 @@ function contractionHits(ctx) {
|
|
|
2427
2462
|
for (const doc2 of documents(ctx)) {
|
|
2428
2463
|
if (doc2.words < 800) continue;
|
|
2429
2464
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2430
|
-
const
|
|
2431
|
-
const rate =
|
|
2465
|
+
const count4 = (text.match(CONTRACTION) ?? []).length;
|
|
2466
|
+
const rate = count4 * 1e3 / doc2.words;
|
|
2432
2467
|
if (rate < 1) {
|
|
2433
2468
|
const first = sentences(doc2)[0];
|
|
2434
2469
|
hits.push({
|
|
2435
2470
|
path: doc2.path,
|
|
2436
2471
|
offset: first?.offset ?? 0,
|
|
2437
|
-
message: `${
|
|
2472
|
+
message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
2438
2473
|
});
|
|
2439
2474
|
}
|
|
2440
2475
|
}
|
|
@@ -2557,16 +2592,16 @@ function filler(words2, seed = 1, options = {}) {
|
|
|
2557
2592
|
return state % n;
|
|
2558
2593
|
};
|
|
2559
2594
|
const out = [];
|
|
2560
|
-
let
|
|
2595
|
+
let count4 = 0;
|
|
2561
2596
|
let i = 0;
|
|
2562
|
-
while (
|
|
2597
|
+
while (count4 < words2) {
|
|
2563
2598
|
const length = uniform ?? shape[i % shape.length];
|
|
2564
2599
|
const s = [];
|
|
2565
2600
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
2566
2601
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
2567
2602
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
2568
2603
|
out.push(`${s.join(" ")}.`);
|
|
2569
|
-
|
|
2604
|
+
count4 += s.length;
|
|
2570
2605
|
i += 1;
|
|
2571
2606
|
}
|
|
2572
2607
|
const paragraphs = [];
|
|
@@ -3706,64 +3741,76 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
|
3706
3741
|
}
|
|
3707
3742
|
];
|
|
3708
3743
|
|
|
3709
|
-
// src/
|
|
3710
|
-
var
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3744
|
+
// src/snapshot/types.ts
|
|
3745
|
+
var SNAPSHOT_VERSION = 2;
|
|
3746
|
+
|
|
3747
|
+
// src/snapshot/fixture.ts
|
|
3748
|
+
function makeSnapshot(patch = {}) {
|
|
3749
|
+
const base = {
|
|
3750
|
+
version: SNAPSHOT_VERSION,
|
|
3751
|
+
url: "https://example.test/",
|
|
3752
|
+
capturedAt: "2026-09-23T00:00:00.000Z",
|
|
3753
|
+
viewport: { width: 1440, height: 900 },
|
|
3754
|
+
pageHeight: 4200,
|
|
3755
|
+
ground: "rgb(255, 255, 255)",
|
|
3756
|
+
colours: {
|
|
3757
|
+
backgrounds: [
|
|
3758
|
+
{ value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
|
|
3759
|
+
{ value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
|
|
3760
|
+
],
|
|
3761
|
+
text: [
|
|
3762
|
+
{ value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
|
|
3763
|
+
{ value: "rgb(31, 58, 46)", area: 0, chars: 300 }
|
|
3764
|
+
]
|
|
3765
|
+
},
|
|
3766
|
+
fonts: [
|
|
3767
|
+
{ family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
|
|
3768
|
+
{ family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
|
|
3769
|
+
],
|
|
3770
|
+
headings: [
|
|
3771
|
+
{ level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
|
|
3772
|
+
{ level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
|
|
3773
|
+
],
|
|
3774
|
+
controls: [
|
|
3775
|
+
{ kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
|
|
3776
|
+
{ kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
|
|
3777
|
+
{ kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
|
|
3778
|
+
{ kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
|
|
3779
|
+
],
|
|
3780
|
+
sections: [
|
|
3781
|
+
{ top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3782
|
+
{ top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3783
|
+
{ top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
|
|
3784
|
+
{ top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3785
|
+
{ top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
|
|
3786
|
+
],
|
|
3787
|
+
effects: {
|
|
3788
|
+
glass: 0,
|
|
3789
|
+
gradientText: 0,
|
|
3790
|
+
gradients: [],
|
|
3791
|
+
glows: 0,
|
|
3792
|
+
gridBackgrounds: 0,
|
|
3793
|
+
marquees: 0,
|
|
3794
|
+
hairlineShadowCards: 0,
|
|
3795
|
+
eyebrowChip: false
|
|
3796
|
+
},
|
|
3797
|
+
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
3798
|
+
metrics: {
|
|
3799
|
+
fontSizes: [14, 16, 20, 36, 64],
|
|
3800
|
+
fontWeights: [400, 500],
|
|
3801
|
+
radii: ["4px"],
|
|
3802
|
+
shadows: [],
|
|
3803
|
+
spacingOnScalePct: 96,
|
|
3804
|
+
bodyLineLengthCh: 68,
|
|
3805
|
+
bodyFontSizePx: 17
|
|
3806
|
+
}
|
|
3766
3807
|
};
|
|
3808
|
+
const out = { ...base };
|
|
3809
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
3810
|
+
const current = base[key];
|
|
3811
|
+
out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
3812
|
+
}
|
|
3813
|
+
return out;
|
|
3767
3814
|
}
|
|
3768
3815
|
|
|
3769
3816
|
// src/character/parse.ts
|
|
@@ -4097,90 +4144,678 @@ function extractStrings(file) {
|
|
|
4097
4144
|
return [];
|
|
4098
4145
|
}
|
|
4099
4146
|
|
|
4100
|
-
// src/
|
|
4101
|
-
var
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
controls: [
|
|
4131
|
-
{ kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
|
|
4132
|
-
{ kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
|
|
4133
|
-
{ kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
|
|
4134
|
-
{ kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
|
|
4135
|
-
],
|
|
4136
|
-
sections: [
|
|
4137
|
-
{ top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
4138
|
-
{ top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
4139
|
-
{ top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
|
|
4140
|
-
{ top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
4141
|
-
{ top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
|
|
4142
|
-
],
|
|
4143
|
-
effects: {
|
|
4144
|
-
glass: 0,
|
|
4145
|
-
gradientText: 0,
|
|
4146
|
-
gradients: [],
|
|
4147
|
-
glows: 0,
|
|
4148
|
-
gridBackgrounds: 0,
|
|
4149
|
-
marquees: 0,
|
|
4150
|
-
hairlineShadowCards: 0,
|
|
4151
|
-
eyebrowChip: false
|
|
4152
|
-
},
|
|
4153
|
-
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
4154
|
-
metrics: {
|
|
4155
|
-
fontSizes: [14, 16, 20, 36, 64],
|
|
4156
|
-
fontWeights: [400, 500],
|
|
4157
|
-
radii: ["4px"],
|
|
4158
|
-
shadows: [],
|
|
4159
|
-
spacingOnScalePct: 96,
|
|
4160
|
-
bodyLineLengthCh: 68,
|
|
4161
|
-
bodyFontSizePx: 17
|
|
4162
|
-
}
|
|
4147
|
+
// src/character/facts.ts
|
|
4148
|
+
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)?)";
|
|
4149
|
+
var PATTERNS = [
|
|
4150
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
4151
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
4152
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
4153
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
4154
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
4155
|
+
["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")],
|
|
4156
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
4157
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
4158
|
+
];
|
|
4159
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
4160
|
+
var normalise = (kind, value) => {
|
|
4161
|
+
const v = value.trim();
|
|
4162
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
4163
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
4164
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
4165
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
4166
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
4167
|
+
};
|
|
4168
|
+
function visibleText(file) {
|
|
4169
|
+
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
4170
|
+
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
4171
|
+
}
|
|
4172
|
+
function protectedFacts(text) {
|
|
4173
|
+
const facts = [];
|
|
4174
|
+
let rest = text;
|
|
4175
|
+
const mask = (start, length) => {
|
|
4176
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
4163
4177
|
};
|
|
4164
|
-
const
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4178
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
4179
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
4180
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
4181
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
4182
|
+
mask(m.index ?? 0, m[0].length);
|
|
4183
|
+
}
|
|
4168
4184
|
}
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4185
|
+
const lines = rest.split("\n");
|
|
4186
|
+
for (const line of lines) {
|
|
4187
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
4188
|
+
for (const m of line.matchAll(NAME)) {
|
|
4189
|
+
const at = m.index ?? 0;
|
|
4190
|
+
const before = line.slice(0, at).trimEnd();
|
|
4191
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
4192
|
+
const word = m[0];
|
|
4193
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
4194
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
4195
|
+
if (word === "I") continue;
|
|
4196
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
4197
|
+
}
|
|
4182
4198
|
}
|
|
4183
|
-
return
|
|
4199
|
+
return facts;
|
|
4200
|
+
}
|
|
4201
|
+
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
4202
|
+
function compareFacts(before, after) {
|
|
4203
|
+
const a = unique(protectedFacts(before));
|
|
4204
|
+
const b = unique(protectedFacts(after));
|
|
4205
|
+
const inA = new Set(a.map((f4) => f4.key));
|
|
4206
|
+
const inB = new Set(b.map((f4) => f4.key));
|
|
4207
|
+
return {
|
|
4208
|
+
before: a.length,
|
|
4209
|
+
after: b.length,
|
|
4210
|
+
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
4211
|
+
added: b.filter((f4) => !inA.has(f4.key))
|
|
4212
|
+
};
|
|
4213
|
+
}
|
|
4214
|
+
function formatComparison(result, beforePath, afterPath) {
|
|
4215
|
+
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
4216
|
+
if (result.lost.length) {
|
|
4217
|
+
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
4218
|
+
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4219
|
+
}
|
|
4220
|
+
if (result.added.length) {
|
|
4221
|
+
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
4222
|
+
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4223
|
+
}
|
|
4224
|
+
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
4225
|
+
return lines.join("\n");
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
// src/character/specificity.ts
|
|
4229
|
+
var TRADE_NOUNS = [
|
|
4230
|
+
// heating, plumbing, electrical
|
|
4231
|
+
"boiler",
|
|
4232
|
+
"radiator",
|
|
4233
|
+
"combi",
|
|
4234
|
+
"cylinder",
|
|
4235
|
+
"thermostat",
|
|
4236
|
+
"underfloor heating",
|
|
4237
|
+
"heat pump",
|
|
4238
|
+
"solar panel",
|
|
4239
|
+
"plumbing",
|
|
4240
|
+
"plumber",
|
|
4241
|
+
"leak",
|
|
4242
|
+
"toilet",
|
|
4243
|
+
"shower",
|
|
4244
|
+
"bathroom",
|
|
4245
|
+
"wet room",
|
|
4246
|
+
"drain",
|
|
4247
|
+
"fuse board",
|
|
4248
|
+
"fuseboard",
|
|
4249
|
+
"consumer unit",
|
|
4250
|
+
"rewire",
|
|
4251
|
+
"socket",
|
|
4252
|
+
"ev charger",
|
|
4253
|
+
"electrician",
|
|
4254
|
+
"gas engineer",
|
|
4255
|
+
"power flush",
|
|
4256
|
+
"landlord certificate",
|
|
4257
|
+
"eicr",
|
|
4258
|
+
// building and outside
|
|
4259
|
+
"extension",
|
|
4260
|
+
"loft conversion",
|
|
4261
|
+
"loft",
|
|
4262
|
+
"kitchen",
|
|
4263
|
+
"roof",
|
|
4264
|
+
"roofer",
|
|
4265
|
+
"gutter",
|
|
4266
|
+
"chimney",
|
|
4267
|
+
"driveway",
|
|
4268
|
+
"patio",
|
|
4269
|
+
"fence",
|
|
4270
|
+
"fencing",
|
|
4271
|
+
"decking",
|
|
4272
|
+
"window",
|
|
4273
|
+
"conservatory",
|
|
4274
|
+
"plastering",
|
|
4275
|
+
"plasterer",
|
|
4276
|
+
"render",
|
|
4277
|
+
"brickwork",
|
|
4278
|
+
"bricklayer",
|
|
4279
|
+
"tiling",
|
|
4280
|
+
"tiler",
|
|
4281
|
+
"flooring",
|
|
4282
|
+
"carpet",
|
|
4283
|
+
"joinery",
|
|
4284
|
+
"joiner",
|
|
4285
|
+
"carpenter",
|
|
4286
|
+
"staircase",
|
|
4287
|
+
"damp",
|
|
4288
|
+
"scaffolding",
|
|
4289
|
+
"garage conversion",
|
|
4290
|
+
"lawn",
|
|
4291
|
+
"hedge",
|
|
4292
|
+
"tree surgery",
|
|
4293
|
+
"stump",
|
|
4294
|
+
"landscaping",
|
|
4295
|
+
"gardener",
|
|
4296
|
+
"gate",
|
|
4297
|
+
"locksmith",
|
|
4298
|
+
"pest control",
|
|
4299
|
+
"wasp",
|
|
4300
|
+
"cleaning",
|
|
4301
|
+
"oven",
|
|
4302
|
+
"end of tenancy",
|
|
4303
|
+
// vehicles
|
|
4304
|
+
"mot",
|
|
4305
|
+
"tyre",
|
|
4306
|
+
"brake",
|
|
4307
|
+
"clutch",
|
|
4308
|
+
"cambelt",
|
|
4309
|
+
"exhaust",
|
|
4310
|
+
"gearbox",
|
|
4311
|
+
"valeting",
|
|
4312
|
+
// personal and health
|
|
4313
|
+
"haircut",
|
|
4314
|
+
"skin fade",
|
|
4315
|
+
"beard",
|
|
4316
|
+
"barber",
|
|
4317
|
+
"balayage",
|
|
4318
|
+
"nails",
|
|
4319
|
+
"lashes",
|
|
4320
|
+
"massage",
|
|
4321
|
+
"physio",
|
|
4322
|
+
"physiotherapy",
|
|
4323
|
+
"osteopath",
|
|
4324
|
+
"chiropractor",
|
|
4325
|
+
"dentist",
|
|
4326
|
+
"dental",
|
|
4327
|
+
"implant",
|
|
4328
|
+
"brace",
|
|
4329
|
+
"invisalign",
|
|
4330
|
+
"hygienist",
|
|
4331
|
+
"optician",
|
|
4332
|
+
"eye test",
|
|
4333
|
+
"vet",
|
|
4334
|
+
"grooming",
|
|
4335
|
+
"dog walking",
|
|
4336
|
+
"personal trainer",
|
|
4337
|
+
"yoga",
|
|
4338
|
+
"pilates",
|
|
4339
|
+
// professional
|
|
4340
|
+
"accountant",
|
|
4341
|
+
"tax return",
|
|
4342
|
+
"bookkeeping",
|
|
4343
|
+
"payroll",
|
|
4344
|
+
"vat",
|
|
4345
|
+
"self assessment",
|
|
4346
|
+
"solicitor",
|
|
4347
|
+
"conveyancing",
|
|
4348
|
+
"probate",
|
|
4349
|
+
"divorce",
|
|
4350
|
+
"lasting power of attorney",
|
|
4351
|
+
"mortgage",
|
|
4352
|
+
"surveyor",
|
|
4353
|
+
"architect",
|
|
4354
|
+
"planning permission",
|
|
4355
|
+
"building regulations",
|
|
4356
|
+
// web and digital, for the agency's own pages
|
|
4357
|
+
"website",
|
|
4358
|
+
"web design",
|
|
4359
|
+
"seo",
|
|
4360
|
+
"hosting",
|
|
4361
|
+
"google business profile",
|
|
4362
|
+
"online shop",
|
|
4363
|
+
"ecommerce",
|
|
4364
|
+
// food and hospitality
|
|
4365
|
+
"breakfast",
|
|
4366
|
+
"lunch",
|
|
4367
|
+
"dinner",
|
|
4368
|
+
"coffee",
|
|
4369
|
+
"bakery",
|
|
4370
|
+
"cake",
|
|
4371
|
+
"catering",
|
|
4372
|
+
"wedding",
|
|
4373
|
+
"venue"
|
|
4374
|
+
];
|
|
4375
|
+
var STOCK_FIGURES = [
|
|
4376
|
+
/\b24\s?\/\s?7\b/gi,
|
|
4377
|
+
/\b24[\s-]hours?\b/gi,
|
|
4378
|
+
/\b1[01]0\s?%/g,
|
|
4379
|
+
/\b(?:5|five)[\s-]?star\b/gi,
|
|
4380
|
+
/#\s?1\b/g,
|
|
4381
|
+
/\bno\.?\s?1\b/gi,
|
|
4382
|
+
/\b365\s+days\b/gi
|
|
4383
|
+
];
|
|
4384
|
+
var NOT_NAMES = new Set(
|
|
4385
|
+
"a an the and of for to in on at by with your you our we us it get free quote quotes call now today book booking contact learn more read see view explore discover start started home about services service quality trusted trust expert experts expertise professional professionals best solutions solution digital team welcome why choose how works local reliable affordable fast friendly premium leading excellence excellent results success growth business businesses customer customers client clients care support help new our story mission vision values innovation innovative modern simple easy smart better great amazing perfect ultimate transform elevate empower unlock experience experienced dedicated passionate committed tailored bespoke seamless peace mind can do all every everything more less here there this that what who where when need needs make difference satisfaction guaranteed".split(" ")
|
|
4386
|
+
);
|
|
4387
|
+
var BRIEF_STOP = new Set(
|
|
4388
|
+
"a an the and or of for to in on at by with from who that which what their they them its it is are was were be been being has have had do does did can will would should could may might must this these those there here as into over under business company small local family run based owned services service provides provide offering offers people customers clients home homes house houses area areas around near across nearby town city county village uk".split(" ")
|
|
4389
|
+
);
|
|
4390
|
+
var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
4391
|
+
var countWords2 = (text) => (text.match(WORD2) ?? []).length;
|
|
4392
|
+
var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4393
|
+
var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
|
|
4394
|
+
var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
|
|
4395
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
|
|
4396
|
+
function caseless(line) {
|
|
4397
|
+
const words2 = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
|
|
4398
|
+
const letters = words2.filter((w) => w.length >= 2);
|
|
4399
|
+
if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
|
|
4400
|
+
const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
|
|
4401
|
+
if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
|
|
4402
|
+
return null;
|
|
4403
|
+
}
|
|
4404
|
+
var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
|
|
4405
|
+
function briefTerms(brief) {
|
|
4406
|
+
const terms = /* @__PURE__ */ new Set();
|
|
4407
|
+
for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
|
|
4408
|
+
const lower = w.toLowerCase();
|
|
4409
|
+
if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower) && !NOT_NAMES.has(lower)) terms.add(lower);
|
|
4410
|
+
}
|
|
4411
|
+
for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
|
|
4412
|
+
for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
|
|
4413
|
+
return [...terms];
|
|
4414
|
+
}
|
|
4415
|
+
function specifics(text, brief) {
|
|
4416
|
+
const found = /* @__PURE__ */ new Map();
|
|
4417
|
+
const add = (kind, value) => {
|
|
4418
|
+
const key = value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
4419
|
+
if (key && !found.has(key)) found.set(key, { kind, value, key });
|
|
4420
|
+
};
|
|
4421
|
+
for (const raw of text.split("\n")) {
|
|
4422
|
+
const line = maskStock(raw);
|
|
4423
|
+
if (!line.trim()) continue;
|
|
4424
|
+
const capitals = caseless(line);
|
|
4425
|
+
for (const f4 of protectedFacts(line)) {
|
|
4426
|
+
if (f4.kind === "name") {
|
|
4427
|
+
if (capitals === "shout") continue;
|
|
4428
|
+
if (capitals === "title") {
|
|
4429
|
+
for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
|
|
4430
|
+
continue;
|
|
4431
|
+
}
|
|
4432
|
+
const words2 = f4.value.split(/\s+/);
|
|
4433
|
+
const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
|
|
4434
|
+
const first = words2.findIndex(named);
|
|
4435
|
+
if (first === -1) continue;
|
|
4436
|
+
const last = words2.length - 1 - [...words2].reverse().findIndex(named);
|
|
4437
|
+
add("name", words2.slice(first, last + 1).join(" "));
|
|
4438
|
+
continue;
|
|
4439
|
+
}
|
|
4440
|
+
add(f4.kind, f4.value);
|
|
4441
|
+
}
|
|
4442
|
+
for (const [term, re] of TRADE_PATTERNS) {
|
|
4443
|
+
const m = re.exec(line);
|
|
4444
|
+
if (m) add("trade", term);
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
if (brief) {
|
|
4448
|
+
for (const term of briefTerms(brief)) {
|
|
4449
|
+
if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
|
|
4450
|
+
if (termPattern(term).test(text)) add("brief", term);
|
|
4451
|
+
}
|
|
4452
|
+
}
|
|
4453
|
+
return [...found.values()];
|
|
4454
|
+
}
|
|
4455
|
+
function specificity(text, brief) {
|
|
4456
|
+
const list = specifics(text, brief);
|
|
4457
|
+
const words2 = countWords2(text);
|
|
4458
|
+
const trade = list.filter((s) => s.kind === "trade").length;
|
|
4459
|
+
const hard = list.length - trade;
|
|
4460
|
+
return {
|
|
4461
|
+
words: words2,
|
|
4462
|
+
specifics: list,
|
|
4463
|
+
hard,
|
|
4464
|
+
trade,
|
|
4465
|
+
perHundred: words2 ? Math.round(list.length / words2 * 1e3) / 10 : 0,
|
|
4466
|
+
generic: hard === 0 && trade < 2
|
|
4467
|
+
};
|
|
4468
|
+
}
|
|
4469
|
+
var NOT_SERVICE_ROLES = /* @__PURE__ */ new Set(["testimonials", "team", "faq", "contact", "logos", "stats", "cta-band", "footer-cta", "marquee"]);
|
|
4470
|
+
var SERVICE_ROLES = /* @__PURE__ */ new Set(["features", "process", "pricing", "cards"]);
|
|
4471
|
+
var SERVICE_LABEL = /\bservices?\b|\bwhat we (?:do|fix|offer|make|build)\b|\bhow we\b|\bour work\b|\bwe (?:fix|fit|install|repair|build|offer|make)\b/i;
|
|
4472
|
+
function sentencesOf(blocks) {
|
|
4473
|
+
const out = [];
|
|
4474
|
+
for (const block of blocks) {
|
|
4475
|
+
for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
|
|
4476
|
+
const s = m[0].trim();
|
|
4477
|
+
if (countWords2(s) >= 3) out.push(s);
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
return [...new Set(out)];
|
|
4481
|
+
}
|
|
4482
|
+
function pageCopy(snapshot) {
|
|
4483
|
+
const withText2 = snapshot.sections.filter((s) => s.text);
|
|
4484
|
+
if (!snapshot.firstScreenText && withText2.length === 0) return null;
|
|
4485
|
+
const hero = [...snapshot.firstScreenText ?? [], ...snapshot.sections.filter((s) => s.kind === "hero").flatMap((s) => s.text ?? [])];
|
|
4486
|
+
const services = snapshot.sections.filter((s) => s.kind !== "hero" && !NOT_SERVICE_ROLES.has(s.role ?? s.kind)).filter((s) => SERVICE_ROLES.has(s.role ?? s.kind) || SERVICE_LABEL.test(s.label)).flatMap((s) => s.text ?? []);
|
|
4487
|
+
const all = [...snapshot.firstScreenText ?? [], ...withText2.flatMap((s) => s.text ?? [])];
|
|
4488
|
+
return { url: snapshot.url, hero: sentencesOf(hero), services: sentencesOf(services), all };
|
|
4489
|
+
}
|
|
4490
|
+
function side(page, other, brief) {
|
|
4491
|
+
const otherKeys = new Set(specifics(other.all.join("\n"), brief).map((s) => s.key));
|
|
4492
|
+
const sentences3 = [.../* @__PURE__ */ new Set([...page.hero, ...page.services])];
|
|
4493
|
+
const interchangeable = sentences3.filter((s) => !specifics(s, brief).some((x) => !otherKeys.has(x.key)));
|
|
4494
|
+
return {
|
|
4495
|
+
url: page.url,
|
|
4496
|
+
sentences: sentences3.length,
|
|
4497
|
+
interchangeable,
|
|
4498
|
+
share: sentences3.length ? Math.round(interchangeable.length / sentences3.length * 1e3) / 1e3 : null,
|
|
4499
|
+
perHundred: specificity(sentences3.join("\n"), brief).perHundred
|
|
4500
|
+
};
|
|
4501
|
+
}
|
|
4502
|
+
function compareCompetitor(ours, competitor, brief) {
|
|
4503
|
+
return { ours: side(ours, competitor, brief), competitor: side(competitor, ours, brief), brief: Boolean(brief) };
|
|
4504
|
+
}
|
|
4505
|
+
var pctOf = (share) => share === null ? "no sentences" : `${Math.round(share * 100)}%`;
|
|
4506
|
+
function formatCompetitor(result) {
|
|
4507
|
+
const row = (label, s) => ` ${label.padEnd(11)} ${s.interchangeable.length} of ${s.sentences} (${pctOf(s.share)}), ${s.perHundred} specifics per 100 words`;
|
|
4508
|
+
const lines = [
|
|
4509
|
+
`craft copy compare: ${result.ours.url} against ${result.competitor.url}`,
|
|
4510
|
+
" Hero and service sentences holding nothing the other page does not also say:",
|
|
4511
|
+
row("ours", result.ours),
|
|
4512
|
+
row("competitor", result.competitor)
|
|
4513
|
+
];
|
|
4514
|
+
if (result.brief) lines.push(" The brief's own terms were recognised on both pages.");
|
|
4515
|
+
if (result.ours.interchangeable.length) {
|
|
4516
|
+
lines.push("", " Interchangeable on ours: add the job, the place, the price or the name only this business has");
|
|
4517
|
+
for (const s of result.ours.interchangeable.slice(0, 12)) lines.push(` "${s.length > 110 ? `${s.slice(0, 107)}...` : s}"`);
|
|
4518
|
+
if (result.ours.interchangeable.length > 12) lines.push(` and ${result.ours.interchangeable.length - 12} more`);
|
|
4519
|
+
} else {
|
|
4520
|
+
lines.push("", " Every hero and service sentence on ours holds something the competitor does not say.");
|
|
4521
|
+
}
|
|
4522
|
+
return lines.join("\n");
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
// src/character/tells/specificity.ts
|
|
4526
|
+
var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4527
|
+
var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
4528
|
+
var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
|
|
4529
|
+
function literalText(inner) {
|
|
4530
|
+
const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
|
|
4531
|
+
if (/[{}]/.test(t)) return null;
|
|
4532
|
+
return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
|
|
4533
|
+
}
|
|
4534
|
+
var HOME_FILE = /(?:^|\/)app\/(?:\([^/]+\)\/)*page\.(?:tsx|jsx|mdx)$|(?:^|\/)pages\/index\.(?:tsx|jsx|astro|vue|mdx)$|(?:^|\/)routes\/(?:\([^/]+\)\/)*\+page\.svelte$|^(?:(?:public|src|site|www)\/)?index\.html?$|(?:^|\/)[\w-]*(?:hero|home)[\w-]*\.(?:tsx|jsx|astro|vue|svelte|html?)$/i;
|
|
4535
|
+
var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
|
|
4536
|
+
var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
|
|
4537
|
+
var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
|
|
4538
|
+
function headlines(ctx) {
|
|
4539
|
+
const out = [];
|
|
4540
|
+
for (const file of ctx.files) {
|
|
4541
|
+
if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
|
|
4542
|
+
for (const m of file.text.matchAll(H1)) {
|
|
4543
|
+
const h1 = literalText(m[1]);
|
|
4544
|
+
if (!h1) continue;
|
|
4545
|
+
const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
|
|
4546
|
+
const p = NEXT_P.exec(after);
|
|
4547
|
+
const sub = p ? literalText(p[1]) : null;
|
|
4548
|
+
out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
|
|
4549
|
+
}
|
|
4550
|
+
}
|
|
4551
|
+
return out;
|
|
4552
|
+
}
|
|
4553
|
+
var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
|
|
4554
|
+
var STANDING_CLAIM = /(?<![\w#])(?:trusted|reliable|reputable|award[- ]winning|top[- ]rated|best[- ]rated|highly (?:rated|recommended|experienced|skilled|qualified)|fully (?:qualified|certified|accredited|licensed|insured)|(?:qualified|certified|accredited|approved|registered|experienced|expert) (?:engineers?|electricians?|plumbers?|builders?|technicians?|installers?|team|tradesmen|professionals?|staff)|(?:years|decades) of experience|(?:5|five)[- ]star (?:service|rated|reviews?)|(?:happy|satisfied) (?:customers|clients)|(?:customers|clients) (?:love|trust|recommend)|number one|no\.? ?1|#1)(?![\w])/i;
|
|
4555
|
+
var PROOF_NAMES = [
|
|
4556
|
+
"Gas Safe",
|
|
4557
|
+
"NICEIC",
|
|
4558
|
+
"NAPIT",
|
|
4559
|
+
"ELECSA",
|
|
4560
|
+
"Stroma",
|
|
4561
|
+
"TrustMark",
|
|
4562
|
+
"Which? Trusted Trader",
|
|
4563
|
+
"Checkatrade",
|
|
4564
|
+
"TrustATrader",
|
|
4565
|
+
"Rated People",
|
|
4566
|
+
"MyBuilder",
|
|
4567
|
+
"Federation of Master Builders",
|
|
4568
|
+
"FMB",
|
|
4569
|
+
"Guild of Master Craftsmen",
|
|
4570
|
+
"OFTEC",
|
|
4571
|
+
"HETAS",
|
|
4572
|
+
"CIPHE",
|
|
4573
|
+
"APHC",
|
|
4574
|
+
"CHAS",
|
|
4575
|
+
"SafeContractor",
|
|
4576
|
+
"Constructionline",
|
|
4577
|
+
"FENSA",
|
|
4578
|
+
"CERTASS",
|
|
4579
|
+
"MCS",
|
|
4580
|
+
"RECC",
|
|
4581
|
+
"BPEC",
|
|
4582
|
+
"City & Guilds",
|
|
4583
|
+
"NVQ",
|
|
4584
|
+
"CSCS",
|
|
4585
|
+
"Worcester Bosch",
|
|
4586
|
+
"Vaillant",
|
|
4587
|
+
"Viessmann",
|
|
4588
|
+
"BAFE",
|
|
4589
|
+
"NSI",
|
|
4590
|
+
"SSAIB",
|
|
4591
|
+
"BPCA",
|
|
4592
|
+
"RICS",
|
|
4593
|
+
"CIOB",
|
|
4594
|
+
"RIBA",
|
|
4595
|
+
"ICAEW",
|
|
4596
|
+
"ACCA",
|
|
4597
|
+
"CIMA",
|
|
4598
|
+
"SRA",
|
|
4599
|
+
"Law Society",
|
|
4600
|
+
"GDC",
|
|
4601
|
+
"GMC",
|
|
4602
|
+
"HCPC",
|
|
4603
|
+
"CQC",
|
|
4604
|
+
"Ofsted",
|
|
4605
|
+
"ISO 9001",
|
|
4606
|
+
"ISO 27001",
|
|
4607
|
+
"Cyber Essentials",
|
|
4608
|
+
"Companies House",
|
|
4609
|
+
"FCA",
|
|
4610
|
+
"Trustpilot",
|
|
4611
|
+
"Feefo",
|
|
4612
|
+
"Reviews.io",
|
|
4613
|
+
"Yell",
|
|
4614
|
+
"Houzz"
|
|
4615
|
+
];
|
|
4616
|
+
var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4617
|
+
var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
|
|
4618
|
+
var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
|
|
4619
|
+
var PROOF_PATTERNS = [
|
|
4620
|
+
PROOF_NAME,
|
|
4621
|
+
/\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
|
|
4622
|
+
// A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
|
|
4623
|
+
/\b[1-5](?:\.\d)?\s?(?:\/\s?5|out of 5)\b|\brated\s+[1-5](?:\.\d)?\b|★{3,}|\b\d[\d,]*\+?\s+(?:verified\s+)?(?:reviews|ratings)\b/i,
|
|
4624
|
+
// A registration or licence number.
|
|
4625
|
+
/\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
|
|
4626
|
+
];
|
|
4627
|
+
var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
|
|
4628
|
+
function sectionHasProof(s, section, index) {
|
|
4629
|
+
if ((section.role ?? section.kind) === "testimonials") return true;
|
|
4630
|
+
const text = section.text ?? [];
|
|
4631
|
+
if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
|
|
4632
|
+
return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
|
|
4633
|
+
}
|
|
4634
|
+
function unprovenClaims(s) {
|
|
4635
|
+
const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
|
|
4636
|
+
const out = [];
|
|
4637
|
+
s.sections.forEach((section, i) => {
|
|
4638
|
+
if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
|
|
4639
|
+
for (const sentence of sentencesOf(section.text)) {
|
|
4640
|
+
const m = STANDING_CLAIM.exec(sentence);
|
|
4641
|
+
if (m) out.push({ claim: m[0], sentence, section: section.label });
|
|
4642
|
+
}
|
|
4643
|
+
});
|
|
4644
|
+
return out;
|
|
4645
|
+
}
|
|
4646
|
+
var withText = (texts, patch = () => ({})) => {
|
|
4647
|
+
const base = makeSnapshot();
|
|
4648
|
+
return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
|
|
4649
|
+
};
|
|
4650
|
+
var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
|
|
4651
|
+
var SPECIFICITY_COPY_TELLS = [
|
|
4652
|
+
{
|
|
4653
|
+
id: "generic-hero-claim",
|
|
4654
|
+
name: "Headline that fits any business",
|
|
4655
|
+
generation: 1,
|
|
4656
|
+
severity: "warn",
|
|
4657
|
+
surface: "copy",
|
|
4658
|
+
why: "The first thing a visitor reads on the home page names no place, no person, no price and no job, so it would fit every competitor's site unchanged. A model writes it when it has nothing particular to say.",
|
|
4659
|
+
fix: 'Put what only this business can say on the first screen: the job, the town, a price, the name. "Boilers fixed the same day in Brackley" beats "Quality you can trust".',
|
|
4660
|
+
detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
|
|
4661
|
+
fixtures: {
|
|
4662
|
+
flag: [
|
|
4663
|
+
{ path: "app/page.tsx", text: '<section>\n <h1 className="text-6xl">Quality you can trust</h1>\n <p>We deliver reliable solutions tailored to your needs.</p>\n</section>' },
|
|
4664
|
+
// Shouted and Title Case: its capitals are not names, and its figures are stock.
|
|
4665
|
+
{ path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
|
|
4666
|
+
],
|
|
4667
|
+
pass: [
|
|
4668
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
|
|
4669
|
+
// A stock headline rescued by the line under it.
|
|
4670
|
+
{ path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
|
|
4671
|
+
// Two trade nouns say what the business does.
|
|
4672
|
+
{ path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
|
|
4673
|
+
// The words come from data: nothing to read.
|
|
4674
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
|
|
4675
|
+
// Another page's headline is a label, not the site's claim.
|
|
4676
|
+
{ path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
|
|
4677
|
+
]
|
|
4678
|
+
},
|
|
4679
|
+
rendered: {
|
|
4680
|
+
detect: (s) => {
|
|
4681
|
+
if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
|
|
4682
|
+
const text = s.firstScreenText.join("\n");
|
|
4683
|
+
if (!text.trim() || !specificity(text).generic) return [];
|
|
4684
|
+
const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
|
|
4685
|
+
return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
|
|
4686
|
+
},
|
|
4687
|
+
fixtures: {
|
|
4688
|
+
flag: [
|
|
4689
|
+
makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
|
|
4690
|
+
makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
|
|
4691
|
+
],
|
|
4692
|
+
pass: [
|
|
4693
|
+
makeSnapshot(),
|
|
4694
|
+
makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
|
|
4695
|
+
makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
|
|
4696
|
+
// A first screen that is a picture and nothing else is not judged on its words.
|
|
4697
|
+
makeSnapshot({ firstScreenText: [] }),
|
|
4698
|
+
// Nor is any page but the home page.
|
|
4699
|
+
makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
|
|
4700
|
+
]
|
|
4701
|
+
}
|
|
4702
|
+
}
|
|
4703
|
+
}
|
|
4704
|
+
];
|
|
4705
|
+
var SPECIFICITY_RENDERED_TELLS = [
|
|
4706
|
+
{
|
|
4707
|
+
id: "unproven-claim",
|
|
4708
|
+
name: "Claim with no proof next to it",
|
|
4709
|
+
generation: 1,
|
|
4710
|
+
severity: "warn",
|
|
4711
|
+
surface: "rendered",
|
|
4712
|
+
why: `"Trusted", "fully qualified" and "award-winning" ask the visitor to take the business's word for it. A page built with no reviews and no registration number makes the claim anyway, with nothing beside it.`,
|
|
4713
|
+
fix: "Put the proof where the claim is: the Gas Safe or NICEIC number, the named review with its town, the rating and where it was left. Or cut the claim.",
|
|
4714
|
+
rendered: {
|
|
4715
|
+
detect: (s) => {
|
|
4716
|
+
if (!s.sections.some((x) => x.text)) return [];
|
|
4717
|
+
const found = unprovenClaims(s);
|
|
4718
|
+
if (found.length === 0) return [];
|
|
4719
|
+
const first = found[0];
|
|
4720
|
+
const where = first.section ? `"${first.section}"` : "an unnamed section";
|
|
4721
|
+
return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
|
|
4722
|
+
},
|
|
4723
|
+
fixtures: {
|
|
4724
|
+
flag: [
|
|
4725
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
4726
|
+
// Proof two sections away is not next to the claim.
|
|
4727
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["Recent jobs"], ["\u201CThey turned up on time and fixed the boiler in an hour.\u201D", "Sam, Brackley"]], (i) => i === 3 ? { role: "testimonials" } : {})
|
|
4728
|
+
],
|
|
4729
|
+
pass: [
|
|
4730
|
+
makeSnapshot(),
|
|
4731
|
+
withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
|
|
4732
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
|
|
4733
|
+
withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
|
|
4734
|
+
{
|
|
4735
|
+
...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
4736
|
+
images: [{ src: "https://example.test/img/gas-safe-register.svg", host: "example.test", width: 96, height: 96, top: 1200, role: "logo", alt: "Gas Safe Register", decorative: false, section: 1, background: false }]
|
|
4737
|
+
}
|
|
4738
|
+
]
|
|
4739
|
+
}
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
];
|
|
4743
|
+
|
|
4744
|
+
// src/character/house.ts
|
|
4745
|
+
var HOUSE = {
|
|
4746
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
4747
|
+
emoji: { tier: "block", label: "emoji" },
|
|
4748
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
4749
|
+
"no-x-no-y": {
|
|
4750
|
+
tier: "block",
|
|
4751
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
4752
|
+
},
|
|
4753
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
4754
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
4755
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
4756
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
4757
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
4758
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
4759
|
+
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
4760
|
+
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
4761
|
+
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
4762
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
4763
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
4764
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
4765
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
4766
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
4767
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
4768
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
4769
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
4770
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
4771
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
4772
|
+
"false-range": { tier: "review", label: "false range" },
|
|
4773
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
4774
|
+
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
4775
|
+
// a proposal can honestly say "the attached research". Review until measured there.
|
|
4776
|
+
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
4777
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
4778
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
4779
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
4780
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
4781
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
4782
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
4783
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
4784
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
4785
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
4786
|
+
// A threshold on a count, not a named pattern: a person decides whether the
|
|
4787
|
+
// headline needed a particular. Review until its estate hits are read.
|
|
4788
|
+
"generic-hero-claim": { tier: "review", label: "headline that fits any business" },
|
|
4789
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
4790
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
4791
|
+
};
|
|
4792
|
+
function houseRule(id, name = id) {
|
|
4793
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
4794
|
+
}
|
|
4795
|
+
var HOUSE_LOCKED = "the house blocking tier cannot be lowered by a repo. A genuine one-off takes copy-ok on its line; a rule that is wrong is changed in craft's house.ts and COPY.md";
|
|
4796
|
+
function holdHouseBlocks(exceptions, severity) {
|
|
4797
|
+
const blocks = (tell) => HOUSE[tell]?.tier === "block";
|
|
4798
|
+
const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
|
|
4799
|
+
return {
|
|
4800
|
+
exceptions: exceptions.filter((e) => !blocks(e.tell)),
|
|
4801
|
+
refused: exceptions.filter((e) => blocks(e.tell)),
|
|
4802
|
+
severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
|
|
4803
|
+
refusedSeverity
|
|
4804
|
+
};
|
|
4805
|
+
}
|
|
4806
|
+
|
|
4807
|
+
// src/snapshot/fonts.ts
|
|
4808
|
+
var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
|
|
4809
|
+
function normaliseFamily(raw) {
|
|
4810
|
+
let name = raw.replace(/["']/g, "").trim();
|
|
4811
|
+
const next = name.match(/^__(.+?)(?:_Fallback)?_[0-9a-f]{5,8}$/i);
|
|
4812
|
+
if (next) name = next[1].replace(/_/g, " ");
|
|
4813
|
+
name = name.replace(/\s+Fallback$/i, "").replace(/\s+Variable$/i, "");
|
|
4814
|
+
if (SYSTEM.test(name)) return "system-ui";
|
|
4815
|
+
if (!/\s/.test(name) && (/[a-z][A-Z]/.test(name) || /^[a-z]+(?:-[a-z]+)+$/.test(name))) {
|
|
4816
|
+
name = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/-/g, " ").replace(/\b([a-z])/g, (c) => c.toUpperCase()).replace(/\bDm\b/, "DM");
|
|
4817
|
+
}
|
|
4818
|
+
return name;
|
|
4184
4819
|
}
|
|
4185
4820
|
var KNOWN = {
|
|
4186
4821
|
fraunces: "serif",
|
|
@@ -4286,7 +4921,7 @@ function blueToPurple(s) {
|
|
|
4286
4921
|
}
|
|
4287
4922
|
return hits;
|
|
4288
4923
|
}
|
|
4289
|
-
var
|
|
4924
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4290
4925
|
var RENDERED_PATHS = {
|
|
4291
4926
|
"reflex-font": {
|
|
4292
4927
|
detect: reflexFonts(REFLEX_FONTS_1),
|
|
@@ -4324,11 +4959,11 @@ var RENDERED_PATHS = {
|
|
|
4324
4959
|
}
|
|
4325
4960
|
},
|
|
4326
4961
|
"gradient-text": {
|
|
4327
|
-
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${
|
|
4962
|
+
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
|
|
4328
4963
|
fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
|
|
4329
4964
|
},
|
|
4330
4965
|
"glass-panel": {
|
|
4331
|
-
detect: (s) => s.effects.glass > 0 ? [hit(s, `${
|
|
4966
|
+
detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
|
|
4332
4967
|
fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
|
|
4333
4968
|
},
|
|
4334
4969
|
"cream-palette": {
|
|
@@ -4406,7 +5041,7 @@ var RENDERED_PATHS = {
|
|
|
4406
5041
|
detect: (s) => {
|
|
4407
5042
|
const radial = s.effects.gradients.filter((g) => g.radial).length;
|
|
4408
5043
|
const n = s.effects.glows + radial;
|
|
4409
|
-
return n > 0 ? [hit(s, `${
|
|
5044
|
+
return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
|
|
4410
5045
|
},
|
|
4411
5046
|
fixtures: {
|
|
4412
5047
|
flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
|
|
@@ -4414,28 +5049,829 @@ var RENDERED_PATHS = {
|
|
|
4414
5049
|
}
|
|
4415
5050
|
},
|
|
4416
5051
|
"grid-background": {
|
|
4417
|
-
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${
|
|
5052
|
+
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
|
|
4418
5053
|
fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
|
|
4419
5054
|
},
|
|
4420
|
-
marquee: {
|
|
4421
|
-
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${
|
|
4422
|
-
fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
|
|
5055
|
+
marquee: {
|
|
5056
|
+
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
|
|
5057
|
+
fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
|
|
5058
|
+
},
|
|
5059
|
+
"thin-border-wide-shadow": {
|
|
5060
|
+
detect: (s) => s.effects.hairlineShadowCards >= 3 ? [hit(s, `${s.effects.hairlineShadowCards} cards with a hairline border under a wide shadow`)] : [],
|
|
5061
|
+
fixtures: { flag: [makeSnapshot({ effects: { hairlineShadowCards: 3 } })], pass: [makeSnapshot(), makeSnapshot({ effects: { hairlineShadowCards: 1 } })] }
|
|
5062
|
+
},
|
|
5063
|
+
"intro-cinematic": {
|
|
5064
|
+
detect: (s) => s.motion.introOverlay ? [hit(s, "a full-screen layer covers the page at load and then leaves")] : [],
|
|
5065
|
+
fixtures: { flag: [makeSnapshot({ motion: { introOverlay: true } })], pass: [makeSnapshot()] }
|
|
5066
|
+
}
|
|
5067
|
+
};
|
|
5068
|
+
|
|
5069
|
+
// src/fingerprint/index.ts
|
|
5070
|
+
var FINGERPRINT_VERSION = 2;
|
|
5071
|
+
var CHROMATIC = 0.03;
|
|
5072
|
+
var MIN_ACCENT_AREA = 2e4;
|
|
5073
|
+
var oklch = (value) => {
|
|
5074
|
+
const parsed = parseColour(value);
|
|
5075
|
+
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
5076
|
+
};
|
|
5077
|
+
function accentOf(s) {
|
|
5078
|
+
const buttons = /* @__PURE__ */ new Map();
|
|
5079
|
+
for (const c of s.controls) {
|
|
5080
|
+
const o = oklch(c.background);
|
|
5081
|
+
if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
|
|
5082
|
+
}
|
|
5083
|
+
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
5084
|
+
if (topButton) return oklch(topButton[0]);
|
|
5085
|
+
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];
|
|
5086
|
+
if (bg) return bg.o;
|
|
5087
|
+
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];
|
|
5088
|
+
return text ? text.o : null;
|
|
5089
|
+
}
|
|
5090
|
+
function faces(s) {
|
|
5091
|
+
const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
|
|
5092
|
+
const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
|
|
5093
|
+
const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
|
|
5094
|
+
const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
|
|
5095
|
+
return {
|
|
5096
|
+
display: { family: displayName, class: fontClass(displayName) },
|
|
5097
|
+
body: { family: bodyName, class: fontClass(bodyName) }
|
|
5098
|
+
};
|
|
5099
|
+
}
|
|
5100
|
+
function median(values) {
|
|
5101
|
+
if (values.length === 0) return null;
|
|
5102
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
5103
|
+
const mid = Math.floor(sorted.length / 2);
|
|
5104
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
5105
|
+
}
|
|
5106
|
+
function fingerprint(s) {
|
|
5107
|
+
const effects = [];
|
|
5108
|
+
if (s.effects.glass > 0) effects.push("glass-panel");
|
|
5109
|
+
if (s.effects.gradientText > 0) effects.push("gradient-text");
|
|
5110
|
+
if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
|
|
5111
|
+
if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
|
|
5112
|
+
if (s.effects.marquees > 0) effects.push("marquee");
|
|
5113
|
+
if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
|
|
5114
|
+
if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
|
|
5115
|
+
if (s.motion.introOverlay) effects.push("intro-cinematic");
|
|
5116
|
+
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
5117
|
+
return {
|
|
5118
|
+
version: FINGERPRINT_VERSION,
|
|
5119
|
+
accent: accentOf(s),
|
|
5120
|
+
ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
|
|
5121
|
+
...faces(s),
|
|
5122
|
+
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
5123
|
+
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
5124
|
+
effects: effects.sort(),
|
|
5125
|
+
layout: s.sections.map((x) => x.kind),
|
|
5126
|
+
...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? {
|
|
5127
|
+
sections: s.sections.map((x) => ({
|
|
5128
|
+
role: x.role,
|
|
5129
|
+
centred: x.geometry.centredShare,
|
|
5130
|
+
width: x.geometry.contentWidthRatio,
|
|
5131
|
+
symmetry: x.geometry.mirrorSymmetry,
|
|
5132
|
+
controls: x.geometry.controls,
|
|
5133
|
+
cards: x.cards,
|
|
5134
|
+
background: x.geometry.background === s.ground ? null : oklch(x.geometry.background),
|
|
5135
|
+
badges: (x.badges ?? []).map((b) => b.toLowerCase()),
|
|
5136
|
+
avatars: x.avatars ?? 0,
|
|
5137
|
+
carousel: x.carousel ?? false,
|
|
5138
|
+
figures: x.figures ?? 0
|
|
5139
|
+
}))
|
|
5140
|
+
} : {}
|
|
5141
|
+
};
|
|
5142
|
+
}
|
|
5143
|
+
var DISTANCE_WEIGHTS = { accent: 0.25, type: 0.25, ground: 0.1, shape: 0.1, motion: 0.1, effects: 0.1, layout: 0.1 };
|
|
5144
|
+
function levenshtein(a, b, cost = (x, y) => x === y ? 0 : 1) {
|
|
5145
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
5146
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
5147
|
+
let prev = row[0];
|
|
5148
|
+
row[0] = i;
|
|
5149
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
5150
|
+
const cur = row[j];
|
|
5151
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost(a[i - 1], b[j - 1]));
|
|
5152
|
+
prev = cur;
|
|
5153
|
+
}
|
|
5154
|
+
}
|
|
5155
|
+
return row[b.length];
|
|
5156
|
+
}
|
|
5157
|
+
function sectionCost(x, y) {
|
|
5158
|
+
if (x.role !== y.role) return 1;
|
|
5159
|
+
return Math.min(0.5, (Math.abs(x.centred - y.centred) + Math.abs(x.width - y.width)) / 2);
|
|
5160
|
+
}
|
|
5161
|
+
function layoutDistance(a, b) {
|
|
5162
|
+
if (a.sections && b.sections) {
|
|
5163
|
+
const longest2 = Math.max(a.sections.length, b.sections.length);
|
|
5164
|
+
return longest2 === 0 ? 0 : levenshtein(a.sections, b.sections, sectionCost) / longest2;
|
|
5165
|
+
}
|
|
5166
|
+
const longest = Math.max(a.layout.length, b.layout.length);
|
|
5167
|
+
return longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
|
|
5168
|
+
}
|
|
5169
|
+
var clamp = (n) => Math.max(0, Math.min(1, n));
|
|
5170
|
+
function faceDistance(a, b) {
|
|
5171
|
+
if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
|
|
5172
|
+
return a.class === b.class ? 0.5 : 1;
|
|
5173
|
+
}
|
|
5174
|
+
function fingerprintDistance(a, b) {
|
|
5175
|
+
const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / 0.3) : a.accent || b.accent ? 1 : 0;
|
|
5176
|
+
const ground = clamp(deltaEOk(a.ground, b.ground) / 0.15);
|
|
5177
|
+
const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
|
|
5178
|
+
const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
|
|
5179
|
+
const motion = clamp(Math.abs(a.motion - b.motion));
|
|
5180
|
+
const union = /* @__PURE__ */ new Set([...a.effects, ...b.effects]);
|
|
5181
|
+
const shared = a.effects.filter((e) => b.effects.includes(e)).length;
|
|
5182
|
+
const effects = union.size === 0 ? 0 : 1 - shared / union.size;
|
|
5183
|
+
const layout = layoutDistance(a, b);
|
|
5184
|
+
const parts2 = { accent, ground, type, shape, motion, effects, layout };
|
|
5185
|
+
const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts2[k], 0);
|
|
5186
|
+
return { total: Math.round(total * 1e3) / 1e3, parts: parts2 };
|
|
5187
|
+
}
|
|
5188
|
+
function nearest(target2, set, k = set.length) {
|
|
5189
|
+
return set.map((item) => ({ id: item.id, distance: fingerprintDistance(target2, item.fingerprint) })).sort((x, y) => x.distance.total - y.distance.total).slice(0, k);
|
|
5190
|
+
}
|
|
5191
|
+
|
|
5192
|
+
// src/null/index.ts
|
|
5193
|
+
var NULL_VERSION = 1;
|
|
5194
|
+
function nullPrompt(brief) {
|
|
5195
|
+
return [
|
|
5196
|
+
`Build the home page for this business: ${brief.trim()}`,
|
|
5197
|
+
"",
|
|
5198
|
+
"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."
|
|
5199
|
+
].join("\n");
|
|
5200
|
+
}
|
|
5201
|
+
function extractHtml(reply) {
|
|
5202
|
+
const start = reply.search(/<!doctype html|<html[\s>]/i);
|
|
5203
|
+
if (start < 0) return null;
|
|
5204
|
+
const end = reply.toLowerCase().lastIndexOf("</html>");
|
|
5205
|
+
const html = end > start ? reply.slice(start, end + "</html>".length) : reply.slice(start).replace(/\s*```\s*$/, "");
|
|
5206
|
+
return html.length > 200 ? html : null;
|
|
5207
|
+
}
|
|
5208
|
+
var MIN_RUNS = 5;
|
|
5209
|
+
var TYPICAL_AT = 0.1;
|
|
5210
|
+
function meanNearest(distances, k) {
|
|
5211
|
+
const sorted = [...distances].sort((a, b) => a - b).slice(0, k);
|
|
5212
|
+
return sorted.reduce((s, d) => s + d, 0) / sorted.length;
|
|
5213
|
+
}
|
|
5214
|
+
function median2(values) {
|
|
5215
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
5216
|
+
const mid = Math.floor(sorted.length / 2);
|
|
5217
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
5218
|
+
}
|
|
5219
|
+
function typicality(target2, runs, k = 3) {
|
|
5220
|
+
if (runs.length < MIN_RUNS) throw new Error(`a null model needs at least ${MIN_RUNS} runs; this one has ${runs.length}`);
|
|
5221
|
+
const kk = Math.min(k, runs.length - 1);
|
|
5222
|
+
const toTarget = runs.map((r) => ({ id: r.id, distance: fingerprintDistance(target2, r.fingerprint).total }));
|
|
5223
|
+
const distance = meanNearest(
|
|
5224
|
+
toTarget.map((t) => t.distance),
|
|
5225
|
+
kk
|
|
5226
|
+
);
|
|
5227
|
+
const loo = runs.map(
|
|
5228
|
+
(r, i) => meanNearest(
|
|
5229
|
+
runs.filter((_, j) => j !== i).map((o) => fingerprintDistance(r.fingerprint, o.fingerprint).total),
|
|
5230
|
+
kk
|
|
5231
|
+
)
|
|
5232
|
+
);
|
|
5233
|
+
const score = loo.filter((d) => d >= distance).length / loo.length;
|
|
5234
|
+
return {
|
|
5235
|
+
score: Math.round(score * 100) / 100,
|
|
5236
|
+
typical: score >= TYPICAL_AT,
|
|
5237
|
+
distance: Math.round(distance * 1e3) / 1e3,
|
|
5238
|
+
baseline: Math.round(median2(loo) * 1e3) / 1e3,
|
|
5239
|
+
nearest: toTarget.sort((a, b) => a.distance - b.distance).slice(0, kk),
|
|
5240
|
+
shared: sharedChoices(target2, runs)
|
|
5241
|
+
};
|
|
5242
|
+
}
|
|
5243
|
+
var HUES = [
|
|
5244
|
+
["red", 0, 45],
|
|
5245
|
+
["orange", 45, 75],
|
|
5246
|
+
["yellow", 75, 110],
|
|
5247
|
+
["green", 110, 165],
|
|
5248
|
+
["teal", 165, 215],
|
|
5249
|
+
["blue", 215, 265],
|
|
5250
|
+
["violet", 265, 315],
|
|
5251
|
+
["pink", 315, 360]
|
|
5252
|
+
];
|
|
5253
|
+
function hueName(o) {
|
|
5254
|
+
if (!o) return "none";
|
|
5255
|
+
if (o.c < 0.03) return "neutral";
|
|
5256
|
+
return HUES.find(([, lo, hi]) => o.h >= lo && o.h < hi)?.[0] ?? "red";
|
|
5257
|
+
}
|
|
5258
|
+
function groundName(o) {
|
|
5259
|
+
if (o.l < 0.3) return "dark";
|
|
5260
|
+
if (isCream(o)) return "cream";
|
|
5261
|
+
if (o.l >= 0.97 && o.c < 0.012) return "white";
|
|
5262
|
+
return o.c < 0.02 ? "grey" : "tinted";
|
|
5263
|
+
}
|
|
5264
|
+
function shapeName(roundness) {
|
|
5265
|
+
if (roundness === null) return "no buttons";
|
|
5266
|
+
if (roundness >= 0.45) return "pill";
|
|
5267
|
+
return roundness >= 0.15 ? "soft" : "square";
|
|
5268
|
+
}
|
|
5269
|
+
function choicesOf(fp) {
|
|
5270
|
+
return {
|
|
5271
|
+
display: [fp.display.family],
|
|
5272
|
+
body: [fp.body.family],
|
|
5273
|
+
accent: [hueName(fp.accent)],
|
|
5274
|
+
ground: [groundName(fp.ground)],
|
|
5275
|
+
shape: [shapeName(fp.roundness)],
|
|
5276
|
+
motion: [fp.motion >= 0.6 ? "reveals most sections" : fp.motion > 0 ? "reveals some sections" : "no scroll reveals"],
|
|
5277
|
+
effects: fp.effects,
|
|
5278
|
+
opening: [fp.layout.slice(0, 3).join(" > ") || "none"]
|
|
5279
|
+
};
|
|
5280
|
+
}
|
|
5281
|
+
function sharedChoices(target2, runs) {
|
|
5282
|
+
const mine = choicesOf(target2);
|
|
5283
|
+
const all = runs.map((r) => choicesOf(r.fingerprint));
|
|
5284
|
+
const out = [];
|
|
5285
|
+
for (const [part, values] of Object.entries(mine)) {
|
|
5286
|
+
for (const value of values) {
|
|
5287
|
+
const count4 = all.filter((c) => c[part].includes(value)).length;
|
|
5288
|
+
if (count4 / runs.length >= 0.25) out.push({ part, value, runs: count4, of: runs.length });
|
|
5289
|
+
}
|
|
5290
|
+
}
|
|
5291
|
+
return out.sort((a, b) => b.runs - a.runs);
|
|
5292
|
+
}
|
|
5293
|
+
var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f4) => f4.toLowerCase()));
|
|
5294
|
+
var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f4) => f4.toLowerCase()));
|
|
5295
|
+
function knownFor(kind, value, sample) {
|
|
5296
|
+
if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
|
|
5297
|
+
if (kind === "accent" && value === "violet") return sample.some((f4) => f4.accent && isAiViolet(f4.accent)) ? "ai-violet" : null;
|
|
5298
|
+
if (kind === "ground" && value === "cream") return "cream-palette";
|
|
5299
|
+
if (kind === "shape" && value === "pill") return "pill-everything";
|
|
5300
|
+
if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
|
|
5301
|
+
if (kind === "tell") return value;
|
|
5302
|
+
if (kind === "opening" && /^hero > (?:logos|stats)/.test(value)) return "hero-then-proof";
|
|
5303
|
+
return null;
|
|
5304
|
+
}
|
|
5305
|
+
var STOP = new Set(
|
|
5306
|
+
"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(" ")
|
|
5307
|
+
);
|
|
5308
|
+
function grams(text) {
|
|
5309
|
+
const out = /* @__PURE__ */ new Set();
|
|
5310
|
+
for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
|
|
5311
|
+
const words2 = clause.match(/[a-z][a-z'-]*/g) ?? [];
|
|
5312
|
+
for (let n = 2; n <= 4; n += 1) {
|
|
5313
|
+
for (let i = 0; i + n <= words2.length; i += 1) {
|
|
5314
|
+
const g = words2.slice(i, i + n);
|
|
5315
|
+
if (STOP.has(g[0]) || STOP.has(g[n - 1])) continue;
|
|
5316
|
+
if (g.filter((w) => !STOP.has(w)).length < 2) continue;
|
|
5317
|
+
out.add(g.join(" "));
|
|
5318
|
+
}
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5321
|
+
return out;
|
|
5322
|
+
}
|
|
5323
|
+
var GRAMMAR = /* @__PURE__ */ new Set([
|
|
5324
|
+
"privacy policy",
|
|
5325
|
+
"cookie policy",
|
|
5326
|
+
"rights reserved",
|
|
5327
|
+
"all rights reserved",
|
|
5328
|
+
"terms of service",
|
|
5329
|
+
"terms and conditions",
|
|
5330
|
+
"terms conditions",
|
|
5331
|
+
"how it works",
|
|
5332
|
+
"what we do",
|
|
5333
|
+
"about us",
|
|
5334
|
+
"contact us",
|
|
5335
|
+
"get in touch",
|
|
5336
|
+
"opening hours",
|
|
5337
|
+
"read more",
|
|
5338
|
+
"learn more",
|
|
5339
|
+
"book now",
|
|
5340
|
+
"free quote",
|
|
5341
|
+
"our services",
|
|
5342
|
+
"quick links",
|
|
5343
|
+
"email address",
|
|
5344
|
+
"phone number",
|
|
5345
|
+
"full name",
|
|
5346
|
+
"first name",
|
|
5347
|
+
"last name",
|
|
5348
|
+
"your name",
|
|
5349
|
+
"your email",
|
|
5350
|
+
"send message"
|
|
5351
|
+
]);
|
|
5352
|
+
var LEGAL = /©|©|\bregistered (?:in|office|number)\b|\bcompany (?:no|number|reg)|\bvat (?:no|number|reg)/i;
|
|
5353
|
+
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;
|
|
5354
|
+
function textGrams(text) {
|
|
5355
|
+
if (LEGAL.test(text)) return /* @__PURE__ */ new Set();
|
|
5356
|
+
const out = grams(text.replace(NOISE, " | "));
|
|
5357
|
+
for (const g of out) if (GRAMMAR.has(g)) out.delete(g);
|
|
5358
|
+
return out;
|
|
5359
|
+
}
|
|
5360
|
+
function properNouns(brief) {
|
|
5361
|
+
return new Set((brief.match(/\b[A-Z][a-z]+/g) ?? []).map((w) => w.toLowerCase()).filter((w) => !STOP.has(w)));
|
|
5362
|
+
}
|
|
5363
|
+
function harvest(models, options = {}) {
|
|
5364
|
+
const minShare = options.minShare ?? 0.25;
|
|
5365
|
+
const runs = models.flatMap((m, b) => m.runs.map((r) => ({ ...r, brief: b })));
|
|
5366
|
+
const of = runs.length;
|
|
5367
|
+
if (of === 0) return [];
|
|
5368
|
+
const multiBrief = models.length > 1;
|
|
5369
|
+
const sample = runs.map((r) => r.fingerprint);
|
|
5370
|
+
const tally = /* @__PURE__ */ new Map();
|
|
5371
|
+
const add = (kind, value, run, brief, example) => {
|
|
5372
|
+
const key = `${kind}\0${value}`;
|
|
5373
|
+
const t = tally.get(key) ?? { kind, value, runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example };
|
|
5374
|
+
t.runs.add(run);
|
|
5375
|
+
t.briefs.add(brief);
|
|
5376
|
+
tally.set(key, t);
|
|
5377
|
+
};
|
|
5378
|
+
runs.forEach((r, i) => {
|
|
5379
|
+
const c = choicesOf(r.fingerprint);
|
|
5380
|
+
for (const f4 of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f4, i, r.brief);
|
|
5381
|
+
add("accent", c.accent[0], i, r.brief);
|
|
5382
|
+
add("ground", c.ground[0], i, r.brief);
|
|
5383
|
+
add("shape", c.shape[0], i, r.brief);
|
|
5384
|
+
add("motion", c.motion[0], i, r.brief);
|
|
5385
|
+
add("opening", c.opening[0], i, r.brief);
|
|
5386
|
+
for (const t of r.tells) add("tell", t, i, r.brief);
|
|
5387
|
+
});
|
|
5388
|
+
const out = [];
|
|
5389
|
+
for (const t of tally.values()) {
|
|
5390
|
+
if (t.runs.size / of < minShare) continue;
|
|
5391
|
+
if (t.kind === "accent" && (t.value === "none" || t.value === "neutral")) continue;
|
|
5392
|
+
if (t.kind === "shape" && t.value === "no buttons") continue;
|
|
5393
|
+
if (t.kind === "ground" && (t.value === "white" || t.value === "grey")) continue;
|
|
5394
|
+
if (t.kind === "motion" && t.value === "no scroll reveals") continue;
|
|
5395
|
+
out.push({ kind: t.kind, value: t.value, runs: t.runs.size, of, briefs: t.briefs.size, known: knownFor(t.kind, t.value, sample) });
|
|
5396
|
+
}
|
|
5397
|
+
const phraseMin = Math.max(3, Math.ceil(of * minShare * 0.4));
|
|
5398
|
+
const names = models.map((m) => properNouns(m.brief));
|
|
5399
|
+
const phrases2 = /* @__PURE__ */ new Map();
|
|
5400
|
+
runs.forEach((r, i) => {
|
|
5401
|
+
for (const text of r.copy) {
|
|
5402
|
+
for (const g of textGrams(text)) {
|
|
5403
|
+
if (g.split(" ").some((w) => names[r.brief].has(w))) continue;
|
|
5404
|
+
const p = phrases2.get(g) ?? { runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example: text.trim().slice(0, 140) };
|
|
5405
|
+
p.runs.add(i);
|
|
5406
|
+
p.briefs.add(r.brief);
|
|
5407
|
+
phrases2.set(g, p);
|
|
5408
|
+
}
|
|
5409
|
+
}
|
|
5410
|
+
});
|
|
5411
|
+
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);
|
|
5412
|
+
const maximal = kept.filter(([g, p]) => !kept.some(([h, q]) => h !== g && h.includes(g) && q.runs.size >= 0.8 * p.runs.size));
|
|
5413
|
+
for (const [g, p] of maximal.slice(0, 40)) {
|
|
5414
|
+
out.push({ kind: "phrase", value: g, runs: p.runs.size, of, briefs: p.briefs.size, known: options.copyTell?.(g) ?? null, example: p.example });
|
|
5415
|
+
}
|
|
5416
|
+
const order = ["face", "accent", "ground", "shape", "motion", "opening", "tell", "phrase"];
|
|
5417
|
+
return out.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.runs - a.runs);
|
|
5418
|
+
}
|
|
5419
|
+
function hueSwatch(runs, hue) {
|
|
5420
|
+
const hits = runs.map((r) => r.fingerprint.accent).filter((o) => !!o && hueName(o) === hue);
|
|
5421
|
+
if (hits.length === 0) return null;
|
|
5422
|
+
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);
|
|
5423
|
+
return formatHex(oklchToRgb(centre));
|
|
5424
|
+
}
|
|
5425
|
+
|
|
5426
|
+
// src/fingerprint/component.ts
|
|
5427
|
+
var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
|
|
5428
|
+
function componentsOf(fp, role) {
|
|
5429
|
+
return (fp.sections ?? []).filter((s) => s.role === role);
|
|
5430
|
+
}
|
|
5431
|
+
var clamp2 = (n) => Math.max(0, Math.min(1, n));
|
|
5432
|
+
var diff = (a, b, scale) => a === void 0 || b === void 0 ? null : clamp2(Math.abs(a - b) / scale);
|
|
5433
|
+
function componentDistance(a, b) {
|
|
5434
|
+
if (a.role !== b.role) return 1;
|
|
5435
|
+
const promo = (s) => (s.badges ?? []).some((t) => PROMO_BADGE.test(t));
|
|
5436
|
+
const parts2 = [
|
|
5437
|
+
diff(a.centred, b.centred, 1),
|
|
5438
|
+
diff(a.width, b.width, 0.5),
|
|
5439
|
+
diff(a.symmetry, b.symmetry, 0.5),
|
|
5440
|
+
diff(a.controls, b.controls, 2),
|
|
5441
|
+
diff(a.cards, b.cards, 3),
|
|
5442
|
+
diff(a.figures, b.figures, 3),
|
|
5443
|
+
diff(a.avatars, b.avatars, 3),
|
|
5444
|
+
a.carousel === void 0 || b.carousel === void 0 ? null : a.carousel === b.carousel ? 0 : 1,
|
|
5445
|
+
a.badges === void 0 || b.badges === void 0 ? null : promo(a) === promo(b) ? 0 : 1,
|
|
5446
|
+
a.background === void 0 || b.background === void 0 ? null : a.background === null || b.background === null ? a.background === b.background ? 0 : 1 : clamp2(deltaEOk(a.background, b.background) / 0.3)
|
|
5447
|
+
];
|
|
5448
|
+
const known = parts2.filter((p) => p !== null);
|
|
5449
|
+
return known.length === 0 ? 0 : Math.round(known.reduce((s, p) => s + p, 0) / known.length * 1e3) / 1e3;
|
|
5450
|
+
}
|
|
5451
|
+
function componentShared(a, b) {
|
|
5452
|
+
if (a.role !== b.role) return [];
|
|
5453
|
+
const out = [];
|
|
5454
|
+
const both = (f4) => f4(a) && f4(b);
|
|
5455
|
+
if (both((s) => s.centred >= 0.7)) out.push("centred");
|
|
5456
|
+
else if (both((s) => s.centred < 0.3)) out.push("left-aligned");
|
|
5457
|
+
if (both((s) => s.width <= 0.6)) out.push("narrow column");
|
|
5458
|
+
else if (both((s) => s.width >= 0.8)) out.push("full width");
|
|
5459
|
+
if (a.controls !== void 0 && a.controls === b.controls && a.controls > 0) out.push(`${a.controls} button${a.controls === 1 ? "" : "s"}`);
|
|
5460
|
+
if (a.cards !== void 0 && a.cards === b.cards && a.cards >= 2) out.push(`${a.cards} cards`);
|
|
5461
|
+
if (both((s) => (s.figures ?? 0) >= 3)) out.push("a row of figures");
|
|
5462
|
+
if (both((s) => (s.avatars ?? 0) >= 2)) out.push("avatars");
|
|
5463
|
+
if (both((s) => s.carousel === true)) out.push("carousel");
|
|
5464
|
+
if (both((s) => (s.badges ?? []).some((t) => PROMO_BADGE.test(t)))) out.push("promo badge");
|
|
5465
|
+
if (a.background && b.background && deltaEOk(a.background, b.background) <= 0.06) out.push("same coloured band");
|
|
5466
|
+
else if (a.background === null && b.background === null) out.push("on the page ground");
|
|
5467
|
+
return out;
|
|
5468
|
+
}
|
|
5469
|
+
function closestComponents(a, b, role) {
|
|
5470
|
+
let best = null;
|
|
5471
|
+
for (const x of componentsOf(a, role)) {
|
|
5472
|
+
for (const y of componentsOf(b, role)) {
|
|
5473
|
+
const distance = componentDistance(x, y);
|
|
5474
|
+
if (!best || distance < best.distance) best = { distance, shared: componentShared(x, y) };
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5477
|
+
return best;
|
|
5478
|
+
}
|
|
5479
|
+
function estateComponentPairs(register, role) {
|
|
5480
|
+
const out = [];
|
|
5481
|
+
const s = register.sites;
|
|
5482
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
5483
|
+
for (let j = i + 1; j < s.length; j += 1) {
|
|
5484
|
+
const c = closestComponents(s[i].fingerprint, s[j].fingerprint, role);
|
|
5485
|
+
if (c) out.push({ a: s[i].id, b: s[j].id, ...c });
|
|
5486
|
+
}
|
|
5487
|
+
}
|
|
5488
|
+
return out.sort((x, y) => x.distance - y.distance);
|
|
5489
|
+
}
|
|
5490
|
+
var meanNearest2 = (d, k) => {
|
|
5491
|
+
const s = [...d].sort((a, b) => a - b).slice(0, k);
|
|
5492
|
+
return s.reduce((t, v) => t + v, 0) / s.length;
|
|
5493
|
+
};
|
|
5494
|
+
function componentTypicality(target2, runs, k = 3) {
|
|
5495
|
+
const pool = runs.flatMap((r) => componentsOf(r.fingerprint, target2.role).slice(0, 1));
|
|
5496
|
+
if (pool.length < MIN_RUNS) return null;
|
|
5497
|
+
const kk = Math.min(k, pool.length - 1);
|
|
5498
|
+
const distance = meanNearest2(pool.map((p) => componentDistance(target2, p)), kk);
|
|
5499
|
+
const loo = pool.map((p, i) => meanNearest2(pool.filter((_, j) => j !== i).map((o) => componentDistance(p, o)), kk));
|
|
5500
|
+
const score = loo.filter((d) => d >= distance).length / loo.length;
|
|
5501
|
+
const sorted = [...loo].sort((a, b) => a - b);
|
|
5502
|
+
const mid = Math.floor(sorted.length / 2);
|
|
5503
|
+
const baseline = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
5504
|
+
return {
|
|
5505
|
+
role: target2.role,
|
|
5506
|
+
score: Math.round(score * 100) / 100,
|
|
5507
|
+
typical: score >= TYPICAL_AT,
|
|
5508
|
+
distance: Math.round(distance * 1e3) / 1e3,
|
|
5509
|
+
baseline: Math.round(baseline * 1e3) / 1e3,
|
|
5510
|
+
runs: pool.length
|
|
5511
|
+
};
|
|
5512
|
+
}
|
|
5513
|
+
var COMPONENT_ROLES = ["cta-band", "footer-cta", "pricing", "testimonials", "faq", "process", "features", "team", "contact", "stats"];
|
|
5514
|
+
function componentTypicalities(fp, runs, k = 3) {
|
|
5515
|
+
const out = [];
|
|
5516
|
+
for (const role of COMPONENT_ROLES) {
|
|
5517
|
+
const first = componentsOf(fp, role)[0];
|
|
5518
|
+
const t = first ? componentTypicality(first, runs, k) : null;
|
|
5519
|
+
if (t) out.push(t);
|
|
5520
|
+
}
|
|
5521
|
+
return out;
|
|
5522
|
+
}
|
|
5523
|
+
|
|
5524
|
+
// src/snapshot/rendered-tells.ts
|
|
5525
|
+
var hit2 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
5526
|
+
var GROUND = "rgb(255, 255, 255)";
|
|
5527
|
+
var BAND = "rgb(31, 58, 46)";
|
|
5528
|
+
var geo = (patch = {}) => ({
|
|
5529
|
+
centredShare: 0.1,
|
|
5530
|
+
mirrorSymmetry: 0.6,
|
|
5531
|
+
whitespaceRatio: 0.7,
|
|
5532
|
+
contentWidthRatio: 0.85,
|
|
5533
|
+
background: GROUND,
|
|
5534
|
+
controls: 0,
|
|
5535
|
+
...patch
|
|
5536
|
+
});
|
|
5537
|
+
function withSection(index, patch, roles = []) {
|
|
5538
|
+
const base = makeSnapshot();
|
|
5539
|
+
return {
|
|
5540
|
+
...base,
|
|
5541
|
+
sections: base.sections.map((s, i) => ({ ...s, role: roles[i] ?? s.kind, geometry: geo(), badges: [], avatars: 0, carousel: false, figures: 0, ...i === index ? patch : {} }))
|
|
5542
|
+
};
|
|
5543
|
+
}
|
|
5544
|
+
var measured = (s) => s.geometry !== void 0 && s.role !== void 0;
|
|
5545
|
+
var RENDERED_TELLS = [
|
|
5546
|
+
{
|
|
5547
|
+
id: "cta-band-stock",
|
|
5548
|
+
name: "Stock call-to-action band",
|
|
5549
|
+
generation: 1,
|
|
5550
|
+
severity: "warn",
|
|
5551
|
+
surface: "rendered",
|
|
5552
|
+
why: "A full-width coloured band with a centred heading, one line and one or two buttons is what a model closes every section run with. It asks for the booking the same way on every site.",
|
|
5553
|
+
fix: "Keep the ask, change the band: put it where the decision is made (next to the prices, after the proof), in the client's own words, laid out like the rest of the page rather than as a centred slab.",
|
|
5554
|
+
rendered: {
|
|
5555
|
+
detect: (s) => s.sections.filter((x) => measured(x) && (x.role === "cta-band" || x.role === "footer-cta")).filter((x) => {
|
|
5556
|
+
const g = x.geometry;
|
|
5557
|
+
return g.centredShare >= 0.8 && g.background !== s.ground && g.controls >= 1 && g.controls <= 2 && g.contentWidthRatio <= 0.7;
|
|
5558
|
+
}).map((x) => hit2(s, `a centred ${x.role === "footer-cta" ? "closing " : ""}band on its own colour with ${x.geometry.controls === 1 ? "one button" : "two buttons"}`, x.label)),
|
|
5559
|
+
fixtures: {
|
|
5560
|
+
flag: [
|
|
5561
|
+
withSection(4, { role: "footer-cta", label: "Boiler out today?", geometry: geo({ centredShare: 1, background: BAND, controls: 1, contentWidthRatio: 0.45 }) }),
|
|
5562
|
+
withSection(3, { role: "cta-band", label: "Ready to book?", geometry: geo({ centredShare: 0.9, background: BAND, controls: 2, contentWidthRatio: 0.5 }) })
|
|
5563
|
+
],
|
|
5564
|
+
pass: [
|
|
5565
|
+
makeSnapshot(),
|
|
5566
|
+
// The same ask, laid out like the page: left-aligned on the ground.
|
|
5567
|
+
withSection(4, { role: "footer-cta", label: "Book a visit", geometry: geo({ centredShare: 0.1, background: GROUND, controls: 1, contentWidthRatio: 0.85 }) })
|
|
5568
|
+
]
|
|
5569
|
+
}
|
|
5570
|
+
}
|
|
5571
|
+
},
|
|
5572
|
+
{
|
|
5573
|
+
id: "pricing-trio-popular",
|
|
5574
|
+
name: "Three-tier pricing with a popular badge",
|
|
5575
|
+
generation: 1,
|
|
5576
|
+
severity: "warn",
|
|
5577
|
+
surface: "rendered",
|
|
5578
|
+
why: "Three price cards with the middle one badged 'Most popular' is the SaaS pricing page, applied to a plumber or a salon whether or not anyone chose the middle option.",
|
|
5579
|
+
fix: "Keep the prices. Show them the way the trade quotes (a price list, per job, per visit), and drop the badge unless it is true and the client can say how many people chose it.",
|
|
5580
|
+
rendered: {
|
|
5581
|
+
detect: (s) => s.sections.filter((x) => measured(x) && x.role === "pricing" && x.cards === 3).filter((x) => (x.badges ?? []).some((b) => PROMO_BADGE.test(b))).map((x) => hit2(s, `three price cards, one badged "${(x.badges ?? []).find((b) => PROMO_BADGE.test(b))}"`, x.label)),
|
|
5582
|
+
fixtures: {
|
|
5583
|
+
flag: [withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: ["Most popular"], label: "Plans" })],
|
|
5584
|
+
pass: [
|
|
5585
|
+
makeSnapshot(),
|
|
5586
|
+
withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: [], label: "Prices" }),
|
|
5587
|
+
withSection(3, { role: "pricing", kind: "text", cards: 0, badges: [], label: "Price list" })
|
|
5588
|
+
]
|
|
5589
|
+
}
|
|
5590
|
+
}
|
|
5591
|
+
},
|
|
5592
|
+
{
|
|
5593
|
+
id: "testimonial-avatar-carousel",
|
|
5594
|
+
name: "Avatar testimonial carousel",
|
|
5595
|
+
generation: 1,
|
|
5596
|
+
severity: "warn",
|
|
5597
|
+
surface: "rendered",
|
|
5598
|
+
why: "Round headshots over quotes in a sliding carousel is the stock testimonial block. Most visitors see the first slide only, and stock or AI faces make every quote read as invented.",
|
|
5599
|
+
fix: "Show the reviews all at once, where they come from (the Google rating, the named job, the town), with real photos of the work rather than of the reviewer.",
|
|
5600
|
+
rendered: {
|
|
5601
|
+
detect: (s) => s.sections.filter((x) => measured(x) && x.role === "testimonials" && x.carousel === true && (x.avatars ?? 0) >= 2).map((x) => hit2(s, `${x.avatars} round portraits in a sliding testimonial carousel`, x.label)),
|
|
5602
|
+
fixtures: {
|
|
5603
|
+
flag: [withSection(2, { role: "testimonials", avatars: 3, carousel: true, label: "What our clients say" })],
|
|
5604
|
+
pass: [
|
|
5605
|
+
makeSnapshot(),
|
|
5606
|
+
withSection(2, { role: "testimonials", avatars: 3, carousel: false, label: "Reviews" }),
|
|
5607
|
+
withSection(2, { role: "testimonials", avatars: 0, carousel: true, label: "Reviews" })
|
|
5608
|
+
]
|
|
5609
|
+
}
|
|
5610
|
+
}
|
|
5611
|
+
},
|
|
5612
|
+
{
|
|
5613
|
+
id: "faq-accordion-closer",
|
|
5614
|
+
name: "FAQ accordion as the closer",
|
|
5615
|
+
generation: 2,
|
|
5616
|
+
severity: "warn",
|
|
5617
|
+
surface: "rendered",
|
|
5618
|
+
why: "Ending the page on a collapsed FAQ, often followed only by a band, is the model's default page ending. The answers a buyer needs are hidden behind clicks at the point they decide.",
|
|
5619
|
+
fix: "Answer the questions that block a booking where they arise (price next to prices, area next to the map), and end on the call to action or the proof.",
|
|
5620
|
+
rendered: {
|
|
5621
|
+
detect: (s) => {
|
|
5622
|
+
const body = s.sections.filter((x) => x.role !== "footer-cta" && x.role !== "cta-band");
|
|
5623
|
+
const last = body[body.length - 1];
|
|
5624
|
+
return last && measured(last) && last.role === "faq" ? [hit2(s, "the page ends on a question-and-answer list", last.label)] : [];
|
|
5625
|
+
},
|
|
5626
|
+
fixtures: {
|
|
5627
|
+
flag: [
|
|
5628
|
+
withSection(4, { role: "faq", label: "Common questions" }),
|
|
5629
|
+
{
|
|
5630
|
+
...withSection(3, { role: "faq", label: "FAQ" }),
|
|
5631
|
+
sections: withSection(3, { role: "faq", label: "FAQ" }).sections.map((x, i) => i === 4 ? { ...x, role: "footer-cta" } : x)
|
|
5632
|
+
}
|
|
5633
|
+
],
|
|
5634
|
+
pass: [makeSnapshot(), withSection(2, { role: "faq", label: "Common questions" })]
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
},
|
|
5638
|
+
{
|
|
5639
|
+
id: "stats-row",
|
|
5640
|
+
name: "Row of big numbers",
|
|
5641
|
+
generation: 1,
|
|
5642
|
+
severity: "warn",
|
|
5643
|
+
surface: "rendered",
|
|
5644
|
+
why: "Three or four large figures in a row (500+ clients, 98% satisfaction, 10 years) is the stock proof block, and the numbers are often round, unsourced or invented.",
|
|
5645
|
+
fix: "Keep a figure only if it is true and sourced, and put it in a sentence next to what it proves. One real number in context beats four in a row.",
|
|
5646
|
+
rendered: {
|
|
5647
|
+
detect: (s) => s.sections.filter((x, i) => measured(x) && (x.role === "stats" || x.kind === "stats") && (x.figures ?? 0) >= 3 && (x.figures ?? 0) <= 6 && !(i === 1 && s.sections[0]?.kind === "hero")).map((x) => hit2(s, `${x.figures} large figures in a row`, x.label)),
|
|
5648
|
+
fixtures: {
|
|
5649
|
+
flag: [withSection(3, { role: "stats", kind: "stats", figures: 4, label: "500+" })],
|
|
5650
|
+
pass: [
|
|
5651
|
+
makeSnapshot(),
|
|
5652
|
+
withSection(3, { role: "text", kind: "text", figures: 1, label: "Since 2009" }),
|
|
5653
|
+
withSection(1, { role: "stats", kind: "stats", figures: 4, label: "500+" })
|
|
5654
|
+
]
|
|
5655
|
+
}
|
|
5656
|
+
}
|
|
5657
|
+
},
|
|
5658
|
+
{
|
|
5659
|
+
id: "centred-everything",
|
|
5660
|
+
name: "Every section centred",
|
|
5661
|
+
generation: 2,
|
|
5662
|
+
severity: "warn",
|
|
5663
|
+
surface: "rendered",
|
|
5664
|
+
why: "When almost every section stacks a centred heading over centred text, the page has no reading line and every block looks like the one before. It is the layout nothing was decided for.",
|
|
5665
|
+
fix: "Pick an alignment and hold it: left-aligned text with a clear edge, keeping centring for the one moment that earns it.",
|
|
5666
|
+
rendered: {
|
|
5667
|
+
detect: (s) => {
|
|
5668
|
+
const m = s.sections.filter(measured);
|
|
5669
|
+
if (m.length < 4) return [];
|
|
5670
|
+
const centred = m.filter((x) => x.geometry.centredShare >= 0.7).length;
|
|
5671
|
+
return centred / m.length >= 0.8 ? [hit2(s, `${centred} of ${m.length} sections centre their text`)] : [];
|
|
5672
|
+
},
|
|
5673
|
+
fixtures: {
|
|
5674
|
+
flag: [{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x) => ({ ...x, role: x.kind, geometry: geo({ centredShare: 0.9 }) })) }],
|
|
5675
|
+
pass: [
|
|
5676
|
+
makeSnapshot(),
|
|
5677
|
+
{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x, i) => ({ ...x, role: x.kind, geometry: geo({ centredShare: i < 2 ? 0.9 : 0.1 }) })) }
|
|
5678
|
+
]
|
|
5679
|
+
}
|
|
5680
|
+
}
|
|
5681
|
+
}
|
|
5682
|
+
];
|
|
5683
|
+
|
|
5684
|
+
// src/snapshot/imagery-tells.ts
|
|
5685
|
+
var hit3 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
5686
|
+
var STOCK_PHOTO_HOSTS = {
|
|
5687
|
+
"unsplash.com": "Unsplash",
|
|
5688
|
+
"pexels.com": "Pexels",
|
|
5689
|
+
"pixabay.com": "Pixabay",
|
|
5690
|
+
"istockphoto.com": "iStock",
|
|
5691
|
+
"gettyimages.com": "Getty Images",
|
|
5692
|
+
"shutterstock.com": "Shutterstock",
|
|
5693
|
+
"ftcdn.net": "Adobe Stock",
|
|
5694
|
+
"dreamstime.com": "Dreamstime",
|
|
5695
|
+
"depositphotos.com": "Depositphotos",
|
|
5696
|
+
"freepik.com": "Freepik",
|
|
5697
|
+
"rawpixel.com": "rawpixel",
|
|
5698
|
+
"stocksnap.io": "StockSnap",
|
|
5699
|
+
"burst.shopifycdn.com": "Burst",
|
|
5700
|
+
"picsum.photos": "Lorem Picsum",
|
|
5701
|
+
"loremflickr.com": "LoremFlickr",
|
|
5702
|
+
"placehold.co": "a placeholder service",
|
|
5703
|
+
"placeholder.com": "a placeholder service",
|
|
5704
|
+
"dummyimage.com": "a placeholder service"
|
|
5705
|
+
};
|
|
5706
|
+
var STOCK_AVATAR_HOSTS = {
|
|
5707
|
+
"randomuser.me": "randomuser.me",
|
|
5708
|
+
"pravatar.cc": "Pravatar",
|
|
5709
|
+
"dicebear.com": "DiceBear",
|
|
5710
|
+
"xsgames.co": "a random-face service",
|
|
5711
|
+
"thispersondoesnotexist.com": "a generated face",
|
|
5712
|
+
"generated.photos": "a generated face"
|
|
5713
|
+
};
|
|
5714
|
+
var STOCK_FILENAMES = [
|
|
5715
|
+
[/(?:^|[/_-])shutterstock[_-]?\d{4,}/i, "Shutterstock"],
|
|
5716
|
+
[/(?:^|[/_-])istock(?:photo)?[_-]?\d{4,}/i, "iStock"],
|
|
5717
|
+
[/(?:^|[/_-])adobestock[_-]?\d{4,}/i, "Adobe Stock"],
|
|
5718
|
+
[/(?:^|[/_-])gettyimages[_-]?\d{4,}/i, "Getty Images"],
|
|
5719
|
+
[/(?:^|[/_-])depositphotos[_-]?\d{4,}/i, "Depositphotos"],
|
|
5720
|
+
[/(?:^|[/_-])dreamstime[_-]/i, "Dreamstime"],
|
|
5721
|
+
[/(?:^|[/_-])pexels-[a-z0-9-]*\d{4,}/i, "Pexels"],
|
|
5722
|
+
[/-unsplash\.(?:jpe?g|png|webp|avif)(?:[?#]|$)/i, "Unsplash"],
|
|
5723
|
+
// Unsplash's photo ids: photo-<13-digit timestamp>-<12 hex>.
|
|
5724
|
+
[/photo-1\d{12}-[0-9a-f]{12}/, "Unsplash"]
|
|
5725
|
+
];
|
|
5726
|
+
var byHost = (host, table) => {
|
|
5727
|
+
if (!host) return null;
|
|
5728
|
+
const h = host.toLowerCase();
|
|
5729
|
+
for (const [domain, name] of Object.entries(table)) if (h === domain || h.endsWith(`.${domain}`)) return name;
|
|
5730
|
+
return null;
|
|
5731
|
+
};
|
|
5732
|
+
function stockSource(image) {
|
|
5733
|
+
const host = byHost(image.host, STOCK_PHOTO_HOSTS);
|
|
5734
|
+
if (host) return host;
|
|
5735
|
+
let path = image.src.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, "").replace(/[?#].*$/, "");
|
|
5736
|
+
try {
|
|
5737
|
+
path = decodeURIComponent(path);
|
|
5738
|
+
} catch {
|
|
5739
|
+
}
|
|
5740
|
+
for (const [re, name] of STOCK_FILENAMES) if (re.test(path)) return name;
|
|
5741
|
+
return null;
|
|
5742
|
+
}
|
|
5743
|
+
function stockAvatarSource(image) {
|
|
5744
|
+
return byHost(image.host, STOCK_AVATAR_HOSTS) ?? stockSource(image);
|
|
5745
|
+
}
|
|
5746
|
+
var picture = (patch = {}) => ({
|
|
5747
|
+
src: "https://example.test/images/boiler-swap-hinton.jpg",
|
|
5748
|
+
host: "example.test",
|
|
5749
|
+
width: 640,
|
|
5750
|
+
height: 480,
|
|
5751
|
+
top: 1800,
|
|
5752
|
+
role: "photo",
|
|
5753
|
+
alt: "The new combi boiler fitted in a kitchen cupboard in Hinton",
|
|
5754
|
+
decorative: false,
|
|
5755
|
+
section: 2,
|
|
5756
|
+
background: false,
|
|
5757
|
+
...patch
|
|
5758
|
+
});
|
|
5759
|
+
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
5760
|
+
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
5761
|
+
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
5762
|
+
var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
5763
|
+
var IMAGERY_TELLS = [
|
|
5764
|
+
{
|
|
5765
|
+
id: "stock-photo",
|
|
5766
|
+
name: "Stock photography",
|
|
5767
|
+
generation: 1,
|
|
5768
|
+
severity: "warn",
|
|
5769
|
+
surface: "rendered",
|
|
5770
|
+
why: "A picture from a stock library is the picture every other site in the trade can use. It shows a job the client did not do, in a kitchen that is not theirs, and a visitor who has seen it before stops believing the rest.",
|
|
5771
|
+
fix: "Use a photo of the client's own work, premises or people, even a plain one taken on a phone. Where there is none yet, use fewer pictures rather than a library one.",
|
|
5772
|
+
rendered: {
|
|
5773
|
+
detect: (s) => {
|
|
5774
|
+
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
5775
|
+
if (found.length === 0) return [];
|
|
5776
|
+
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
5777
|
+
return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
5778
|
+
},
|
|
5779
|
+
fixtures: {
|
|
5780
|
+
flag: [
|
|
5781
|
+
withImages([picture({ src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=1600", host: "images.unsplash.com", alt: "Plumber fixing a sink" })]),
|
|
5782
|
+
// Downloaded and self-hosted, found by the library's own file name.
|
|
5783
|
+
withImages([picture({ src: "https://example.test/img/shutterstock_1234567890.jpg" })])
|
|
5784
|
+
],
|
|
5785
|
+
pass: [makeSnapshot(), withImages([picture()]), withImages([picture({ role: "icon", width: 48, height: 48, src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952", host: "images.unsplash.com" })])]
|
|
5786
|
+
}
|
|
5787
|
+
}
|
|
5788
|
+
},
|
|
5789
|
+
{
|
|
5790
|
+
id: "stock-avatar",
|
|
5791
|
+
name: "Stock or placeholder faces",
|
|
5792
|
+
generation: 1,
|
|
5793
|
+
severity: "warn",
|
|
5794
|
+
surface: "rendered",
|
|
5795
|
+
why: "Round portraits from a placeholder-face service or a stock library next to reviews make every quote read as invented, and some of those faces are generated people who do not exist.",
|
|
5796
|
+
fix: "Drop the portraits. Name the reviewer, the town and the job, and link the review to where it was left.",
|
|
5797
|
+
rendered: {
|
|
5798
|
+
detect: (s) => {
|
|
5799
|
+
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
5800
|
+
if (found.length === 0) return [];
|
|
5801
|
+
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
5802
|
+
return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
5803
|
+
},
|
|
5804
|
+
fixtures: {
|
|
5805
|
+
flag: [
|
|
5806
|
+
withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://i.pravatar.cc/150?img=12", host: "i.pravatar.cc", alt: "Sarah" })]),
|
|
5807
|
+
withImages([picture({ role: "avatar", width: 64, height: 64, src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?crop=faces", host: "images.unsplash.com" })])
|
|
5808
|
+
],
|
|
5809
|
+
pass: [makeSnapshot(), withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://example.test/team/sam.jpg", alt: "Sam, engineer" })])]
|
|
5810
|
+
}
|
|
5811
|
+
}
|
|
4423
5812
|
},
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
5813
|
+
{
|
|
5814
|
+
id: "ai-image",
|
|
5815
|
+
name: "Generated image",
|
|
5816
|
+
generation: 2,
|
|
5817
|
+
severity: "warn",
|
|
5818
|
+
surface: "rendered",
|
|
5819
|
+
why: "The file itself says a model made it (its XMP or Content Credentials name a trained-model source). A generated picture stands in for work the client did not photograph, and anyone can read the label.",
|
|
5820
|
+
fix: "Replace it with a photo of the real thing: the work, the premises, the people. Where there is none yet, use fewer pictures.",
|
|
5821
|
+
rendered: {
|
|
5822
|
+
detect: (s) => {
|
|
5823
|
+
const found = (s.images ?? []).filter((i) => i.provenance?.digitalSourceType);
|
|
5824
|
+
if (found.length === 0) return [];
|
|
5825
|
+
const first = found[0].provenance;
|
|
5826
|
+
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
5827
|
+
return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
5828
|
+
},
|
|
5829
|
+
fixtures: {
|
|
5830
|
+
flag: [
|
|
5831
|
+
withImages([picture({ provenance: AI_XMP })]),
|
|
5832
|
+
withImages([picture({ provenance: { digitalSourceType: "compositeWithTrainedAlgorithmicMedia", source: "c2pa", c2pa: true, bytes: 12e4 } })])
|
|
5833
|
+
],
|
|
5834
|
+
// Credentials that say nothing about a model, and an image never read, are not findings.
|
|
5835
|
+
pass: [makeSnapshot(), withImages([picture({ provenance: NOTHING })]), withImages([picture({ provenance: { ...NOTHING, c2pa: true } })]), withImages([picture()])]
|
|
5836
|
+
}
|
|
5837
|
+
}
|
|
4427
5838
|
},
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
5839
|
+
{
|
|
5840
|
+
id: "no-real-imagery",
|
|
5841
|
+
name: "No photograph on the page",
|
|
5842
|
+
generation: 1,
|
|
5843
|
+
severity: "warn",
|
|
5844
|
+
surface: "rendered",
|
|
5845
|
+
why: "A page with no photograph at all, only icons and illustrations, is the page a model builds when it has no assets. Icon tiles stand in for the evidence a buyer looks for: the work, the premises, the people.",
|
|
5846
|
+
fix: "Add one real photo of the work where the claim is made: a finished job next to the service, the team next to the booking. One plain photo beats any number of icons.",
|
|
5847
|
+
rendered: {
|
|
5848
|
+
detect: (s) => {
|
|
5849
|
+
if (!s.images || s.sections.length < 4) return [];
|
|
5850
|
+
if (s.images.some((i) => i.role === "photo")) return [];
|
|
5851
|
+
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
5852
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
5853
|
+
},
|
|
5854
|
+
fixtures: {
|
|
5855
|
+
flag: [
|
|
5856
|
+
withImages([picture({ role: "icon", width: 48, height: 48, src: "https://example.test/icons/wrench.svg" }), picture({ role: "illustration", width: 480, height: 360, src: "https://example.test/hero.svg" })]),
|
|
5857
|
+
withImages([])
|
|
5858
|
+
],
|
|
5859
|
+
// A snapshot with no image record is never judged; nor is a page too short to be a site.
|
|
5860
|
+
pass: [makeSnapshot(), withImages([picture()]), { ...withImages([]), sections: makeSnapshot().sections.slice(0, 3) }]
|
|
5861
|
+
}
|
|
5862
|
+
}
|
|
4431
5863
|
}
|
|
4432
|
-
|
|
5864
|
+
];
|
|
4433
5865
|
|
|
4434
5866
|
// src/character/check.ts
|
|
4435
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
4436
|
-
var CATALOGUE = [
|
|
4437
|
-
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
4438
|
-
|
|
5867
|
+
var CATALOGUE_VERSION = "2026.09.12";
|
|
5868
|
+
var CATALOGUE = [
|
|
5869
|
+
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
5870
|
+
...SPECIFICITY_COPY_TELLS,
|
|
5871
|
+
...RENDERED_TELLS,
|
|
5872
|
+
...IMAGERY_TELLS,
|
|
5873
|
+
...SPECIFICITY_RENDERED_TELLS
|
|
5874
|
+
];
|
|
4439
5875
|
function tellById(id) {
|
|
4440
5876
|
return CATALOGUE.find((t) => t.id === id);
|
|
4441
5877
|
}
|
|
@@ -4457,17 +5893,17 @@ var MARKER = /\b(?:copy|craft)-ok\b/;
|
|
|
4457
5893
|
function marked(lines, line) {
|
|
4458
5894
|
return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
|
|
4459
5895
|
}
|
|
4460
|
-
function toFinding(tell,
|
|
4461
|
-
const text = texts.get(
|
|
5896
|
+
function toFinding(tell, hit4, texts) {
|
|
5897
|
+
const text = texts.get(hit4.path) ?? "";
|
|
4462
5898
|
return {
|
|
4463
5899
|
tell: tell.id,
|
|
4464
5900
|
name: tell.name,
|
|
4465
5901
|
generation: tell.generation,
|
|
4466
5902
|
severity: tell.severity,
|
|
4467
|
-
path:
|
|
4468
|
-
line: texts.has(
|
|
4469
|
-
excerpt:
|
|
4470
|
-
message:
|
|
5903
|
+
path: hit4.path,
|
|
5904
|
+
line: texts.has(hit4.path) ? lineAt(text, hit4.offset) : 0,
|
|
5905
|
+
excerpt: hit4.excerpt ?? excerptAt(text, hit4.offset),
|
|
5906
|
+
message: hit4.message,
|
|
4471
5907
|
fix: tell.fix,
|
|
4472
5908
|
...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
|
|
4473
5909
|
};
|
|
@@ -4486,14 +5922,14 @@ function report(tells, files, run, options) {
|
|
|
4486
5922
|
};
|
|
4487
5923
|
const excepted = /* @__PURE__ */ new Map();
|
|
4488
5924
|
for (const tell of selected) {
|
|
4489
|
-
for (const
|
|
4490
|
-
const exception = applied.find((e) => e.tell === tell.id && (!e.path ||
|
|
5925
|
+
for (const hit4 of run(tell)) {
|
|
5926
|
+
const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit4.path.includes(e.path)));
|
|
4491
5927
|
if (exception) {
|
|
4492
5928
|
excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
|
|
4493
5929
|
continue;
|
|
4494
5930
|
}
|
|
4495
|
-
const finding = toFinding(tell,
|
|
4496
|
-
if (marked(linesOf(
|
|
5931
|
+
const finding = toFinding(tell, hit4, texts);
|
|
5932
|
+
if (marked(linesOf(hit4.path), finding.line)) {
|
|
4497
5933
|
suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
|
|
4498
5934
|
continue;
|
|
4499
5935
|
}
|
|
@@ -4511,7 +5947,7 @@ function report(tells, files, run, options) {
|
|
|
4511
5947
|
catalogueVersion: CATALOGUE_VERSION,
|
|
4512
5948
|
findings,
|
|
4513
5949
|
rejectedExceptions: rejected,
|
|
4514
|
-
excepted: [...excepted].map(([e,
|
|
5950
|
+
excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
|
|
4515
5951
|
suppressed,
|
|
4516
5952
|
summary: {
|
|
4517
5953
|
files: files.length,
|
|
@@ -4547,11 +5983,12 @@ function auditSnapshot(snapshot, options = {}) {
|
|
|
4547
5983
|
}
|
|
4548
5984
|
function runTell(tell, files) {
|
|
4549
5985
|
if (tell.surface === "source") return tell.detect({ files: files.map(parseFile) });
|
|
5986
|
+
if (tell.surface === "rendered") throw new Error(`${tell.id} is seen on a rendered page only; run tell.rendered.detect(snapshot)`);
|
|
4550
5987
|
return tell.detect(copyContext(files));
|
|
4551
5988
|
}
|
|
4552
5989
|
function catalogueTable() {
|
|
4553
5990
|
const rows = CATALOGUE.map(
|
|
4554
|
-
(t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
|
|
5991
|
+
(t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered && t.surface !== "rendered" ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
|
|
4555
5992
|
);
|
|
4556
5993
|
return [
|
|
4557
5994
|
`Catalogue version \`${CATALOGUE_VERSION}\`, ${CATALOGUE.length} tells.`,
|
|
@@ -4616,87 +6053,6 @@ function formatReport(report2, title) {
|
|
|
4616
6053
|
return lines.join("\n").replace(/^\n/, "");
|
|
4617
6054
|
}
|
|
4618
6055
|
|
|
4619
|
-
// src/character/facts.ts
|
|
4620
|
-
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)?)";
|
|
4621
|
-
var PATTERNS = [
|
|
4622
|
-
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
4623
|
-
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
4624
|
-
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
4625
|
-
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
4626
|
-
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
4627
|
-
["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")],
|
|
4628
|
-
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
4629
|
-
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
4630
|
-
];
|
|
4631
|
-
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
4632
|
-
var normalise = (kind, value) => {
|
|
4633
|
-
const v = value.trim();
|
|
4634
|
-
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
4635
|
-
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
4636
|
-
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
4637
|
-
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
4638
|
-
return v.replace(/\s+/g, " ").toLowerCase();
|
|
4639
|
-
};
|
|
4640
|
-
function visibleText(file) {
|
|
4641
|
-
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
4642
|
-
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
4643
|
-
}
|
|
4644
|
-
function protectedFacts(text) {
|
|
4645
|
-
const facts = [];
|
|
4646
|
-
let rest = text;
|
|
4647
|
-
const mask = (start, length) => {
|
|
4648
|
-
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
4649
|
-
};
|
|
4650
|
-
for (const [kind, pattern] of PATTERNS) {
|
|
4651
|
-
for (const m of [...rest.matchAll(pattern)]) {
|
|
4652
|
-
const value = m[0].replace(/[.,;:]+$/, "");
|
|
4653
|
-
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
4654
|
-
mask(m.index ?? 0, m[0].length);
|
|
4655
|
-
}
|
|
4656
|
-
}
|
|
4657
|
-
const lines = rest.split("\n");
|
|
4658
|
-
for (const line of lines) {
|
|
4659
|
-
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
4660
|
-
for (const m of line.matchAll(NAME)) {
|
|
4661
|
-
const at = m.index ?? 0;
|
|
4662
|
-
const before = line.slice(0, at).trimEnd();
|
|
4663
|
-
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
4664
|
-
const word = m[0];
|
|
4665
|
-
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
4666
|
-
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
4667
|
-
if (word === "I") continue;
|
|
4668
|
-
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
4669
|
-
}
|
|
4670
|
-
}
|
|
4671
|
-
return facts;
|
|
4672
|
-
}
|
|
4673
|
-
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
4674
|
-
function compareFacts(before, after) {
|
|
4675
|
-
const a = unique(protectedFacts(before));
|
|
4676
|
-
const b = unique(protectedFacts(after));
|
|
4677
|
-
const inA = new Set(a.map((f4) => f4.key));
|
|
4678
|
-
const inB = new Set(b.map((f4) => f4.key));
|
|
4679
|
-
return {
|
|
4680
|
-
before: a.length,
|
|
4681
|
-
after: b.length,
|
|
4682
|
-
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
4683
|
-
added: b.filter((f4) => !inA.has(f4.key))
|
|
4684
|
-
};
|
|
4685
|
-
}
|
|
4686
|
-
function formatComparison(result, beforePath, afterPath) {
|
|
4687
|
-
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
4688
|
-
if (result.lost.length) {
|
|
4689
|
-
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
4690
|
-
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4691
|
-
}
|
|
4692
|
-
if (result.added.length) {
|
|
4693
|
-
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
4694
|
-
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4695
|
-
}
|
|
4696
|
-
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
4697
|
-
return lines.join("\n");
|
|
4698
|
-
}
|
|
4699
|
-
|
|
4700
6056
|
// src/character/claims.ts
|
|
4701
6057
|
var CLAIM_KINDS = /* @__PURE__ */ new Set(["money", "number", "date"]);
|
|
4702
6058
|
var PROPER = "[A-Z][\\w&.\u2019'-]*(?:\\s+(?:of|and|&|the|for)?\\s*[A-Z][\\w&.\u2019'-]*)*";
|
|
@@ -4999,6 +6355,41 @@ function toJson(data) {
|
|
|
4999
6355
|
return JSON.stringify({ schemaVersion: JSON_SCHEMA_VERSION, ...data }, null, 2);
|
|
5000
6356
|
}
|
|
5001
6357
|
|
|
6358
|
+
// src/snapshot/provenance.ts
|
|
6359
|
+
var PROVENANCE_SCAN_BYTES = 256 * 1024;
|
|
6360
|
+
var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
|
|
6361
|
+
var MARKER2 = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
|
|
6362
|
+
var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
|
|
6363
|
+
var C2PA_PNG = /caBX/;
|
|
6364
|
+
function latin1(bytes) {
|
|
6365
|
+
let out = "";
|
|
6366
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
6367
|
+
out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
|
|
6368
|
+
}
|
|
6369
|
+
return out;
|
|
6370
|
+
}
|
|
6371
|
+
function xmpPackets(text) {
|
|
6372
|
+
const out = [];
|
|
6373
|
+
const open = /<x:xmpmeta\b|<\?xpacket begin/g;
|
|
6374
|
+
let m;
|
|
6375
|
+
while (m = open.exec(text)) {
|
|
6376
|
+
const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
|
|
6377
|
+
out.push([m.index, close === -1 ? text.length : close]);
|
|
6378
|
+
if (close === -1) break;
|
|
6379
|
+
open.lastIndex = close;
|
|
6380
|
+
}
|
|
6381
|
+
return out;
|
|
6382
|
+
}
|
|
6383
|
+
function scanProvenance(bytes) {
|
|
6384
|
+
const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
|
|
6385
|
+
const text = latin1(head);
|
|
6386
|
+
const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
|
|
6387
|
+
const m = MARKER2.exec(text);
|
|
6388
|
+
if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
|
|
6389
|
+
const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
|
|
6390
|
+
return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
|
|
6391
|
+
}
|
|
6392
|
+
|
|
5002
6393
|
// src/snapshot/migrate.ts
|
|
5003
6394
|
var READABLE_SNAPSHOT_VERSIONS = [1, 2];
|
|
5004
6395
|
function readSnapshot(data, source = "snapshot") {
|
|
@@ -5040,143 +6431,34 @@ function visualMeasures(img, maxSamples = 1e6) {
|
|
|
5040
6431
|
const yb = 0.5 * (r + g) - b;
|
|
5041
6432
|
sumRg += rg;
|
|
5042
6433
|
sumYb += yb;
|
|
5043
|
-
sumRg2 += rg * rg;
|
|
5044
|
-
sumYb2 += yb * yb;
|
|
5045
|
-
n += 1;
|
|
5046
|
-
const l = lum(x, y);
|
|
5047
|
-
if (x + step < w && y + step < h) {
|
|
5048
|
-
if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
|
|
5049
|
-
edgeN += 1;
|
|
5050
|
-
}
|
|
5051
|
-
if (x < w / 2) {
|
|
5052
|
-
asym += Math.abs(l - lum(w - 1 - x, y));
|
|
5053
|
-
asymN += 1;
|
|
5054
|
-
}
|
|
5055
|
-
}
|
|
5056
|
-
}
|
|
5057
|
-
const meanRg = sumRg / n;
|
|
5058
|
-
const meanYb = sumYb / n;
|
|
5059
|
-
const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
|
|
5060
|
-
const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
|
|
5061
|
-
const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
|
|
5062
|
-
const round2 = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
|
|
5063
|
-
return {
|
|
5064
|
-
colourfulness: round2(colourfulness, 1),
|
|
5065
|
-
edgeDensity: round2(edgeN ? edges / edgeN : 0),
|
|
5066
|
-
symmetry: round2(asymN ? 1 - asym / asymN / 255 : 1),
|
|
5067
|
-
width: w,
|
|
5068
|
-
height: h,
|
|
5069
|
-
sampled: n
|
|
5070
|
-
};
|
|
5071
|
-
}
|
|
5072
|
-
|
|
5073
|
-
// src/fingerprint/index.ts
|
|
5074
|
-
var FINGERPRINT_VERSION = 2;
|
|
5075
|
-
var CHROMATIC = 0.03;
|
|
5076
|
-
var MIN_ACCENT_AREA = 2e4;
|
|
5077
|
-
var oklch = (value) => {
|
|
5078
|
-
const parsed = parseColour(value);
|
|
5079
|
-
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
5080
|
-
};
|
|
5081
|
-
function accentOf(s) {
|
|
5082
|
-
const buttons = /* @__PURE__ */ new Map();
|
|
5083
|
-
for (const c of s.controls) {
|
|
5084
|
-
const o = oklch(c.background);
|
|
5085
|
-
if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
|
|
5086
|
-
}
|
|
5087
|
-
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
5088
|
-
if (topButton) return oklch(topButton[0]);
|
|
5089
|
-
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];
|
|
5090
|
-
if (bg) return bg.o;
|
|
5091
|
-
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];
|
|
5092
|
-
return text ? text.o : null;
|
|
5093
|
-
}
|
|
5094
|
-
function faces(s) {
|
|
5095
|
-
const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
|
|
5096
|
-
const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
|
|
5097
|
-
const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
|
|
5098
|
-
const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
|
|
5099
|
-
return {
|
|
5100
|
-
display: { family: displayName, class: fontClass(displayName) },
|
|
5101
|
-
body: { family: bodyName, class: fontClass(bodyName) }
|
|
5102
|
-
};
|
|
5103
|
-
}
|
|
5104
|
-
function median(values) {
|
|
5105
|
-
if (values.length === 0) return null;
|
|
5106
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
5107
|
-
const mid = Math.floor(sorted.length / 2);
|
|
5108
|
-
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
5109
|
-
}
|
|
5110
|
-
function fingerprint(s) {
|
|
5111
|
-
const effects = [];
|
|
5112
|
-
if (s.effects.glass > 0) effects.push("glass-panel");
|
|
5113
|
-
if (s.effects.gradientText > 0) effects.push("gradient-text");
|
|
5114
|
-
if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
|
|
5115
|
-
if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
|
|
5116
|
-
if (s.effects.marquees > 0) effects.push("marquee");
|
|
5117
|
-
if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
|
|
5118
|
-
if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
|
|
5119
|
-
if (s.motion.introOverlay) effects.push("intro-cinematic");
|
|
5120
|
-
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
5121
|
-
return {
|
|
5122
|
-
version: FINGERPRINT_VERSION,
|
|
5123
|
-
accent: accentOf(s),
|
|
5124
|
-
ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
|
|
5125
|
-
...faces(s),
|
|
5126
|
-
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
5127
|
-
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
5128
|
-
effects: effects.sort(),
|
|
5129
|
-
layout: s.sections.map((x) => x.kind),
|
|
5130
|
-
...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? { sections: s.sections.map((x) => ({ role: x.role, centred: x.geometry.centredShare, width: x.geometry.contentWidthRatio })) } : {}
|
|
5131
|
-
};
|
|
5132
|
-
}
|
|
5133
|
-
var DISTANCE_WEIGHTS = { accent: 0.25, type: 0.25, ground: 0.1, shape: 0.1, motion: 0.1, effects: 0.1, layout: 0.1 };
|
|
5134
|
-
function levenshtein(a, b, cost = (x, y) => x === y ? 0 : 1) {
|
|
5135
|
-
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
5136
|
-
for (let i = 1; i <= a.length; i += 1) {
|
|
5137
|
-
let prev = row[0];
|
|
5138
|
-
row[0] = i;
|
|
5139
|
-
for (let j = 1; j <= b.length; j += 1) {
|
|
5140
|
-
const cur = row[j];
|
|
5141
|
-
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost(a[i - 1], b[j - 1]));
|
|
5142
|
-
prev = cur;
|
|
5143
|
-
}
|
|
5144
|
-
}
|
|
5145
|
-
return row[b.length];
|
|
5146
|
-
}
|
|
5147
|
-
function sectionCost(x, y) {
|
|
5148
|
-
if (x.role !== y.role) return 1;
|
|
5149
|
-
return Math.min(0.5, (Math.abs(x.centred - y.centred) + Math.abs(x.width - y.width)) / 2);
|
|
5150
|
-
}
|
|
5151
|
-
function layoutDistance(a, b) {
|
|
5152
|
-
if (a.sections && b.sections) {
|
|
5153
|
-
const longest2 = Math.max(a.sections.length, b.sections.length);
|
|
5154
|
-
return longest2 === 0 ? 0 : levenshtein(a.sections, b.sections, sectionCost) / longest2;
|
|
5155
|
-
}
|
|
5156
|
-
const longest = Math.max(a.layout.length, b.layout.length);
|
|
5157
|
-
return longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
|
|
5158
|
-
}
|
|
5159
|
-
var clamp = (n) => Math.max(0, Math.min(1, n));
|
|
5160
|
-
function faceDistance(a, b) {
|
|
5161
|
-
if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
|
|
5162
|
-
return a.class === b.class ? 0.5 : 1;
|
|
5163
|
-
}
|
|
5164
|
-
function fingerprintDistance(a, b) {
|
|
5165
|
-
const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / 0.3) : a.accent || b.accent ? 1 : 0;
|
|
5166
|
-
const ground = clamp(deltaEOk(a.ground, b.ground) / 0.15);
|
|
5167
|
-
const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
|
|
5168
|
-
const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
|
|
5169
|
-
const motion = clamp(Math.abs(a.motion - b.motion));
|
|
5170
|
-
const union = /* @__PURE__ */ new Set([...a.effects, ...b.effects]);
|
|
5171
|
-
const shared = a.effects.filter((e) => b.effects.includes(e)).length;
|
|
5172
|
-
const effects = union.size === 0 ? 0 : 1 - shared / union.size;
|
|
5173
|
-
const layout = layoutDistance(a, b);
|
|
5174
|
-
const parts2 = { accent, ground, type, shape, motion, effects, layout };
|
|
5175
|
-
const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts2[k], 0);
|
|
5176
|
-
return { total: Math.round(total * 1e3) / 1e3, parts: parts2 };
|
|
5177
|
-
}
|
|
5178
|
-
function nearest(target2, set, k = set.length) {
|
|
5179
|
-
return set.map((item) => ({ id: item.id, distance: fingerprintDistance(target2, item.fingerprint) })).sort((x, y) => x.distance.total - y.distance.total).slice(0, k);
|
|
6434
|
+
sumRg2 += rg * rg;
|
|
6435
|
+
sumYb2 += yb * yb;
|
|
6436
|
+
n += 1;
|
|
6437
|
+
const l = lum(x, y);
|
|
6438
|
+
if (x + step < w && y + step < h) {
|
|
6439
|
+
if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
|
|
6440
|
+
edgeN += 1;
|
|
6441
|
+
}
|
|
6442
|
+
if (x < w / 2) {
|
|
6443
|
+
asym += Math.abs(l - lum(w - 1 - x, y));
|
|
6444
|
+
asymN += 1;
|
|
6445
|
+
}
|
|
6446
|
+
}
|
|
6447
|
+
}
|
|
6448
|
+
const meanRg = sumRg / n;
|
|
6449
|
+
const meanYb = sumYb / n;
|
|
6450
|
+
const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
|
|
6451
|
+
const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
|
|
6452
|
+
const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
|
|
6453
|
+
const round2 = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
|
|
6454
|
+
return {
|
|
6455
|
+
colourfulness: round2(colourfulness, 1),
|
|
6456
|
+
edgeDensity: round2(edgeN ? edges / edgeN : 0),
|
|
6457
|
+
symmetry: round2(asymN ? 1 - asym / asymN / 255 : 1),
|
|
6458
|
+
width: w,
|
|
6459
|
+
height: h,
|
|
6460
|
+
sampled: n
|
|
6461
|
+
};
|
|
5180
6462
|
}
|
|
5181
6463
|
|
|
5182
6464
|
// src/direction/types.ts
|
|
@@ -5313,9 +6595,9 @@ function licencesJson() {
|
|
|
5313
6595
|
var PREFERENCE = /\b(?:client (?:likes|liked|wants|wanted|asked|chose|prefers?)|(?:we|they|i) (?:like|liked|love|loved|prefer)|on[- ]trend|trendy|popular|what (?:everyone|competitors) (?:uses?|does)|brand (?:colou?r|font|guidelines?) (?:is|are|says?))\b/i;
|
|
5314
6596
|
var MOOD = /\b(?:modern|clean|fresh|professional|trustworthy|premium|elegant|sleek|timeless|minimal(?:ist)?|bold|vibrant|friendly|approachable|luxur(?:y|ious)|sophisticated|contemporary|stylish|warm|inviting|high[- ]end)\b/gi;
|
|
5315
6597
|
var VALUE_WORDS = /* @__PURE__ */ new Set(["green", "blue", "navy", "black", "white", "cream", "grey", "gray", "gold", "yellow", "orange", "purple", "violet", "pink", "brown", "teal", "bottle", "dark", "light", "deep", "pale", "bright", "colour", "color", "font", "face", "serif", "sans", "type", "lettering"]);
|
|
5316
|
-
var
|
|
6598
|
+
var STOP2 = /* @__PURE__ */ new Set(["with", "that", "this", "from", "their", "they", "have", "been", "were", "which", "what", "when", "where", "there", "about", "into", "onto", "over", "same", "each", "every"]);
|
|
5317
6599
|
function words(text) {
|
|
5318
|
-
return new Set((text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).map((w) => w.replace(/'s$/, "")).filter((w) => !
|
|
6600
|
+
return new Set((text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).map((w) => w.replace(/'s$/, "")).filter((w) => !STOP2.has(w) && !VALUE_WORDS.has(w)));
|
|
5319
6601
|
}
|
|
5320
6602
|
function tellFor(key, value) {
|
|
5321
6603
|
if (key === "accent") {
|
|
@@ -5448,14 +6730,14 @@ function validateDirection(input, ctx = {}) {
|
|
|
5448
6730
|
if (fp && typeof c.value === "string") {
|
|
5449
6731
|
if ((key === "accent" || key === "ground") && parseColour(c.value)) {
|
|
5450
6732
|
const declared = parseColour(c.value).oklch;
|
|
5451
|
-
const
|
|
5452
|
-
if (
|
|
5453
|
-
warn(`${at}.value`, `the page does not show it: its ${key} measures oklch(${
|
|
6733
|
+
const measured2 = key === "accent" ? fp.accent : fp.ground;
|
|
6734
|
+
if (measured2 && deltaEOk(declared, measured2) > 0.08) {
|
|
6735
|
+
warn(`${at}.value`, `the page does not show it: its ${key} measures oklch(${measured2.l.toFixed(2)} ${measured2.c.toFixed(3)} ${measured2.h.toFixed(0)})`);
|
|
5454
6736
|
}
|
|
5455
6737
|
}
|
|
5456
6738
|
if (key === "display" || key === "body") {
|
|
5457
|
-
const
|
|
5458
|
-
if (normaliseFamily(c.value).toLowerCase() !==
|
|
6739
|
+
const measured2 = key === "display" ? fp.display.family : fp.body.family;
|
|
6740
|
+
if (normaliseFamily(c.value).toLowerCase() !== measured2.toLowerCase()) warn(`${at}.value`, `the page sets ${measured2}, not ${c.value}`);
|
|
5459
6741
|
}
|
|
5460
6742
|
}
|
|
5461
6743
|
if (ok) decided += 1;
|
|
@@ -5466,8 +6748,8 @@ function validateDirection(input, ctx = {}) {
|
|
|
5466
6748
|
// src/direction/init.ts
|
|
5467
6749
|
var hex = (o) => {
|
|
5468
6750
|
const { r, g, b } = oklchToRgb(o);
|
|
5469
|
-
const
|
|
5470
|
-
return formatHex({ r:
|
|
6751
|
+
const clamp3 = (n) => Math.min(1, Math.max(0, n));
|
|
6752
|
+
return formatHex({ r: clamp3(r), g: clamp3(g), b: clamp3(b) });
|
|
5471
6753
|
};
|
|
5472
6754
|
function initDirection(input) {
|
|
5473
6755
|
const fp = input.current;
|
|
@@ -5607,240 +6889,6 @@ function propose(input) {
|
|
|
5607
6889
|
};
|
|
5608
6890
|
}
|
|
5609
6891
|
|
|
5610
|
-
// src/null/index.ts
|
|
5611
|
-
var NULL_VERSION = 1;
|
|
5612
|
-
function nullPrompt(brief) {
|
|
5613
|
-
return [
|
|
5614
|
-
`Build the home page for this business: ${brief.trim()}`,
|
|
5615
|
-
"",
|
|
5616
|
-
"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."
|
|
5617
|
-
].join("\n");
|
|
5618
|
-
}
|
|
5619
|
-
function extractHtml(reply) {
|
|
5620
|
-
const start = reply.search(/<!doctype html|<html[\s>]/i);
|
|
5621
|
-
if (start < 0) return null;
|
|
5622
|
-
const end = reply.toLowerCase().lastIndexOf("</html>");
|
|
5623
|
-
const html = end > start ? reply.slice(start, end + "</html>".length) : reply.slice(start).replace(/\s*```\s*$/, "");
|
|
5624
|
-
return html.length > 200 ? html : null;
|
|
5625
|
-
}
|
|
5626
|
-
var MIN_RUNS = 5;
|
|
5627
|
-
var TYPICAL_AT = 0.1;
|
|
5628
|
-
function meanNearest(distances, k) {
|
|
5629
|
-
const sorted = [...distances].sort((a, b) => a - b).slice(0, k);
|
|
5630
|
-
return sorted.reduce((s, d) => s + d, 0) / sorted.length;
|
|
5631
|
-
}
|
|
5632
|
-
function median2(values) {
|
|
5633
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
5634
|
-
const mid = Math.floor(sorted.length / 2);
|
|
5635
|
-
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
5636
|
-
}
|
|
5637
|
-
function typicality(target2, runs, k = 3) {
|
|
5638
|
-
if (runs.length < MIN_RUNS) throw new Error(`a null model needs at least ${MIN_RUNS} runs; this one has ${runs.length}`);
|
|
5639
|
-
const kk = Math.min(k, runs.length - 1);
|
|
5640
|
-
const toTarget = runs.map((r) => ({ id: r.id, distance: fingerprintDistance(target2, r.fingerprint).total }));
|
|
5641
|
-
const distance = meanNearest(
|
|
5642
|
-
toTarget.map((t) => t.distance),
|
|
5643
|
-
kk
|
|
5644
|
-
);
|
|
5645
|
-
const loo = runs.map(
|
|
5646
|
-
(r, i) => meanNearest(
|
|
5647
|
-
runs.filter((_, j) => j !== i).map((o) => fingerprintDistance(r.fingerprint, o.fingerprint).total),
|
|
5648
|
-
kk
|
|
5649
|
-
)
|
|
5650
|
-
);
|
|
5651
|
-
const score = loo.filter((d) => d >= distance).length / loo.length;
|
|
5652
|
-
return {
|
|
5653
|
-
score: Math.round(score * 100) / 100,
|
|
5654
|
-
typical: score >= TYPICAL_AT,
|
|
5655
|
-
distance: Math.round(distance * 1e3) / 1e3,
|
|
5656
|
-
baseline: Math.round(median2(loo) * 1e3) / 1e3,
|
|
5657
|
-
nearest: toTarget.sort((a, b) => a.distance - b.distance).slice(0, kk),
|
|
5658
|
-
shared: sharedChoices(target2, runs)
|
|
5659
|
-
};
|
|
5660
|
-
}
|
|
5661
|
-
var HUES = [
|
|
5662
|
-
["red", 0, 45],
|
|
5663
|
-
["orange", 45, 75],
|
|
5664
|
-
["yellow", 75, 110],
|
|
5665
|
-
["green", 110, 165],
|
|
5666
|
-
["teal", 165, 215],
|
|
5667
|
-
["blue", 215, 265],
|
|
5668
|
-
["violet", 265, 315],
|
|
5669
|
-
["pink", 315, 360]
|
|
5670
|
-
];
|
|
5671
|
-
function hueName(o) {
|
|
5672
|
-
if (!o) return "none";
|
|
5673
|
-
if (o.c < 0.03) return "neutral";
|
|
5674
|
-
return HUES.find(([, lo, hi]) => o.h >= lo && o.h < hi)?.[0] ?? "red";
|
|
5675
|
-
}
|
|
5676
|
-
function groundName(o) {
|
|
5677
|
-
if (o.l < 0.3) return "dark";
|
|
5678
|
-
if (isCream(o)) return "cream";
|
|
5679
|
-
if (o.l >= 0.97 && o.c < 0.012) return "white";
|
|
5680
|
-
return o.c < 0.02 ? "grey" : "tinted";
|
|
5681
|
-
}
|
|
5682
|
-
function shapeName(roundness) {
|
|
5683
|
-
if (roundness === null) return "no buttons";
|
|
5684
|
-
if (roundness >= 0.45) return "pill";
|
|
5685
|
-
return roundness >= 0.15 ? "soft" : "square";
|
|
5686
|
-
}
|
|
5687
|
-
function choicesOf(fp) {
|
|
5688
|
-
return {
|
|
5689
|
-
display: [fp.display.family],
|
|
5690
|
-
body: [fp.body.family],
|
|
5691
|
-
accent: [hueName(fp.accent)],
|
|
5692
|
-
ground: [groundName(fp.ground)],
|
|
5693
|
-
shape: [shapeName(fp.roundness)],
|
|
5694
|
-
motion: [fp.motion >= 0.6 ? "reveals most sections" : fp.motion > 0 ? "reveals some sections" : "no scroll reveals"],
|
|
5695
|
-
effects: fp.effects,
|
|
5696
|
-
opening: [fp.layout.slice(0, 3).join(" > ") || "none"]
|
|
5697
|
-
};
|
|
5698
|
-
}
|
|
5699
|
-
function sharedChoices(target2, runs) {
|
|
5700
|
-
const mine = choicesOf(target2);
|
|
5701
|
-
const all = runs.map((r) => choicesOf(r.fingerprint));
|
|
5702
|
-
const out = [];
|
|
5703
|
-
for (const [part, values] of Object.entries(mine)) {
|
|
5704
|
-
for (const value of values) {
|
|
5705
|
-
const count2 = all.filter((c) => c[part].includes(value)).length;
|
|
5706
|
-
if (count2 / runs.length >= 0.25) out.push({ part, value, runs: count2, of: runs.length });
|
|
5707
|
-
}
|
|
5708
|
-
}
|
|
5709
|
-
return out.sort((a, b) => b.runs - a.runs);
|
|
5710
|
-
}
|
|
5711
|
-
var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f4) => f4.toLowerCase()));
|
|
5712
|
-
var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f4) => f4.toLowerCase()));
|
|
5713
|
-
function knownFor(kind, value, sample) {
|
|
5714
|
-
if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
|
|
5715
|
-
if (kind === "accent" && value === "violet") return sample.some((f4) => f4.accent && isAiViolet(f4.accent)) ? "ai-violet" : null;
|
|
5716
|
-
if (kind === "ground" && value === "cream") return "cream-palette";
|
|
5717
|
-
if (kind === "shape" && value === "pill") return "pill-everything";
|
|
5718
|
-
if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
|
|
5719
|
-
if (kind === "tell") return value;
|
|
5720
|
-
if (kind === "opening" && /^hero > (?:logos|stats)/.test(value)) return "hero-then-proof";
|
|
5721
|
-
return null;
|
|
5722
|
-
}
|
|
5723
|
-
var STOP2 = new Set(
|
|
5724
|
-
"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(" ")
|
|
5725
|
-
);
|
|
5726
|
-
function grams(text) {
|
|
5727
|
-
const out = /* @__PURE__ */ new Set();
|
|
5728
|
-
for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
|
|
5729
|
-
const words2 = clause.match(/[a-z][a-z'-]*/g) ?? [];
|
|
5730
|
-
for (let n = 2; n <= 4; n += 1) {
|
|
5731
|
-
for (let i = 0; i + n <= words2.length; i += 1) {
|
|
5732
|
-
const g = words2.slice(i, i + n);
|
|
5733
|
-
if (STOP2.has(g[0]) || STOP2.has(g[n - 1])) continue;
|
|
5734
|
-
if (g.filter((w) => !STOP2.has(w)).length < 2) continue;
|
|
5735
|
-
out.add(g.join(" "));
|
|
5736
|
-
}
|
|
5737
|
-
}
|
|
5738
|
-
}
|
|
5739
|
-
return out;
|
|
5740
|
-
}
|
|
5741
|
-
var GRAMMAR = /* @__PURE__ */ new Set([
|
|
5742
|
-
"privacy policy",
|
|
5743
|
-
"cookie policy",
|
|
5744
|
-
"rights reserved",
|
|
5745
|
-
"all rights reserved",
|
|
5746
|
-
"terms of service",
|
|
5747
|
-
"terms and conditions",
|
|
5748
|
-
"terms conditions",
|
|
5749
|
-
"how it works",
|
|
5750
|
-
"what we do",
|
|
5751
|
-
"about us",
|
|
5752
|
-
"contact us",
|
|
5753
|
-
"get in touch",
|
|
5754
|
-
"opening hours",
|
|
5755
|
-
"read more",
|
|
5756
|
-
"learn more",
|
|
5757
|
-
"book now",
|
|
5758
|
-
"free quote",
|
|
5759
|
-
"our services",
|
|
5760
|
-
"quick links",
|
|
5761
|
-
"email address",
|
|
5762
|
-
"phone number",
|
|
5763
|
-
"full name",
|
|
5764
|
-
"first name",
|
|
5765
|
-
"last name",
|
|
5766
|
-
"your name",
|
|
5767
|
-
"your email",
|
|
5768
|
-
"send message"
|
|
5769
|
-
]);
|
|
5770
|
-
var LEGAL = /©|©|\bregistered (?:in|office|number)\b|\bcompany (?:no|number|reg)|\bvat (?:no|number|reg)/i;
|
|
5771
|
-
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;
|
|
5772
|
-
function textGrams(text) {
|
|
5773
|
-
if (LEGAL.test(text)) return /* @__PURE__ */ new Set();
|
|
5774
|
-
const out = grams(text.replace(NOISE, " | "));
|
|
5775
|
-
for (const g of out) if (GRAMMAR.has(g)) out.delete(g);
|
|
5776
|
-
return out;
|
|
5777
|
-
}
|
|
5778
|
-
function properNouns(brief) {
|
|
5779
|
-
return new Set((brief.match(/\b[A-Z][a-z]+/g) ?? []).map((w) => w.toLowerCase()).filter((w) => !STOP2.has(w)));
|
|
5780
|
-
}
|
|
5781
|
-
function harvest(models, options = {}) {
|
|
5782
|
-
const minShare = options.minShare ?? 0.25;
|
|
5783
|
-
const runs = models.flatMap((m, b) => m.runs.map((r) => ({ ...r, brief: b })));
|
|
5784
|
-
const of = runs.length;
|
|
5785
|
-
if (of === 0) return [];
|
|
5786
|
-
const multiBrief = models.length > 1;
|
|
5787
|
-
const sample = runs.map((r) => r.fingerprint);
|
|
5788
|
-
const tally = /* @__PURE__ */ new Map();
|
|
5789
|
-
const add = (kind, value, run, brief, example) => {
|
|
5790
|
-
const key = `${kind}\0${value}`;
|
|
5791
|
-
const t = tally.get(key) ?? { kind, value, runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example };
|
|
5792
|
-
t.runs.add(run);
|
|
5793
|
-
t.briefs.add(brief);
|
|
5794
|
-
tally.set(key, t);
|
|
5795
|
-
};
|
|
5796
|
-
runs.forEach((r, i) => {
|
|
5797
|
-
const c = choicesOf(r.fingerprint);
|
|
5798
|
-
for (const f4 of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f4, i, r.brief);
|
|
5799
|
-
add("accent", c.accent[0], i, r.brief);
|
|
5800
|
-
add("ground", c.ground[0], i, r.brief);
|
|
5801
|
-
add("shape", c.shape[0], i, r.brief);
|
|
5802
|
-
add("motion", c.motion[0], i, r.brief);
|
|
5803
|
-
add("opening", c.opening[0], i, r.brief);
|
|
5804
|
-
for (const t of r.tells) add("tell", t, i, r.brief);
|
|
5805
|
-
});
|
|
5806
|
-
const out = [];
|
|
5807
|
-
for (const t of tally.values()) {
|
|
5808
|
-
if (t.runs.size / of < minShare) continue;
|
|
5809
|
-
if (t.kind === "accent" && (t.value === "none" || t.value === "neutral")) continue;
|
|
5810
|
-
if (t.kind === "shape" && t.value === "no buttons") continue;
|
|
5811
|
-
if (t.kind === "ground" && (t.value === "white" || t.value === "grey")) continue;
|
|
5812
|
-
if (t.kind === "motion" && t.value === "no scroll reveals") continue;
|
|
5813
|
-
out.push({ kind: t.kind, value: t.value, runs: t.runs.size, of, briefs: t.briefs.size, known: knownFor(t.kind, t.value, sample) });
|
|
5814
|
-
}
|
|
5815
|
-
const phraseMin = Math.max(3, Math.ceil(of * minShare * 0.4));
|
|
5816
|
-
const names = models.map((m) => properNouns(m.brief));
|
|
5817
|
-
const phrases2 = /* @__PURE__ */ new Map();
|
|
5818
|
-
runs.forEach((r, i) => {
|
|
5819
|
-
for (const text of r.copy) {
|
|
5820
|
-
for (const g of textGrams(text)) {
|
|
5821
|
-
if (g.split(" ").some((w) => names[r.brief].has(w))) continue;
|
|
5822
|
-
const p = phrases2.get(g) ?? { runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example: text.trim().slice(0, 140) };
|
|
5823
|
-
p.runs.add(i);
|
|
5824
|
-
p.briefs.add(r.brief);
|
|
5825
|
-
phrases2.set(g, p);
|
|
5826
|
-
}
|
|
5827
|
-
}
|
|
5828
|
-
});
|
|
5829
|
-
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);
|
|
5830
|
-
const maximal = kept.filter(([g, p]) => !kept.some(([h, q]) => h !== g && h.includes(g) && q.runs.size >= 0.8 * p.runs.size));
|
|
5831
|
-
for (const [g, p] of maximal.slice(0, 40)) {
|
|
5832
|
-
out.push({ kind: "phrase", value: g, runs: p.runs.size, of, briefs: p.briefs.size, known: options.copyTell?.(g) ?? null, example: p.example });
|
|
5833
|
-
}
|
|
5834
|
-
const order = ["face", "accent", "ground", "shape", "motion", "opening", "tell", "phrase"];
|
|
5835
|
-
return out.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.runs - a.runs);
|
|
5836
|
-
}
|
|
5837
|
-
function hueSwatch(runs, hue) {
|
|
5838
|
-
const hits = runs.map((r) => r.fingerprint.accent).filter((o) => !!o && hueName(o) === hue);
|
|
5839
|
-
if (hits.length === 0) return null;
|
|
5840
|
-
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);
|
|
5841
|
-
return formatHex(oklchToRgb(centre));
|
|
5842
|
-
}
|
|
5843
|
-
|
|
5844
6892
|
// src/estate/index.ts
|
|
5845
6893
|
var ESTATE_VERSION = 1;
|
|
5846
6894
|
var SIBLING_AT = 0.31;
|
|
@@ -6098,6 +7146,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6098
7146
|
// Annotate the CommonJS export names for ESM import in node:
|
|
6099
7147
|
0 && (module.exports = {
|
|
6100
7148
|
AI_PHRASES,
|
|
7149
|
+
AI_SOURCE_TYPES,
|
|
6101
7150
|
AI_VIOLET,
|
|
6102
7151
|
AI_WORDS,
|
|
6103
7152
|
BASELINE_VERSION,
|
|
@@ -6105,6 +7154,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6105
7154
|
CATALOGUE,
|
|
6106
7155
|
CATALOGUE_VERSION,
|
|
6107
7156
|
CHOICE_KEYS,
|
|
7157
|
+
COMPONENT_ROLES,
|
|
6108
7158
|
DEFAULT_IGNORE,
|
|
6109
7159
|
DENSITIES,
|
|
6110
7160
|
DENSITY_NAMES,
|
|
@@ -6118,10 +7168,13 @@ function retrofitPlan(report2, direction) {
|
|
|
6118
7168
|
ESTATE_VERSION,
|
|
6119
7169
|
EXIT_RATIO,
|
|
6120
7170
|
FINGERPRINT_VERSION,
|
|
7171
|
+
HOME_FILE,
|
|
7172
|
+
HOME_URL,
|
|
6121
7173
|
HOUSE,
|
|
6122
7174
|
HOUSE_BUDGET,
|
|
6123
7175
|
HOUSE_LOCKED,
|
|
6124
7176
|
HOUSE_TYPE,
|
|
7177
|
+
IMAGERY_TELLS,
|
|
6125
7178
|
JSON_SCHEMA_VERSION,
|
|
6126
7179
|
LICENCES,
|
|
6127
7180
|
LIGHTNESS_CURVE,
|
|
@@ -6131,12 +7184,16 @@ function retrofitPlan(report2, direction) {
|
|
|
6131
7184
|
NULL_VERSION,
|
|
6132
7185
|
PILL_LIMIT,
|
|
6133
7186
|
PLAINER_WORDS,
|
|
7187
|
+
PROMO_BADGE,
|
|
7188
|
+
PROOF_NAMES,
|
|
6134
7189
|
PROPOSED,
|
|
7190
|
+
PROVENANCE_SCAN_BYTES,
|
|
6135
7191
|
RAMP_STEPS,
|
|
6136
7192
|
READABLE_SNAPSHOT_VERSIONS,
|
|
6137
7193
|
REFLEX_FONTS_1,
|
|
6138
7194
|
REFLEX_FONTS_2,
|
|
6139
7195
|
RENDERED_PATHS,
|
|
7196
|
+
RENDERED_TELLS,
|
|
6140
7197
|
REVEAL_LIMIT,
|
|
6141
7198
|
REVIEW_PHRASES,
|
|
6142
7199
|
SCALE,
|
|
@@ -6146,9 +7203,16 @@ function retrofitPlan(report2, direction) {
|
|
|
6146
7203
|
SNAPSHOT_VERSION,
|
|
6147
7204
|
SOURCE_KINDS,
|
|
6148
7205
|
SPACE_STEPS,
|
|
7206
|
+
SPECIFICITY_COPY_TELLS,
|
|
7207
|
+
SPECIFICITY_RENDERED_TELLS,
|
|
6149
7208
|
SPRING,
|
|
7209
|
+
STANDING_CLAIM,
|
|
7210
|
+
STOCK_AVATAR_HOSTS,
|
|
7211
|
+
STOCK_FIGURES,
|
|
7212
|
+
STOCK_PHOTO_HOSTS,
|
|
6150
7213
|
STOCK_PHRASES,
|
|
6151
7214
|
TELL_HEAVY,
|
|
7215
|
+
TRADE_NOUNS,
|
|
6152
7216
|
TYPE_STEPS,
|
|
6153
7217
|
TYPICAL_AT,
|
|
6154
7218
|
VAGUE_WORDS,
|
|
@@ -6159,14 +7223,23 @@ function retrofitPlan(report2, direction) {
|
|
|
6159
7223
|
applyHouseGate,
|
|
6160
7224
|
applySeverity,
|
|
6161
7225
|
auditSnapshot,
|
|
7226
|
+
briefTerms,
|
|
6162
7227
|
catalogueTable,
|
|
6163
7228
|
characterReport,
|
|
6164
7229
|
checkCopy,
|
|
6165
7230
|
checkPair,
|
|
6166
7231
|
checkRestraint,
|
|
6167
7232
|
choicesOf,
|
|
7233
|
+
closestComponents,
|
|
7234
|
+
compareCompetitor,
|
|
6168
7235
|
compareFacts,
|
|
6169
7236
|
compareToEstate,
|
|
7237
|
+
componentDistance,
|
|
7238
|
+
componentShared,
|
|
7239
|
+
componentTypicalities,
|
|
7240
|
+
componentTypicality,
|
|
7241
|
+
componentsOf,
|
|
7242
|
+
countWords,
|
|
6170
7243
|
craftTokens,
|
|
6171
7244
|
createBaseline,
|
|
6172
7245
|
deltaEOk,
|
|
@@ -6175,6 +7248,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6175
7248
|
densityTokens,
|
|
6176
7249
|
emitCss,
|
|
6177
7250
|
emptyEstate,
|
|
7251
|
+
estateComponentPairs,
|
|
6178
7252
|
estatePairs,
|
|
6179
7253
|
exitDuration,
|
|
6180
7254
|
extractHtml,
|
|
@@ -6190,6 +7264,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6190
7264
|
fontClass,
|
|
6191
7265
|
formatClaims,
|
|
6192
7266
|
formatComparison,
|
|
7267
|
+
formatCompetitor,
|
|
6193
7268
|
formatHex,
|
|
6194
7269
|
formatReport,
|
|
6195
7270
|
globToRegExp,
|
|
@@ -6216,6 +7291,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6216
7291
|
nullPrompt,
|
|
6217
7292
|
oklchToHex,
|
|
6218
7293
|
oklchToRgb,
|
|
7294
|
+
pageCopy,
|
|
6219
7295
|
paletteFromPixels,
|
|
6220
7296
|
parseBaseline,
|
|
6221
7297
|
parseColour,
|
|
@@ -6228,14 +7304,20 @@ function retrofitPlan(report2, direction) {
|
|
|
6228
7304
|
readSnapshot,
|
|
6229
7305
|
retrofitPlan,
|
|
6230
7306
|
runTell,
|
|
7307
|
+
scanProvenance,
|
|
6231
7308
|
scanSource,
|
|
6232
7309
|
sectionRhythm,
|
|
6233
7310
|
semantic,
|
|
7311
|
+
sentencesOf,
|
|
6234
7312
|
severityExceptions,
|
|
6235
7313
|
shapeName,
|
|
6236
7314
|
sharedParts,
|
|
6237
7315
|
shouldAnimate,
|
|
6238
7316
|
siblingLine,
|
|
7317
|
+
specificity,
|
|
7318
|
+
specifics,
|
|
7319
|
+
stockAvatarSource,
|
|
7320
|
+
stockSource,
|
|
6239
7321
|
tailwindV3Preset,
|
|
6240
7322
|
tailwindV4Theme,
|
|
6241
7323
|
tellById,
|
|
@@ -6244,6 +7326,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6244
7326
|
toSarif,
|
|
6245
7327
|
typeFeatureTokens,
|
|
6246
7328
|
typicality,
|
|
7329
|
+
unprovenClaims,
|
|
6247
7330
|
validateDirection,
|
|
6248
7331
|
visibleText,
|
|
6249
7332
|
visualMeasures,
|