@domandigital/craft 0.12.0 → 0.13.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 +20 -0
- package/CHARACTER.md +92 -1
- package/dist/{audit-5QWFLZ6L.js → audit-5TYDYRXV.js} +6 -3
- package/dist/audit.cjs +613 -88
- package/dist/audit.d.cts +81 -1
- package/dist/audit.d.ts +81 -1
- package/dist/audit.js +611 -88
- package/dist/{chunk-J3QUROJ5.js → chunk-3VTPI7BU.js} +9 -8
- package/dist/{chunk-ZKVJ7K6X.js → chunk-CZDOMIVS.js} +1 -1
- package/dist/chunk-ESCVTKPN.js +217 -0
- package/dist/{chunk-AL6MRG6V.js → chunk-GMVXADJ6.js} +2912 -2102
- package/dist/{chunk-PU5G5DUC.js → chunk-IMB4V34S.js} +26 -13
- package/dist/{chunk-CDAJUS5U.js → chunk-K2BZN4GA.js} +49 -14
- package/dist/{chunk-ULPI335S.js → chunk-ZLDA2SYB.js} +168 -8
- package/dist/{chunk-S3NZAVU6.js → chunk-ZZ5CKOJP.js} +1 -1
- package/dist/cli-BKJMLD3H.js +18 -0
- package/dist/{cli-ZPJIWVEP.js → cli-DRKAMFAH.js} +5 -7
- package/dist/{cli-C3JLFXMG.js → cli-ILH7OO5G.js} +4 -5
- package/dist/{cli-ZB2VJ7CY.js → cli-J5ECRCD4.js} +13 -15
- package/dist/cli-N2IDXH7W.js +20 -0
- package/dist/cli.js +2 -2
- package/dist/index.cjs +929 -401
- package/dist/index.d.cts +408 -196
- package/dist/index.d.ts +408 -196
- package/dist/index.js +911 -401
- 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,
|
|
@@ -45,6 +47,7 @@ __export(index_exports, {
|
|
|
45
47
|
HOUSE_BUDGET: () => HOUSE_BUDGET,
|
|
46
48
|
HOUSE_LOCKED: () => HOUSE_LOCKED,
|
|
47
49
|
HOUSE_TYPE: () => HOUSE_TYPE,
|
|
50
|
+
IMAGERY_TELLS: () => IMAGERY_TELLS,
|
|
48
51
|
JSON_SCHEMA_VERSION: () => JSON_SCHEMA_VERSION,
|
|
49
52
|
LICENCES: () => LICENCES,
|
|
50
53
|
LIGHTNESS_CURVE: () => LIGHTNESS_CURVE,
|
|
@@ -54,12 +57,15 @@ __export(index_exports, {
|
|
|
54
57
|
NULL_VERSION: () => NULL_VERSION,
|
|
55
58
|
PILL_LIMIT: () => PILL_LIMIT,
|
|
56
59
|
PLAINER_WORDS: () => PLAINER_WORDS,
|
|
60
|
+
PROMO_BADGE: () => PROMO_BADGE,
|
|
57
61
|
PROPOSED: () => PROPOSED,
|
|
62
|
+
PROVENANCE_SCAN_BYTES: () => PROVENANCE_SCAN_BYTES,
|
|
58
63
|
RAMP_STEPS: () => RAMP_STEPS,
|
|
59
64
|
READABLE_SNAPSHOT_VERSIONS: () => READABLE_SNAPSHOT_VERSIONS,
|
|
60
65
|
REFLEX_FONTS_1: () => REFLEX_FONTS_1,
|
|
61
66
|
REFLEX_FONTS_2: () => REFLEX_FONTS_2,
|
|
62
67
|
RENDERED_PATHS: () => RENDERED_PATHS,
|
|
68
|
+
RENDERED_TELLS: () => RENDERED_TELLS,
|
|
63
69
|
REVEAL_LIMIT: () => REVEAL_LIMIT,
|
|
64
70
|
REVIEW_PHRASES: () => REVIEW_PHRASES,
|
|
65
71
|
SCALE: () => SCALE,
|
|
@@ -70,6 +76,8 @@ __export(index_exports, {
|
|
|
70
76
|
SOURCE_KINDS: () => SOURCE_KINDS,
|
|
71
77
|
SPACE_STEPS: () => SPACE_STEPS,
|
|
72
78
|
SPRING: () => SPRING,
|
|
79
|
+
STOCK_AVATAR_HOSTS: () => STOCK_AVATAR_HOSTS,
|
|
80
|
+
STOCK_PHOTO_HOSTS: () => STOCK_PHOTO_HOSTS,
|
|
73
81
|
STOCK_PHRASES: () => STOCK_PHRASES,
|
|
74
82
|
TELL_HEAVY: () => TELL_HEAVY,
|
|
75
83
|
TYPE_STEPS: () => TYPE_STEPS,
|
|
@@ -88,8 +96,14 @@ __export(index_exports, {
|
|
|
88
96
|
checkPair: () => checkPair,
|
|
89
97
|
checkRestraint: () => checkRestraint,
|
|
90
98
|
choicesOf: () => choicesOf,
|
|
99
|
+
closestComponents: () => closestComponents,
|
|
91
100
|
compareFacts: () => compareFacts,
|
|
92
101
|
compareToEstate: () => compareToEstate,
|
|
102
|
+
componentDistance: () => componentDistance,
|
|
103
|
+
componentShared: () => componentShared,
|
|
104
|
+
componentTypicalities: () => componentTypicalities,
|
|
105
|
+
componentTypicality: () => componentTypicality,
|
|
106
|
+
componentsOf: () => componentsOf,
|
|
93
107
|
craftTokens: () => craftTokens,
|
|
94
108
|
createBaseline: () => createBaseline,
|
|
95
109
|
deltaEOk: () => deltaEOk,
|
|
@@ -98,6 +112,7 @@ __export(index_exports, {
|
|
|
98
112
|
densityTokens: () => densityTokens,
|
|
99
113
|
emitCss: () => emitCss,
|
|
100
114
|
emptyEstate: () => emptyEstate,
|
|
115
|
+
estateComponentPairs: () => estateComponentPairs,
|
|
101
116
|
estatePairs: () => estatePairs,
|
|
102
117
|
exitDuration: () => exitDuration,
|
|
103
118
|
extractHtml: () => extractHtml,
|
|
@@ -151,6 +166,7 @@ __export(index_exports, {
|
|
|
151
166
|
readSnapshot: () => readSnapshot,
|
|
152
167
|
retrofitPlan: () => retrofitPlan,
|
|
153
168
|
runTell: () => runTell,
|
|
169
|
+
scanProvenance: () => scanProvenance,
|
|
154
170
|
scanSource: () => scanSource,
|
|
155
171
|
sectionRhythm: () => sectionRhythm,
|
|
156
172
|
semantic: () => semantic,
|
|
@@ -159,6 +175,8 @@ __export(index_exports, {
|
|
|
159
175
|
sharedParts: () => sharedParts,
|
|
160
176
|
shouldAnimate: () => shouldAnimate,
|
|
161
177
|
siblingLine: () => siblingLine,
|
|
178
|
+
stockAvatarSource: () => stockAvatarSource,
|
|
179
|
+
stockSource: () => stockSource,
|
|
162
180
|
tailwindV3Preset: () => tailwindV3Preset,
|
|
163
181
|
tailwindV4Theme: () => tailwindV4Theme,
|
|
164
182
|
tellById: () => tellById,
|
|
@@ -2427,14 +2445,14 @@ function contractionHits(ctx) {
|
|
|
2427
2445
|
for (const doc2 of documents(ctx)) {
|
|
2428
2446
|
if (doc2.words < 800) continue;
|
|
2429
2447
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2430
|
-
const
|
|
2431
|
-
const rate =
|
|
2448
|
+
const count3 = (text.match(CONTRACTION) ?? []).length;
|
|
2449
|
+
const rate = count3 * 1e3 / doc2.words;
|
|
2432
2450
|
if (rate < 1) {
|
|
2433
2451
|
const first = sentences(doc2)[0];
|
|
2434
2452
|
hits.push({
|
|
2435
2453
|
path: doc2.path,
|
|
2436
2454
|
offset: first?.offset ?? 0,
|
|
2437
|
-
message: `${
|
|
2455
|
+
message: `${count3} contraction${count3 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
2438
2456
|
});
|
|
2439
2457
|
}
|
|
2440
2458
|
}
|
|
@@ -2557,16 +2575,16 @@ function filler(words2, seed = 1, options = {}) {
|
|
|
2557
2575
|
return state % n;
|
|
2558
2576
|
};
|
|
2559
2577
|
const out = [];
|
|
2560
|
-
let
|
|
2578
|
+
let count3 = 0;
|
|
2561
2579
|
let i = 0;
|
|
2562
|
-
while (
|
|
2580
|
+
while (count3 < words2) {
|
|
2563
2581
|
const length = uniform ?? shape[i % shape.length];
|
|
2564
2582
|
const s = [];
|
|
2565
2583
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
2566
2584
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
2567
2585
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
2568
2586
|
out.push(`${s.join(" ")}.`);
|
|
2569
|
-
|
|
2587
|
+
count3 += s.length;
|
|
2570
2588
|
i += 1;
|
|
2571
2589
|
}
|
|
2572
2590
|
const paragraphs = [];
|
|
@@ -4431,11 +4449,810 @@ var RENDERED_PATHS = {
|
|
|
4431
4449
|
}
|
|
4432
4450
|
};
|
|
4433
4451
|
|
|
4452
|
+
// src/fingerprint/index.ts
|
|
4453
|
+
var FINGERPRINT_VERSION = 2;
|
|
4454
|
+
var CHROMATIC = 0.03;
|
|
4455
|
+
var MIN_ACCENT_AREA = 2e4;
|
|
4456
|
+
var oklch = (value) => {
|
|
4457
|
+
const parsed = parseColour(value);
|
|
4458
|
+
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
4459
|
+
};
|
|
4460
|
+
function accentOf(s) {
|
|
4461
|
+
const buttons = /* @__PURE__ */ new Map();
|
|
4462
|
+
for (const c of s.controls) {
|
|
4463
|
+
const o = oklch(c.background);
|
|
4464
|
+
if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
|
|
4465
|
+
}
|
|
4466
|
+
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
4467
|
+
if (topButton) return oklch(topButton[0]);
|
|
4468
|
+
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];
|
|
4469
|
+
if (bg) return bg.o;
|
|
4470
|
+
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];
|
|
4471
|
+
return text ? text.o : null;
|
|
4472
|
+
}
|
|
4473
|
+
function faces(s) {
|
|
4474
|
+
const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
|
|
4475
|
+
const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
|
|
4476
|
+
const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
|
|
4477
|
+
const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
|
|
4478
|
+
return {
|
|
4479
|
+
display: { family: displayName, class: fontClass(displayName) },
|
|
4480
|
+
body: { family: bodyName, class: fontClass(bodyName) }
|
|
4481
|
+
};
|
|
4482
|
+
}
|
|
4483
|
+
function median(values) {
|
|
4484
|
+
if (values.length === 0) return null;
|
|
4485
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
4486
|
+
const mid = Math.floor(sorted.length / 2);
|
|
4487
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
4488
|
+
}
|
|
4489
|
+
function fingerprint(s) {
|
|
4490
|
+
const effects = [];
|
|
4491
|
+
if (s.effects.glass > 0) effects.push("glass-panel");
|
|
4492
|
+
if (s.effects.gradientText > 0) effects.push("gradient-text");
|
|
4493
|
+
if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
|
|
4494
|
+
if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
|
|
4495
|
+
if (s.effects.marquees > 0) effects.push("marquee");
|
|
4496
|
+
if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
|
|
4497
|
+
if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
|
|
4498
|
+
if (s.motion.introOverlay) effects.push("intro-cinematic");
|
|
4499
|
+
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
4500
|
+
return {
|
|
4501
|
+
version: FINGERPRINT_VERSION,
|
|
4502
|
+
accent: accentOf(s),
|
|
4503
|
+
ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
|
|
4504
|
+
...faces(s),
|
|
4505
|
+
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
4506
|
+
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
4507
|
+
effects: effects.sort(),
|
|
4508
|
+
layout: s.sections.map((x) => x.kind),
|
|
4509
|
+
...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? {
|
|
4510
|
+
sections: s.sections.map((x) => ({
|
|
4511
|
+
role: x.role,
|
|
4512
|
+
centred: x.geometry.centredShare,
|
|
4513
|
+
width: x.geometry.contentWidthRatio,
|
|
4514
|
+
symmetry: x.geometry.mirrorSymmetry,
|
|
4515
|
+
controls: x.geometry.controls,
|
|
4516
|
+
cards: x.cards,
|
|
4517
|
+
background: x.geometry.background === s.ground ? null : oklch(x.geometry.background),
|
|
4518
|
+
badges: (x.badges ?? []).map((b) => b.toLowerCase()),
|
|
4519
|
+
avatars: x.avatars ?? 0,
|
|
4520
|
+
carousel: x.carousel ?? false,
|
|
4521
|
+
figures: x.figures ?? 0
|
|
4522
|
+
}))
|
|
4523
|
+
} : {}
|
|
4524
|
+
};
|
|
4525
|
+
}
|
|
4526
|
+
var DISTANCE_WEIGHTS = { accent: 0.25, type: 0.25, ground: 0.1, shape: 0.1, motion: 0.1, effects: 0.1, layout: 0.1 };
|
|
4527
|
+
function levenshtein(a, b, cost = (x, y) => x === y ? 0 : 1) {
|
|
4528
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
4529
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
4530
|
+
let prev = row[0];
|
|
4531
|
+
row[0] = i;
|
|
4532
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
4533
|
+
const cur = row[j];
|
|
4534
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost(a[i - 1], b[j - 1]));
|
|
4535
|
+
prev = cur;
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
return row[b.length];
|
|
4539
|
+
}
|
|
4540
|
+
function sectionCost(x, y) {
|
|
4541
|
+
if (x.role !== y.role) return 1;
|
|
4542
|
+
return Math.min(0.5, (Math.abs(x.centred - y.centred) + Math.abs(x.width - y.width)) / 2);
|
|
4543
|
+
}
|
|
4544
|
+
function layoutDistance(a, b) {
|
|
4545
|
+
if (a.sections && b.sections) {
|
|
4546
|
+
const longest2 = Math.max(a.sections.length, b.sections.length);
|
|
4547
|
+
return longest2 === 0 ? 0 : levenshtein(a.sections, b.sections, sectionCost) / longest2;
|
|
4548
|
+
}
|
|
4549
|
+
const longest = Math.max(a.layout.length, b.layout.length);
|
|
4550
|
+
return longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
|
|
4551
|
+
}
|
|
4552
|
+
var clamp = (n) => Math.max(0, Math.min(1, n));
|
|
4553
|
+
function faceDistance(a, b) {
|
|
4554
|
+
if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
|
|
4555
|
+
return a.class === b.class ? 0.5 : 1;
|
|
4556
|
+
}
|
|
4557
|
+
function fingerprintDistance(a, b) {
|
|
4558
|
+
const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / 0.3) : a.accent || b.accent ? 1 : 0;
|
|
4559
|
+
const ground = clamp(deltaEOk(a.ground, b.ground) / 0.15);
|
|
4560
|
+
const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
|
|
4561
|
+
const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
|
|
4562
|
+
const motion = clamp(Math.abs(a.motion - b.motion));
|
|
4563
|
+
const union = /* @__PURE__ */ new Set([...a.effects, ...b.effects]);
|
|
4564
|
+
const shared = a.effects.filter((e) => b.effects.includes(e)).length;
|
|
4565
|
+
const effects = union.size === 0 ? 0 : 1 - shared / union.size;
|
|
4566
|
+
const layout = layoutDistance(a, b);
|
|
4567
|
+
const parts2 = { accent, ground, type, shape, motion, effects, layout };
|
|
4568
|
+
const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts2[k], 0);
|
|
4569
|
+
return { total: Math.round(total * 1e3) / 1e3, parts: parts2 };
|
|
4570
|
+
}
|
|
4571
|
+
function nearest(target2, set, k = set.length) {
|
|
4572
|
+
return set.map((item) => ({ id: item.id, distance: fingerprintDistance(target2, item.fingerprint) })).sort((x, y) => x.distance.total - y.distance.total).slice(0, k);
|
|
4573
|
+
}
|
|
4574
|
+
|
|
4575
|
+
// src/null/index.ts
|
|
4576
|
+
var NULL_VERSION = 1;
|
|
4577
|
+
function nullPrompt(brief) {
|
|
4578
|
+
return [
|
|
4579
|
+
`Build the home page for this business: ${brief.trim()}`,
|
|
4580
|
+
"",
|
|
4581
|
+
"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."
|
|
4582
|
+
].join("\n");
|
|
4583
|
+
}
|
|
4584
|
+
function extractHtml(reply) {
|
|
4585
|
+
const start = reply.search(/<!doctype html|<html[\s>]/i);
|
|
4586
|
+
if (start < 0) return null;
|
|
4587
|
+
const end = reply.toLowerCase().lastIndexOf("</html>");
|
|
4588
|
+
const html = end > start ? reply.slice(start, end + "</html>".length) : reply.slice(start).replace(/\s*```\s*$/, "");
|
|
4589
|
+
return html.length > 200 ? html : null;
|
|
4590
|
+
}
|
|
4591
|
+
var MIN_RUNS = 5;
|
|
4592
|
+
var TYPICAL_AT = 0.1;
|
|
4593
|
+
function meanNearest(distances, k) {
|
|
4594
|
+
const sorted = [...distances].sort((a, b) => a - b).slice(0, k);
|
|
4595
|
+
return sorted.reduce((s, d) => s + d, 0) / sorted.length;
|
|
4596
|
+
}
|
|
4597
|
+
function median2(values) {
|
|
4598
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
4599
|
+
const mid = Math.floor(sorted.length / 2);
|
|
4600
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
4601
|
+
}
|
|
4602
|
+
function typicality(target2, runs, k = 3) {
|
|
4603
|
+
if (runs.length < MIN_RUNS) throw new Error(`a null model needs at least ${MIN_RUNS} runs; this one has ${runs.length}`);
|
|
4604
|
+
const kk = Math.min(k, runs.length - 1);
|
|
4605
|
+
const toTarget = runs.map((r) => ({ id: r.id, distance: fingerprintDistance(target2, r.fingerprint).total }));
|
|
4606
|
+
const distance = meanNearest(
|
|
4607
|
+
toTarget.map((t) => t.distance),
|
|
4608
|
+
kk
|
|
4609
|
+
);
|
|
4610
|
+
const loo = runs.map(
|
|
4611
|
+
(r, i) => meanNearest(
|
|
4612
|
+
runs.filter((_, j) => j !== i).map((o) => fingerprintDistance(r.fingerprint, o.fingerprint).total),
|
|
4613
|
+
kk
|
|
4614
|
+
)
|
|
4615
|
+
);
|
|
4616
|
+
const score = loo.filter((d) => d >= distance).length / loo.length;
|
|
4617
|
+
return {
|
|
4618
|
+
score: Math.round(score * 100) / 100,
|
|
4619
|
+
typical: score >= TYPICAL_AT,
|
|
4620
|
+
distance: Math.round(distance * 1e3) / 1e3,
|
|
4621
|
+
baseline: Math.round(median2(loo) * 1e3) / 1e3,
|
|
4622
|
+
nearest: toTarget.sort((a, b) => a.distance - b.distance).slice(0, kk),
|
|
4623
|
+
shared: sharedChoices(target2, runs)
|
|
4624
|
+
};
|
|
4625
|
+
}
|
|
4626
|
+
var HUES = [
|
|
4627
|
+
["red", 0, 45],
|
|
4628
|
+
["orange", 45, 75],
|
|
4629
|
+
["yellow", 75, 110],
|
|
4630
|
+
["green", 110, 165],
|
|
4631
|
+
["teal", 165, 215],
|
|
4632
|
+
["blue", 215, 265],
|
|
4633
|
+
["violet", 265, 315],
|
|
4634
|
+
["pink", 315, 360]
|
|
4635
|
+
];
|
|
4636
|
+
function hueName(o) {
|
|
4637
|
+
if (!o) return "none";
|
|
4638
|
+
if (o.c < 0.03) return "neutral";
|
|
4639
|
+
return HUES.find(([, lo, hi]) => o.h >= lo && o.h < hi)?.[0] ?? "red";
|
|
4640
|
+
}
|
|
4641
|
+
function groundName(o) {
|
|
4642
|
+
if (o.l < 0.3) return "dark";
|
|
4643
|
+
if (isCream(o)) return "cream";
|
|
4644
|
+
if (o.l >= 0.97 && o.c < 0.012) return "white";
|
|
4645
|
+
return o.c < 0.02 ? "grey" : "tinted";
|
|
4646
|
+
}
|
|
4647
|
+
function shapeName(roundness) {
|
|
4648
|
+
if (roundness === null) return "no buttons";
|
|
4649
|
+
if (roundness >= 0.45) return "pill";
|
|
4650
|
+
return roundness >= 0.15 ? "soft" : "square";
|
|
4651
|
+
}
|
|
4652
|
+
function choicesOf(fp) {
|
|
4653
|
+
return {
|
|
4654
|
+
display: [fp.display.family],
|
|
4655
|
+
body: [fp.body.family],
|
|
4656
|
+
accent: [hueName(fp.accent)],
|
|
4657
|
+
ground: [groundName(fp.ground)],
|
|
4658
|
+
shape: [shapeName(fp.roundness)],
|
|
4659
|
+
motion: [fp.motion >= 0.6 ? "reveals most sections" : fp.motion > 0 ? "reveals some sections" : "no scroll reveals"],
|
|
4660
|
+
effects: fp.effects,
|
|
4661
|
+
opening: [fp.layout.slice(0, 3).join(" > ") || "none"]
|
|
4662
|
+
};
|
|
4663
|
+
}
|
|
4664
|
+
function sharedChoices(target2, runs) {
|
|
4665
|
+
const mine = choicesOf(target2);
|
|
4666
|
+
const all = runs.map((r) => choicesOf(r.fingerprint));
|
|
4667
|
+
const out = [];
|
|
4668
|
+
for (const [part, values] of Object.entries(mine)) {
|
|
4669
|
+
for (const value of values) {
|
|
4670
|
+
const count3 = all.filter((c) => c[part].includes(value)).length;
|
|
4671
|
+
if (count3 / runs.length >= 0.25) out.push({ part, value, runs: count3, of: runs.length });
|
|
4672
|
+
}
|
|
4673
|
+
}
|
|
4674
|
+
return out.sort((a, b) => b.runs - a.runs);
|
|
4675
|
+
}
|
|
4676
|
+
var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f4) => f4.toLowerCase()));
|
|
4677
|
+
var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f4) => f4.toLowerCase()));
|
|
4678
|
+
function knownFor(kind, value, sample) {
|
|
4679
|
+
if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
|
|
4680
|
+
if (kind === "accent" && value === "violet") return sample.some((f4) => f4.accent && isAiViolet(f4.accent)) ? "ai-violet" : null;
|
|
4681
|
+
if (kind === "ground" && value === "cream") return "cream-palette";
|
|
4682
|
+
if (kind === "shape" && value === "pill") return "pill-everything";
|
|
4683
|
+
if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
|
|
4684
|
+
if (kind === "tell") return value;
|
|
4685
|
+
if (kind === "opening" && /^hero > (?:logos|stats)/.test(value)) return "hero-then-proof";
|
|
4686
|
+
return null;
|
|
4687
|
+
}
|
|
4688
|
+
var STOP = new Set(
|
|
4689
|
+
"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(" ")
|
|
4690
|
+
);
|
|
4691
|
+
function grams(text) {
|
|
4692
|
+
const out = /* @__PURE__ */ new Set();
|
|
4693
|
+
for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
|
|
4694
|
+
const words2 = clause.match(/[a-z][a-z'-]*/g) ?? [];
|
|
4695
|
+
for (let n = 2; n <= 4; n += 1) {
|
|
4696
|
+
for (let i = 0; i + n <= words2.length; i += 1) {
|
|
4697
|
+
const g = words2.slice(i, i + n);
|
|
4698
|
+
if (STOP.has(g[0]) || STOP.has(g[n - 1])) continue;
|
|
4699
|
+
if (g.filter((w) => !STOP.has(w)).length < 2) continue;
|
|
4700
|
+
out.add(g.join(" "));
|
|
4701
|
+
}
|
|
4702
|
+
}
|
|
4703
|
+
}
|
|
4704
|
+
return out;
|
|
4705
|
+
}
|
|
4706
|
+
var GRAMMAR = /* @__PURE__ */ new Set([
|
|
4707
|
+
"privacy policy",
|
|
4708
|
+
"cookie policy",
|
|
4709
|
+
"rights reserved",
|
|
4710
|
+
"all rights reserved",
|
|
4711
|
+
"terms of service",
|
|
4712
|
+
"terms and conditions",
|
|
4713
|
+
"terms conditions",
|
|
4714
|
+
"how it works",
|
|
4715
|
+
"what we do",
|
|
4716
|
+
"about us",
|
|
4717
|
+
"contact us",
|
|
4718
|
+
"get in touch",
|
|
4719
|
+
"opening hours",
|
|
4720
|
+
"read more",
|
|
4721
|
+
"learn more",
|
|
4722
|
+
"book now",
|
|
4723
|
+
"free quote",
|
|
4724
|
+
"our services",
|
|
4725
|
+
"quick links",
|
|
4726
|
+
"email address",
|
|
4727
|
+
"phone number",
|
|
4728
|
+
"full name",
|
|
4729
|
+
"first name",
|
|
4730
|
+
"last name",
|
|
4731
|
+
"your name",
|
|
4732
|
+
"your email",
|
|
4733
|
+
"send message"
|
|
4734
|
+
]);
|
|
4735
|
+
var LEGAL = /©|©|\bregistered (?:in|office|number)\b|\bcompany (?:no|number|reg)|\bvat (?:no|number|reg)/i;
|
|
4736
|
+
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;
|
|
4737
|
+
function textGrams(text) {
|
|
4738
|
+
if (LEGAL.test(text)) return /* @__PURE__ */ new Set();
|
|
4739
|
+
const out = grams(text.replace(NOISE, " | "));
|
|
4740
|
+
for (const g of out) if (GRAMMAR.has(g)) out.delete(g);
|
|
4741
|
+
return out;
|
|
4742
|
+
}
|
|
4743
|
+
function properNouns(brief) {
|
|
4744
|
+
return new Set((brief.match(/\b[A-Z][a-z]+/g) ?? []).map((w) => w.toLowerCase()).filter((w) => !STOP.has(w)));
|
|
4745
|
+
}
|
|
4746
|
+
function harvest(models, options = {}) {
|
|
4747
|
+
const minShare = options.minShare ?? 0.25;
|
|
4748
|
+
const runs = models.flatMap((m, b) => m.runs.map((r) => ({ ...r, brief: b })));
|
|
4749
|
+
const of = runs.length;
|
|
4750
|
+
if (of === 0) return [];
|
|
4751
|
+
const multiBrief = models.length > 1;
|
|
4752
|
+
const sample = runs.map((r) => r.fingerprint);
|
|
4753
|
+
const tally = /* @__PURE__ */ new Map();
|
|
4754
|
+
const add = (kind, value, run, brief, example) => {
|
|
4755
|
+
const key = `${kind}\0${value}`;
|
|
4756
|
+
const t = tally.get(key) ?? { kind, value, runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example };
|
|
4757
|
+
t.runs.add(run);
|
|
4758
|
+
t.briefs.add(brief);
|
|
4759
|
+
tally.set(key, t);
|
|
4760
|
+
};
|
|
4761
|
+
runs.forEach((r, i) => {
|
|
4762
|
+
const c = choicesOf(r.fingerprint);
|
|
4763
|
+
for (const f4 of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f4, i, r.brief);
|
|
4764
|
+
add("accent", c.accent[0], i, r.brief);
|
|
4765
|
+
add("ground", c.ground[0], i, r.brief);
|
|
4766
|
+
add("shape", c.shape[0], i, r.brief);
|
|
4767
|
+
add("motion", c.motion[0], i, r.brief);
|
|
4768
|
+
add("opening", c.opening[0], i, r.brief);
|
|
4769
|
+
for (const t of r.tells) add("tell", t, i, r.brief);
|
|
4770
|
+
});
|
|
4771
|
+
const out = [];
|
|
4772
|
+
for (const t of tally.values()) {
|
|
4773
|
+
if (t.runs.size / of < minShare) continue;
|
|
4774
|
+
if (t.kind === "accent" && (t.value === "none" || t.value === "neutral")) continue;
|
|
4775
|
+
if (t.kind === "shape" && t.value === "no buttons") continue;
|
|
4776
|
+
if (t.kind === "ground" && (t.value === "white" || t.value === "grey")) continue;
|
|
4777
|
+
if (t.kind === "motion" && t.value === "no scroll reveals") continue;
|
|
4778
|
+
out.push({ kind: t.kind, value: t.value, runs: t.runs.size, of, briefs: t.briefs.size, known: knownFor(t.kind, t.value, sample) });
|
|
4779
|
+
}
|
|
4780
|
+
const phraseMin = Math.max(3, Math.ceil(of * minShare * 0.4));
|
|
4781
|
+
const names = models.map((m) => properNouns(m.brief));
|
|
4782
|
+
const phrases2 = /* @__PURE__ */ new Map();
|
|
4783
|
+
runs.forEach((r, i) => {
|
|
4784
|
+
for (const text of r.copy) {
|
|
4785
|
+
for (const g of textGrams(text)) {
|
|
4786
|
+
if (g.split(" ").some((w) => names[r.brief].has(w))) continue;
|
|
4787
|
+
const p = phrases2.get(g) ?? { runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example: text.trim().slice(0, 140) };
|
|
4788
|
+
p.runs.add(i);
|
|
4789
|
+
p.briefs.add(r.brief);
|
|
4790
|
+
phrases2.set(g, p);
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4793
|
+
});
|
|
4794
|
+
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);
|
|
4795
|
+
const maximal = kept.filter(([g, p]) => !kept.some(([h, q]) => h !== g && h.includes(g) && q.runs.size >= 0.8 * p.runs.size));
|
|
4796
|
+
for (const [g, p] of maximal.slice(0, 40)) {
|
|
4797
|
+
out.push({ kind: "phrase", value: g, runs: p.runs.size, of, briefs: p.briefs.size, known: options.copyTell?.(g) ?? null, example: p.example });
|
|
4798
|
+
}
|
|
4799
|
+
const order = ["face", "accent", "ground", "shape", "motion", "opening", "tell", "phrase"];
|
|
4800
|
+
return out.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.runs - a.runs);
|
|
4801
|
+
}
|
|
4802
|
+
function hueSwatch(runs, hue) {
|
|
4803
|
+
const hits = runs.map((r) => r.fingerprint.accent).filter((o) => !!o && hueName(o) === hue);
|
|
4804
|
+
if (hits.length === 0) return null;
|
|
4805
|
+
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);
|
|
4806
|
+
return formatHex(oklchToRgb(centre));
|
|
4807
|
+
}
|
|
4808
|
+
|
|
4809
|
+
// src/fingerprint/component.ts
|
|
4810
|
+
var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
|
|
4811
|
+
function componentsOf(fp, role) {
|
|
4812
|
+
return (fp.sections ?? []).filter((s) => s.role === role);
|
|
4813
|
+
}
|
|
4814
|
+
var clamp2 = (n) => Math.max(0, Math.min(1, n));
|
|
4815
|
+
var diff = (a, b, scale) => a === void 0 || b === void 0 ? null : clamp2(Math.abs(a - b) / scale);
|
|
4816
|
+
function componentDistance(a, b) {
|
|
4817
|
+
if (a.role !== b.role) return 1;
|
|
4818
|
+
const promo = (s) => (s.badges ?? []).some((t) => PROMO_BADGE.test(t));
|
|
4819
|
+
const parts2 = [
|
|
4820
|
+
diff(a.centred, b.centred, 1),
|
|
4821
|
+
diff(a.width, b.width, 0.5),
|
|
4822
|
+
diff(a.symmetry, b.symmetry, 0.5),
|
|
4823
|
+
diff(a.controls, b.controls, 2),
|
|
4824
|
+
diff(a.cards, b.cards, 3),
|
|
4825
|
+
diff(a.figures, b.figures, 3),
|
|
4826
|
+
diff(a.avatars, b.avatars, 3),
|
|
4827
|
+
a.carousel === void 0 || b.carousel === void 0 ? null : a.carousel === b.carousel ? 0 : 1,
|
|
4828
|
+
a.badges === void 0 || b.badges === void 0 ? null : promo(a) === promo(b) ? 0 : 1,
|
|
4829
|
+
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)
|
|
4830
|
+
];
|
|
4831
|
+
const known = parts2.filter((p) => p !== null);
|
|
4832
|
+
return known.length === 0 ? 0 : Math.round(known.reduce((s, p) => s + p, 0) / known.length * 1e3) / 1e3;
|
|
4833
|
+
}
|
|
4834
|
+
function componentShared(a, b) {
|
|
4835
|
+
if (a.role !== b.role) return [];
|
|
4836
|
+
const out = [];
|
|
4837
|
+
const both = (f4) => f4(a) && f4(b);
|
|
4838
|
+
if (both((s) => s.centred >= 0.7)) out.push("centred");
|
|
4839
|
+
else if (both((s) => s.centred < 0.3)) out.push("left-aligned");
|
|
4840
|
+
if (both((s) => s.width <= 0.6)) out.push("narrow column");
|
|
4841
|
+
else if (both((s) => s.width >= 0.8)) out.push("full width");
|
|
4842
|
+
if (a.controls !== void 0 && a.controls === b.controls && a.controls > 0) out.push(`${a.controls} button${a.controls === 1 ? "" : "s"}`);
|
|
4843
|
+
if (a.cards !== void 0 && a.cards === b.cards && a.cards >= 2) out.push(`${a.cards} cards`);
|
|
4844
|
+
if (both((s) => (s.figures ?? 0) >= 3)) out.push("a row of figures");
|
|
4845
|
+
if (both((s) => (s.avatars ?? 0) >= 2)) out.push("avatars");
|
|
4846
|
+
if (both((s) => s.carousel === true)) out.push("carousel");
|
|
4847
|
+
if (both((s) => (s.badges ?? []).some((t) => PROMO_BADGE.test(t)))) out.push("promo badge");
|
|
4848
|
+
if (a.background && b.background && deltaEOk(a.background, b.background) <= 0.06) out.push("same coloured band");
|
|
4849
|
+
else if (a.background === null && b.background === null) out.push("on the page ground");
|
|
4850
|
+
return out;
|
|
4851
|
+
}
|
|
4852
|
+
function closestComponents(a, b, role) {
|
|
4853
|
+
let best = null;
|
|
4854
|
+
for (const x of componentsOf(a, role)) {
|
|
4855
|
+
for (const y of componentsOf(b, role)) {
|
|
4856
|
+
const distance = componentDistance(x, y);
|
|
4857
|
+
if (!best || distance < best.distance) best = { distance, shared: componentShared(x, y) };
|
|
4858
|
+
}
|
|
4859
|
+
}
|
|
4860
|
+
return best;
|
|
4861
|
+
}
|
|
4862
|
+
function estateComponentPairs(register, role) {
|
|
4863
|
+
const out = [];
|
|
4864
|
+
const s = register.sites;
|
|
4865
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
4866
|
+
for (let j = i + 1; j < s.length; j += 1) {
|
|
4867
|
+
const c = closestComponents(s[i].fingerprint, s[j].fingerprint, role);
|
|
4868
|
+
if (c) out.push({ a: s[i].id, b: s[j].id, ...c });
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4871
|
+
return out.sort((x, y) => x.distance - y.distance);
|
|
4872
|
+
}
|
|
4873
|
+
var meanNearest2 = (d, k) => {
|
|
4874
|
+
const s = [...d].sort((a, b) => a - b).slice(0, k);
|
|
4875
|
+
return s.reduce((t, v) => t + v, 0) / s.length;
|
|
4876
|
+
};
|
|
4877
|
+
function componentTypicality(target2, runs, k = 3) {
|
|
4878
|
+
const pool = runs.flatMap((r) => componentsOf(r.fingerprint, target2.role).slice(0, 1));
|
|
4879
|
+
if (pool.length < MIN_RUNS) return null;
|
|
4880
|
+
const kk = Math.min(k, pool.length - 1);
|
|
4881
|
+
const distance = meanNearest2(pool.map((p) => componentDistance(target2, p)), kk);
|
|
4882
|
+
const loo = pool.map((p, i) => meanNearest2(pool.filter((_, j) => j !== i).map((o) => componentDistance(p, o)), kk));
|
|
4883
|
+
const score = loo.filter((d) => d >= distance).length / loo.length;
|
|
4884
|
+
const sorted = [...loo].sort((a, b) => a - b);
|
|
4885
|
+
const mid = Math.floor(sorted.length / 2);
|
|
4886
|
+
const baseline = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
4887
|
+
return {
|
|
4888
|
+
role: target2.role,
|
|
4889
|
+
score: Math.round(score * 100) / 100,
|
|
4890
|
+
typical: score >= TYPICAL_AT,
|
|
4891
|
+
distance: Math.round(distance * 1e3) / 1e3,
|
|
4892
|
+
baseline: Math.round(baseline * 1e3) / 1e3,
|
|
4893
|
+
runs: pool.length
|
|
4894
|
+
};
|
|
4895
|
+
}
|
|
4896
|
+
var COMPONENT_ROLES = ["cta-band", "footer-cta", "pricing", "testimonials", "faq", "process", "features", "team", "contact", "stats"];
|
|
4897
|
+
function componentTypicalities(fp, runs, k = 3) {
|
|
4898
|
+
const out = [];
|
|
4899
|
+
for (const role of COMPONENT_ROLES) {
|
|
4900
|
+
const first = componentsOf(fp, role)[0];
|
|
4901
|
+
const t = first ? componentTypicality(first, runs, k) : null;
|
|
4902
|
+
if (t) out.push(t);
|
|
4903
|
+
}
|
|
4904
|
+
return out;
|
|
4905
|
+
}
|
|
4906
|
+
|
|
4907
|
+
// src/snapshot/rendered-tells.ts
|
|
4908
|
+
var hit2 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
4909
|
+
var GROUND = "rgb(255, 255, 255)";
|
|
4910
|
+
var BAND = "rgb(31, 58, 46)";
|
|
4911
|
+
var geo = (patch = {}) => ({
|
|
4912
|
+
centredShare: 0.1,
|
|
4913
|
+
mirrorSymmetry: 0.6,
|
|
4914
|
+
whitespaceRatio: 0.7,
|
|
4915
|
+
contentWidthRatio: 0.85,
|
|
4916
|
+
background: GROUND,
|
|
4917
|
+
controls: 0,
|
|
4918
|
+
...patch
|
|
4919
|
+
});
|
|
4920
|
+
function withSection(index, patch, roles = []) {
|
|
4921
|
+
const base = makeSnapshot();
|
|
4922
|
+
return {
|
|
4923
|
+
...base,
|
|
4924
|
+
sections: base.sections.map((s, i) => ({ ...s, role: roles[i] ?? s.kind, geometry: geo(), badges: [], avatars: 0, carousel: false, figures: 0, ...i === index ? patch : {} }))
|
|
4925
|
+
};
|
|
4926
|
+
}
|
|
4927
|
+
var measured = (s) => s.geometry !== void 0 && s.role !== void 0;
|
|
4928
|
+
var RENDERED_TELLS = [
|
|
4929
|
+
{
|
|
4930
|
+
id: "cta-band-stock",
|
|
4931
|
+
name: "Stock call-to-action band",
|
|
4932
|
+
generation: 1,
|
|
4933
|
+
severity: "warn",
|
|
4934
|
+
surface: "rendered",
|
|
4935
|
+
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.",
|
|
4936
|
+
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.",
|
|
4937
|
+
rendered: {
|
|
4938
|
+
detect: (s) => s.sections.filter((x) => measured(x) && (x.role === "cta-band" || x.role === "footer-cta")).filter((x) => {
|
|
4939
|
+
const g = x.geometry;
|
|
4940
|
+
return g.centredShare >= 0.8 && g.background !== s.ground && g.controls >= 1 && g.controls <= 2 && g.contentWidthRatio <= 0.7;
|
|
4941
|
+
}).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)),
|
|
4942
|
+
fixtures: {
|
|
4943
|
+
flag: [
|
|
4944
|
+
withSection(4, { role: "footer-cta", label: "Boiler out today?", geometry: geo({ centredShare: 1, background: BAND, controls: 1, contentWidthRatio: 0.45 }) }),
|
|
4945
|
+
withSection(3, { role: "cta-band", label: "Ready to book?", geometry: geo({ centredShare: 0.9, background: BAND, controls: 2, contentWidthRatio: 0.5 }) })
|
|
4946
|
+
],
|
|
4947
|
+
pass: [
|
|
4948
|
+
makeSnapshot(),
|
|
4949
|
+
// The same ask, laid out like the page: left-aligned on the ground.
|
|
4950
|
+
withSection(4, { role: "footer-cta", label: "Book a visit", geometry: geo({ centredShare: 0.1, background: GROUND, controls: 1, contentWidthRatio: 0.85 }) })
|
|
4951
|
+
]
|
|
4952
|
+
}
|
|
4953
|
+
}
|
|
4954
|
+
},
|
|
4955
|
+
{
|
|
4956
|
+
id: "pricing-trio-popular",
|
|
4957
|
+
name: "Three-tier pricing with a popular badge",
|
|
4958
|
+
generation: 1,
|
|
4959
|
+
severity: "warn",
|
|
4960
|
+
surface: "rendered",
|
|
4961
|
+
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.",
|
|
4962
|
+
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.",
|
|
4963
|
+
rendered: {
|
|
4964
|
+
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)),
|
|
4965
|
+
fixtures: {
|
|
4966
|
+
flag: [withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: ["Most popular"], label: "Plans" })],
|
|
4967
|
+
pass: [
|
|
4968
|
+
makeSnapshot(),
|
|
4969
|
+
withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: [], label: "Prices" }),
|
|
4970
|
+
withSection(3, { role: "pricing", kind: "text", cards: 0, badges: [], label: "Price list" })
|
|
4971
|
+
]
|
|
4972
|
+
}
|
|
4973
|
+
}
|
|
4974
|
+
},
|
|
4975
|
+
{
|
|
4976
|
+
id: "testimonial-avatar-carousel",
|
|
4977
|
+
name: "Avatar testimonial carousel",
|
|
4978
|
+
generation: 1,
|
|
4979
|
+
severity: "warn",
|
|
4980
|
+
surface: "rendered",
|
|
4981
|
+
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.",
|
|
4982
|
+
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.",
|
|
4983
|
+
rendered: {
|
|
4984
|
+
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)),
|
|
4985
|
+
fixtures: {
|
|
4986
|
+
flag: [withSection(2, { role: "testimonials", avatars: 3, carousel: true, label: "What our clients say" })],
|
|
4987
|
+
pass: [
|
|
4988
|
+
makeSnapshot(),
|
|
4989
|
+
withSection(2, { role: "testimonials", avatars: 3, carousel: false, label: "Reviews" }),
|
|
4990
|
+
withSection(2, { role: "testimonials", avatars: 0, carousel: true, label: "Reviews" })
|
|
4991
|
+
]
|
|
4992
|
+
}
|
|
4993
|
+
}
|
|
4994
|
+
},
|
|
4995
|
+
{
|
|
4996
|
+
id: "faq-accordion-closer",
|
|
4997
|
+
name: "FAQ accordion as the closer",
|
|
4998
|
+
generation: 2,
|
|
4999
|
+
severity: "warn",
|
|
5000
|
+
surface: "rendered",
|
|
5001
|
+
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.",
|
|
5002
|
+
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.",
|
|
5003
|
+
rendered: {
|
|
5004
|
+
detect: (s) => {
|
|
5005
|
+
const body = s.sections.filter((x) => x.role !== "footer-cta" && x.role !== "cta-band");
|
|
5006
|
+
const last = body[body.length - 1];
|
|
5007
|
+
return last && measured(last) && last.role === "faq" ? [hit2(s, "the page ends on a question-and-answer list", last.label)] : [];
|
|
5008
|
+
},
|
|
5009
|
+
fixtures: {
|
|
5010
|
+
flag: [
|
|
5011
|
+
withSection(4, { role: "faq", label: "Common questions" }),
|
|
5012
|
+
{
|
|
5013
|
+
...withSection(3, { role: "faq", label: "FAQ" }),
|
|
5014
|
+
sections: withSection(3, { role: "faq", label: "FAQ" }).sections.map((x, i) => i === 4 ? { ...x, role: "footer-cta" } : x)
|
|
5015
|
+
}
|
|
5016
|
+
],
|
|
5017
|
+
pass: [makeSnapshot(), withSection(2, { role: "faq", label: "Common questions" })]
|
|
5018
|
+
}
|
|
5019
|
+
}
|
|
5020
|
+
},
|
|
5021
|
+
{
|
|
5022
|
+
id: "stats-row",
|
|
5023
|
+
name: "Row of big numbers",
|
|
5024
|
+
generation: 1,
|
|
5025
|
+
severity: "warn",
|
|
5026
|
+
surface: "rendered",
|
|
5027
|
+
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.",
|
|
5028
|
+
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.",
|
|
5029
|
+
rendered: {
|
|
5030
|
+
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)),
|
|
5031
|
+
fixtures: {
|
|
5032
|
+
flag: [withSection(3, { role: "stats", kind: "stats", figures: 4, label: "500+" })],
|
|
5033
|
+
pass: [
|
|
5034
|
+
makeSnapshot(),
|
|
5035
|
+
withSection(3, { role: "text", kind: "text", figures: 1, label: "Since 2009" }),
|
|
5036
|
+
withSection(1, { role: "stats", kind: "stats", figures: 4, label: "500+" })
|
|
5037
|
+
]
|
|
5038
|
+
}
|
|
5039
|
+
}
|
|
5040
|
+
},
|
|
5041
|
+
{
|
|
5042
|
+
id: "centred-everything",
|
|
5043
|
+
name: "Every section centred",
|
|
5044
|
+
generation: 2,
|
|
5045
|
+
severity: "warn",
|
|
5046
|
+
surface: "rendered",
|
|
5047
|
+
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.",
|
|
5048
|
+
fix: "Pick an alignment and hold it: left-aligned text with a clear edge, keeping centring for the one moment that earns it.",
|
|
5049
|
+
rendered: {
|
|
5050
|
+
detect: (s) => {
|
|
5051
|
+
const m = s.sections.filter(measured);
|
|
5052
|
+
if (m.length < 4) return [];
|
|
5053
|
+
const centred = m.filter((x) => x.geometry.centredShare >= 0.7).length;
|
|
5054
|
+
return centred / m.length >= 0.8 ? [hit2(s, `${centred} of ${m.length} sections centre their text`)] : [];
|
|
5055
|
+
},
|
|
5056
|
+
fixtures: {
|
|
5057
|
+
flag: [{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x) => ({ ...x, role: x.kind, geometry: geo({ centredShare: 0.9 }) })) }],
|
|
5058
|
+
pass: [
|
|
5059
|
+
makeSnapshot(),
|
|
5060
|
+
{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x, i) => ({ ...x, role: x.kind, geometry: geo({ centredShare: i < 2 ? 0.9 : 0.1 }) })) }
|
|
5061
|
+
]
|
|
5062
|
+
}
|
|
5063
|
+
}
|
|
5064
|
+
}
|
|
5065
|
+
];
|
|
5066
|
+
|
|
5067
|
+
// src/snapshot/imagery-tells.ts
|
|
5068
|
+
var hit3 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
5069
|
+
var STOCK_PHOTO_HOSTS = {
|
|
5070
|
+
"unsplash.com": "Unsplash",
|
|
5071
|
+
"pexels.com": "Pexels",
|
|
5072
|
+
"pixabay.com": "Pixabay",
|
|
5073
|
+
"istockphoto.com": "iStock",
|
|
5074
|
+
"gettyimages.com": "Getty Images",
|
|
5075
|
+
"shutterstock.com": "Shutterstock",
|
|
5076
|
+
"ftcdn.net": "Adobe Stock",
|
|
5077
|
+
"dreamstime.com": "Dreamstime",
|
|
5078
|
+
"depositphotos.com": "Depositphotos",
|
|
5079
|
+
"freepik.com": "Freepik",
|
|
5080
|
+
"rawpixel.com": "rawpixel",
|
|
5081
|
+
"stocksnap.io": "StockSnap",
|
|
5082
|
+
"burst.shopifycdn.com": "Burst",
|
|
5083
|
+
"picsum.photos": "Lorem Picsum",
|
|
5084
|
+
"loremflickr.com": "LoremFlickr",
|
|
5085
|
+
"placehold.co": "a placeholder service",
|
|
5086
|
+
"placeholder.com": "a placeholder service",
|
|
5087
|
+
"dummyimage.com": "a placeholder service"
|
|
5088
|
+
};
|
|
5089
|
+
var STOCK_AVATAR_HOSTS = {
|
|
5090
|
+
"randomuser.me": "randomuser.me",
|
|
5091
|
+
"pravatar.cc": "Pravatar",
|
|
5092
|
+
"dicebear.com": "DiceBear",
|
|
5093
|
+
"xsgames.co": "a random-face service",
|
|
5094
|
+
"thispersondoesnotexist.com": "a generated face",
|
|
5095
|
+
"generated.photos": "a generated face"
|
|
5096
|
+
};
|
|
5097
|
+
var STOCK_FILENAMES = [
|
|
5098
|
+
[/(?:^|[/_-])shutterstock[_-]?\d{4,}/i, "Shutterstock"],
|
|
5099
|
+
[/(?:^|[/_-])istock(?:photo)?[_-]?\d{4,}/i, "iStock"],
|
|
5100
|
+
[/(?:^|[/_-])adobestock[_-]?\d{4,}/i, "Adobe Stock"],
|
|
5101
|
+
[/(?:^|[/_-])gettyimages[_-]?\d{4,}/i, "Getty Images"],
|
|
5102
|
+
[/(?:^|[/_-])depositphotos[_-]?\d{4,}/i, "Depositphotos"],
|
|
5103
|
+
[/(?:^|[/_-])dreamstime[_-]/i, "Dreamstime"],
|
|
5104
|
+
[/(?:^|[/_-])pexels-[a-z0-9-]*\d{4,}/i, "Pexels"],
|
|
5105
|
+
[/-unsplash\.(?:jpe?g|png|webp|avif)(?:[?#]|$)/i, "Unsplash"],
|
|
5106
|
+
// Unsplash's photo ids: photo-<13-digit timestamp>-<12 hex>.
|
|
5107
|
+
[/photo-1\d{12}-[0-9a-f]{12}/, "Unsplash"]
|
|
5108
|
+
];
|
|
5109
|
+
var byHost = (host, table) => {
|
|
5110
|
+
if (!host) return null;
|
|
5111
|
+
const h = host.toLowerCase();
|
|
5112
|
+
for (const [domain, name] of Object.entries(table)) if (h === domain || h.endsWith(`.${domain}`)) return name;
|
|
5113
|
+
return null;
|
|
5114
|
+
};
|
|
5115
|
+
function stockSource(image) {
|
|
5116
|
+
const host = byHost(image.host, STOCK_PHOTO_HOSTS);
|
|
5117
|
+
if (host) return host;
|
|
5118
|
+
let path = image.src.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, "").replace(/[?#].*$/, "");
|
|
5119
|
+
try {
|
|
5120
|
+
path = decodeURIComponent(path);
|
|
5121
|
+
} catch {
|
|
5122
|
+
}
|
|
5123
|
+
for (const [re, name] of STOCK_FILENAMES) if (re.test(path)) return name;
|
|
5124
|
+
return null;
|
|
5125
|
+
}
|
|
5126
|
+
function stockAvatarSource(image) {
|
|
5127
|
+
return byHost(image.host, STOCK_AVATAR_HOSTS) ?? stockSource(image);
|
|
5128
|
+
}
|
|
5129
|
+
var picture = (patch = {}) => ({
|
|
5130
|
+
src: "https://example.test/images/boiler-swap-hinton.jpg",
|
|
5131
|
+
host: "example.test",
|
|
5132
|
+
width: 640,
|
|
5133
|
+
height: 480,
|
|
5134
|
+
top: 1800,
|
|
5135
|
+
role: "photo",
|
|
5136
|
+
alt: "The new combi boiler fitted in a kitchen cupboard in Hinton",
|
|
5137
|
+
decorative: false,
|
|
5138
|
+
section: 2,
|
|
5139
|
+
background: false,
|
|
5140
|
+
...patch
|
|
5141
|
+
});
|
|
5142
|
+
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
5143
|
+
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
5144
|
+
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
5145
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
5146
|
+
var IMAGERY_TELLS = [
|
|
5147
|
+
{
|
|
5148
|
+
id: "stock-photo",
|
|
5149
|
+
name: "Stock photography",
|
|
5150
|
+
generation: 1,
|
|
5151
|
+
severity: "warn",
|
|
5152
|
+
surface: "rendered",
|
|
5153
|
+
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.",
|
|
5154
|
+
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.",
|
|
5155
|
+
rendered: {
|
|
5156
|
+
detect: (s) => {
|
|
5157
|
+
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
5158
|
+
if (found.length === 0) return [];
|
|
5159
|
+
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
5160
|
+
return [hit3(s, `${count2(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
5161
|
+
},
|
|
5162
|
+
fixtures: {
|
|
5163
|
+
flag: [
|
|
5164
|
+
withImages([picture({ src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=1600", host: "images.unsplash.com", alt: "Plumber fixing a sink" })]),
|
|
5165
|
+
// Downloaded and self-hosted, found by the library's own file name.
|
|
5166
|
+
withImages([picture({ src: "https://example.test/img/shutterstock_1234567890.jpg" })])
|
|
5167
|
+
],
|
|
5168
|
+
pass: [makeSnapshot(), withImages([picture()]), withImages([picture({ role: "icon", width: 48, height: 48, src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952", host: "images.unsplash.com" })])]
|
|
5169
|
+
}
|
|
5170
|
+
}
|
|
5171
|
+
},
|
|
5172
|
+
{
|
|
5173
|
+
id: "stock-avatar",
|
|
5174
|
+
name: "Stock or placeholder faces",
|
|
5175
|
+
generation: 1,
|
|
5176
|
+
severity: "warn",
|
|
5177
|
+
surface: "rendered",
|
|
5178
|
+
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.",
|
|
5179
|
+
fix: "Drop the portraits. Name the reviewer, the town and the job, and link the review to where it was left.",
|
|
5180
|
+
rendered: {
|
|
5181
|
+
detect: (s) => {
|
|
5182
|
+
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
5183
|
+
if (found.length === 0) return [];
|
|
5184
|
+
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
5185
|
+
return [hit3(s, `${count2(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
5186
|
+
},
|
|
5187
|
+
fixtures: {
|
|
5188
|
+
flag: [
|
|
5189
|
+
withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://i.pravatar.cc/150?img=12", host: "i.pravatar.cc", alt: "Sarah" })]),
|
|
5190
|
+
withImages([picture({ role: "avatar", width: 64, height: 64, src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?crop=faces", host: "images.unsplash.com" })])
|
|
5191
|
+
],
|
|
5192
|
+
pass: [makeSnapshot(), withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://example.test/team/sam.jpg", alt: "Sam, engineer" })])]
|
|
5193
|
+
}
|
|
5194
|
+
}
|
|
5195
|
+
},
|
|
5196
|
+
{
|
|
5197
|
+
id: "ai-image",
|
|
5198
|
+
name: "Generated image",
|
|
5199
|
+
generation: 2,
|
|
5200
|
+
severity: "warn",
|
|
5201
|
+
surface: "rendered",
|
|
5202
|
+
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.",
|
|
5203
|
+
fix: "Replace it with a photo of the real thing: the work, the premises, the people. Where there is none yet, use fewer pictures.",
|
|
5204
|
+
rendered: {
|
|
5205
|
+
detect: (s) => {
|
|
5206
|
+
const found = (s.images ?? []).filter((i) => i.provenance?.digitalSourceType);
|
|
5207
|
+
if (found.length === 0) return [];
|
|
5208
|
+
const first = found[0].provenance;
|
|
5209
|
+
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
5210
|
+
return [hit3(s, `${count2(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
5211
|
+
},
|
|
5212
|
+
fixtures: {
|
|
5213
|
+
flag: [
|
|
5214
|
+
withImages([picture({ provenance: AI_XMP })]),
|
|
5215
|
+
withImages([picture({ provenance: { digitalSourceType: "compositeWithTrainedAlgorithmicMedia", source: "c2pa", c2pa: true, bytes: 12e4 } })])
|
|
5216
|
+
],
|
|
5217
|
+
// Credentials that say nothing about a model, and an image never read, are not findings.
|
|
5218
|
+
pass: [makeSnapshot(), withImages([picture({ provenance: NOTHING })]), withImages([picture({ provenance: { ...NOTHING, c2pa: true } })]), withImages([picture()])]
|
|
5219
|
+
}
|
|
5220
|
+
}
|
|
5221
|
+
},
|
|
5222
|
+
{
|
|
5223
|
+
id: "no-real-imagery",
|
|
5224
|
+
name: "No photograph on the page",
|
|
5225
|
+
generation: 1,
|
|
5226
|
+
severity: "warn",
|
|
5227
|
+
surface: "rendered",
|
|
5228
|
+
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.",
|
|
5229
|
+
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.",
|
|
5230
|
+
rendered: {
|
|
5231
|
+
detect: (s) => {
|
|
5232
|
+
if (!s.images || s.sections.length < 4) return [];
|
|
5233
|
+
if (s.images.some((i) => i.role === "photo")) return [];
|
|
5234
|
+
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
5235
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count2(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
5236
|
+
},
|
|
5237
|
+
fixtures: {
|
|
5238
|
+
flag: [
|
|
5239
|
+
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" })]),
|
|
5240
|
+
withImages([])
|
|
5241
|
+
],
|
|
5242
|
+
// A snapshot with no image record is never judged; nor is a page too short to be a site.
|
|
5243
|
+
pass: [makeSnapshot(), withImages([picture()]), { ...withImages([]), sections: makeSnapshot().sections.slice(0, 3) }]
|
|
5244
|
+
}
|
|
5245
|
+
}
|
|
5246
|
+
}
|
|
5247
|
+
];
|
|
5248
|
+
|
|
4434
5249
|
// 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
|
-
|
|
5250
|
+
var CATALOGUE_VERSION = "2026.09.11";
|
|
5251
|
+
var CATALOGUE = [
|
|
5252
|
+
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
5253
|
+
...RENDERED_TELLS,
|
|
5254
|
+
...IMAGERY_TELLS
|
|
5255
|
+
];
|
|
4439
5256
|
function tellById(id) {
|
|
4440
5257
|
return CATALOGUE.find((t) => t.id === id);
|
|
4441
5258
|
}
|
|
@@ -4457,17 +5274,17 @@ var MARKER = /\b(?:copy|craft)-ok\b/;
|
|
|
4457
5274
|
function marked(lines, line) {
|
|
4458
5275
|
return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
|
|
4459
5276
|
}
|
|
4460
|
-
function toFinding(tell,
|
|
4461
|
-
const text = texts.get(
|
|
5277
|
+
function toFinding(tell, hit4, texts) {
|
|
5278
|
+
const text = texts.get(hit4.path) ?? "";
|
|
4462
5279
|
return {
|
|
4463
5280
|
tell: tell.id,
|
|
4464
5281
|
name: tell.name,
|
|
4465
5282
|
generation: tell.generation,
|
|
4466
5283
|
severity: tell.severity,
|
|
4467
|
-
path:
|
|
4468
|
-
line: texts.has(
|
|
4469
|
-
excerpt:
|
|
4470
|
-
message:
|
|
5284
|
+
path: hit4.path,
|
|
5285
|
+
line: texts.has(hit4.path) ? lineAt(text, hit4.offset) : 0,
|
|
5286
|
+
excerpt: hit4.excerpt ?? excerptAt(text, hit4.offset),
|
|
5287
|
+
message: hit4.message,
|
|
4471
5288
|
fix: tell.fix,
|
|
4472
5289
|
...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
|
|
4473
5290
|
};
|
|
@@ -4486,14 +5303,14 @@ function report(tells, files, run, options) {
|
|
|
4486
5303
|
};
|
|
4487
5304
|
const excepted = /* @__PURE__ */ new Map();
|
|
4488
5305
|
for (const tell of selected) {
|
|
4489
|
-
for (const
|
|
4490
|
-
const exception = applied.find((e) => e.tell === tell.id && (!e.path ||
|
|
5306
|
+
for (const hit4 of run(tell)) {
|
|
5307
|
+
const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit4.path.includes(e.path)));
|
|
4491
5308
|
if (exception) {
|
|
4492
5309
|
excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
|
|
4493
5310
|
continue;
|
|
4494
5311
|
}
|
|
4495
|
-
const finding = toFinding(tell,
|
|
4496
|
-
if (marked(linesOf(
|
|
5312
|
+
const finding = toFinding(tell, hit4, texts);
|
|
5313
|
+
if (marked(linesOf(hit4.path), finding.line)) {
|
|
4497
5314
|
suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
|
|
4498
5315
|
continue;
|
|
4499
5316
|
}
|
|
@@ -4511,7 +5328,7 @@ function report(tells, files, run, options) {
|
|
|
4511
5328
|
catalogueVersion: CATALOGUE_VERSION,
|
|
4512
5329
|
findings,
|
|
4513
5330
|
rejectedExceptions: rejected,
|
|
4514
|
-
excepted: [...excepted].map(([e,
|
|
5331
|
+
excepted: [...excepted].map(([e, count3]) => ({ tell: e.tell, because: e.because, count: count3 })),
|
|
4515
5332
|
suppressed,
|
|
4516
5333
|
summary: {
|
|
4517
5334
|
files: files.length,
|
|
@@ -4547,6 +5364,7 @@ function auditSnapshot(snapshot, options = {}) {
|
|
|
4547
5364
|
}
|
|
4548
5365
|
function runTell(tell, files) {
|
|
4549
5366
|
if (tell.surface === "source") return tell.detect({ files: files.map(parseFile) });
|
|
5367
|
+
if (tell.surface === "rendered") throw new Error(`${tell.id} is seen on a rendered page only; run tell.rendered.detect(snapshot)`);
|
|
4550
5368
|
return tell.detect(copyContext(files));
|
|
4551
5369
|
}
|
|
4552
5370
|
function catalogueTable() {
|
|
@@ -4999,6 +5817,41 @@ function toJson(data) {
|
|
|
4999
5817
|
return JSON.stringify({ schemaVersion: JSON_SCHEMA_VERSION, ...data }, null, 2);
|
|
5000
5818
|
}
|
|
5001
5819
|
|
|
5820
|
+
// src/snapshot/provenance.ts
|
|
5821
|
+
var PROVENANCE_SCAN_BYTES = 256 * 1024;
|
|
5822
|
+
var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
|
|
5823
|
+
var MARKER2 = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
|
|
5824
|
+
var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
|
|
5825
|
+
var C2PA_PNG = /caBX/;
|
|
5826
|
+
function latin1(bytes) {
|
|
5827
|
+
let out = "";
|
|
5828
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
5829
|
+
out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
|
|
5830
|
+
}
|
|
5831
|
+
return out;
|
|
5832
|
+
}
|
|
5833
|
+
function xmpPackets(text) {
|
|
5834
|
+
const out = [];
|
|
5835
|
+
const open = /<x:xmpmeta\b|<\?xpacket begin/g;
|
|
5836
|
+
let m;
|
|
5837
|
+
while (m = open.exec(text)) {
|
|
5838
|
+
const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
|
|
5839
|
+
out.push([m.index, close === -1 ? text.length : close]);
|
|
5840
|
+
if (close === -1) break;
|
|
5841
|
+
open.lastIndex = close;
|
|
5842
|
+
}
|
|
5843
|
+
return out;
|
|
5844
|
+
}
|
|
5845
|
+
function scanProvenance(bytes) {
|
|
5846
|
+
const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
|
|
5847
|
+
const text = latin1(head);
|
|
5848
|
+
const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
|
|
5849
|
+
const m = MARKER2.exec(text);
|
|
5850
|
+
if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
|
|
5851
|
+
const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
|
|
5852
|
+
return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
|
|
5853
|
+
}
|
|
5854
|
+
|
|
5002
5855
|
// src/snapshot/migrate.ts
|
|
5003
5856
|
var READABLE_SNAPSHOT_VERSIONS = [1, 2];
|
|
5004
5857
|
function readSnapshot(data, source = "snapshot") {
|
|
@@ -5040,143 +5893,34 @@ function visualMeasures(img, maxSamples = 1e6) {
|
|
|
5040
5893
|
const yb = 0.5 * (r + g) - b;
|
|
5041
5894
|
sumRg += rg;
|
|
5042
5895
|
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);
|
|
5896
|
+
sumRg2 += rg * rg;
|
|
5897
|
+
sumYb2 += yb * yb;
|
|
5898
|
+
n += 1;
|
|
5899
|
+
const l = lum(x, y);
|
|
5900
|
+
if (x + step < w && y + step < h) {
|
|
5901
|
+
if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
|
|
5902
|
+
edgeN += 1;
|
|
5903
|
+
}
|
|
5904
|
+
if (x < w / 2) {
|
|
5905
|
+
asym += Math.abs(l - lum(w - 1 - x, y));
|
|
5906
|
+
asymN += 1;
|
|
5907
|
+
}
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5910
|
+
const meanRg = sumRg / n;
|
|
5911
|
+
const meanYb = sumYb / n;
|
|
5912
|
+
const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
|
|
5913
|
+
const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
|
|
5914
|
+
const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
|
|
5915
|
+
const round2 = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
|
|
5916
|
+
return {
|
|
5917
|
+
colourfulness: round2(colourfulness, 1),
|
|
5918
|
+
edgeDensity: round2(edgeN ? edges / edgeN : 0),
|
|
5919
|
+
symmetry: round2(asymN ? 1 - asym / asymN / 255 : 1),
|
|
5920
|
+
width: w,
|
|
5921
|
+
height: h,
|
|
5922
|
+
sampled: n
|
|
5923
|
+
};
|
|
5180
5924
|
}
|
|
5181
5925
|
|
|
5182
5926
|
// src/direction/types.ts
|
|
@@ -5313,9 +6057,9 @@ function licencesJson() {
|
|
|
5313
6057
|
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
6058
|
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
6059
|
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
|
|
6060
|
+
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
6061
|
function words(text) {
|
|
5318
|
-
return new Set((text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).map((w) => w.replace(/'s$/, "")).filter((w) => !
|
|
6062
|
+
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
6063
|
}
|
|
5320
6064
|
function tellFor(key, value) {
|
|
5321
6065
|
if (key === "accent") {
|
|
@@ -5448,14 +6192,14 @@ function validateDirection(input, ctx = {}) {
|
|
|
5448
6192
|
if (fp && typeof c.value === "string") {
|
|
5449
6193
|
if ((key === "accent" || key === "ground") && parseColour(c.value)) {
|
|
5450
6194
|
const declared = parseColour(c.value).oklch;
|
|
5451
|
-
const
|
|
5452
|
-
if (
|
|
5453
|
-
warn(`${at}.value`, `the page does not show it: its ${key} measures oklch(${
|
|
6195
|
+
const measured2 = key === "accent" ? fp.accent : fp.ground;
|
|
6196
|
+
if (measured2 && deltaEOk(declared, measured2) > 0.08) {
|
|
6197
|
+
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
6198
|
}
|
|
5455
6199
|
}
|
|
5456
6200
|
if (key === "display" || key === "body") {
|
|
5457
|
-
const
|
|
5458
|
-
if (normaliseFamily(c.value).toLowerCase() !==
|
|
6201
|
+
const measured2 = key === "display" ? fp.display.family : fp.body.family;
|
|
6202
|
+
if (normaliseFamily(c.value).toLowerCase() !== measured2.toLowerCase()) warn(`${at}.value`, `the page sets ${measured2}, not ${c.value}`);
|
|
5459
6203
|
}
|
|
5460
6204
|
}
|
|
5461
6205
|
if (ok) decided += 1;
|
|
@@ -5466,8 +6210,8 @@ function validateDirection(input, ctx = {}) {
|
|
|
5466
6210
|
// src/direction/init.ts
|
|
5467
6211
|
var hex = (o) => {
|
|
5468
6212
|
const { r, g, b } = oklchToRgb(o);
|
|
5469
|
-
const
|
|
5470
|
-
return formatHex({ r:
|
|
6213
|
+
const clamp3 = (n) => Math.min(1, Math.max(0, n));
|
|
6214
|
+
return formatHex({ r: clamp3(r), g: clamp3(g), b: clamp3(b) });
|
|
5471
6215
|
};
|
|
5472
6216
|
function initDirection(input) {
|
|
5473
6217
|
const fp = input.current;
|
|
@@ -5607,240 +6351,6 @@ function propose(input) {
|
|
|
5607
6351
|
};
|
|
5608
6352
|
}
|
|
5609
6353
|
|
|
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
6354
|
// src/estate/index.ts
|
|
5845
6355
|
var ESTATE_VERSION = 1;
|
|
5846
6356
|
var SIBLING_AT = 0.31;
|
|
@@ -6098,6 +6608,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6098
6608
|
// Annotate the CommonJS export names for ESM import in node:
|
|
6099
6609
|
0 && (module.exports = {
|
|
6100
6610
|
AI_PHRASES,
|
|
6611
|
+
AI_SOURCE_TYPES,
|
|
6101
6612
|
AI_VIOLET,
|
|
6102
6613
|
AI_WORDS,
|
|
6103
6614
|
BASELINE_VERSION,
|
|
@@ -6105,6 +6616,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6105
6616
|
CATALOGUE,
|
|
6106
6617
|
CATALOGUE_VERSION,
|
|
6107
6618
|
CHOICE_KEYS,
|
|
6619
|
+
COMPONENT_ROLES,
|
|
6108
6620
|
DEFAULT_IGNORE,
|
|
6109
6621
|
DENSITIES,
|
|
6110
6622
|
DENSITY_NAMES,
|
|
@@ -6122,6 +6634,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6122
6634
|
HOUSE_BUDGET,
|
|
6123
6635
|
HOUSE_LOCKED,
|
|
6124
6636
|
HOUSE_TYPE,
|
|
6637
|
+
IMAGERY_TELLS,
|
|
6125
6638
|
JSON_SCHEMA_VERSION,
|
|
6126
6639
|
LICENCES,
|
|
6127
6640
|
LIGHTNESS_CURVE,
|
|
@@ -6131,12 +6644,15 @@ function retrofitPlan(report2, direction) {
|
|
|
6131
6644
|
NULL_VERSION,
|
|
6132
6645
|
PILL_LIMIT,
|
|
6133
6646
|
PLAINER_WORDS,
|
|
6647
|
+
PROMO_BADGE,
|
|
6134
6648
|
PROPOSED,
|
|
6649
|
+
PROVENANCE_SCAN_BYTES,
|
|
6135
6650
|
RAMP_STEPS,
|
|
6136
6651
|
READABLE_SNAPSHOT_VERSIONS,
|
|
6137
6652
|
REFLEX_FONTS_1,
|
|
6138
6653
|
REFLEX_FONTS_2,
|
|
6139
6654
|
RENDERED_PATHS,
|
|
6655
|
+
RENDERED_TELLS,
|
|
6140
6656
|
REVEAL_LIMIT,
|
|
6141
6657
|
REVIEW_PHRASES,
|
|
6142
6658
|
SCALE,
|
|
@@ -6147,6 +6663,8 @@ function retrofitPlan(report2, direction) {
|
|
|
6147
6663
|
SOURCE_KINDS,
|
|
6148
6664
|
SPACE_STEPS,
|
|
6149
6665
|
SPRING,
|
|
6666
|
+
STOCK_AVATAR_HOSTS,
|
|
6667
|
+
STOCK_PHOTO_HOSTS,
|
|
6150
6668
|
STOCK_PHRASES,
|
|
6151
6669
|
TELL_HEAVY,
|
|
6152
6670
|
TYPE_STEPS,
|
|
@@ -6165,8 +6683,14 @@ function retrofitPlan(report2, direction) {
|
|
|
6165
6683
|
checkPair,
|
|
6166
6684
|
checkRestraint,
|
|
6167
6685
|
choicesOf,
|
|
6686
|
+
closestComponents,
|
|
6168
6687
|
compareFacts,
|
|
6169
6688
|
compareToEstate,
|
|
6689
|
+
componentDistance,
|
|
6690
|
+
componentShared,
|
|
6691
|
+
componentTypicalities,
|
|
6692
|
+
componentTypicality,
|
|
6693
|
+
componentsOf,
|
|
6170
6694
|
craftTokens,
|
|
6171
6695
|
createBaseline,
|
|
6172
6696
|
deltaEOk,
|
|
@@ -6175,6 +6699,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6175
6699
|
densityTokens,
|
|
6176
6700
|
emitCss,
|
|
6177
6701
|
emptyEstate,
|
|
6702
|
+
estateComponentPairs,
|
|
6178
6703
|
estatePairs,
|
|
6179
6704
|
exitDuration,
|
|
6180
6705
|
extractHtml,
|
|
@@ -6228,6 +6753,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6228
6753
|
readSnapshot,
|
|
6229
6754
|
retrofitPlan,
|
|
6230
6755
|
runTell,
|
|
6756
|
+
scanProvenance,
|
|
6231
6757
|
scanSource,
|
|
6232
6758
|
sectionRhythm,
|
|
6233
6759
|
semantic,
|
|
@@ -6236,6 +6762,8 @@ function retrofitPlan(report2, direction) {
|
|
|
6236
6762
|
sharedParts,
|
|
6237
6763
|
shouldAnimate,
|
|
6238
6764
|
siblingLine,
|
|
6765
|
+
stockAvatarSource,
|
|
6766
|
+
stockSource,
|
|
6239
6767
|
tailwindV3Preset,
|
|
6240
6768
|
tailwindV4Theme,
|
|
6241
6769
|
tellById,
|