@domandigital/craft 0.5.0 → 0.6.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 +85 -0
- package/CHARACTER.md +335 -0
- package/PRINCIPLES.md +14 -0
- package/README.md +36 -1
- package/STANDARD.md +4 -1
- package/art-direction.schema.json +59 -0
- package/dist/audit.cjs +2444 -0
- package/dist/audit.d.cts +376 -0
- package/dist/audit.d.ts +376 -0
- package/dist/audit.js +2412 -0
- package/dist/chunk-GWMSSRIQ.js +2542 -0
- package/dist/chunk-HYGFP3LB.js +68 -0
- package/dist/cli-7ME4VIRG.js +470 -0
- package/dist/cli-F7W4F3ER.js +488 -0
- package/dist/cli.js +21 -0
- package/dist/index.cjs +2818 -20
- package/dist/index.d.cts +726 -1
- package/dist/index.d.ts +726 -1
- package/dist/index.js +2772 -20
- package/package.json +27 -4
package/dist/audit.cjs
ADDED
|
@@ -0,0 +1,2444 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/audit/index.ts
|
|
21
|
+
var audit_exports = {};
|
|
22
|
+
__export(audit_exports, {
|
|
23
|
+
audit: () => audit,
|
|
24
|
+
collectInPage: () => collectInPage,
|
|
25
|
+
loadPlaywright: () => loadPlaywright,
|
|
26
|
+
overlayInPage: () => overlayInPage,
|
|
27
|
+
snapshotPage: () => snapshotPage,
|
|
28
|
+
snapshotUrl: () => snapshotUrl
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(audit_exports);
|
|
31
|
+
|
|
32
|
+
// src/character/tells/copy.ts
|
|
33
|
+
var f = (path, text) => ({ path, text });
|
|
34
|
+
var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
|
|
35
|
+
function allText(ctx) {
|
|
36
|
+
return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
|
|
37
|
+
}
|
|
38
|
+
function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
|
|
39
|
+
return (ctx) => {
|
|
40
|
+
const hits = [];
|
|
41
|
+
for (const block of source(ctx)) {
|
|
42
|
+
const spans = [];
|
|
43
|
+
for (const pattern of patterns) {
|
|
44
|
+
const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
45
|
+
for (const m of block.text.matchAll(global)) {
|
|
46
|
+
if (skip(block, m[0], m.index ?? 0)) continue;
|
|
47
|
+
spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
51
|
+
let end = -1;
|
|
52
|
+
for (const [start, stop, text] of spans) {
|
|
53
|
+
if (start < end) continue;
|
|
54
|
+
end = stop;
|
|
55
|
+
const lead = text.length - text.trimStart().length;
|
|
56
|
+
hits.push({ path: block.path, offset: block.offset + start + lead, message: label(text.trim()) });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return hits;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/'/g, "['\u2019]?").replace(/ /g, "\\s+");
|
|
63
|
+
var wordList = (words) => new RegExp(`\\b(?:${[...words].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
|
|
64
|
+
var quoted = (m) => `"${m}"`;
|
|
65
|
+
var AI_WORDS = [
|
|
66
|
+
"tapestry",
|
|
67
|
+
"testament",
|
|
68
|
+
"realm",
|
|
69
|
+
"embark",
|
|
70
|
+
"embarking",
|
|
71
|
+
"unleash",
|
|
72
|
+
"unlocks",
|
|
73
|
+
"unlocking",
|
|
74
|
+
"elevate",
|
|
75
|
+
"elevates",
|
|
76
|
+
"elevated",
|
|
77
|
+
"elevating",
|
|
78
|
+
"bustling",
|
|
79
|
+
"nestled",
|
|
80
|
+
"meticulous",
|
|
81
|
+
"meticulously",
|
|
82
|
+
"paramount",
|
|
83
|
+
"furthermore",
|
|
84
|
+
"moreover",
|
|
85
|
+
"navigating",
|
|
86
|
+
"ever-evolving",
|
|
87
|
+
"unparalleled"
|
|
88
|
+
];
|
|
89
|
+
var STOCK_PHRASES = [
|
|
90
|
+
"in today's digital age",
|
|
91
|
+
"to the next level",
|
|
92
|
+
"one-stop shop",
|
|
93
|
+
"second to none",
|
|
94
|
+
"we pride ourselves",
|
|
95
|
+
"passionate about",
|
|
96
|
+
"your journey",
|
|
97
|
+
"our journey",
|
|
98
|
+
"curated",
|
|
99
|
+
"crafted with care",
|
|
100
|
+
"tailored to your",
|
|
101
|
+
"peace of mind"
|
|
102
|
+
];
|
|
103
|
+
var AI_PHRASES = [
|
|
104
|
+
"let's dive in",
|
|
105
|
+
"dive into",
|
|
106
|
+
"let's unpack",
|
|
107
|
+
"let's break this down",
|
|
108
|
+
"let's break it down",
|
|
109
|
+
"here's the thing",
|
|
110
|
+
"it's important to note",
|
|
111
|
+
"it's important to remember",
|
|
112
|
+
"it is worth noting that",
|
|
113
|
+
"at its core",
|
|
114
|
+
"the key takeaway",
|
|
115
|
+
"the reality is",
|
|
116
|
+
"the truth is",
|
|
117
|
+
"increasingly digital",
|
|
118
|
+
"seamless",
|
|
119
|
+
"seamlessly",
|
|
120
|
+
"unlock your",
|
|
121
|
+
"unlock the power",
|
|
122
|
+
"meaningful impact",
|
|
123
|
+
"drive meaningful",
|
|
124
|
+
"foster collaboration",
|
|
125
|
+
"fosters collaboration",
|
|
126
|
+
"fostering collaboration",
|
|
127
|
+
"comprehensive guide to",
|
|
128
|
+
"look no further",
|
|
129
|
+
"in the event that",
|
|
130
|
+
"at no additional cost to you",
|
|
131
|
+
"rest assured",
|
|
132
|
+
"when it comes to",
|
|
133
|
+
"we have got you covered",
|
|
134
|
+
"we've got you covered"
|
|
135
|
+
];
|
|
136
|
+
var PLAINER_WORDS = [
|
|
137
|
+
"empower",
|
|
138
|
+
"empowers",
|
|
139
|
+
"empowered",
|
|
140
|
+
"empowering",
|
|
141
|
+
"harness",
|
|
142
|
+
"harnesses",
|
|
143
|
+
"harnessed",
|
|
144
|
+
"harnessing",
|
|
145
|
+
"delve",
|
|
146
|
+
"delves",
|
|
147
|
+
"delved",
|
|
148
|
+
"delving",
|
|
149
|
+
"leverage",
|
|
150
|
+
"leverages",
|
|
151
|
+
"leveraged",
|
|
152
|
+
"leveraging"
|
|
153
|
+
];
|
|
154
|
+
var BUZZWORDS = [
|
|
155
|
+
"synergy",
|
|
156
|
+
"synergise",
|
|
157
|
+
"synergize",
|
|
158
|
+
"cutting-edge",
|
|
159
|
+
"cutting edge",
|
|
160
|
+
"best-in-class",
|
|
161
|
+
"best in class",
|
|
162
|
+
"best of breed",
|
|
163
|
+
"game-changer",
|
|
164
|
+
"game changer",
|
|
165
|
+
"paradigm shift",
|
|
166
|
+
"world-class",
|
|
167
|
+
"state of the art",
|
|
168
|
+
"state-of-the-art",
|
|
169
|
+
"mission-critical",
|
|
170
|
+
"low-hanging fruit",
|
|
171
|
+
"move the needle",
|
|
172
|
+
"circle back",
|
|
173
|
+
"touch base",
|
|
174
|
+
"reach out",
|
|
175
|
+
"value-add",
|
|
176
|
+
"robust",
|
|
177
|
+
"turnkey",
|
|
178
|
+
"holistic",
|
|
179
|
+
"disruptive"
|
|
180
|
+
];
|
|
181
|
+
var NEGATIVE_REASSURANCE = [
|
|
182
|
+
"no risk",
|
|
183
|
+
"never locked in",
|
|
184
|
+
"nothing goes wrong",
|
|
185
|
+
"no hidden fees",
|
|
186
|
+
"never pay",
|
|
187
|
+
"without the hassle",
|
|
188
|
+
"no surprises"
|
|
189
|
+
];
|
|
190
|
+
var VAGUE_WORDS = ["innovative", "scalable", "end-to-end", "streamline", "solutions"];
|
|
191
|
+
var REVIEW_PHRASES = [
|
|
192
|
+
"great question",
|
|
193
|
+
"of course!",
|
|
194
|
+
"absolutely!",
|
|
195
|
+
"absolutely,",
|
|
196
|
+
"truly",
|
|
197
|
+
"genuinely",
|
|
198
|
+
"deeply",
|
|
199
|
+
"ultimately",
|
|
200
|
+
"one-size-fits-all",
|
|
201
|
+
"one size fits all",
|
|
202
|
+
"let me know if you'd",
|
|
203
|
+
"the single most",
|
|
204
|
+
"by far the most",
|
|
205
|
+
"hands down the",
|
|
206
|
+
"arguably the best",
|
|
207
|
+
"bar none"
|
|
208
|
+
];
|
|
209
|
+
var NEGATION = [
|
|
210
|
+
// "It's not just a haircut, it's an experience."
|
|
211
|
+
/\b(?:it['’]?s|it is|this is|this isn['’]t|we['’]re|we are|they['’]re)\s+(?:not|more than)\s+(?:just|only|merely|simply)?\s*[^.!?\n]{1,60}?[,;.—-]\s*(?:it['’]?s|it is|this is|we['’]re|we are|they['’]re)\b/i,
|
|
212
|
+
/\bmore\s+than\s+just\b/i,
|
|
213
|
+
// copular: "It's not a website, it's a growth engine"
|
|
214
|
+
/\b(?:it|that|this|you|we|they|she|he)(?:’s|'s|’re|'re)\s+not\s+[^.;:]{2,50}[,:]\s*(?:it|that|this|you|we|they|she|he)(?:’s|'s|’re|'re)/i,
|
|
215
|
+
// verbal: "I don't teach the treatment you'd choose: I teach the standard"
|
|
216
|
+
/\b(i|we|you|they|she|he)\s+(?:do|does|did|would|will|ca|wo)(?:n’t|n't)\s+(\w+)\b[^.;:]{2,60}[,:]\s*(?:i|we|you|they|she|he)\s+\2\b/i,
|
|
217
|
+
/\bnot\s+(?:just|merely|simply)\b/i,
|
|
218
|
+
// "X isn't a job, it's art"
|
|
219
|
+
/\b(?:is|are|was|were)(?:n’t|n't)\b[^.;]{2,60},\s*(?:it|they|that|this)(?:’s|'s|’re|'re)\b/i,
|
|
220
|
+
/\bless\s+about\b[^.;]{2,50}\bmore\s+about\b/i,
|
|
221
|
+
// parallel participle, no repeated subject: "dispatched from here, not shipped in from Seoul"
|
|
222
|
+
/\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i
|
|
223
|
+
];
|
|
224
|
+
var DATA_ABSENCE = /\b(?:yet|found|available|configured|selected|assigned|scheduled|recorded|on file|to show|to display|results|match|matches|matching|data|in this period)\b/i;
|
|
225
|
+
var SIGNED_IN = /(?:^|\/)(?:admin|portal|dashboard|embedded)\//;
|
|
226
|
+
function lineAround(text, index) {
|
|
227
|
+
const start = text.lastIndexOf("\n", index) + 1;
|
|
228
|
+
const end = text.indexOf("\n", index);
|
|
229
|
+
return text.slice(start, end === -1 ? text.length : end);
|
|
230
|
+
}
|
|
231
|
+
var emptyState = (block, match, index = 0) => SIGNED_IN.test(block.path) || DATA_ABSENCE.test(isProsePath(block.path) ? lineAround(block.text, index) : block.text) || DATA_ABSENCE.test(match);
|
|
232
|
+
var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
|
|
233
|
+
var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
|
|
234
|
+
var EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}]+/u;
|
|
235
|
+
var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
|
|
236
|
+
function badgeHits(ctx) {
|
|
237
|
+
const hits = [];
|
|
238
|
+
for (const block of ctx.strings) {
|
|
239
|
+
let text = block.text;
|
|
240
|
+
let lead = text.length - text.trimStart().length;
|
|
241
|
+
if (isProsePath(block.path)) {
|
|
242
|
+
if (/^\s*#{1,6}\s/.test(text) || /[<>{}=;]/.test(text)) continue;
|
|
243
|
+
const bullet = text.trimStart().match(/^[-*]\s+/);
|
|
244
|
+
if (bullet) lead += bullet[0].length;
|
|
245
|
+
text = text.slice(lead);
|
|
246
|
+
}
|
|
247
|
+
const candidate = text.trim();
|
|
248
|
+
if (NO_X_BADGE.test(candidate) && !emptyState(block, candidate)) {
|
|
249
|
+
hits.push({ path: block.path, offset: block.offset + lead, message: quoted(candidate) });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return hits;
|
|
253
|
+
}
|
|
254
|
+
var COPY_TELLS = [
|
|
255
|
+
{
|
|
256
|
+
id: "ai-vocabulary",
|
|
257
|
+
name: "AI vocabulary",
|
|
258
|
+
generation: 1,
|
|
259
|
+
severity: "warn",
|
|
260
|
+
surface: "copy",
|
|
261
|
+
why: "Tapestry, elevate, nestled, unparalleled: words that appear in generated copy far more than in anything a business owner writes.",
|
|
262
|
+
fix: "Say the plain thing. 'Elevate your look' becomes what actually happens: 'Gel that lasts three weeks'.",
|
|
263
|
+
detect: phrases([wordList(AI_WORDS), /\bunlock\b(?!\s+(?:your|the power))/i], quoted),
|
|
264
|
+
fixtures: {
|
|
265
|
+
flag: [f("content/home.md", "Elevate your nails in our bustling studio."), f("content/home.md", "We unlock it for you.")],
|
|
266
|
+
pass: [f("content/home.md", "Gel nails that last three weeks. Book online in a minute.")]
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: "stock-phrase",
|
|
271
|
+
name: "Stock phrase",
|
|
272
|
+
generation: 1,
|
|
273
|
+
severity: "warn",
|
|
274
|
+
surface: "copy",
|
|
275
|
+
why: "Phrases every generated services page uses. They fill space where a fact should be.",
|
|
276
|
+
fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
|
|
277
|
+
detect: phrases([wordList(STOCK_PHRASES)], quoted),
|
|
278
|
+
fixtures: {
|
|
279
|
+
flag: [f("content/home.md", "Take your plumbing to the next level with peace of mind.")],
|
|
280
|
+
pass: [f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley.")]
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: "ai-phrase",
|
|
285
|
+
name: "AI phrase",
|
|
286
|
+
generation: 1,
|
|
287
|
+
severity: "warn",
|
|
288
|
+
surface: "copy",
|
|
289
|
+
why: "Let's dive in, here's the thing, at its core, seamless, rest assured: the phrase list of the house copy rules. A reader has seen each one in a thousand generated pages.",
|
|
290
|
+
fix: "Cut the phrase and start with the point it was introducing. 'Seamless booking' becomes 'Book in three taps'.",
|
|
291
|
+
detect: phrases(
|
|
292
|
+
[
|
|
293
|
+
wordList(AI_PHRASES),
|
|
294
|
+
/\bin today['’]?s (?:rapidly )?(?:evolving|changing|fast-paced)\b/i,
|
|
295
|
+
/\bwhether you['’]re an? [^.,;]{2,30} or\b/i
|
|
296
|
+
],
|
|
297
|
+
quoted
|
|
298
|
+
),
|
|
299
|
+
fixtures: {
|
|
300
|
+
flag: [
|
|
301
|
+
f("content/home.md", "Let's dive in to what we do."),
|
|
302
|
+
f("content/home.md", "In today's rapidly evolving market, we help."),
|
|
303
|
+
f("content/home.md", "Whether you're a landlord or a tenant, call us.")
|
|
304
|
+
],
|
|
305
|
+
pass: [f("content/home.md", "Book in three taps. We confirm by text within the hour.")]
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
id: "plainer-word",
|
|
310
|
+
name: "A plainer word exists",
|
|
311
|
+
generation: 1,
|
|
312
|
+
severity: "warn",
|
|
313
|
+
surface: "copy",
|
|
314
|
+
why: "Empower, leverage, harness, delve: each stands in for a plainer verb, and the swap is a reliable sign nobody chose the word.",
|
|
315
|
+
fix: "Name the mechanism instead. 'Empower your team' becomes 'Your team can publish without us'.",
|
|
316
|
+
detect: phrases([wordList(PLAINER_WORDS)], quoted),
|
|
317
|
+
fixtures: {
|
|
318
|
+
flag: [f("content/home.md", "We empower small firms to grow.")],
|
|
319
|
+
pass: [f("content/home.md", "Small firms can publish without waiting for us.")]
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "plain-english",
|
|
324
|
+
name: "'Plain English'",
|
|
325
|
+
generation: 2,
|
|
326
|
+
severity: "warn",
|
|
327
|
+
surface: "copy",
|
|
328
|
+
why: "Told to avoid jargon, a model announces that it is avoiding jargon. 'Explained plainly' swaps a synonym and keeps the tell.",
|
|
329
|
+
fix: "Use the house replacement, 'properly explained', or drop the claim and let the copy show it.",
|
|
330
|
+
detect: phrases([/\b(?:plain english|explained plainly|plainly explained)\b/i], quoted),
|
|
331
|
+
fixtures: {
|
|
332
|
+
flag: [f("content/home.md", "Your options, in plain English.")],
|
|
333
|
+
pass: [f("content/home.md", "Your options, properly explained.")]
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
id: "buzzword",
|
|
338
|
+
name: "Buzzword",
|
|
339
|
+
generation: 1,
|
|
340
|
+
severity: "warn",
|
|
341
|
+
surface: "copy",
|
|
342
|
+
why: "Cutting-edge, world-class, reach out, turnkey: corporate filler older than any model, and still the first thing a model writes about a business it knows nothing about.",
|
|
343
|
+
fix: "Say what is actually true. 'World-class service' becomes 'We answer the phone until 8pm'. 'Reach out' becomes 'get in touch'.",
|
|
344
|
+
detect: phrases([wordList(BUZZWORDS)], quoted),
|
|
345
|
+
fixtures: {
|
|
346
|
+
flag: [f("content/home.md", "Reach out for world-class plumbing.")],
|
|
347
|
+
pass: [f("content/home.md", "Get in touch. We answer until 8pm.")]
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: "negative-reassurance",
|
|
352
|
+
name: "Negative reassurance",
|
|
353
|
+
generation: 1,
|
|
354
|
+
severity: "warn",
|
|
355
|
+
surface: "copy",
|
|
356
|
+
why: "'No hidden fees', 'no surprises', 'never locked in' reassure by naming the fear, and plant it in a reader who did not have it.",
|
|
357
|
+
fix: "Say what they get: 'The price on the quote is the price you pay', 'Cancel any month'.",
|
|
358
|
+
detect: phrases([wordList(NEGATIVE_REASSURANCE)], quoted),
|
|
359
|
+
fixtures: {
|
|
360
|
+
flag: [f("content/home.md", "Clear pricing with no hidden fees.")],
|
|
361
|
+
pass: [f("content/home.md", "The price on the quote is the price you pay.")]
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
id: "vague-word",
|
|
366
|
+
name: "Vague word",
|
|
367
|
+
generation: 1,
|
|
368
|
+
severity: "warn",
|
|
369
|
+
surface: "copy",
|
|
370
|
+
why: "Innovative, scalable, end-to-end, solutions: each can be true, and each is used where the writer had nothing specific to say.",
|
|
371
|
+
fix: "Replace it with the specific thing. 'Innovative solutions' becomes the actual product and what it does.",
|
|
372
|
+
detect: phrases([wordList(VAGUE_WORDS)], quoted),
|
|
373
|
+
fixtures: {
|
|
374
|
+
flag: [f("content/home.md", "Innovative solutions for every business.")],
|
|
375
|
+
pass: [f("content/home.md", "Rewires, fuse boards and EV chargers across Northamptonshire.")]
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
id: "review-phrase",
|
|
380
|
+
name: "Review-tier phrase",
|
|
381
|
+
generation: 1,
|
|
382
|
+
severity: "warn",
|
|
383
|
+
surface: "copy",
|
|
384
|
+
why: "Truly, genuinely, ultimately, 'the single most', 'bar none': normal English once, and a tell when they stack. The house rules keep them for review, never blocking.",
|
|
385
|
+
fix: "Keep the one that lands hardest and cut the rest. 'What is genuinely excellent' reads better as 'what is excellent'.",
|
|
386
|
+
detect: phrases([new RegExp(`(?:^|\\b)(?:${[...REVIEW_PHRASES].sort((a, b) => b.length - a.length).map((p) => escape(p).replace(/^(\w)/, "\\b$1")).join("|")})`, "i")], quoted),
|
|
387
|
+
fixtures: {
|
|
388
|
+
flag: [f("content/home.md", "We are truly proud of it.")],
|
|
389
|
+
pass: [f("content/home.md", "We have fitted 400 boilers since 2009.")]
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: "hollow-imperative",
|
|
394
|
+
name: "Hollow imperative",
|
|
395
|
+
generation: 1,
|
|
396
|
+
severity: "warn",
|
|
397
|
+
surface: "copy",
|
|
398
|
+
why: "'Discover', 'Experience', 'Transform your' as the opening verb of a heading or button is a call to action with nothing in it.",
|
|
399
|
+
fix: "Name the action and the outcome: 'Book a 45-minute facial', not 'Experience the difference'.",
|
|
400
|
+
detect: phrases(
|
|
401
|
+
[/(?:^|[.!?]\s+|\n\s*|>\s*)(?:Discover|Experience|Explore|Transform|Indulge in|Embrace|Unlock|Elevate)\s+(?:the|your|our|a|true|real)\b[^.!?\n<]{0,40}/],
|
|
402
|
+
(m) => `"${m.replace(/^[.!?>\s]+/, "")}"`
|
|
403
|
+
),
|
|
404
|
+
fixtures: {
|
|
405
|
+
flag: [f("content/home.md", "Experience the difference today.")],
|
|
406
|
+
pass: [f("content/home.md", "Book a 45-minute facial.")]
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
id: "rhetorical-opener",
|
|
411
|
+
name: "Rhetorical question opener",
|
|
412
|
+
generation: 1,
|
|
413
|
+
severity: "warn",
|
|
414
|
+
surface: "copy",
|
|
415
|
+
why: "'Looking for...?', 'Tired of...?', 'Ready to...?' opens with the model guessing at the reader instead of telling them something.",
|
|
416
|
+
fix: "Open with the answer.",
|
|
417
|
+
detect: phrases([/(?:^|\n\s*|>\s*)(?:Looking for|Tired of|Ready to|Want to|Struggling with|Need a)\b[^?\n<]{0,80}\?/], (m) => `"${m.replace(/^[>\s]+/, "")}"`),
|
|
418
|
+
fixtures: {
|
|
419
|
+
flag: [f("content/home.md", "Looking for a reliable plumber?")],
|
|
420
|
+
pass: [f("content/home.md", "A plumber in Brackley, on call until 10pm.")]
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
id: "em-dash",
|
|
425
|
+
name: "Em dash",
|
|
426
|
+
generation: 2,
|
|
427
|
+
severity: "warn",
|
|
428
|
+
surface: "copy",
|
|
429
|
+
why: "Generated copy leans on the em dash to join clauses. House copy does not use it.",
|
|
430
|
+
fix: "Use a full stop, a comma, or a colon. Two sentences are clearer than one sentence with a dash in it.",
|
|
431
|
+
detect: phrases([/\S{0,20}\s?—\s?\S{0,20}/], quoted, (ctx) => ctx.strings, (block) => placeholderDash(block)),
|
|
432
|
+
fixtures: {
|
|
433
|
+
flag: [
|
|
434
|
+
f("content/home.md", "Walk-ins welcome \u2014 just ring first."),
|
|
435
|
+
f("app/hours.tsx", `export const hours = { weekdays: "Mon \u2014 Fri" };`)
|
|
436
|
+
],
|
|
437
|
+
pass: [
|
|
438
|
+
f("content/home.md", "Walk-ins welcome. Ring first. Open 9-5."),
|
|
439
|
+
f("app/row.tsx", `export const Cell = ({ v }) => <td>{v ?? "\u2014"}</td>;`),
|
|
440
|
+
f("app/row.tsx", `export const Empty = () => <td className="muted">\u2014</td>;`)
|
|
441
|
+
]
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
id: "emoji",
|
|
446
|
+
name: "Emoji in copy",
|
|
447
|
+
generation: 1,
|
|
448
|
+
severity: "warn",
|
|
449
|
+
surface: "copy",
|
|
450
|
+
why: "A sparkle or a rocket beside a heading is decoration a model adds to seem friendly. It dates the page and reads as a social post.",
|
|
451
|
+
fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
|
|
452
|
+
detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
|
|
453
|
+
fixtures: {
|
|
454
|
+
flag: [f("content/home.md", "New treatments this month \u2728\u{1F680}"), f("app/cta.tsx", `export const label = "Book \u{1F680}";`)],
|
|
455
|
+
pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192")]
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
id: "not-just-but",
|
|
460
|
+
name: "Contrastive negation",
|
|
461
|
+
generation: 2,
|
|
462
|
+
severity: "warn",
|
|
463
|
+
surface: "copy",
|
|
464
|
+
why: "'It's not just a haircut, it's an experience', 'dispatched from here, not shipped in': the contrast-and-reveal template is the most recognisable construction in generated copy.",
|
|
465
|
+
fix: "Say the positive claim and drop the contrast.",
|
|
466
|
+
detect: phrases(NEGATION, quoted, allText),
|
|
467
|
+
fixtures: {
|
|
468
|
+
flag: [
|
|
469
|
+
f("content/home.md", "It's not just a haircut, it's an experience."),
|
|
470
|
+
f("content/home.md", "It's not a website, it's a growth engine."),
|
|
471
|
+
f("content/home.md", "I don't teach the treatment you'd choose: I teach the standard."),
|
|
472
|
+
f("content/home.md", "Hairdressing isn't a job, it's art."),
|
|
473
|
+
f("content/home.md", "It is less about the tools and more about the eye."),
|
|
474
|
+
f("content/home.md", "Order today and it's dispatched from here, not shipped in from Seoul."),
|
|
475
|
+
f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`)
|
|
476
|
+
],
|
|
477
|
+
pass: [
|
|
478
|
+
f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
|
|
479
|
+
f("content/home.md", "They are cosmetics, not medicines."),
|
|
480
|
+
f("content/home.md", "The box arrived red, not led astray by a dented corner.")
|
|
481
|
+
]
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
id: "no-x-no-y",
|
|
486
|
+
name: "'No X, no Y' list",
|
|
487
|
+
generation: 2,
|
|
488
|
+
severity: "warn",
|
|
489
|
+
surface: "copy",
|
|
490
|
+
why: "'No obligation, no spam.' Defining the business by what it is not, itemised, is the same template as contrastive negation.",
|
|
491
|
+
fix: "Say what they get: 'We reply within a day and only about your enquiry'.",
|
|
492
|
+
detect: phrases([NO_X_NO_Y], quoted, allText, emptyState),
|
|
493
|
+
fixtures: {
|
|
494
|
+
flag: [
|
|
495
|
+
f("content/home.md", "We reply within a day. No obligation, no spam."),
|
|
496
|
+
f("app/hero.tsx", `export const Hero = () => <p className="mt-4">We reply within a day. No
|
|
497
|
+
obligation, no spam.</p>;`)
|
|
498
|
+
],
|
|
499
|
+
pass: [
|
|
500
|
+
f("content/home.md", "We reply within a day, and only about your enquiry."),
|
|
501
|
+
f("app/admin/list.tsx", `export const Empty = () => <p>No clients, no bookings.</p>;`),
|
|
502
|
+
f("app/list.tsx", `export const Empty = () => <p>No orders yet, no invoices found.</p>;`)
|
|
503
|
+
]
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
id: "no-x-badge",
|
|
508
|
+
name: "'No X' badge",
|
|
509
|
+
generation: 2,
|
|
510
|
+
severity: "warn",
|
|
511
|
+
surface: "copy",
|
|
512
|
+
why: "'No catch.', 'NO JARGON GUIDE': a short standalone 'No X' reads as a slapped-on kicker, and worse when the same one is reused across pieces.",
|
|
513
|
+
fix: "Replace it with the positive fact it hints at, or cut it.",
|
|
514
|
+
detect: badgeHits,
|
|
515
|
+
fixtures: {
|
|
516
|
+
flag: [f("app/badge.tsx", `export const kicker = "No catch.";`), f("content/home.md", "Straight answers.\n\nNo jargon guide\n")],
|
|
517
|
+
pass: [
|
|
518
|
+
f("app/list.tsx", `export const empty = "No results found.";`),
|
|
519
|
+
f("app/portal/list.tsx", `export const empty = "No bookings";`),
|
|
520
|
+
f("content/home.md", "### No clarity on ideal client\n")
|
|
521
|
+
]
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
id: "staccato-triplet",
|
|
526
|
+
name: "Staccato triplet",
|
|
527
|
+
generation: 2,
|
|
528
|
+
severity: "warn",
|
|
529
|
+
surface: "copy",
|
|
530
|
+
why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
|
|
531
|
+
fix: "Write one sentence that says which of the three is true, and how you know.",
|
|
532
|
+
detect: phrases([/(?:\b[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!]\s+){2}[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
|
|
533
|
+
fixtures: {
|
|
534
|
+
flag: [f("content/home.md", "No fuss. No jargon. Just results.")],
|
|
535
|
+
pass: [f("content/home.md", "We fix boilers the same day, and we tell you the price before we start.")]
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
id: "where-x-meets-y",
|
|
540
|
+
name: "'Where X meets Y'",
|
|
541
|
+
generation: 2,
|
|
542
|
+
severity: "warn",
|
|
543
|
+
surface: "copy",
|
|
544
|
+
why: "'Where luxury meets comfort' is a tagline shape that fits every business and so describes none.",
|
|
545
|
+
fix: "Say what the place is and where it is.",
|
|
546
|
+
detect: phrases([/\bwhere\s+[\w'’-]+(?:\s+[\w'’-]+)?\s+meets?\s+[\w'’-]+/i], quoted),
|
|
547
|
+
fixtures: {
|
|
548
|
+
flag: [f("content/home.md", "Where luxury meets comfort.")],
|
|
549
|
+
pass: [f("content/home.md", "A two-chair studio above the bakery on Market Place.")]
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
];
|
|
553
|
+
|
|
554
|
+
// src/color/oklch.ts
|
|
555
|
+
var clamp01 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
|
|
556
|
+
function srgbToLinear(c) {
|
|
557
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
558
|
+
}
|
|
559
|
+
function parseHex(hex) {
|
|
560
|
+
const raw = hex.trim().replace(/^#/, "");
|
|
561
|
+
const expanded = raw.length === 3 || raw.length === 4 ? raw.slice(0, 3).split("").map((ch) => ch + ch).join("") : raw.slice(0, 6);
|
|
562
|
+
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) {
|
|
563
|
+
throw new Error(`craft: not a hex colour: ${JSON.stringify(hex)}`);
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
r: parseInt(expanded.slice(0, 2), 16) / 255,
|
|
567
|
+
g: parseInt(expanded.slice(2, 4), 16) / 255,
|
|
568
|
+
b: parseInt(expanded.slice(4, 6), 16) / 255
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function formatHex({ r, g, b }) {
|
|
572
|
+
const byte = (n) => Math.round(clamp01(n) * 255).toString(16).padStart(2, "0");
|
|
573
|
+
return `#${byte(r)}${byte(g)}${byte(b)}`;
|
|
574
|
+
}
|
|
575
|
+
function linearRgbToOklab(r, g, b) {
|
|
576
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
577
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
578
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
579
|
+
const l_ = Math.cbrt(l);
|
|
580
|
+
const m_ = Math.cbrt(m);
|
|
581
|
+
const s_ = Math.cbrt(s);
|
|
582
|
+
return [
|
|
583
|
+
0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
|
|
584
|
+
1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
|
|
585
|
+
0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_
|
|
586
|
+
];
|
|
587
|
+
}
|
|
588
|
+
var RAD = 180 / Math.PI;
|
|
589
|
+
function hexToOklch(hex) {
|
|
590
|
+
const { r, g, b } = parseHex(hex);
|
|
591
|
+
const [L, A, B] = linearRgbToOklab(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b));
|
|
592
|
+
const c = Math.sqrt(A * A + B * B);
|
|
593
|
+
const h = c < 1e-7 ? 0 : (Math.atan2(B, A) * RAD % 360 + 360) % 360;
|
|
594
|
+
return { l: L, c, h };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/character/color.ts
|
|
598
|
+
var COLOUR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3,4}\b|\b(?:rgba?|hsla?|oklch|oklab)\([^)]*\)|\bcolor\(\s*srgb\b[^)]*\)/g;
|
|
599
|
+
function num(token, percentOf) {
|
|
600
|
+
const t = token.trim();
|
|
601
|
+
if (t.endsWith("%")) return parseFloat(t) / 100 * percentOf;
|
|
602
|
+
return parseFloat(t);
|
|
603
|
+
}
|
|
604
|
+
function parts(fn) {
|
|
605
|
+
const inner = fn.slice(fn.indexOf("(") + 1, -1).trim();
|
|
606
|
+
const [main, slashAlpha] = inner.split("/");
|
|
607
|
+
const channels = main.split(/[\s,]+/).filter(Boolean);
|
|
608
|
+
let alpha = 1;
|
|
609
|
+
if (slashAlpha !== void 0) alpha = num(slashAlpha, 1);
|
|
610
|
+
else if (channels.length === 4) alpha = num(channels.pop(), 1);
|
|
611
|
+
return { channels, alpha: Number.isFinite(alpha) ? alpha : 1 };
|
|
612
|
+
}
|
|
613
|
+
function hslToRgb(h, s, l) {
|
|
614
|
+
const k = (n) => (n + h / 30) % 12;
|
|
615
|
+
const a = s * Math.min(l, 1 - l);
|
|
616
|
+
const f3 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
617
|
+
return { r: f3(0), g: f3(8), b: f3(4) };
|
|
618
|
+
}
|
|
619
|
+
var clamp012 = (n) => Math.min(1, Math.max(0, n));
|
|
620
|
+
function parseColour(raw) {
|
|
621
|
+
try {
|
|
622
|
+
if (raw.startsWith("#")) {
|
|
623
|
+
const hex = raw.slice(1);
|
|
624
|
+
const alpha2 = hex.length === 8 ? parseInt(hex.slice(6), 16) / 255 : hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
|
625
|
+
return { oklch: hexToOklch(raw), alpha: alpha2 };
|
|
626
|
+
}
|
|
627
|
+
const fn = raw.slice(0, raw.indexOf("(")).toLowerCase();
|
|
628
|
+
if (fn === "color") {
|
|
629
|
+
const { channels: channels2, alpha: alpha2 } = parts(raw.replace(/^color\(\s*srgb\s+/i, "srgb("));
|
|
630
|
+
const [r, g, b] = channels2.map((c) => clamp012(num(c, 1)));
|
|
631
|
+
if (![r, g, b].every(Number.isFinite)) return null;
|
|
632
|
+
return { oklch: hexToOklch(formatHex({ r, g, b })), alpha: alpha2 };
|
|
633
|
+
}
|
|
634
|
+
const { channels, alpha } = parts(raw);
|
|
635
|
+
if (channels.length < 3 || channels.some((c) => c.startsWith("var"))) return null;
|
|
636
|
+
if (fn === "oklch") {
|
|
637
|
+
const l = num(channels[0], 1);
|
|
638
|
+
const c = num(channels[1], 0.4);
|
|
639
|
+
const h = parseFloat(channels[2]);
|
|
640
|
+
if (![l, c].every(Number.isFinite)) return null;
|
|
641
|
+
return { oklch: { l, c, h: Number.isFinite(h) ? h : 0 }, alpha };
|
|
642
|
+
}
|
|
643
|
+
if (fn === "oklab") {
|
|
644
|
+
const l = num(channels[0], 1);
|
|
645
|
+
const a = num(channels[1], 0.4);
|
|
646
|
+
const b = num(channels[2], 0.4);
|
|
647
|
+
if (![l, a, b].every(Number.isFinite)) return null;
|
|
648
|
+
const c = Math.sqrt(a * a + b * b);
|
|
649
|
+
const h = c < 1e-7 ? 0 : (Math.atan2(b, a) * 180 / Math.PI % 360 + 360) % 360;
|
|
650
|
+
return { oklch: { l, c, h }, alpha };
|
|
651
|
+
}
|
|
652
|
+
if (fn === "rgb" || fn === "rgba") {
|
|
653
|
+
const [r, g, b] = channels.map((c) => clamp012(num(c, 255) / 255));
|
|
654
|
+
if (![r, g, b].every(Number.isFinite)) return null;
|
|
655
|
+
return { oklch: hexToOklch(formatHex({ r, g, b })), alpha };
|
|
656
|
+
}
|
|
657
|
+
if (fn === "hsl" || fn === "hsla") {
|
|
658
|
+
const h = parseFloat(channels[0]);
|
|
659
|
+
const s = num(channels[1], 1);
|
|
660
|
+
const l = num(channels[2], 1);
|
|
661
|
+
if (![h, s, l].every(Number.isFinite)) return null;
|
|
662
|
+
const rgb = hslToRgb((h % 360 + 360) % 360, clamp012(s), clamp012(l));
|
|
663
|
+
return { oklch: hexToOklch(formatHex(rgb)), alpha };
|
|
664
|
+
}
|
|
665
|
+
} catch {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
function findColours(value) {
|
|
671
|
+
const out = [];
|
|
672
|
+
for (const m of value.matchAll(COLOUR)) {
|
|
673
|
+
const parsed = parseColour(m[0]);
|
|
674
|
+
if (parsed) out.push({ raw: m[0], ...parsed, index: m.index ?? 0 });
|
|
675
|
+
}
|
|
676
|
+
return out;
|
|
677
|
+
}
|
|
678
|
+
var AI_VIOLET = { hueMin: 268, hueMax: 312, minChroma: 0.1, minL: 0.3, maxL: 0.85 };
|
|
679
|
+
function isAiViolet({ l, c, h }) {
|
|
680
|
+
return c >= AI_VIOLET.minChroma && h >= AI_VIOLET.hueMin && h <= AI_VIOLET.hueMax && l >= AI_VIOLET.minL && l <= AI_VIOLET.maxL;
|
|
681
|
+
}
|
|
682
|
+
function isCream({ l, c, h }) {
|
|
683
|
+
return l >= 0.9 && l <= 0.985 && c >= 8e-3 && c <= 0.06 && h >= 55 && h <= 105;
|
|
684
|
+
}
|
|
685
|
+
function isBlueToPurple({ c, h }) {
|
|
686
|
+
return c >= 0.08 && h >= 240 && h <= 330;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// src/character/tells/source.ts
|
|
690
|
+
function baseClass(cls) {
|
|
691
|
+
let depth = 0;
|
|
692
|
+
let cut = 0;
|
|
693
|
+
for (let i = 0; i < cls.length; i += 1) {
|
|
694
|
+
if (cls[i] === "[") depth += 1;
|
|
695
|
+
else if (cls[i] === "]") depth -= 1;
|
|
696
|
+
else if (cls[i] === ":" && depth === 0) cut = i + 1;
|
|
697
|
+
}
|
|
698
|
+
return cls.slice(cut).replace(/^!/, "");
|
|
699
|
+
}
|
|
700
|
+
function bases(unit) {
|
|
701
|
+
return unit.classes.map(baseClass);
|
|
702
|
+
}
|
|
703
|
+
function has(unit, pattern) {
|
|
704
|
+
return bases(unit).some((c) => pattern.test(c));
|
|
705
|
+
}
|
|
706
|
+
function find(unit, pattern) {
|
|
707
|
+
return bases(unit).find((c) => pattern.test(c));
|
|
708
|
+
}
|
|
709
|
+
function arbitrary(cls) {
|
|
710
|
+
const m = cls.match(/\[(.*)\]$/);
|
|
711
|
+
return m ? m[1].replace(/_/g, " ") : null;
|
|
712
|
+
}
|
|
713
|
+
function eachUnit(ctx, fn) {
|
|
714
|
+
const hits = [];
|
|
715
|
+
for (const file of ctx.files) {
|
|
716
|
+
for (const unit of file.classUnits) {
|
|
717
|
+
const message = fn(unit, file);
|
|
718
|
+
if (message) hits.push({ path: file.path, offset: unit.offset, message });
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return hits;
|
|
722
|
+
}
|
|
723
|
+
function firstMatch(text, pattern) {
|
|
724
|
+
const m = pattern.exec(text);
|
|
725
|
+
pattern.lastIndex = 0;
|
|
726
|
+
return m ? m.index : -1;
|
|
727
|
+
}
|
|
728
|
+
var FRAMEWORK_PALETTE = /^--(?:color-)?(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+$|^--tw-/;
|
|
729
|
+
var f2 = (path, text) => ({ path, text });
|
|
730
|
+
function familyNames(value) {
|
|
731
|
+
return value.split(",").map((v) => v.trim().replace(/^["']|["']$/g, "").trim()).filter(Boolean);
|
|
732
|
+
}
|
|
733
|
+
function fontMentions(file) {
|
|
734
|
+
const out = [];
|
|
735
|
+
for (const d of file.declarations) {
|
|
736
|
+
if (d.property === "font-family" || d.property === "font" || d.property.startsWith("--font")) {
|
|
737
|
+
for (const name of familyNames(d.value)) out.push({ name, offset: d.offset });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const text = file.text;
|
|
741
|
+
for (const m of text.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']next\/font\/google["']/g)) {
|
|
742
|
+
for (const spec of m[1].split(",")) {
|
|
743
|
+
const name = spec.trim().split(/\s+as\s+/)[0].replace(/_/g, " ").trim();
|
|
744
|
+
if (name) out.push({ name, offset: m.index ?? 0 });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
for (const m of text.matchAll(/fonts\.googleapis\.com\/css2?\?[^"'\s)>]*/g)) {
|
|
748
|
+
for (const fam of m[0].matchAll(/family=([^&:;]+)/g)) {
|
|
749
|
+
out.push({ name: decodeURIComponent(fam[1]).replace(/\+/g, " "), offset: m.index ?? 0 });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
for (const m of text.matchAll(/@fontsource(?:-variable)?\/([a-z0-9-]+)/g)) {
|
|
753
|
+
out.push({ name: m[1].replace(/-/g, " "), offset: m.index ?? 0 });
|
|
754
|
+
}
|
|
755
|
+
for (const m of text.matchAll(/\bfont-\[([^\]]+)\]/g)) {
|
|
756
|
+
for (const name of familyNames(m[1].replace(/_/g, " "))) out.push({ name, offset: m.index ?? 0 });
|
|
757
|
+
}
|
|
758
|
+
if (file.kind === "script" || file.kind === "markup") {
|
|
759
|
+
for (const m of text.matchAll(/fontFamily\s*[:=]\s*(\{[\s\S]*?\}|\[[^\]]*\]|["'][^"']*["'])/g)) {
|
|
760
|
+
for (const lit of m[1].matchAll(/["']([^"']+)["']/g)) {
|
|
761
|
+
for (const name of familyNames(lit[1])) out.push({ name, offset: (m.index ?? 0) + (lit.index ?? 0) });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return out;
|
|
766
|
+
}
|
|
767
|
+
function fontTell(fonts) {
|
|
768
|
+
const wanted = new Map(fonts.map((n) => [n.toLowerCase(), n]));
|
|
769
|
+
return (ctx) => {
|
|
770
|
+
const hits = [];
|
|
771
|
+
for (const file of ctx.files) {
|
|
772
|
+
const seen = /* @__PURE__ */ new Set();
|
|
773
|
+
for (const { name, offset } of fontMentions(file)) {
|
|
774
|
+
const canonical = wanted.get(name.toLowerCase());
|
|
775
|
+
if (!canonical || seen.has(canonical)) continue;
|
|
776
|
+
seen.add(canonical);
|
|
777
|
+
hits.push({ path: file.path, offset, message: `${canonical} is a reflex font` });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return hits;
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
var REFLEX_FONTS_1 = ["Inter", "Roboto", "Open Sans", "Poppins", "Montserrat", "Lato"];
|
|
784
|
+
var REFLEX_FONTS_2 = [
|
|
785
|
+
"Inter Tight",
|
|
786
|
+
"DM Sans",
|
|
787
|
+
"Manrope",
|
|
788
|
+
"Space Grotesk",
|
|
789
|
+
"Plus Jakarta Sans",
|
|
790
|
+
"Outfit",
|
|
791
|
+
"Geist",
|
|
792
|
+
"Sora",
|
|
793
|
+
"Instrument Sans",
|
|
794
|
+
"Instrument Serif",
|
|
795
|
+
"Fraunces",
|
|
796
|
+
"Playfair Display",
|
|
797
|
+
"Cormorant Garamond",
|
|
798
|
+
"DM Serif Display"
|
|
799
|
+
];
|
|
800
|
+
var TW_VIOLET = /^(?:bg|text|from|via|to|border|ring|fill|stroke|shadow|outline|decoration|accent|caret|divide)-(indigo|violet|purple)-([3-8]00)(?:\/[\w.[\]]+)?$/;
|
|
801
|
+
function violetHits(ctx) {
|
|
802
|
+
const hits = [];
|
|
803
|
+
for (const file of ctx.files) {
|
|
804
|
+
const seen = /* @__PURE__ */ new Set();
|
|
805
|
+
const push = (key, offset, message) => {
|
|
806
|
+
if (seen.has(key)) return;
|
|
807
|
+
seen.add(key);
|
|
808
|
+
hits.push({ path: file.path, offset, message });
|
|
809
|
+
};
|
|
810
|
+
for (const unit of file.classUnits) {
|
|
811
|
+
for (const cls of bases(unit)) {
|
|
812
|
+
const tw = cls.match(TW_VIOLET);
|
|
813
|
+
if (tw) push(`${tw[1]}-${tw[2]}`, unit.offset, `${tw[1]}-${tw[2]} is the default AI accent`);
|
|
814
|
+
const arb = arbitrary(cls);
|
|
815
|
+
if (arb) {
|
|
816
|
+
for (const c of findColours(arb)) {
|
|
817
|
+
if (isAiViolet(c.oklch)) push(c.raw.toLowerCase(), unit.offset, `${c.raw} sits in the indigo-violet band (h ${c.oklch.h.toFixed(0)}\xB0)`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
for (const d of file.declarations) {
|
|
823
|
+
if (FRAMEWORK_PALETTE.test(d.property)) continue;
|
|
824
|
+
for (const c of findColours(d.value)) {
|
|
825
|
+
if (c.alpha >= 0.5 && isAiViolet(c.oklch)) {
|
|
826
|
+
push(c.raw.toLowerCase(), d.offset, `${c.raw} sits in the indigo-violet band (h ${c.oklch.h.toFixed(0)}\xB0)`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return hits;
|
|
832
|
+
}
|
|
833
|
+
var BLUEISH = /^(?:blue|indigo|sky|cyan)$/;
|
|
834
|
+
var PURPLEISH = /^(?:violet|purple|fuchsia|pink)$/;
|
|
835
|
+
function gradientHits(ctx) {
|
|
836
|
+
const hits = eachUnit(ctx, (unit) => {
|
|
837
|
+
if (!has(unit, /^bg-(?:gradient-to|linear|radial|conic)/)) return null;
|
|
838
|
+
const stops = bases(unit).map((c) => c.match(/^(?:from|via|to)-([a-z]+)-\d+/)?.[1]).filter((x) => Boolean(x));
|
|
839
|
+
if (stops.some((s) => BLUEISH.test(s)) && stops.some((s) => PURPLEISH.test(s))) {
|
|
840
|
+
return `gradient from ${stops.join(" to ")}`;
|
|
841
|
+
}
|
|
842
|
+
return null;
|
|
843
|
+
});
|
|
844
|
+
for (const file of ctx.files) {
|
|
845
|
+
for (const d of file.declarations) {
|
|
846
|
+
if (!/gradient\(/.test(d.value) || FRAMEWORK_PALETTE.test(d.property)) continue;
|
|
847
|
+
const colours = findColours(d.value).filter((c) => isBlueToPurple(c.oklch));
|
|
848
|
+
if (colours.length < 2) continue;
|
|
849
|
+
const hues = colours.map((c) => c.oklch.h);
|
|
850
|
+
if (Math.max(...hues) - Math.min(...hues) >= 25) {
|
|
851
|
+
hits.push({ path: file.path, offset: d.offset, message: `gradient from ${colours.map((c) => c.raw).join(" to ")}` });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return hits;
|
|
856
|
+
}
|
|
857
|
+
var CREAM_CLASS = /^bg-(?:cream|ivory|parchment|linen|sand|oat|bone|eggshell)(?:-\d+)?(?:\/\d+)?$/;
|
|
858
|
+
var BG_PROPERTY = /background|^--(?:color-)?(?:bg|background|surface|canvas|paper|cream|base|page)/;
|
|
859
|
+
function creamHits(ctx) {
|
|
860
|
+
const hits = [];
|
|
861
|
+
for (const file of ctx.files) {
|
|
862
|
+
const seen = /* @__PURE__ */ new Set();
|
|
863
|
+
const push = (key, offset, message) => {
|
|
864
|
+
if (seen.has(key)) return;
|
|
865
|
+
seen.add(key);
|
|
866
|
+
hits.push({ path: file.path, offset, message });
|
|
867
|
+
};
|
|
868
|
+
for (const unit of file.classUnits) {
|
|
869
|
+
for (const cls of bases(unit)) {
|
|
870
|
+
if (CREAM_CLASS.test(cls)) push(cls, unit.offset, `${cls} is a cream ground`);
|
|
871
|
+
const arb = cls.startsWith("bg-") ? arbitrary(cls) : null;
|
|
872
|
+
if (arb) {
|
|
873
|
+
for (const c of findColours(arb)) if (isCream(c.oklch)) push(c.raw.toLowerCase(), unit.offset, `${c.raw} is a cream ground`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
for (const d of file.declarations) {
|
|
878
|
+
if (!BG_PROPERTY.test(d.property) || FRAMEWORK_PALETTE.test(d.property)) continue;
|
|
879
|
+
for (const c of findColours(d.value)) {
|
|
880
|
+
if (c.alpha >= 0.9 && isCream(c.oklch)) push(c.raw.toLowerCase(), d.offset, `${c.raw} is a cream ground`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return hits;
|
|
885
|
+
}
|
|
886
|
+
function glassHits(ctx) {
|
|
887
|
+
const hits = eachUnit(
|
|
888
|
+
ctx,
|
|
889
|
+
(unit) => has(unit, /^backdrop-blur/) && has(unit, /^bg-[a-z]+(?:-\d+)?\/(?:\d+|\[[\d.]+\])$/) ? "translucent blurred panel" : null
|
|
890
|
+
);
|
|
891
|
+
for (const file of ctx.files) {
|
|
892
|
+
for (const d of file.declarations) {
|
|
893
|
+
if (/^(?:-webkit-)?backdrop-filter$/.test(d.property) && /blur\(/.test(d.value)) {
|
|
894
|
+
hits.push({ path: file.path, offset: d.offset, message: "backdrop blur panel" });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return hits;
|
|
899
|
+
}
|
|
900
|
+
function gradientTextHits(ctx) {
|
|
901
|
+
const hits = eachUnit(
|
|
902
|
+
ctx,
|
|
903
|
+
(unit) => has(unit, /^bg-clip-text$/) && has(unit, /^text-transparent$/) ? "gradient-filled text" : null
|
|
904
|
+
);
|
|
905
|
+
for (const file of ctx.files) {
|
|
906
|
+
for (const d of file.declarations) {
|
|
907
|
+
if (/^(?:-webkit-)?background-clip$/.test(d.property) && /\btext\b/.test(d.value)) {
|
|
908
|
+
hits.push({ path: file.path, offset: d.offset, message: "gradient-filled text" });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
return hits;
|
|
913
|
+
}
|
|
914
|
+
function thinBorderWideShadowHits(ctx) {
|
|
915
|
+
const hits = eachUnit(ctx, (unit) => {
|
|
916
|
+
const faint = has(unit, /^border-(?:black|white)\/(?:[1-9]|10|\[0?\.\d+\])$|^border-(?:gray|slate|zinc|neutral|stone)-(?:50|100|200)(?:\/\d+)?$/);
|
|
917
|
+
const wide = has(unit, /^shadow-(?:xl|2xl)$/) || bases(unit).some((c) => c.startsWith("shadow-[") && Number((arbitrary(c) ?? "").split(/\s+/)[2]?.replace("px", "")) >= 24);
|
|
918
|
+
return faint && wide && has(unit, /^border$/) ? "hairline border under a wide soft shadow" : null;
|
|
919
|
+
});
|
|
920
|
+
for (const file of ctx.files) {
|
|
921
|
+
const bySelector = /* @__PURE__ */ new Map();
|
|
922
|
+
for (const d of file.declarations) {
|
|
923
|
+
const entry = bySelector.get(d.selector) ?? { offset: d.offset };
|
|
924
|
+
if (/^border(?:-top|-bottom)?$/.test(d.property) && /\b1px\b/.test(d.value)) {
|
|
925
|
+
const c = findColours(d.value)[0];
|
|
926
|
+
if (c && c.alpha <= 0.12) entry.border = d.offset;
|
|
927
|
+
}
|
|
928
|
+
if (d.property === "box-shadow") {
|
|
929
|
+
const blur = Math.max(...[...d.value.matchAll(/(?:^|,)\s*(?:inset\s+)?-?[\d.]+(?:px)?\s+-?[\d.]+(?:px)?\s+([\d.]+)px/g)].map((m) => Number(m[1])), 0);
|
|
930
|
+
if (blur >= 24) entry.shadow = d.offset;
|
|
931
|
+
}
|
|
932
|
+
bySelector.set(d.selector, entry);
|
|
933
|
+
}
|
|
934
|
+
for (const entry of bySelector.values()) {
|
|
935
|
+
if (entry.border !== void 0 && entry.shadow !== void 0) {
|
|
936
|
+
hits.push({ path: file.path, offset: entry.border, message: "hairline border under a wide soft shadow" });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return hits;
|
|
941
|
+
}
|
|
942
|
+
function glowHits(ctx) {
|
|
943
|
+
const hits = eachUnit(ctx, (unit) => {
|
|
944
|
+
if (bases(unit).some((c) => c.startsWith("bg-[radial-gradient"))) return "radial spotlight behind content";
|
|
945
|
+
const blob = has(unit, /^(?:absolute|fixed)$/) && has(unit, /^blur-(?:2xl|3xl|\[(?:[4-9]\d|\d{3,})px\])$/) && has(unit, /^(?:rounded-full|bg-gradient|bg-[a-z]+-\d+\/\d+|opacity-)/);
|
|
946
|
+
return blob ? "blurred glow blob" : null;
|
|
947
|
+
});
|
|
948
|
+
for (const file of ctx.files) {
|
|
949
|
+
for (const d of file.declarations) {
|
|
950
|
+
if (!/^background(?:-image)?$/.test(d.property) || !/radial-gradient\(/.test(d.value)) continue;
|
|
951
|
+
const colours = findColours(d.value);
|
|
952
|
+
if (/transparent/.test(d.value) || colours.some((c) => c.alpha < 0.5)) {
|
|
953
|
+
hits.push({ path: file.path, offset: d.offset, message: "radial spotlight fading to transparent" });
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return hits;
|
|
958
|
+
}
|
|
959
|
+
function gridBackgroundHits(ctx) {
|
|
960
|
+
const hits = eachUnit(ctx, (unit) => {
|
|
961
|
+
if (has(unit, /^bg-(?:grid|dot|dots|graph)(?:-|$)/)) return "decorative grid background";
|
|
962
|
+
const arb = bases(unit).filter((c) => c.startsWith("bg-[") || c.startsWith("[background")).map((c) => arbitrary(c) ?? "");
|
|
963
|
+
const lines = arb.join(" ").match(/(?:linear|radial)-gradient\([^)]*\b1px\b/g) ?? [];
|
|
964
|
+
return lines.length > 0 ? "decorative grid background" : null;
|
|
965
|
+
});
|
|
966
|
+
for (const file of ctx.files) {
|
|
967
|
+
for (const d of file.declarations) {
|
|
968
|
+
if (!/^background(?:-image)?$/.test(d.property)) continue;
|
|
969
|
+
const lines = d.value.match(/(?:linear|radial)-gradient\([^;]*?\b1px\b/g) ?? [];
|
|
970
|
+
if (lines.length >= 1 && /gradient\([\s\S]*gradient\(/.test(d.value)) {
|
|
971
|
+
hits.push({ path: file.path, offset: d.offset, message: "decorative grid background" });
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return hits;
|
|
976
|
+
}
|
|
977
|
+
function italicSerifHits(ctx) {
|
|
978
|
+
const hits = eachUnit(
|
|
979
|
+
ctx,
|
|
980
|
+
(unit) => has(unit, /^italic$/) && has(unit, /^font-(?:serif|display|heading|title|\[)/) && !has(unit, /^font-(?:sans|mono)$/) ? "italic serif display" : null
|
|
981
|
+
);
|
|
982
|
+
for (const file of ctx.files) {
|
|
983
|
+
for (const m of file.text.matchAll(/<h[1-3]\b[^>]*>(?:(?!<\/h[1-3]>)[\s\S]){0,200}?<(?:em|i)\b/g)) {
|
|
984
|
+
hits.push({ path: file.path, offset: m.index ?? 0, message: "italic word set inside a display heading" });
|
|
985
|
+
}
|
|
986
|
+
for (const d of file.declarations) {
|
|
987
|
+
if (d.property === "font-style" && d.value === "italic" && /(?:^|[\s,>])(?:h[1-3]|\.?(?:display|hero|headline|title))\b/i.test(d.selector)) {
|
|
988
|
+
hits.push({ path: file.path, offset: d.offset, message: `italic display type on ${d.selector}` });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return hits;
|
|
993
|
+
}
|
|
994
|
+
function eyebrowChipHits(ctx) {
|
|
995
|
+
const hits = [];
|
|
996
|
+
for (const file of ctx.files) {
|
|
997
|
+
const h1 = file.text.search(/<h1\b/);
|
|
998
|
+
if (h1 === -1) continue;
|
|
999
|
+
for (const unit of file.classUnits) {
|
|
1000
|
+
if (unit.offset > h1 || h1 - unit.offset > 900) continue;
|
|
1001
|
+
const chip = has(unit, /^rounded-full$/) && has(unit, /^(?:border|ring-1|bg-[a-z]+-\d+\/\d+|bg-[a-z]+-(?:50|100))$/) && has(unit, /^text-(?:xs|sm|\[1[0-3]px\])$/) && has(unit, /^px-(?:2|2\.5|3|3\.5|4)$/);
|
|
1002
|
+
if (chip) hits.push({ path: file.path, offset: unit.offset, message: "pill badge above the hero heading" });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return hits;
|
|
1006
|
+
}
|
|
1007
|
+
function iconTiles(file) {
|
|
1008
|
+
return file.classUnits.filter((unit) => {
|
|
1009
|
+
const sized = has(unit, /^size-(?:8|9|10|11|12|14)$/) || (find(unit, /^h-(?:8|9|10|11|12|14)$/) ?? "x").slice(2) === (find(unit, /^w-(?:8|9|10|11|12|14)$/) ?? "y").slice(2);
|
|
1010
|
+
return sized && has(unit, /^rounded-(?:md|lg|xl|2xl|full)$/) && has(unit, /^(?:items-center|place-items-center|place-content-center)$/) && has(unit, /^bg-/);
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
var ICON_LIBRARY = /from\s+["'](?:lucide-react|@heroicons\/react[^"']*|react-icons[^"']*|@phosphor-icons\/react|@tabler\/icons-react|@radix-ui\/react-icons)["']/;
|
|
1014
|
+
function iconTileStackHits(ctx) {
|
|
1015
|
+
const hits = [];
|
|
1016
|
+
for (const file of ctx.files) {
|
|
1017
|
+
if (!ICON_LIBRARY.test(file.text)) continue;
|
|
1018
|
+
const tiles = iconTiles(file);
|
|
1019
|
+
if (tiles.length > 0) {
|
|
1020
|
+
hits.push({ path: file.path, offset: tiles[0].offset, message: `icon in a tinted tile (${tiles.length} in this file)` });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return hits;
|
|
1024
|
+
}
|
|
1025
|
+
function iconGridHits(ctx) {
|
|
1026
|
+
const hits = [];
|
|
1027
|
+
for (const file of ctx.files) {
|
|
1028
|
+
if (!ICON_LIBRARY.test(file.text) || iconTiles(file).length === 0) continue;
|
|
1029
|
+
const grid = file.classUnits.find((u) => has(u, /^grid-cols-3$/) && has(u, /^grid$/));
|
|
1030
|
+
if (grid) hits.push({ path: file.path, offset: grid.offset, message: "three-column grid of icon cards" });
|
|
1031
|
+
}
|
|
1032
|
+
return hits;
|
|
1033
|
+
}
|
|
1034
|
+
function bentoHits(ctx) {
|
|
1035
|
+
const hits = [];
|
|
1036
|
+
for (const file of ctx.files) {
|
|
1037
|
+
const all = file.classUnits.flatMap(bases);
|
|
1038
|
+
const colSpan = all.some((c) => /^col-span-[2-3]$/.test(c));
|
|
1039
|
+
const rowSpan = file.classUnits.find((u) => has(u, /^row-span-[2-3]$/));
|
|
1040
|
+
if (colSpan && rowSpan) {
|
|
1041
|
+
hits.push({ path: file.path, offset: rowSpan.offset, message: "mixed column and row spans: a bento grid" });
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return hits;
|
|
1045
|
+
}
|
|
1046
|
+
var WRAPPER = /^(?:Section|Container|Reveal|FadeIn|FadeUp|Fade|Animate\w*|Motion\w*|Suspense|Fragment|Wrapper|Layout|Main|ErrorBoundary)$/;
|
|
1047
|
+
var PROOF = /Trust|Logo|Proof|Stat|Badge|Partner|Client|Marquee|Press|Rating|Review|Testimonial|Accredit|Brands/;
|
|
1048
|
+
function heroThenProofHits(ctx) {
|
|
1049
|
+
const hits = [];
|
|
1050
|
+
for (const file of ctx.files) {
|
|
1051
|
+
if (file.kind !== "markup") continue;
|
|
1052
|
+
const tags = [...file.text.matchAll(/<([A-Z][A-Za-z0-9]*)\b/g)].map((m) => ({ name: m[1], offset: m.index ?? 0 }));
|
|
1053
|
+
const hero = tags.findIndex((t) => /Hero/.test(t.name));
|
|
1054
|
+
if (hero === -1) continue;
|
|
1055
|
+
const next = tags.slice(hero + 1).find((t) => !WRAPPER.test(t.name) && !/Hero/.test(t.name));
|
|
1056
|
+
if (next && PROOF.test(next.name)) {
|
|
1057
|
+
hits.push({ path: file.path, offset: next.offset, message: `${tags[hero].name} is followed straight by ${next.name}` });
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return hits;
|
|
1061
|
+
}
|
|
1062
|
+
var REVEAL_LIMIT = 12;
|
|
1063
|
+
var PILL_LIMIT = 30;
|
|
1064
|
+
var SHADCN_LIMIT = 12;
|
|
1065
|
+
var REVEAL = /<(?:Reveal|ScrollReveal|FadeIn|FadeUp|FadeInUp|SlideIn|SlideUp|AnimateIn|AnimateOnScroll|InView|RevealOnScroll)\b|\bwhileInView\b|\bdata-aos=|\buseInView\s*\(|\bscrollTrigger\s*:/g;
|
|
1066
|
+
function countHits(ctx, perFile, limit, label) {
|
|
1067
|
+
let total = 0;
|
|
1068
|
+
let worst = null;
|
|
1069
|
+
for (const file of ctx.files) {
|
|
1070
|
+
const offsets = perFile(file);
|
|
1071
|
+
total += offsets.length;
|
|
1072
|
+
if (!worst || offsets.length > worst.offsets.length) worst = { file, offsets };
|
|
1073
|
+
}
|
|
1074
|
+
if (total <= limit || !worst || worst.offsets.length === 0) return [];
|
|
1075
|
+
return [{ path: worst.file.path, offset: worst.offsets[0], message: label(total, `${worst.file.path} has ${worst.offsets.length}`) }];
|
|
1076
|
+
}
|
|
1077
|
+
function revealHits(ctx) {
|
|
1078
|
+
return countHits(
|
|
1079
|
+
ctx,
|
|
1080
|
+
(file) => file.kind === "markup" || file.kind === "script" ? [...file.text.matchAll(REVEAL)].map((m) => m.index ?? 0) : [],
|
|
1081
|
+
REVEAL_LIMIT,
|
|
1082
|
+
(n, top) => `${n} scroll reveals (limit ${REVEAL_LIMIT}); ${top}`
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
var PILL_RADIUS = /\b(?:9{3,}|[1-9]\d{3,})px\b|\b(?:50|100)vh\b|\b100vmax\b|\b9{3,}rem\b/;
|
|
1086
|
+
function pillHits(ctx) {
|
|
1087
|
+
return countHits(
|
|
1088
|
+
ctx,
|
|
1089
|
+
(file) => [
|
|
1090
|
+
...file.classUnits.filter((u) => has(u, /^rounded-full$/)).map((u) => u.offset),
|
|
1091
|
+
...file.declarations.filter((d) => /^border(?:-[a-z]+)*-radius$/.test(d.property) && PILL_RADIUS.test(d.value)).map((d) => d.offset)
|
|
1092
|
+
],
|
|
1093
|
+
PILL_LIMIT,
|
|
1094
|
+
(n, top) => `${n} fully rounded shapes (limit ${PILL_LIMIT}); ${top}`
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
var SHADCN_PRIMITIVES = [
|
|
1098
|
+
"accordion",
|
|
1099
|
+
"alert",
|
|
1100
|
+
"alert-dialog",
|
|
1101
|
+
"aspect-ratio",
|
|
1102
|
+
"avatar",
|
|
1103
|
+
"badge",
|
|
1104
|
+
"breadcrumb",
|
|
1105
|
+
"button",
|
|
1106
|
+
"calendar",
|
|
1107
|
+
"card",
|
|
1108
|
+
"carousel",
|
|
1109
|
+
"chart",
|
|
1110
|
+
"checkbox",
|
|
1111
|
+
"collapsible",
|
|
1112
|
+
"command",
|
|
1113
|
+
"context-menu",
|
|
1114
|
+
"dialog",
|
|
1115
|
+
"drawer",
|
|
1116
|
+
"dropdown-menu",
|
|
1117
|
+
"form",
|
|
1118
|
+
"hover-card",
|
|
1119
|
+
"input",
|
|
1120
|
+
"input-otp",
|
|
1121
|
+
"label",
|
|
1122
|
+
"menubar",
|
|
1123
|
+
"navigation-menu",
|
|
1124
|
+
"pagination",
|
|
1125
|
+
"popover",
|
|
1126
|
+
"progress",
|
|
1127
|
+
"radio-group",
|
|
1128
|
+
"resizable",
|
|
1129
|
+
"scroll-area",
|
|
1130
|
+
"select",
|
|
1131
|
+
"separator",
|
|
1132
|
+
"sheet",
|
|
1133
|
+
"sidebar",
|
|
1134
|
+
"skeleton",
|
|
1135
|
+
"slider",
|
|
1136
|
+
"sonner",
|
|
1137
|
+
"switch",
|
|
1138
|
+
"table",
|
|
1139
|
+
"tabs",
|
|
1140
|
+
"textarea",
|
|
1141
|
+
"toast",
|
|
1142
|
+
"toaster",
|
|
1143
|
+
"toggle",
|
|
1144
|
+
"toggle-group",
|
|
1145
|
+
"tooltip"
|
|
1146
|
+
];
|
|
1147
|
+
function shadcnHits(ctx) {
|
|
1148
|
+
const stock = new Set(SHADCN_PRIMITIVES);
|
|
1149
|
+
const ui = ctx.files.filter((file) => {
|
|
1150
|
+
const m = file.path.match(/(?:^|\/)components\/ui\/([a-z-]+)\.(?:tsx|jsx|ts)$/);
|
|
1151
|
+
return m !== null && stock.has(m[1]);
|
|
1152
|
+
});
|
|
1153
|
+
if (ui.length < SHADCN_LIMIT) return [];
|
|
1154
|
+
const others = ctx.files.filter((file) => !ui.includes(file));
|
|
1155
|
+
const imported = (name) => others.some((o) => new RegExp(`ui/${name}["']`).test(o.text)) || ui.some((o) => new RegExp(`["']\\./${name}["']|ui/${name}["']`).test(o.text));
|
|
1156
|
+
const unused = others.length > 0 ? ui.filter((file) => !imported(file.path.match(/([a-z-]+)\.[a-z]+$/)[1])) : [];
|
|
1157
|
+
const detail = unused.length > 0 ? `; ${unused.length} imported nowhere` : "";
|
|
1158
|
+
return [{ path: ui[0].path, offset: 0, message: `${ui.length} stock shadcn primitives${detail}` }];
|
|
1159
|
+
}
|
|
1160
|
+
function marqueeHits(ctx) {
|
|
1161
|
+
const hits = [];
|
|
1162
|
+
const pattern = /@keyframes\s+[\w-]*marquee|animate-(?:marquee|scroll-x|infinite-scroll)\b|<Marquee\b|["']react-fast-marquee["']|ui\/marquee["']|animation(?:-name)?\s*:\s*[^;]*marquee/i;
|
|
1163
|
+
for (const file of ctx.files) {
|
|
1164
|
+
const at = firstMatch(file.text, pattern);
|
|
1165
|
+
if (at !== -1) hits.push({ path: file.path, offset: at, message: "scrolling marquee" });
|
|
1166
|
+
}
|
|
1167
|
+
return hits;
|
|
1168
|
+
}
|
|
1169
|
+
function introHits(ctx) {
|
|
1170
|
+
const hits = [];
|
|
1171
|
+
const name = /(?:^|\/)(?:intro(?:-?(?:cinematic|sequence|animation|overlay))?|preloader|splash(?:-?screen)?|loading-?screen|curtain)\.(?:tsx|jsx|vue|svelte|astro)$/i;
|
|
1172
|
+
for (const file of ctx.files) {
|
|
1173
|
+
if (name.test(file.path) && /sessionStorage|localStorage|AnimatePresence|setTimeout|gsap|@keyframes|animate\(/.test(file.text)) {
|
|
1174
|
+
hits.push({ path: file.path, offset: 0, message: "intro sequence before the page" });
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
const use = file.text.match(/<(?:IntroCinematic|Intro|Preloader|Splash|SplashScreen|LoadingScreen|Curtain)\b/);
|
|
1178
|
+
if (use && file.kind === "markup") hits.push({ path: file.path, offset: use.index ?? 0, message: `${use[0].slice(1)} runs before the page` });
|
|
1179
|
+
}
|
|
1180
|
+
return hits;
|
|
1181
|
+
}
|
|
1182
|
+
var SOURCE_TELLS = [
|
|
1183
|
+
{
|
|
1184
|
+
id: "reflex-font",
|
|
1185
|
+
name: "Reflex font",
|
|
1186
|
+
generation: 1,
|
|
1187
|
+
severity: "warn",
|
|
1188
|
+
surface: "source",
|
|
1189
|
+
why: "Inter, Roboto, Poppins and friends are what a model sets when nothing in the brief names a face. Every site in the estate ships one.",
|
|
1190
|
+
fix: "Pick the display face from the client's world (signage, packaging, the trade's lettering), record why in art-direction.json, and keep it unique across the estate.",
|
|
1191
|
+
detect: fontTell(REFLEX_FONTS_1),
|
|
1192
|
+
fixtures: {
|
|
1193
|
+
flag: [
|
|
1194
|
+
f2("app/layout.tsx", `import { Inter } from "next/font/google";
|
|
1195
|
+
const inter = Inter({ subsets: ["latin"] });`),
|
|
1196
|
+
f2("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
|
|
1197
|
+
f2("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
|
|
1198
|
+
f2("main.ts", `import "@fontsource/montserrat";`),
|
|
1199
|
+
f2("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
|
|
1200
|
+
],
|
|
1201
|
+
pass: [f2("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
|
|
1202
|
+
}
|
|
1203
|
+
},
|
|
1204
|
+
{
|
|
1205
|
+
id: "reflex-font-2",
|
|
1206
|
+
name: "Second-wave reflex font",
|
|
1207
|
+
generation: 2,
|
|
1208
|
+
severity: "warn",
|
|
1209
|
+
surface: "source",
|
|
1210
|
+
why: "Ban Inter and a model reaches for Inter Tight, DM Sans, Manrope, Space Grotesk or an Instrument/Fraunces serif. A swap inside the reflex list is not a decision.",
|
|
1211
|
+
fix: "Same as reflex-font. If this face really is the right one, declare an exception with the reason and the evidence.",
|
|
1212
|
+
detect: fontTell(REFLEX_FONTS_2),
|
|
1213
|
+
fixtures: {
|
|
1214
|
+
flag: [f2("src/styles.css", `@theme {
|
|
1215
|
+
--font-sans: "DM Sans", system-ui, sans-serif;
|
|
1216
|
+
}`)],
|
|
1217
|
+
pass: [f2("src/styles.css", `@theme {
|
|
1218
|
+
--font-sans: "Hanken Grotesk", system-ui, sans-serif;
|
|
1219
|
+
}`)]
|
|
1220
|
+
}
|
|
1221
|
+
},
|
|
1222
|
+
{
|
|
1223
|
+
id: "ai-violet",
|
|
1224
|
+
name: "Indigo-violet accent",
|
|
1225
|
+
generation: 1,
|
|
1226
|
+
severity: "warn",
|
|
1227
|
+
surface: "source",
|
|
1228
|
+
why: "Tailwind's indigo and violet are the accent a model picks for any brand it knows nothing about. Hue is read in OKLCh, so a hex violet is caught as well as a class name.",
|
|
1229
|
+
fix: 'Derive the accent from something the client owns (livery, shopfront, product). If violet is the brand, declare it: `{ "tell": "ai-violet", "because": "..." }`.',
|
|
1230
|
+
detect: violetHits,
|
|
1231
|
+
fixtures: {
|
|
1232
|
+
flag: [
|
|
1233
|
+
f2("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
|
|
1234
|
+
f2("app/globals.css", `:root { --accent: #5a35d1; }`)
|
|
1235
|
+
],
|
|
1236
|
+
pass: [f2("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
{
|
|
1240
|
+
id: "blue-purple-gradient",
|
|
1241
|
+
name: "Blue-to-purple gradient",
|
|
1242
|
+
generation: 1,
|
|
1243
|
+
severity: "warn",
|
|
1244
|
+
surface: "source",
|
|
1245
|
+
why: "The first-wave hero background. It says 'software product' on a plumber's site.",
|
|
1246
|
+
fix: "Use a flat ground from the palette. If the design needs depth, get it from photography or material texture from the client's world.",
|
|
1247
|
+
detect: gradientHits,
|
|
1248
|
+
fixtures: {
|
|
1249
|
+
flag: [
|
|
1250
|
+
f2("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
|
|
1251
|
+
f2("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
|
|
1252
|
+
],
|
|
1253
|
+
pass: [f2("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
|
|
1254
|
+
}
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
id: "gradient-text",
|
|
1258
|
+
name: "Gradient-filled heading",
|
|
1259
|
+
generation: 1,
|
|
1260
|
+
severity: "warn",
|
|
1261
|
+
surface: "source",
|
|
1262
|
+
why: "Gradient text is the model's way of making a heading look designed without deciding what it should look like.",
|
|
1263
|
+
fix: "Set the heading in the display face at a real size and weight. Let type do the work.",
|
|
1264
|
+
detect: gradientTextHits,
|
|
1265
|
+
fixtures: {
|
|
1266
|
+
flag: [
|
|
1267
|
+
f2("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
|
|
1268
|
+
f2("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
|
|
1269
|
+
],
|
|
1270
|
+
pass: [f2("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
|
|
1271
|
+
}
|
|
1272
|
+
},
|
|
1273
|
+
{
|
|
1274
|
+
id: "glass-panel",
|
|
1275
|
+
name: "Glass panel",
|
|
1276
|
+
generation: 1,
|
|
1277
|
+
severity: "warn",
|
|
1278
|
+
surface: "source",
|
|
1279
|
+
why: "A translucent blurred card over a busy background is the stock way to put text on a hero image. It costs contrast and says nothing about the client.",
|
|
1280
|
+
fix: "Give text a solid ground, or crop the image so the text sits on a quiet area.",
|
|
1281
|
+
detect: glassHits,
|
|
1282
|
+
fixtures: {
|
|
1283
|
+
flag: [
|
|
1284
|
+
f2("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
|
|
1285
|
+
f2("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
|
|
1286
|
+
],
|
|
1287
|
+
pass: [f2("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
|
|
1288
|
+
}
|
|
1289
|
+
},
|
|
1290
|
+
{
|
|
1291
|
+
id: "hero-then-proof",
|
|
1292
|
+
name: "Hero straight into a trust strip",
|
|
1293
|
+
generation: 1,
|
|
1294
|
+
severity: "warn",
|
|
1295
|
+
surface: "source",
|
|
1296
|
+
why: "Six of seven estate sites go hero, then logos or stats. It is the landing-page template's order, not the client's story.",
|
|
1297
|
+
fix: "Follow the hero with the thing only this business has: the work, the place, the person. Put proof where a doubt arises.",
|
|
1298
|
+
detect: heroThenProofHits,
|
|
1299
|
+
fixtures: {
|
|
1300
|
+
flag: [f2("app/page.tsx", `export default function Page() {
|
|
1301
|
+
return (<main><Hero /><TrustStrip /><Services /></main>);
|
|
1302
|
+
}`)],
|
|
1303
|
+
pass: [f2("app/page.tsx", `export default function Page() {
|
|
1304
|
+
return (<main><Hero /><Workshop /><TrustStrip /></main>);
|
|
1305
|
+
}`)]
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
{
|
|
1309
|
+
id: "icon-tile-grid",
|
|
1310
|
+
name: "Three-column icon-card grid",
|
|
1311
|
+
generation: 1,
|
|
1312
|
+
severity: "warn",
|
|
1313
|
+
surface: "source",
|
|
1314
|
+
why: "Three cards, each an icon, a heading and two lines, is what a model builds for any list of services.",
|
|
1315
|
+
fix: "Show the services as the client would: a price list, a menu board, photographs of the work, a sequence.",
|
|
1316
|
+
detect: iconGridHits,
|
|
1317
|
+
fixtures: {
|
|
1318
|
+
flag: [
|
|
1319
|
+
f2(
|
|
1320
|
+
"components/Features.tsx",
|
|
1321
|
+
`import { Heart } from "lucide-react";
|
|
1322
|
+
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
1323
|
+
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-rose-100"><Heart /></div>
|
|
1324
|
+
</div>`
|
|
1325
|
+
)
|
|
1326
|
+
],
|
|
1327
|
+
pass: [f2("components/Features.tsx", `<ul className="divide-y">
|
|
1328
|
+
<li className="flex justify-between py-3">Gel manicure <span>\xA332</span></li>
|
|
1329
|
+
</ul>`)]
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1332
|
+
{
|
|
1333
|
+
id: "reveal-everywhere",
|
|
1334
|
+
name: "Reveal on every section",
|
|
1335
|
+
generation: 1,
|
|
1336
|
+
severity: "warn",
|
|
1337
|
+
surface: "source",
|
|
1338
|
+
why: "When every block fades up on scroll, the motion stops meaning anything and the page feels slow. MMM had 70, DD 40, sen-sphere 27.",
|
|
1339
|
+
fix: `Keep one gated signature moment per page (STANDARD section 7) and let the rest arrive still. The limit here is ${REVEAL_LIMIT} per scan.`,
|
|
1340
|
+
detect: revealHits,
|
|
1341
|
+
fixtures: {
|
|
1342
|
+
flag: [f2("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
|
|
1343
|
+
pass: [f2("app/page.tsx", `<Reveal><Signature /></Reveal>
|
|
1344
|
+
<Block />
|
|
1345
|
+
<Block />`)]
|
|
1346
|
+
}
|
|
1347
|
+
},
|
|
1348
|
+
{
|
|
1349
|
+
id: "pill-everything",
|
|
1350
|
+
name: "Pills everywhere",
|
|
1351
|
+
generation: 1,
|
|
1352
|
+
severity: "warn",
|
|
1353
|
+
surface: "source",
|
|
1354
|
+
why: "rounded-full on every button, badge and tag is the framework's friendliest default. MMM had 162, DD 125. A shape used everywhere is not a shape language.",
|
|
1355
|
+
fix: `Choose the shape language on purpose (and record it), then use the pill only where it means something. The limit here is ${PILL_LIMIT} per scan.`,
|
|
1356
|
+
detect: pillHits,
|
|
1357
|
+
fixtures: {
|
|
1358
|
+
flag: [f2("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
|
|
1359
|
+
pass: [f2("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
|
|
1360
|
+
<a className="rounded-sm px-4">Book</a>`)]
|
|
1361
|
+
}
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
id: "shadcn-dump",
|
|
1365
|
+
name: "Stock component dump",
|
|
1366
|
+
generation: 1,
|
|
1367
|
+
severity: "warn",
|
|
1368
|
+
surface: "source",
|
|
1369
|
+
why: "A generator exports the whole shadcn/ui kit whether the site uses it or not. Rise & Bloom ships a full set under its own book, vine and petal assets.",
|
|
1370
|
+
fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
|
|
1371
|
+
detect: shadcnHits,
|
|
1372
|
+
fixtures: {
|
|
1373
|
+
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f2(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
|
|
1374
|
+
pass: [f2("src/components/ui/button.tsx", `export function Button() { return null; }`)]
|
|
1375
|
+
}
|
|
1376
|
+
},
|
|
1377
|
+
{
|
|
1378
|
+
id: "cream-palette",
|
|
1379
|
+
name: "Cream ground",
|
|
1380
|
+
generation: 2,
|
|
1381
|
+
severity: "warn",
|
|
1382
|
+
surface: "source",
|
|
1383
|
+
why: "Once white-and-violet was named, models moved to a warm off-white: parchment, linen, oat. It reads as 'tasteful' in the same way everywhere.",
|
|
1384
|
+
fix: "Take the ground from the client's material world (paper stock, wall colour, tile). If it really is cream, say why.",
|
|
1385
|
+
detect: creamHits,
|
|
1386
|
+
fixtures: {
|
|
1387
|
+
flag: [
|
|
1388
|
+
f2("app/globals.css", `body { background-color: #f5efe6; }`),
|
|
1389
|
+
f2("app/page.tsx", `<main className="bg-cream text-ink">`),
|
|
1390
|
+
f2("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
|
|
1391
|
+
],
|
|
1392
|
+
pass: [f2("app/globals.css", `body { background-color: #ffffff; }`)]
|
|
1393
|
+
}
|
|
1394
|
+
},
|
|
1395
|
+
{
|
|
1396
|
+
id: "italic-serif-display",
|
|
1397
|
+
name: "Italic serif display",
|
|
1398
|
+
generation: 2,
|
|
1399
|
+
severity: "warn",
|
|
1400
|
+
surface: "source",
|
|
1401
|
+
why: "A serif heading with one italic word ('Beauty, reimagined') is the second wave's signature move.",
|
|
1402
|
+
fix: "Use emphasis only where the sentence needs it, and set it the way the display face wants, not by reflex.",
|
|
1403
|
+
detect: italicSerifHits,
|
|
1404
|
+
fixtures: {
|
|
1405
|
+
flag: [
|
|
1406
|
+
f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
|
|
1407
|
+
f2("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
|
|
1408
|
+
f2("app/globals.css", `.hero h1 { font-style: italic; }`)
|
|
1409
|
+
],
|
|
1410
|
+
pass: [f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
|
|
1411
|
+
<p><em>Walk-ins</em> welcome.</p>`)]
|
|
1412
|
+
}
|
|
1413
|
+
},
|
|
1414
|
+
{
|
|
1415
|
+
id: "hero-eyebrow-chip",
|
|
1416
|
+
name: "Eyebrow chip above the hero",
|
|
1417
|
+
generation: 2,
|
|
1418
|
+
severity: "warn",
|
|
1419
|
+
surface: "source",
|
|
1420
|
+
why: "A small bordered pill ('New \xB7 Now booking') above the headline is in almost every generated hero.",
|
|
1421
|
+
fix: "Drop it. If the fact matters, put it in the headline or the first line of copy.",
|
|
1422
|
+
detect: eyebrowChipHits,
|
|
1423
|
+
fixtures: {
|
|
1424
|
+
flag: [
|
|
1425
|
+
f2("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
|
|
1426
|
+
<h1>Nails in Brackley</h1>`),
|
|
1427
|
+
f2("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
|
|
1428
|
+
<h1>Nails</h1>`)
|
|
1429
|
+
],
|
|
1430
|
+
pass: [f2("components/Hero.tsx", `<h1>Nails in Brackley</h1>
|
|
1431
|
+
<span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
|
|
1432
|
+
}
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
id: "icon-tile-stack",
|
|
1436
|
+
name: "Icon in a tinted tile",
|
|
1437
|
+
generation: 2,
|
|
1438
|
+
severity: "warn",
|
|
1439
|
+
surface: "source",
|
|
1440
|
+
why: "A library icon in a soft rounded square, stacked above a heading, is the stock card header.",
|
|
1441
|
+
fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
|
|
1442
|
+
detect: iconTileStackHits,
|
|
1443
|
+
fixtures: {
|
|
1444
|
+
flag: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
1445
|
+
<div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
|
|
1446
|
+
pass: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
1447
|
+
<p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
|
|
1448
|
+
}
|
|
1449
|
+
},
|
|
1450
|
+
{
|
|
1451
|
+
id: "bento-grid",
|
|
1452
|
+
name: "Bento grid",
|
|
1453
|
+
generation: 2,
|
|
1454
|
+
severity: "warn",
|
|
1455
|
+
surface: "source",
|
|
1456
|
+
why: "Mixed-size tiles in a grid became the default 'features' layout of the second wave.",
|
|
1457
|
+
fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
|
|
1458
|
+
detect: bentoHits,
|
|
1459
|
+
fixtures: {
|
|
1460
|
+
flag: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
|
|
1461
|
+
pass: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
|
|
1462
|
+
}
|
|
1463
|
+
},
|
|
1464
|
+
{
|
|
1465
|
+
id: "radial-spotlight-glow",
|
|
1466
|
+
name: "Radial glow",
|
|
1467
|
+
generation: 2,
|
|
1468
|
+
severity: "warn",
|
|
1469
|
+
surface: "source",
|
|
1470
|
+
why: "A soft blurred blob or radial spotlight behind the hero is atmosphere without a subject.",
|
|
1471
|
+
fix: "Light the page with real imagery, or leave the ground flat.",
|
|
1472
|
+
detect: glowHits,
|
|
1473
|
+
fixtures: {
|
|
1474
|
+
flag: [
|
|
1475
|
+
f2("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
|
|
1476
|
+
f2("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
|
|
1477
|
+
],
|
|
1478
|
+
pass: [f2("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
|
|
1479
|
+
}
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
id: "grid-background",
|
|
1483
|
+
name: "Graph-paper background",
|
|
1484
|
+
generation: 2,
|
|
1485
|
+
severity: "warn",
|
|
1486
|
+
surface: "source",
|
|
1487
|
+
why: "A faint 1px line or dot grid behind the hero is the developer-tool look, borrowed by everyone.",
|
|
1488
|
+
fix: "Remove it. If the brand has a pattern, draw it from the brand.",
|
|
1489
|
+
detect: gridBackgroundHits,
|
|
1490
|
+
fixtures: {
|
|
1491
|
+
flag: [
|
|
1492
|
+
f2(
|
|
1493
|
+
"app/globals.css",
|
|
1494
|
+
`.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
|
|
1495
|
+
),
|
|
1496
|
+
f2("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
|
|
1497
|
+
],
|
|
1498
|
+
pass: [f2("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
|
|
1499
|
+
}
|
|
1500
|
+
},
|
|
1501
|
+
{
|
|
1502
|
+
id: "marquee",
|
|
1503
|
+
name: "Scrolling marquee",
|
|
1504
|
+
generation: 2,
|
|
1505
|
+
severity: "warn",
|
|
1506
|
+
surface: "source",
|
|
1507
|
+
why: "An endless strip of logos or words is the second wave's trust strip. It moves so it looks alive.",
|
|
1508
|
+
fix: "Show proof still and in context: one named review, one accreditation, where the doubt arises.",
|
|
1509
|
+
detect: marqueeHits,
|
|
1510
|
+
fixtures: {
|
|
1511
|
+
flag: [
|
|
1512
|
+
f2("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
|
|
1513
|
+
f2("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
|
|
1514
|
+
],
|
|
1515
|
+
pass: [f2("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
|
|
1516
|
+
}
|
|
1517
|
+
},
|
|
1518
|
+
{
|
|
1519
|
+
id: "thin-border-wide-shadow",
|
|
1520
|
+
name: "Hairline border, wide shadow",
|
|
1521
|
+
generation: 2,
|
|
1522
|
+
severity: "warn",
|
|
1523
|
+
surface: "source",
|
|
1524
|
+
why: "A near-invisible border under a large soft shadow is the stock 'elevated card'.",
|
|
1525
|
+
fix: "Pick one: a border that is visible, or a shadow the light source explains. Keep within the four-shadow budget.",
|
|
1526
|
+
detect: thinBorderWideShadowHits,
|
|
1527
|
+
fixtures: {
|
|
1528
|
+
flag: [
|
|
1529
|
+
f2("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
|
|
1530
|
+
f2("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
|
|
1531
|
+
],
|
|
1532
|
+
pass: [f2("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
|
|
1533
|
+
}
|
|
1534
|
+
},
|
|
1535
|
+
{
|
|
1536
|
+
id: "intro-cinematic",
|
|
1537
|
+
name: "Intro cinematic",
|
|
1538
|
+
generation: 2,
|
|
1539
|
+
severity: "warn",
|
|
1540
|
+
surface: "source",
|
|
1541
|
+
why: "A logo animation that runs before the page is something a model adds to make a site feel premium. Three estate sites open with one. It delays the page for every visitor.",
|
|
1542
|
+
fix: "Remove it. Spend the signature moment on something the visitor came for.",
|
|
1543
|
+
detect: introHits,
|
|
1544
|
+
fixtures: {
|
|
1545
|
+
flag: [
|
|
1546
|
+
f2("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
|
|
1547
|
+
export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
1548
|
+
f2("app/page.tsx", `<><Preloader /><Hero /></>`)
|
|
1549
|
+
],
|
|
1550
|
+
pass: [f2("components/Introduction.md", `An introduction to the studio.`)]
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
];
|
|
1554
|
+
|
|
1555
|
+
// src/character/parse.ts
|
|
1556
|
+
function lineAt(text, offset) {
|
|
1557
|
+
let line = 1;
|
|
1558
|
+
for (let i = 0; i < offset && i < text.length; i += 1) {
|
|
1559
|
+
if (text.charCodeAt(i) === 10) line += 1;
|
|
1560
|
+
}
|
|
1561
|
+
return line;
|
|
1562
|
+
}
|
|
1563
|
+
function excerptAt(text, offset, max = 140) {
|
|
1564
|
+
const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
|
1565
|
+
const endRaw = text.indexOf("\n", offset);
|
|
1566
|
+
const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
|
|
1567
|
+
return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// src/snapshot/types.ts
|
|
1571
|
+
var SNAPSHOT_VERSION = 1;
|
|
1572
|
+
|
|
1573
|
+
// src/snapshot/fixture.ts
|
|
1574
|
+
function makeSnapshot(patch = {}) {
|
|
1575
|
+
const base = {
|
|
1576
|
+
version: SNAPSHOT_VERSION,
|
|
1577
|
+
url: "https://example.test/",
|
|
1578
|
+
capturedAt: "2026-09-23T00:00:00.000Z",
|
|
1579
|
+
viewport: { width: 1440, height: 900 },
|
|
1580
|
+
pageHeight: 4200,
|
|
1581
|
+
ground: "rgb(255, 255, 255)",
|
|
1582
|
+
colours: {
|
|
1583
|
+
backgrounds: [
|
|
1584
|
+
{ value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
|
|
1585
|
+
{ value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
|
|
1586
|
+
],
|
|
1587
|
+
text: [
|
|
1588
|
+
{ value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
|
|
1589
|
+
{ value: "rgb(31, 58, 46)", area: 0, chars: 300 }
|
|
1590
|
+
]
|
|
1591
|
+
},
|
|
1592
|
+
fonts: [
|
|
1593
|
+
{ family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
|
|
1594
|
+
{ family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
|
|
1595
|
+
],
|
|
1596
|
+
headings: [
|
|
1597
|
+
{ level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
|
|
1598
|
+
{ level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
|
|
1599
|
+
],
|
|
1600
|
+
controls: [
|
|
1601
|
+
{ kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
|
|
1602
|
+
{ kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
|
|
1603
|
+
{ kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
|
|
1604
|
+
{ kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
|
|
1605
|
+
],
|
|
1606
|
+
sections: [
|
|
1607
|
+
{ top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
1608
|
+
{ top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
1609
|
+
{ top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
|
|
1610
|
+
{ top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
1611
|
+
{ top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
|
|
1612
|
+
],
|
|
1613
|
+
effects: {
|
|
1614
|
+
glass: 0,
|
|
1615
|
+
gradientText: 0,
|
|
1616
|
+
gradients: [],
|
|
1617
|
+
glows: 0,
|
|
1618
|
+
gridBackgrounds: 0,
|
|
1619
|
+
marquees: 0,
|
|
1620
|
+
hairlineShadowCards: 0,
|
|
1621
|
+
eyebrowChip: false
|
|
1622
|
+
},
|
|
1623
|
+
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
1624
|
+
metrics: {
|
|
1625
|
+
fontSizes: [14, 16, 20, 36, 64],
|
|
1626
|
+
fontWeights: [400, 500],
|
|
1627
|
+
radii: ["4px"],
|
|
1628
|
+
shadows: [],
|
|
1629
|
+
spacingOnScalePct: 96,
|
|
1630
|
+
bodyLineLengthCh: 68,
|
|
1631
|
+
bodyFontSizePx: 17
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
const out = { ...base };
|
|
1635
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
1636
|
+
const current = base[key];
|
|
1637
|
+
out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
1638
|
+
}
|
|
1639
|
+
return out;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// src/snapshot/fonts.ts
|
|
1643
|
+
function normaliseFamily(raw) {
|
|
1644
|
+
let name = raw.replace(/["']/g, "").trim();
|
|
1645
|
+
const next = name.match(/^__(.+?)(?:_Fallback)?_[0-9a-f]{5,8}$/i);
|
|
1646
|
+
if (next) name = next[1].replace(/_/g, " ");
|
|
1647
|
+
name = name.replace(/\s+Fallback$/i, "").replace(/\s+Variable$/i, "");
|
|
1648
|
+
if (!/\s/.test(name) && (/[a-z][A-Z]/.test(name) || /^[a-z]+(?:-[a-z]+)+$/.test(name))) {
|
|
1649
|
+
name = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/-/g, " ").replace(/\b([a-z])/g, (c) => c.toUpperCase()).replace(/\bDm\b/, "DM");
|
|
1650
|
+
}
|
|
1651
|
+
if (/^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|sans-serif|serif|monospace)$/i.test(name)) return "system-ui";
|
|
1652
|
+
return name;
|
|
1653
|
+
}
|
|
1654
|
+
var KNOWN = {
|
|
1655
|
+
fraunces: "serif",
|
|
1656
|
+
"playfair display": "serif",
|
|
1657
|
+
"cormorant garamond": "serif",
|
|
1658
|
+
cormorant: "serif",
|
|
1659
|
+
"eb garamond": "serif",
|
|
1660
|
+
lora: "serif",
|
|
1661
|
+
merriweather: "serif",
|
|
1662
|
+
"libre baskerville": "serif",
|
|
1663
|
+
"instrument serif": "serif",
|
|
1664
|
+
newsreader: "serif",
|
|
1665
|
+
"source serif 4": "serif",
|
|
1666
|
+
"crimson pro": "serif",
|
|
1667
|
+
canela: "serif",
|
|
1668
|
+
"tiempos headline": "serif",
|
|
1669
|
+
"tiempos text": "serif",
|
|
1670
|
+
"gt super": "serif",
|
|
1671
|
+
"freight display pro": "serif",
|
|
1672
|
+
"gt sectra": "serif",
|
|
1673
|
+
georgia: "serif",
|
|
1674
|
+
"times new roman": "serif",
|
|
1675
|
+
"dm serif display": "serif",
|
|
1676
|
+
"bodoni moda": "serif",
|
|
1677
|
+
"libre caslon text": "serif",
|
|
1678
|
+
"young serif": "serif",
|
|
1679
|
+
"caveat": "script",
|
|
1680
|
+
"dancing script": "script",
|
|
1681
|
+
"great vibes": "script",
|
|
1682
|
+
"pinyon script": "script"
|
|
1683
|
+
};
|
|
1684
|
+
function fontClass(family) {
|
|
1685
|
+
const name = normaliseFamily(family);
|
|
1686
|
+
if (name === "system-ui") return "system";
|
|
1687
|
+
const known = KNOWN[name.toLowerCase()];
|
|
1688
|
+
if (known) return known;
|
|
1689
|
+
if (/\bmono\b|code|courier/i.test(name)) return "mono";
|
|
1690
|
+
if (/script|hand/i.test(name)) return "script";
|
|
1691
|
+
if (/serif/i.test(name) && !/sans/i.test(name)) return "serif";
|
|
1692
|
+
return "sans";
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// src/snapshot/rendered.ts
|
|
1696
|
+
var hit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
1697
|
+
var pct = (n) => `${Math.round(n * 100)}%`;
|
|
1698
|
+
var oklchOf = (value) => parseColour(value)?.oklch ?? null;
|
|
1699
|
+
function reflexFonts(list) {
|
|
1700
|
+
const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
|
|
1701
|
+
return (s) => {
|
|
1702
|
+
const total = s.fonts.reduce((n, f3) => n + f3.chars, 0) || 1;
|
|
1703
|
+
const display = s.fonts.reduce((n, f3) => n + f3.displayChars, 0) || 1;
|
|
1704
|
+
const hits = [];
|
|
1705
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1706
|
+
const h1 = s.headings.find((h) => h.level === 1);
|
|
1707
|
+
const h1Name = h1 ? wanted.get(normaliseFamily(h1.family).toLowerCase()) : void 0;
|
|
1708
|
+
if (h1 && h1Name) {
|
|
1709
|
+
seen.add(h1Name);
|
|
1710
|
+
hits.push(hit(s, `${h1Name} sets the headline`, h1.text));
|
|
1711
|
+
}
|
|
1712
|
+
for (const font of s.fonts) {
|
|
1713
|
+
const name = wanted.get(normaliseFamily(font.family).toLowerCase());
|
|
1714
|
+
if (!name || seen.has(name)) continue;
|
|
1715
|
+
const share = font.chars / total;
|
|
1716
|
+
const displayShare = font.displayChars / display;
|
|
1717
|
+
if (share >= 0.15 || displayShare >= 0.3) {
|
|
1718
|
+
seen.add(name);
|
|
1719
|
+
hits.push(hit(s, `${name} sets ${pct(share)} of the text on the page${displayShare >= 0.3 ? ` and ${pct(displayShare)} of the headings` : ""}`));
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
return hits;
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
function violetHits2(s) {
|
|
1726
|
+
const hits = [];
|
|
1727
|
+
const controls = s.controls.filter((c) => {
|
|
1728
|
+
const o = oklchOf(c.background);
|
|
1729
|
+
return o !== null && (parseColour(c.background)?.alpha ?? 1) > 0.5 && isAiViolet(o);
|
|
1730
|
+
});
|
|
1731
|
+
if (controls.length > 0) hits.push(hit(s, `${controls.length} button${controls.length === 1 ? "" : "s"} filled with an indigo-violet accent`, `${controls[0].text}: ${controls[0].background}`));
|
|
1732
|
+
const totalChars = s.colours.text.reduce((n, t) => n + t.chars, 0) || 1;
|
|
1733
|
+
const violetText = s.colours.text.filter((t) => {
|
|
1734
|
+
const o = oklchOf(t.value);
|
|
1735
|
+
return o !== null && isAiViolet(o);
|
|
1736
|
+
});
|
|
1737
|
+
const textChars = violetText.reduce((n, t) => n + t.chars, 0);
|
|
1738
|
+
if (textChars >= 40 && textChars / totalChars >= 0.01) hits.push(hit(s, `${pct(textChars / totalChars)} of the text is set in indigo-violet`, violetText[0].value));
|
|
1739
|
+
const pageArea = s.pageHeight * s.viewport.width || 1;
|
|
1740
|
+
const violetBg = s.colours.backgrounds.filter((b) => {
|
|
1741
|
+
const o = oklchOf(b.value);
|
|
1742
|
+
return o !== null && (parseColour(b.value)?.alpha ?? 1) > 0.5 && isAiViolet(o);
|
|
1743
|
+
});
|
|
1744
|
+
const bgArea = violetBg.reduce((n, b) => n + b.area, 0);
|
|
1745
|
+
if (bgArea / pageArea >= 0.01 && controls.length === 0) hits.push(hit(s, `indigo-violet covers ${pct(bgArea / pageArea)} of the page`, violetBg[0].value));
|
|
1746
|
+
return hits;
|
|
1747
|
+
}
|
|
1748
|
+
function blueToPurple(s) {
|
|
1749
|
+
const hits = [];
|
|
1750
|
+
for (const g of s.effects.gradients) {
|
|
1751
|
+
const hues = g.stops.map(oklchOf).filter((o) => o !== null && o.c >= 0.08).map((o) => o.h);
|
|
1752
|
+
if (hues.some((h) => h >= 235 && h <= 272) && hues.some((h) => h >= 285 && h <= 330)) {
|
|
1753
|
+
hits.push(hit(s, "a blue-to-purple gradient covers a large area", g.stops.join(" \u2192 ")));
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
return hits;
|
|
1757
|
+
}
|
|
1758
|
+
var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
1759
|
+
var RENDERED_PATHS = {
|
|
1760
|
+
"reflex-font": {
|
|
1761
|
+
detect: reflexFonts(REFLEX_FONTS_1),
|
|
1762
|
+
fixtures: {
|
|
1763
|
+
flag: [makeSnapshot({ fonts: [{ family: "__Inter_d65c78", chars: 5e3, displayChars: 900, italicChars: 0, weights: [400, 700] }] })],
|
|
1764
|
+
pass: [makeSnapshot(), makeSnapshot({ fonts: [{ family: "S\xF6hne", chars: 5e3, displayChars: 900, italicChars: 0, weights: [400] }, { family: "Inter", chars: 60, displayChars: 0, italicChars: 0, weights: [400] }] })]
|
|
1765
|
+
}
|
|
1766
|
+
},
|
|
1767
|
+
"reflex-font-2": {
|
|
1768
|
+
detect: reflexFonts(REFLEX_FONTS_2),
|
|
1769
|
+
fixtures: {
|
|
1770
|
+
flag: [
|
|
1771
|
+
makeSnapshot({ fonts: [{ family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400] }, { family: "Fraunces", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }] }),
|
|
1772
|
+
makeSnapshot({ headings: [{ level: 1, family: "Instrument Serif", sizePx: 72, weight: 400, italic: false, italicPart: false, text: "Beauty", top: 200 }] })
|
|
1773
|
+
],
|
|
1774
|
+
pass: [makeSnapshot()]
|
|
1775
|
+
}
|
|
1776
|
+
},
|
|
1777
|
+
"ai-violet": {
|
|
1778
|
+
detect: violetHits2,
|
|
1779
|
+
fixtures: {
|
|
1780
|
+
flag: [
|
|
1781
|
+
makeSnapshot({ controls: [{ kind: "button", radiusPx: 4, heightPx: 44, background: "oklch(0.541 0.281 293.009)", text: "Get started" }] }),
|
|
1782
|
+
makeSnapshot({ colours: { backgrounds: makeSnapshot().colours.backgrounds, text: [{ value: "rgb(28, 28, 26)", area: 0, chars: 5e3 }, { value: "rgb(79, 70, 229)", area: 0, chars: 400 }] } }),
|
|
1783
|
+
makeSnapshot({ colours: { text: makeSnapshot().colours.text, backgrounds: [{ value: "rgb(255, 255, 255)", area: 5e6, chars: 0 }, { value: "rgb(90, 53, 209)", area: 4e5, chars: 0 }] } })
|
|
1784
|
+
],
|
|
1785
|
+
pass: [makeSnapshot(), makeSnapshot({ controls: [{ kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(30, 58, 138)", text: "Book" }] })]
|
|
1786
|
+
}
|
|
1787
|
+
},
|
|
1788
|
+
"blue-purple-gradient": {
|
|
1789
|
+
detect: blueToPurple,
|
|
1790
|
+
fixtures: {
|
|
1791
|
+
flag: [makeSnapshot({ effects: { gradients: [{ stops: ["rgb(37, 99, 235)", "rgb(147, 51, 234)"], area: 12e5, radial: false }] } })],
|
|
1792
|
+
pass: [makeSnapshot({ effects: { gradients: [{ stops: ["rgb(250, 247, 240)", "rgb(255, 255, 255)"], area: 12e5, radial: false }] } })]
|
|
1793
|
+
}
|
|
1794
|
+
},
|
|
1795
|
+
"gradient-text": {
|
|
1796
|
+
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
|
|
1797
|
+
fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
|
|
1798
|
+
},
|
|
1799
|
+
"glass-panel": {
|
|
1800
|
+
detect: (s) => s.effects.glass > 0 ? [hit(s, `${count(s.effects.glass, "translucent blurred panel")}`)] : [],
|
|
1801
|
+
fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
|
|
1802
|
+
},
|
|
1803
|
+
"cream-palette": {
|
|
1804
|
+
detect: (s) => {
|
|
1805
|
+
const ground = oklchOf(s.ground);
|
|
1806
|
+
if (ground && isCream(ground)) return [hit(s, "the page ground is a warm off-white", s.ground)];
|
|
1807
|
+
const pageArea = s.pageHeight * s.viewport.width || 1;
|
|
1808
|
+
const cream = s.colours.backgrounds.find((b) => {
|
|
1809
|
+
const o = oklchOf(b.value);
|
|
1810
|
+
return o !== null && isCream(o) && b.area / pageArea >= 0.3;
|
|
1811
|
+
});
|
|
1812
|
+
return cream ? [hit(s, `a warm off-white covers ${pct(cream.area / pageArea)} of the page`, cream.value)] : [];
|
|
1813
|
+
},
|
|
1814
|
+
fixtures: {
|
|
1815
|
+
flag: [makeSnapshot({ ground: "rgb(250, 246, 238)" }), makeSnapshot({ colours: { text: makeSnapshot().colours.text, backgrounds: [{ value: "rgb(245, 240, 230)", area: 3e6, chars: 0 }] } })],
|
|
1816
|
+
pass: [makeSnapshot(), makeSnapshot({ ground: "rgb(254, 252, 232)" })]
|
|
1817
|
+
}
|
|
1818
|
+
},
|
|
1819
|
+
"italic-serif-display": {
|
|
1820
|
+
detect: (s) => {
|
|
1821
|
+
const h = s.headings.find((x) => x.level <= 2 && fontClass(x.family) === "serif" && (x.italic || x.italicPart));
|
|
1822
|
+
return h ? [hit(s, `a serif ${h.level === 1 ? "headline" : "heading"} with an italic turn`, h.text)] : [];
|
|
1823
|
+
},
|
|
1824
|
+
fixtures: {
|
|
1825
|
+
flag: [makeSnapshot({ headings: [{ level: 1, family: "__Instrument_Serif_1a2b3c", sizePx: 72, weight: 400, italic: false, italicPart: true, text: "Beauty, reimagined", top: 200 }] })],
|
|
1826
|
+
pass: [makeSnapshot(), makeSnapshot({ headings: [{ level: 1, family: "S\xF6hne", sizePx: 64, weight: 600, italic: false, italicPart: true, text: "Boilers, fixed", top: 200 }] })]
|
|
1827
|
+
}
|
|
1828
|
+
},
|
|
1829
|
+
"hero-eyebrow-chip": {
|
|
1830
|
+
detect: (s) => s.effects.eyebrowChip ? [hit(s, "a small bordered pill sits above the headline")] : [],
|
|
1831
|
+
fixtures: { flag: [makeSnapshot({ effects: { eyebrowChip: true } })], pass: [makeSnapshot()] }
|
|
1832
|
+
},
|
|
1833
|
+
"icon-tile-grid": {
|
|
1834
|
+
detect: (s) => s.sections.filter((x) => x.cards >= 3 && x.iconCards >= 3).map((x) => hit(s, `${x.iconCards} icon cards in a row`, x.label)),
|
|
1835
|
+
fixtures: {
|
|
1836
|
+
flag: [makeSnapshot({ sections: [...makeSnapshot().sections.slice(0, 2), { top: 1700, height: 700, kind: "cards", label: "Our services", cards: 3, iconCards: 3, hiddenAtLoad: false }] })],
|
|
1837
|
+
pass: [makeSnapshot()]
|
|
1838
|
+
}
|
|
1839
|
+
},
|
|
1840
|
+
"hero-then-proof": {
|
|
1841
|
+
detect: (s) => {
|
|
1842
|
+
const [first, second] = s.sections;
|
|
1843
|
+
return first?.kind === "hero" && second && ["logos", "stats", "marquee"].includes(second.kind) ? [hit(s, `the hero is followed straight away by ${second.kind === "stats" ? "a row of numbers" : "a strip of logos"}`, second.label)] : [];
|
|
1844
|
+
},
|
|
1845
|
+
fixtures: {
|
|
1846
|
+
flag: [
|
|
1847
|
+
makeSnapshot({ sections: [makeSnapshot().sections[0], { top: 800, height: 160, kind: "logos", label: "Trusted by", cards: 0, iconCards: 0, hiddenAtLoad: false }, ...makeSnapshot().sections.slice(1)] }),
|
|
1848
|
+
makeSnapshot({ sections: [makeSnapshot().sections[0], { top: 800, height: 220, kind: "stats", label: "500+", cards: 0, iconCards: 0, hiddenAtLoad: false }] })
|
|
1849
|
+
],
|
|
1850
|
+
pass: [makeSnapshot()]
|
|
1851
|
+
}
|
|
1852
|
+
},
|
|
1853
|
+
"reveal-everywhere": {
|
|
1854
|
+
detect: (s) => {
|
|
1855
|
+
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
1856
|
+
const hidden = s.motion.hiddenSections;
|
|
1857
|
+
return hidden >= 4 && below > 0 && hidden / below >= 0.6 ? [hit(s, `${hidden} of ${below} sections below the fold wait for a scroll to appear`)] : [];
|
|
1858
|
+
},
|
|
1859
|
+
fixtures: {
|
|
1860
|
+
flag: [makeSnapshot({ motion: { hiddenSections: 4 }, sections: makeSnapshot().sections.map((x, i) => ({ ...x, hiddenAtLoad: i > 0 })) })],
|
|
1861
|
+
pass: [makeSnapshot(), makeSnapshot({ motion: { hiddenSections: 1 } })]
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
"pill-everything": {
|
|
1865
|
+
detect: (s) => {
|
|
1866
|
+
const pills = s.controls.filter((c) => c.radiusPx >= c.heightPx / 2 - 1);
|
|
1867
|
+
return s.controls.length >= 4 && pills.length / s.controls.length >= 0.8 ? [hit(s, `${pills.length} of ${s.controls.length} buttons are pills`)] : [];
|
|
1868
|
+
},
|
|
1869
|
+
fixtures: {
|
|
1870
|
+
flag: [makeSnapshot({ controls: makeSnapshot().controls.map((c) => ({ ...c, radiusPx: 9999 })) })],
|
|
1871
|
+
pass: [makeSnapshot(), makeSnapshot({ controls: makeSnapshot().controls.slice(0, 2).map((c) => ({ ...c, radiusPx: 9999 })) })]
|
|
1872
|
+
}
|
|
1873
|
+
},
|
|
1874
|
+
"radial-spotlight-glow": {
|
|
1875
|
+
detect: (s) => {
|
|
1876
|
+
const radial = s.effects.gradients.filter((g) => g.radial).length;
|
|
1877
|
+
const n = s.effects.glows + radial;
|
|
1878
|
+
return n > 0 ? [hit(s, `${count(n, "soft glow")} behind the content`)] : [];
|
|
1879
|
+
},
|
|
1880
|
+
fixtures: {
|
|
1881
|
+
flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
|
|
1882
|
+
pass: [makeSnapshot()]
|
|
1883
|
+
}
|
|
1884
|
+
},
|
|
1885
|
+
"grid-background": {
|
|
1886
|
+
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
|
|
1887
|
+
fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
|
|
1888
|
+
},
|
|
1889
|
+
marquee: {
|
|
1890
|
+
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count(s.effects.marquees, "endless scrolling strip")}`)] : [],
|
|
1891
|
+
fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
|
|
1892
|
+
},
|
|
1893
|
+
"thin-border-wide-shadow": {
|
|
1894
|
+
detect: (s) => s.effects.hairlineShadowCards >= 3 ? [hit(s, `${s.effects.hairlineShadowCards} cards with a hairline border under a wide shadow`)] : [],
|
|
1895
|
+
fixtures: { flag: [makeSnapshot({ effects: { hairlineShadowCards: 3 } })], pass: [makeSnapshot(), makeSnapshot({ effects: { hairlineShadowCards: 1 } })] }
|
|
1896
|
+
},
|
|
1897
|
+
"intro-cinematic": {
|
|
1898
|
+
detect: (s) => s.motion.introOverlay ? [hit(s, "a full-screen layer covers the page at load and then leaves")] : [],
|
|
1899
|
+
fixtures: { flag: [makeSnapshot({ motion: { introOverlay: true } })], pass: [makeSnapshot()] }
|
|
1900
|
+
}
|
|
1901
|
+
};
|
|
1902
|
+
|
|
1903
|
+
// src/character/check.ts
|
|
1904
|
+
var CATALOGUE_VERSION = "2026.09.3";
|
|
1905
|
+
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS].map(
|
|
1906
|
+
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
1907
|
+
);
|
|
1908
|
+
function tellById(id) {
|
|
1909
|
+
return CATALOGUE.find((t) => t.id === id);
|
|
1910
|
+
}
|
|
1911
|
+
function validateExceptions(exceptions) {
|
|
1912
|
+
const applied = [];
|
|
1913
|
+
const rejected = [];
|
|
1914
|
+
for (const exception of exceptions) {
|
|
1915
|
+
if (!tellById(exception.tell)) {
|
|
1916
|
+
rejected.push({ exception, reason: `no tell called "${exception.tell}"` });
|
|
1917
|
+
} else if (typeof exception.because !== "string" || exception.because.trim().length < 12) {
|
|
1918
|
+
rejected.push({ exception, reason: "an exception needs a because of at least a sentence" });
|
|
1919
|
+
} else {
|
|
1920
|
+
applied.push(exception);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
return { applied, rejected };
|
|
1924
|
+
}
|
|
1925
|
+
var MARKER = /\b(?:copy|craft)-ok\b/;
|
|
1926
|
+
function marked(lines, line) {
|
|
1927
|
+
return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
|
|
1928
|
+
}
|
|
1929
|
+
function toFinding(tell, hit2, texts) {
|
|
1930
|
+
const text = texts.get(hit2.path) ?? "";
|
|
1931
|
+
return {
|
|
1932
|
+
tell: tell.id,
|
|
1933
|
+
name: tell.name,
|
|
1934
|
+
generation: tell.generation,
|
|
1935
|
+
severity: tell.severity,
|
|
1936
|
+
path: hit2.path,
|
|
1937
|
+
line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
|
|
1938
|
+
excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
|
|
1939
|
+
message: hit2.message,
|
|
1940
|
+
fix: tell.fix
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
function report(tells, files, run, options) {
|
|
1944
|
+
const { applied, rejected } = validateExceptions(options.exceptions ?? []);
|
|
1945
|
+
const texts = new Map(files.map((file) => [file.path, file.text]));
|
|
1946
|
+
const selected = options.only ? tells.filter((t) => options.only.includes(t.id)) : tells;
|
|
1947
|
+
const findings = [];
|
|
1948
|
+
const suppressed = [];
|
|
1949
|
+
const lineCache = /* @__PURE__ */ new Map();
|
|
1950
|
+
const linesOf = (path) => {
|
|
1951
|
+
let lines = lineCache.get(path);
|
|
1952
|
+
if (!lines) lineCache.set(path, lines = (texts.get(path) ?? "").split("\n"));
|
|
1953
|
+
return lines;
|
|
1954
|
+
};
|
|
1955
|
+
const excepted = /* @__PURE__ */ new Map();
|
|
1956
|
+
for (const tell of selected) {
|
|
1957
|
+
for (const hit2 of run(tell)) {
|
|
1958
|
+
const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit2.path.includes(e.path)));
|
|
1959
|
+
if (exception) {
|
|
1960
|
+
excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
|
|
1961
|
+
continue;
|
|
1962
|
+
}
|
|
1963
|
+
const finding = toFinding(tell, hit2, texts);
|
|
1964
|
+
if (marked(linesOf(hit2.path), finding.line)) {
|
|
1965
|
+
suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
|
|
1966
|
+
continue;
|
|
1967
|
+
}
|
|
1968
|
+
findings.push(finding);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
findings.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.tell.localeCompare(b.tell));
|
|
1972
|
+
const byGeneration = { 1: 0, 2: 0, 3: 0 };
|
|
1973
|
+
const byTell = {};
|
|
1974
|
+
for (const finding of findings) {
|
|
1975
|
+
byGeneration[finding.generation] += 1;
|
|
1976
|
+
byTell[finding.tell] = (byTell[finding.tell] ?? 0) + 1;
|
|
1977
|
+
}
|
|
1978
|
+
return {
|
|
1979
|
+
catalogueVersion: CATALOGUE_VERSION,
|
|
1980
|
+
findings,
|
|
1981
|
+
rejectedExceptions: rejected,
|
|
1982
|
+
excepted: [...excepted].map(([e, count2]) => ({ tell: e.tell, because: e.because, count: count2 })),
|
|
1983
|
+
suppressed,
|
|
1984
|
+
summary: {
|
|
1985
|
+
files: files.length,
|
|
1986
|
+
findings: findings.length,
|
|
1987
|
+
byGeneration,
|
|
1988
|
+
byTell,
|
|
1989
|
+
blocking: findings.filter((x) => x.severity === "block").length
|
|
1990
|
+
}
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
function auditSnapshot(snapshot, options = {}) {
|
|
1994
|
+
const tells = CATALOGUE.filter((t) => t.rendered);
|
|
1995
|
+
const result = report(tells, [], (tell) => tell.rendered.detect(snapshot), options);
|
|
1996
|
+
result.summary.files = 1;
|
|
1997
|
+
return result;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// src/fingerprint/index.ts
|
|
2001
|
+
var FINGERPRINT_VERSION = 1;
|
|
2002
|
+
var CHROMATIC = 0.03;
|
|
2003
|
+
var oklch = (value) => {
|
|
2004
|
+
const parsed = parseColour(value);
|
|
2005
|
+
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
2006
|
+
};
|
|
2007
|
+
function accentOf(s) {
|
|
2008
|
+
const buttons = /* @__PURE__ */ new Map();
|
|
2009
|
+
for (const c of s.controls) {
|
|
2010
|
+
const o = oklch(c.background);
|
|
2011
|
+
if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
|
|
2012
|
+
}
|
|
2013
|
+
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
2014
|
+
if (topButton) return oklch(topButton[0]);
|
|
2015
|
+
const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
|
|
2016
|
+
if (bg) return bg.o;
|
|
2017
|
+
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];
|
|
2018
|
+
return text ? text.o : null;
|
|
2019
|
+
}
|
|
2020
|
+
function faces(s) {
|
|
2021
|
+
const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
|
|
2022
|
+
const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
|
|
2023
|
+
const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
|
|
2024
|
+
const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
|
|
2025
|
+
return {
|
|
2026
|
+
display: { family: displayName, class: fontClass(displayName) },
|
|
2027
|
+
body: { family: bodyName, class: fontClass(bodyName) }
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
function median(values) {
|
|
2031
|
+
if (values.length === 0) return null;
|
|
2032
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
2033
|
+
const mid = Math.floor(sorted.length / 2);
|
|
2034
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
2035
|
+
}
|
|
2036
|
+
function fingerprint(s) {
|
|
2037
|
+
const effects = [];
|
|
2038
|
+
if (s.effects.glass > 0) effects.push("glass-panel");
|
|
2039
|
+
if (s.effects.gradientText > 0) effects.push("gradient-text");
|
|
2040
|
+
if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
|
|
2041
|
+
if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
|
|
2042
|
+
if (s.effects.marquees > 0) effects.push("marquee");
|
|
2043
|
+
if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
|
|
2044
|
+
if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
|
|
2045
|
+
if (s.motion.introOverlay) effects.push("intro-cinematic");
|
|
2046
|
+
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
2047
|
+
return {
|
|
2048
|
+
version: FINGERPRINT_VERSION,
|
|
2049
|
+
accent: accentOf(s),
|
|
2050
|
+
ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
|
|
2051
|
+
...faces(s),
|
|
2052
|
+
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
2053
|
+
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
2054
|
+
effects: effects.sort(),
|
|
2055
|
+
layout: s.sections.map((x) => x.kind)
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// src/audit/collect.ts
|
|
2060
|
+
function collectInPage() {
|
|
2061
|
+
const vw = window.innerWidth;
|
|
2062
|
+
const vh = window.innerHeight;
|
|
2063
|
+
const scrollY = window.scrollY;
|
|
2064
|
+
const LIMIT = 8e3;
|
|
2065
|
+
const all = Array.from(document.body.querySelectorAll("*")).slice(0, LIMIT);
|
|
2066
|
+
const styles = /* @__PURE__ */ new Map();
|
|
2067
|
+
const style = (el) => {
|
|
2068
|
+
let cs = styles.get(el);
|
|
2069
|
+
if (!cs) styles.set(el, cs = getComputedStyle(el));
|
|
2070
|
+
return cs;
|
|
2071
|
+
};
|
|
2072
|
+
const box = (el) => {
|
|
2073
|
+
const r = el.getBoundingClientRect();
|
|
2074
|
+
return { top: r.top + scrollY, left: r.left, width: r.width, height: r.height };
|
|
2075
|
+
};
|
|
2076
|
+
const shown = (el) => {
|
|
2077
|
+
const cs = style(el);
|
|
2078
|
+
if (cs.display === "none" || cs.visibility === "hidden") return false;
|
|
2079
|
+
const r = el.getBoundingClientRect();
|
|
2080
|
+
return r.width > 0 && r.height > 0;
|
|
2081
|
+
};
|
|
2082
|
+
const alphaOf = (c) => {
|
|
2083
|
+
const m = c.match(/rgba?\(([^)]+)\)/);
|
|
2084
|
+
if (!m) return c === "transparent" ? 0 : 1;
|
|
2085
|
+
const parts2 = m[1].split(/[\s,/]+/).filter(Boolean);
|
|
2086
|
+
return parts2.length >= 4 ? parseFloat(parts2[3]) : 1;
|
|
2087
|
+
};
|
|
2088
|
+
const painted = (c) => Boolean(c) && alphaOf(c) > 0.02;
|
|
2089
|
+
const ownText = (el) => Array.from(el.childNodes).filter((n) => n.nodeType === 3).map((n) => n.nodeValue ?? "").join("").replace(/\s+/g, " ").trim();
|
|
2090
|
+
const firstFamily = (stack) => (stack.split(",")[0] ?? "").replace(/["']/g, "").trim();
|
|
2091
|
+
const px = (v) => parseFloat(v) || 0;
|
|
2092
|
+
const bg = /* @__PURE__ */ new Map();
|
|
2093
|
+
const text = /* @__PURE__ */ new Map();
|
|
2094
|
+
const fonts = /* @__PURE__ */ new Map();
|
|
2095
|
+
const sizes = /* @__PURE__ */ new Set();
|
|
2096
|
+
const weights = /* @__PURE__ */ new Set();
|
|
2097
|
+
const radii = /* @__PURE__ */ new Set();
|
|
2098
|
+
const shadows = /* @__PURE__ */ new Set();
|
|
2099
|
+
const spacing = [];
|
|
2100
|
+
const pageArea = Math.max(1, document.documentElement.scrollHeight * vw);
|
|
2101
|
+
for (const el of all) {
|
|
2102
|
+
if (!shown(el)) continue;
|
|
2103
|
+
const cs = style(el);
|
|
2104
|
+
const b = box(el);
|
|
2105
|
+
const area = Math.min(b.width * b.height, pageArea);
|
|
2106
|
+
if (painted(cs.backgroundColor)) {
|
|
2107
|
+
const e = bg.get(cs.backgroundColor) ?? { area: 0, chars: 0 };
|
|
2108
|
+
e.area += area;
|
|
2109
|
+
bg.set(cs.backgroundColor, e);
|
|
2110
|
+
}
|
|
2111
|
+
const own = ownText(el);
|
|
2112
|
+
if (own.length > 1) {
|
|
2113
|
+
const chars = own.length;
|
|
2114
|
+
const t = text.get(cs.color) ?? { area: 0, chars: 0 };
|
|
2115
|
+
t.chars += chars;
|
|
2116
|
+
text.set(cs.color, t);
|
|
2117
|
+
const family = firstFamily(cs.fontFamily);
|
|
2118
|
+
const f3 = fonts.get(family) ?? { chars: 0, displayChars: 0, italicChars: 0, weights: /* @__PURE__ */ new Set() };
|
|
2119
|
+
const size = px(cs.fontSize);
|
|
2120
|
+
const weight = parseInt(cs.fontWeight, 10) || 400;
|
|
2121
|
+
f3.chars += chars;
|
|
2122
|
+
if (/^H[1-3]$/.test(el.tagName) || size >= 28) f3.displayChars += chars;
|
|
2123
|
+
if (cs.fontStyle === "italic" || cs.fontStyle.startsWith("oblique")) f3.italicChars += chars;
|
|
2124
|
+
f3.weights.add(weight);
|
|
2125
|
+
fonts.set(family, f3);
|
|
2126
|
+
sizes.add(Math.round(size));
|
|
2127
|
+
weights.add(weight);
|
|
2128
|
+
}
|
|
2129
|
+
if (/^(A|BUTTON|SECTION|ARTICLE|ASIDE)$/.test(el.tagName) || /card|panel|box|tile/i.test(typeof el.className === "string" ? el.className : "")) {
|
|
2130
|
+
if (cs.borderRadius && cs.borderRadius !== "0px") radii.add(cs.borderRadius);
|
|
2131
|
+
if (cs.boxShadow && cs.boxShadow !== "none") shadows.add(cs.boxShadow);
|
|
2132
|
+
}
|
|
2133
|
+
for (const prop of ["marginTop", "marginBottom", "paddingTop", "paddingBottom"]) {
|
|
2134
|
+
const v = Math.round(px(cs[prop]));
|
|
2135
|
+
if (v > 0 && v <= 200) spacing.push(v);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
|
|
2139
|
+
const bodyBg = getComputedStyle(document.body).backgroundColor;
|
|
2140
|
+
const ground = painted(bodyBg) ? bodyBg : painted(htmlBg) ? htmlBg : "rgb(255, 255, 255)";
|
|
2141
|
+
const headings = Array.from(document.querySelectorAll("h1, h2, h3")).filter(shown).slice(0, 60).map((h) => {
|
|
2142
|
+
const cs = style(h);
|
|
2143
|
+
const italic = cs.fontStyle !== "normal";
|
|
2144
|
+
const italicPart = !italic && Array.from(h.querySelectorAll("*")).some((c) => style(c).fontStyle !== "normal" && (c.textContent ?? "").trim().length > 0);
|
|
2145
|
+
return {
|
|
2146
|
+
level: Number(h.tagName[1]),
|
|
2147
|
+
family: firstFamily(cs.fontFamily),
|
|
2148
|
+
sizePx: Math.round(px(cs.fontSize)),
|
|
2149
|
+
weight: parseInt(cs.fontWeight, 10) || 400,
|
|
2150
|
+
italic,
|
|
2151
|
+
italicPart,
|
|
2152
|
+
text: (h.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120),
|
|
2153
|
+
top: Math.round(box(h).top)
|
|
2154
|
+
};
|
|
2155
|
+
});
|
|
2156
|
+
const controls = [];
|
|
2157
|
+
for (const el of all) {
|
|
2158
|
+
const tag = el.tagName;
|
|
2159
|
+
const isButton = tag === "BUTTON" || tag === "INPUT" && /^(submit|button)$/i.test(el.type);
|
|
2160
|
+
if (!isButton && tag !== "A") continue;
|
|
2161
|
+
if (!shown(el)) continue;
|
|
2162
|
+
const cs = style(el);
|
|
2163
|
+
const b = box(el);
|
|
2164
|
+
const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
|
|
2165
|
+
if (!label || b.height < 24 || b.height > 90) continue;
|
|
2166
|
+
if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) continue;
|
|
2167
|
+
if (!isButton && px(cs.paddingLeft) < 8) continue;
|
|
2168
|
+
const r = cs.borderTopLeftRadius;
|
|
2169
|
+
const radius = r.endsWith("%") ? parseFloat(r) / 100 * b.height : px(r);
|
|
2170
|
+
controls.push({
|
|
2171
|
+
kind: isButton ? "button" : "link",
|
|
2172
|
+
radiusPx: Math.round(radius),
|
|
2173
|
+
heightPx: Math.round(b.height),
|
|
2174
|
+
background: cs.backgroundColor,
|
|
2175
|
+
text: label.slice(0, 60)
|
|
2176
|
+
});
|
|
2177
|
+
if (controls.length >= 200) break;
|
|
2178
|
+
}
|
|
2179
|
+
const anims = [];
|
|
2180
|
+
const docAny = document;
|
|
2181
|
+
for (const a of docAny.getAnimations ? docAny.getAnimations() : []) {
|
|
2182
|
+
const effect = a.effect;
|
|
2183
|
+
if (!effect) continue;
|
|
2184
|
+
let translateX = false;
|
|
2185
|
+
try {
|
|
2186
|
+
translateX = effect.getKeyframes().some((k) => /translate(?:X|3d)?\(\s*-?[\d.]+(?:%|px)/.test(String(k.transform ?? "")) && !/translateY/.test(String(k.transform ?? "")));
|
|
2187
|
+
} catch {
|
|
2188
|
+
translateX = false;
|
|
2189
|
+
}
|
|
2190
|
+
anims.push({ target: effect.target, infinite: effect.getTiming().iterations === Infinity, translateX });
|
|
2191
|
+
}
|
|
2192
|
+
const marqueeTargets = anims.filter((a) => a.infinite && a.translateX && a.target).map((a) => a.target);
|
|
2193
|
+
const root = document.querySelector("main") ?? document.body;
|
|
2194
|
+
const blocks = [];
|
|
2195
|
+
const pushBlocks = (parent, depth) => {
|
|
2196
|
+
for (const child of Array.from(parent.children)) {
|
|
2197
|
+
if (!shown(child) && !(style(child).opacity === "0")) continue;
|
|
2198
|
+
const b = box(child);
|
|
2199
|
+
if (b.height < 80 || b.width < vw * 0.5) continue;
|
|
2200
|
+
const kids = Array.from(child.children).filter((k) => box(k).height >= 80 && box(k).width >= vw * 0.5);
|
|
2201
|
+
const covered = kids.reduce((n, k) => n + box(k).height, 0);
|
|
2202
|
+
const wrapper = depth < 6 && !/^(SECTION|ARTICLE|HEADER|FOOTER|ASIDE|NAV)$/.test(child.tagName) && (kids.length === 1 && box(kids[0]).height >= b.height * 0.8 || kids.length >= 2 && covered >= b.height * 0.6);
|
|
2203
|
+
if (wrapper) pushBlocks(child, depth + 1);
|
|
2204
|
+
else blocks.push(child);
|
|
2205
|
+
}
|
|
2206
|
+
};
|
|
2207
|
+
pushBlocks(root, 0);
|
|
2208
|
+
const header = document.querySelector("body > header, header");
|
|
2209
|
+
const h1 = document.querySelector("h1");
|
|
2210
|
+
const hiddenAtLoad = (el) => {
|
|
2211
|
+
const check = [el, ...Array.from(el.children).slice(0, 6)];
|
|
2212
|
+
return check.some((c) => {
|
|
2213
|
+
const cs = style(c);
|
|
2214
|
+
const op = parseFloat(cs.opacity);
|
|
2215
|
+
return op < 0.1 || op < 1 && cs.transform !== "none" || c.hasAttribute("data-aos") || /\breveal\b|\bfade-?(?:in|up)\b|\banimate-on-scroll\b/i.test(typeof c.className === "string" ? c.className : "");
|
|
2216
|
+
});
|
|
2217
|
+
};
|
|
2218
|
+
const cardsIn = (section) => {
|
|
2219
|
+
let best = { cards: 0, iconCards: 0 };
|
|
2220
|
+
const containers = [section, ...Array.from(section.querySelectorAll("div, ul, ol"))].slice(0, 300);
|
|
2221
|
+
for (const c of containers) {
|
|
2222
|
+
const kids = Array.from(c.children).filter(shown);
|
|
2223
|
+
if (kids.length < 3) continue;
|
|
2224
|
+
const boxes = kids.map(box);
|
|
2225
|
+
const row = boxes.filter((b) => Math.abs(b.top - boxes[0].top) < 12);
|
|
2226
|
+
if (row.length < 3) continue;
|
|
2227
|
+
const w = row.map((b) => b.width);
|
|
2228
|
+
if (Math.max(...w) > Math.min(...w) * 1.35 || Math.max(...w) > vw * 0.45) continue;
|
|
2229
|
+
const withText = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
|
|
2230
|
+
if (withText.length < 3) continue;
|
|
2231
|
+
const iconCards = withText.filter((k) => {
|
|
2232
|
+
const first = k.querySelector("svg, img");
|
|
2233
|
+
if (!first) return false;
|
|
2234
|
+
const fb = box(first);
|
|
2235
|
+
const kb = box(k);
|
|
2236
|
+
return fb.width <= 72 && fb.height <= 72 && fb.top - kb.top < 120;
|
|
2237
|
+
}).length;
|
|
2238
|
+
if (withText.length > best.cards) best = { cards: withText.length, iconCards };
|
|
2239
|
+
}
|
|
2240
|
+
return best;
|
|
2241
|
+
};
|
|
2242
|
+
const sections = blocks.slice(0, 40).map((el, index) => {
|
|
2243
|
+
const b = box(el);
|
|
2244
|
+
const heading = el.querySelector("h1, h2, h3");
|
|
2245
|
+
const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
2246
|
+
const images = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
|
|
2247
|
+
const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
|
|
2248
|
+
const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
|
|
2249
|
+
const { cards, iconCards } = cardsIn(el);
|
|
2250
|
+
let kind = "text";
|
|
2251
|
+
if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
|
|
2252
|
+
else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
|
|
2253
|
+
else if (images.length >= 4 && textChars < 300) kind = "logos";
|
|
2254
|
+
else if (numbers >= 3 && textChars < 600) kind = "stats";
|
|
2255
|
+
else if (cards >= 3) kind = "cards";
|
|
2256
|
+
return {
|
|
2257
|
+
top: Math.round(b.top),
|
|
2258
|
+
height: Math.round(b.height),
|
|
2259
|
+
kind,
|
|
2260
|
+
label,
|
|
2261
|
+
cards,
|
|
2262
|
+
iconCards,
|
|
2263
|
+
hiddenAtLoad: b.top > vh && hiddenAtLoad(el)
|
|
2264
|
+
};
|
|
2265
|
+
});
|
|
2266
|
+
if (header && sections.length === 0) {
|
|
2267
|
+
const b = box(header);
|
|
2268
|
+
sections.push({ top: Math.round(b.top), height: Math.round(b.height), kind: "hero", label: "", cards: 0, iconCards: 0, hiddenAtLoad: false });
|
|
2269
|
+
}
|
|
2270
|
+
const colourStops = (image) => image.match(/rgba?\([^)]*\)|#[0-9a-f]{3,8}\b/gi) ?? [];
|
|
2271
|
+
const effects = {
|
|
2272
|
+
glass: 0,
|
|
2273
|
+
gradientText: 0,
|
|
2274
|
+
gradients: [],
|
|
2275
|
+
glows: 0,
|
|
2276
|
+
gridBackgrounds: 0,
|
|
2277
|
+
marquees: new Set(marqueeTargets).size,
|
|
2278
|
+
hairlineShadowCards: 0,
|
|
2279
|
+
eyebrowChip: false
|
|
2280
|
+
};
|
|
2281
|
+
for (const el of all) {
|
|
2282
|
+
if (!shown(el)) continue;
|
|
2283
|
+
const cs = style(el);
|
|
2284
|
+
const b = box(el);
|
|
2285
|
+
const area = b.width * b.height;
|
|
2286
|
+
const backdrop = cs.backdropFilter || cs.webkitBackdropFilter || "";
|
|
2287
|
+
if (/blur\(\s*[1-9]/.test(backdrop) && alphaOf(cs.backgroundColor) < 0.95 && b.width >= 200 && b.height >= 120 && cs.position !== "fixed" && cs.position !== "sticky" && !el.closest("header, nav, [role=dialog], dialog")) {
|
|
2288
|
+
effects.glass += 1;
|
|
2289
|
+
}
|
|
2290
|
+
const image = cs.backgroundImage || "";
|
|
2291
|
+
const clip = cs.backgroundClip || cs.webkitBackgroundClip || "";
|
|
2292
|
+
if (clip === "text" && /gradient/.test(image)) effects.gradientText += 1;
|
|
2293
|
+
if (/gradient/.test(image)) {
|
|
2294
|
+
const size = cs.backgroundSize || "";
|
|
2295
|
+
const small = /(?:^|\s)(?:[1-9]|[1-5]\d|6[0-4])px/.test(size);
|
|
2296
|
+
const lines = (image.match(/linear-gradient/g) ?? []).length;
|
|
2297
|
+
if (small && (lines >= 2 || /radial-gradient/.test(image)) || /repeating-linear-gradient/.test(image)) {
|
|
2298
|
+
effects.gridBackgrounds += 1;
|
|
2299
|
+
} else if (area >= vw * vh * 0.05) {
|
|
2300
|
+
effects.gradients.push({ stops: colourStops(image), area: Math.round(area), radial: /radial-gradient/.test(image) });
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
const blur = (cs.filter || "").match(/blur\(\s*([\d.]+)px/);
|
|
2304
|
+
if ((blur && parseFloat(blur[1]) >= 40 || /radial-gradient/.test(image) && !/(?:^|\s)\d{1,2}px/.test(cs.backgroundSize || "")) && area >= 4e4 && (el.textContent ?? "").trim().length === 0) {
|
|
2305
|
+
effects.glows += 1;
|
|
2306
|
+
}
|
|
2307
|
+
const shadow = cs.boxShadow || "";
|
|
2308
|
+
const shadowBlur = shadow.match(/(?:-?[\d.]+px\s+){2}([\d.]+)px/);
|
|
2309
|
+
if (shadowBlur && parseFloat(shadowBlur[1]) >= 24 && px(cs.borderTopWidth) >= 1 && alphaOf(cs.borderTopColor) <= 0.2 && area >= 2e4) {
|
|
2310
|
+
effects.hairlineShadowCards += 1;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
if (h1) {
|
|
2314
|
+
let prev = h1.previousElementSibling;
|
|
2315
|
+
if (!prev && h1.parentElement) prev = h1.parentElement.previousElementSibling;
|
|
2316
|
+
if (prev && shown(prev)) {
|
|
2317
|
+
const cs = style(prev);
|
|
2318
|
+
const b = box(prev);
|
|
2319
|
+
const pill = b.height <= 44 && px(cs.borderTopLeftRadius) >= b.height / 2 - 1;
|
|
2320
|
+
const drawn = painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1;
|
|
2321
|
+
const chip = pill && drawn ? prev : Array.from(prev.querySelectorAll("*")).find((c) => {
|
|
2322
|
+
const ccs = style(c);
|
|
2323
|
+
const cb = box(c);
|
|
2324
|
+
return cb.height <= 44 && px(ccs.borderTopLeftRadius) >= cb.height / 2 - 1 && (painted(ccs.backgroundColor) || px(ccs.borderTopWidth) >= 1);
|
|
2325
|
+
});
|
|
2326
|
+
effects.eyebrowChip = Boolean(chip && (chip.textContent ?? "").trim().length > 0 && (chip.textContent ?? "").trim().length <= 48);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
const onScale = spacing.filter((v) => v % 4 === 0).length;
|
|
2330
|
+
let bodyLineLengthCh = null;
|
|
2331
|
+
let bodyFontSizePx = null;
|
|
2332
|
+
const para = Array.from(document.querySelectorAll("p")).find((p) => shown(p) && (p.innerText || "").trim().split(/\s+/).length > 20);
|
|
2333
|
+
if (para) {
|
|
2334
|
+
const cs = style(para);
|
|
2335
|
+
bodyFontSizePx = Math.round(px(cs.fontSize));
|
|
2336
|
+
const probe = document.createElement("span");
|
|
2337
|
+
probe.style.cssText = `font:${cs.font};visibility:hidden;position:absolute;white-space:pre`;
|
|
2338
|
+
probe.textContent = "abcdefghijklmnopqrstuvwxyz";
|
|
2339
|
+
document.body.appendChild(probe);
|
|
2340
|
+
const avg = probe.getBoundingClientRect().width / 26;
|
|
2341
|
+
probe.remove();
|
|
2342
|
+
const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
|
|
2343
|
+
if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
|
|
2344
|
+
}
|
|
2345
|
+
const toList = (m) => Array.from(m, ([value, v]) => ({ value, area: Math.round(v.area), chars: v.chars })).sort((a, b) => b.area - a.area || b.chars - a.chars);
|
|
2346
|
+
const nums = (s) => Array.from(s).sort((a, b) => a - b);
|
|
2347
|
+
return {
|
|
2348
|
+
viewport: { width: vw, height: vh },
|
|
2349
|
+
pageHeight: document.documentElement.scrollHeight,
|
|
2350
|
+
ground,
|
|
2351
|
+
colours: { backgrounds: toList(bg).slice(0, 60), text: toList(text).sort((a, b) => b.chars - a.chars).slice(0, 40) },
|
|
2352
|
+
fonts: Array.from(fonts, ([family, f3]) => ({ family, chars: f3.chars, displayChars: f3.displayChars, italicChars: f3.italicChars, weights: nums(f3.weights) })).sort((a, b) => b.chars - a.chars),
|
|
2353
|
+
headings,
|
|
2354
|
+
controls,
|
|
2355
|
+
sections,
|
|
2356
|
+
effects,
|
|
2357
|
+
motion: {
|
|
2358
|
+
hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
|
|
2359
|
+
sections: sections.length,
|
|
2360
|
+
animations: anims.length
|
|
2361
|
+
},
|
|
2362
|
+
metrics: {
|
|
2363
|
+
fontSizes: nums(sizes),
|
|
2364
|
+
fontWeights: nums(weights),
|
|
2365
|
+
radii: Array.from(radii).sort(),
|
|
2366
|
+
shadows: Array.from(shadows).sort().slice(0, 20),
|
|
2367
|
+
spacingOnScalePct: spacing.length ? Math.round(onScale / spacing.length * 1e3) / 10 : null,
|
|
2368
|
+
bodyLineLengthCh,
|
|
2369
|
+
bodyFontSizePx
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
function overlayInPage() {
|
|
2374
|
+
const vw = window.innerWidth;
|
|
2375
|
+
const vh = window.innerHeight;
|
|
2376
|
+
for (const el of Array.from(document.body.querySelectorAll("*")).slice(0, 4e3)) {
|
|
2377
|
+
const cs = getComputedStyle(el);
|
|
2378
|
+
if (cs.position !== "fixed" && cs.position !== "absolute") continue;
|
|
2379
|
+
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity) < 0.5) continue;
|
|
2380
|
+
const r = el.getBoundingClientRect();
|
|
2381
|
+
if (r.width < vw * 0.9 || r.height < vh * 0.9 || r.top > 10) continue;
|
|
2382
|
+
const bg = cs.backgroundColor;
|
|
2383
|
+
const alpha = /rgba?\(([^)]+)\)/.test(bg) ? parseFloat((bg.match(/[\d.]+/g) ?? [])[3] ?? "1") : 0;
|
|
2384
|
+
if (alpha > 0.8 || /gradient|url\(/.test(cs.backgroundImage)) return true;
|
|
2385
|
+
}
|
|
2386
|
+
return false;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// src/audit/index.ts
|
|
2390
|
+
async function loadPlaywright() {
|
|
2391
|
+
for (const name of ["playwright", "playwright-core"]) {
|
|
2392
|
+
try {
|
|
2393
|
+
return await import(
|
|
2394
|
+
/* @vite-ignore */
|
|
2395
|
+
name
|
|
2396
|
+
);
|
|
2397
|
+
} catch {
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
throw new Error("craft audit needs a browser. Install one: npm i -D playwright && npx playwright install chromium");
|
|
2401
|
+
}
|
|
2402
|
+
async function snapshotPage(page, url, options = {}) {
|
|
2403
|
+
const early = await page.evaluate(overlayInPage).catch(() => false);
|
|
2404
|
+
await page.waitForTimeout(options.introWindowMs ?? 5e3);
|
|
2405
|
+
const late = await page.evaluate(overlayInPage).catch(() => false);
|
|
2406
|
+
const data = await page.evaluate(collectInPage);
|
|
2407
|
+
return {
|
|
2408
|
+
version: SNAPSHOT_VERSION,
|
|
2409
|
+
url,
|
|
2410
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2411
|
+
...data,
|
|
2412
|
+
motion: { ...data.motion, introOverlay: early && !late }
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
async function snapshotUrl(url, options = {}) {
|
|
2416
|
+
const pw = await loadPlaywright();
|
|
2417
|
+
const env = globalThis.process?.env ?? {};
|
|
2418
|
+
const executablePath = options.executablePath ?? env.CRAFT_CHROMIUM ?? void 0;
|
|
2419
|
+
const host = new URL(url).hostname;
|
|
2420
|
+
const noProxy = [`localhost`, `127.0.0.1`, `::1`, ...(env.NO_PROXY ?? env.no_proxy ?? "").split(",")].map((h) => h.trim().replace(/^\*?\./, "")).filter(Boolean);
|
|
2421
|
+
const direct = /^(?:localhost|127\.|\[?::1\]?$)/.test(host) || noProxy.some((h) => host === h || host.endsWith(`.${h}`));
|
|
2422
|
+
const proxy = direct ? void 0 : env.HTTPS_PROXY ?? env.https_proxy;
|
|
2423
|
+
const browser = await pw.chromium.launch({ headless: true, ...executablePath ? { executablePath } : {}, ...proxy ? { proxy: { server: proxy } } : {} });
|
|
2424
|
+
try {
|
|
2425
|
+
const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
|
|
2426
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
|
|
2427
|
+
return await snapshotPage(page, url, options);
|
|
2428
|
+
} finally {
|
|
2429
|
+
await browser.close();
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
async function audit(url, options = {}) {
|
|
2433
|
+
const snapshot = await snapshotUrl(url, options);
|
|
2434
|
+
return { snapshot, report: auditSnapshot(snapshot, options), fingerprint: fingerprint(snapshot) };
|
|
2435
|
+
}
|
|
2436
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2437
|
+
0 && (module.exports = {
|
|
2438
|
+
audit,
|
|
2439
|
+
collectInPage,
|
|
2440
|
+
loadPlaywright,
|
|
2441
|
+
overlayInPage,
|
|
2442
|
+
snapshotPage,
|
|
2443
|
+
snapshotUrl
|
|
2444
|
+
});
|