@domandigital/craft 0.6.0 → 0.7.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.
@@ -1,3 +1,334 @@
1
+ // src/character/parse.ts
2
+ var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
3
+ var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
4
+ var CSS = /\.(css|scss|sass|less|pcss)$/i;
5
+ var PROSE = /\.(md|markdown|txt)$/i;
6
+ function fileKind(path) {
7
+ if (MARKUP.test(path)) return "markup";
8
+ if (CSS.test(path)) return "css";
9
+ if (SCRIPT.test(path)) return "script";
10
+ if (PROSE.test(path)) return "prose";
11
+ return "other";
12
+ }
13
+ function lineAt(text, offset) {
14
+ let line = 1;
15
+ for (let i = 0; i < offset && i < text.length; i += 1) {
16
+ if (text.charCodeAt(i) === 10) line += 1;
17
+ }
18
+ return line;
19
+ }
20
+ function excerptAt(text, offset, max = 140) {
21
+ const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
22
+ const endRaw = text.indexOf("\n", offset);
23
+ const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
24
+ return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
25
+ }
26
+ var LITERAL = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
27
+ function stringLiterals(text, base = 0) {
28
+ const out = [];
29
+ for (const m of text.matchAll(LITERAL)) {
30
+ out.push({ value: m[0].slice(1, -1), offset: base + (m.index ?? 0) });
31
+ }
32
+ return out;
33
+ }
34
+ function matchBracket(text, open) {
35
+ const pairs = { "{": "}", "(": ")", "[": "]" };
36
+ const close = pairs[text[open]];
37
+ const opener = text[open];
38
+ let depth = 0;
39
+ for (let i = open; i < text.length; i += 1) {
40
+ const ch = text[i];
41
+ if (ch === '"' || ch === "'" || ch === "`") {
42
+ const end = skipString(text, i);
43
+ i = end - 1;
44
+ continue;
45
+ }
46
+ if (ch === opener) depth += 1;
47
+ else if (ch === close) {
48
+ depth -= 1;
49
+ if (depth === 0) return i + 1;
50
+ }
51
+ }
52
+ return text.length;
53
+ }
54
+ function skipString(text, start) {
55
+ const quote = text[start];
56
+ for (let i = start + 1; i < text.length; i += 1) {
57
+ if (text[i] === "\\") {
58
+ i += 1;
59
+ continue;
60
+ }
61
+ if (text[i] === quote) return i + 1;
62
+ if (text[i] === "\n" && quote !== "`") return i + 1;
63
+ }
64
+ return text.length;
65
+ }
66
+ function split(classes) {
67
+ return classes.split(/\s+/).filter((c) => c.length > 0 && !c.includes("${"));
68
+ }
69
+ function classUnits(text) {
70
+ const units = [];
71
+ const covered = [];
72
+ const attr = /\b(?:className|class|:class)\s*=\s*/g;
73
+ for (const m of text.matchAll(attr)) {
74
+ const at = (m.index ?? 0) + m[0].length;
75
+ const ch = text[at];
76
+ if (ch === '"' || ch === "'") {
77
+ const end = skipString(text, at);
78
+ units.push({ classes: split(text.slice(at + 1, end - 1)), offset: m.index ?? 0 });
79
+ } else if (ch === "{") {
80
+ const end = matchBracket(text, at);
81
+ const joined = stringLiterals(text.slice(at, end)).map((l) => l.value).join(" ");
82
+ units.push({ classes: split(joined), offset: m.index ?? 0 });
83
+ covered.push([at, end]);
84
+ }
85
+ }
86
+ const call = /\b(cn|clsx|twMerge|cx|cva|tv)\s*\(/g;
87
+ for (const m of text.matchAll(call)) {
88
+ const start = m.index ?? 0;
89
+ if (covered.some(([a, b]) => start >= a && start < b)) continue;
90
+ const open = start + m[0].length - 1;
91
+ const end = matchBracket(text, open);
92
+ const literals = stringLiterals(text.slice(open, end), open);
93
+ if (m[1] === "cva" || m[1] === "tv") {
94
+ for (const l of literals) units.push({ classes: split(l.value), offset: l.offset });
95
+ } else {
96
+ units.push({ classes: split(literals.map((l) => l.value).join(" ")), offset: start });
97
+ }
98
+ covered.push([open, end]);
99
+ }
100
+ return units.filter((u) => u.classes.length > 0);
101
+ }
102
+ function blankComments(css) {
103
+ return css.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " "));
104
+ }
105
+ function cssDeclarations(css, base = 0) {
106
+ const src = blankComments(css);
107
+ const out = [];
108
+ const stack = [];
109
+ let segStart = 0;
110
+ let parens = 0;
111
+ const flush = (end) => {
112
+ const seg = src.slice(segStart, end);
113
+ const colon = seg.indexOf(":");
114
+ if (stack.length === 0 || colon === -1) return;
115
+ const property = seg.slice(0, colon).trim();
116
+ if (!/^(--)?[a-zA-Z-]+$/.test(property)) return;
117
+ const lead = seg.length - seg.trimStart().length;
118
+ out.push({
119
+ property: property.toLowerCase(),
120
+ value: seg.slice(colon + 1).trim(),
121
+ selector: stack[stack.length - 1],
122
+ offset: base + segStart + lead
123
+ });
124
+ };
125
+ for (let i = 0; i < src.length; i += 1) {
126
+ const ch = src[i];
127
+ if (ch === '"' || ch === "'") {
128
+ i = skipString(src, i) - 1;
129
+ continue;
130
+ }
131
+ if (ch === "(") parens += 1;
132
+ else if (ch === ")") parens = Math.max(0, parens - 1);
133
+ if (parens > 0) continue;
134
+ if (ch === "{") {
135
+ stack.push(src.slice(segStart, i).trim());
136
+ segStart = i + 1;
137
+ } else if (ch === ";") {
138
+ flush(i);
139
+ segStart = i + 1;
140
+ } else if (ch === "}") {
141
+ flush(i);
142
+ stack.pop();
143
+ segStart = i + 1;
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+ function styleBlocks(text) {
149
+ const out = [];
150
+ for (const m of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
151
+ const bodyStart = (m.index ?? 0) + m[0].indexOf(">") + 1;
152
+ out.push(...cssDeclarations(m[1], bodyStart));
153
+ }
154
+ return out;
155
+ }
156
+ function parseFile(file) {
157
+ const kind = fileKind(file.path);
158
+ return {
159
+ ...file,
160
+ kind,
161
+ classUnits: kind === "markup" || kind === "script" ? classUnits(file.text) : [],
162
+ declarations: kind === "css" ? cssDeclarations(file.text) : kind === "markup" ? styleBlocks(file.text) : []
163
+ };
164
+ }
165
+
166
+ // src/character/prose.ts
167
+ var blank = (s) => s.replace(/[^\n]/g, " ");
168
+ function readsAsProse(value) {
169
+ if (value.length < 12 || !/\s/.test(value)) return false;
170
+ if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
171
+ const words = withoutMarkup(value);
172
+ if (looksLikeCode(words) || /[<>]/.test(words)) return false;
173
+ return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words);
174
+ }
175
+ function withoutMarkup(value) {
176
+ return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
177
+ }
178
+ function looksLikeCode(words) {
179
+ return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words);
180
+ }
181
+ var UTILITY = /^(?:flex|grid|block|inline|hidden|contents|relative|absolute|fixed|sticky|static|rounded|border|shadow|underline|italic|uppercase|lowercase|capitalize|truncate|container|group|peer|transition|antialiased|prose|sr-only|outline|ring|grow|shrink|isolate|invisible|visible|collapse)$/;
182
+ function looksLikeClasses(value) {
183
+ const tokens = value.trim().split(/\s+/);
184
+ if (tokens.length === 0 || !tokens.every((t) => /^[!\w@:/[\]().#%,>&*+=~-]+$/.test(t))) return false;
185
+ const classy = tokens.filter((t) => /[-:[\]/]/.test(t) || UTILITY.test(t)).length;
186
+ return classy / tokens.length >= 0.6 && /[a-z]-|:|\[/.test(value);
187
+ }
188
+ function looksLikeAddress(value) {
189
+ return /^(?:\.{0,2}\/|@\/|https?:|mailto:|tel:)/.test(value);
190
+ }
191
+ function readsAsText(value) {
192
+ if (value.trim().length === 0) return false;
193
+ if (looksLikeAddress(value) || looksLikeClasses(value)) return false;
194
+ return /[^\s\w]|[a-zA-Z]/.test(value);
195
+ }
196
+ function literalIsText(value) {
197
+ return readsAsText(value) && !looksLikeCode(withoutMarkup(value));
198
+ }
199
+ function maskScriptComments(text) {
200
+ let out = "";
201
+ let i = 0;
202
+ while (i < text.length) {
203
+ const ch = text[i];
204
+ if (ch === '"' || ch === "'" || ch === "`") {
205
+ const start = i;
206
+ i += 1;
207
+ while (i < text.length) {
208
+ if (text[i] === "\\") {
209
+ i += 2;
210
+ continue;
211
+ }
212
+ if (text[i] === ch) {
213
+ i += 1;
214
+ break;
215
+ }
216
+ if (text[i] === "\n" && ch !== "`") break;
217
+ i += 1;
218
+ }
219
+ out += text.slice(start, i);
220
+ continue;
221
+ }
222
+ if (ch === "/" && text[i + 1] === "/") {
223
+ const end = text.indexOf("\n", i);
224
+ const stop = end === -1 ? text.length : end;
225
+ out += " ".repeat(stop - i);
226
+ i = stop;
227
+ continue;
228
+ }
229
+ if (ch === "/" && text[i + 1] === "*") {
230
+ const end = text.indexOf("*/", i + 2);
231
+ const stop = end === -1 ? text.length : end + 2;
232
+ out += blank(text.slice(i, stop));
233
+ i = stop;
234
+ continue;
235
+ }
236
+ out += ch;
237
+ i += 1;
238
+ }
239
+ return out;
240
+ }
241
+ function maskMarkupComments(text) {
242
+ return text.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, blank).replace(/\{\/\*[\s\S]*?\*\/\}|<!--[\s\S]*?-->/g, blank).replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, blank).replace(/^[ \t]*\/\/[^\n]*/gm, blank).replace(/([;,{}()[\]])([ \t]+)\/\/[^\n]*/g, (m, p, s) => p + s + " ".repeat(m.length - p.length - s.length)).replace(/^\s*import\b[^\n]*$/gm, blank);
243
+ }
244
+ function markupParts(file) {
245
+ const masked = maskMarkupComments(file.text);
246
+ const textNodes = [];
247
+ let rest = masked;
248
+ for (const m of masked.matchAll(/([>}])([^<>{}]+)(?=[<{])/g)) {
249
+ const body = m[2];
250
+ const before = masked[(m.index ?? 0) - 1] ?? "";
251
+ const after = masked[(m.index ?? 0) + m[0].length];
252
+ if (m[1] === ">" && /[=-]/.test(before)) continue;
253
+ if (m[1] === "}" && (/^\s*(?:[)\],;:]|(?:else|catch|finally|return|from|as|if|const|let|export|function)\b)/.test(body) || /;\s*$/.test(body))) continue;
254
+ if (m[1] === "}" && after === "{" && (/[=()`'"]/.test(body) || !/[a-zA-Z]{2,}|[^\s\w,|&?:]/.test(body))) continue;
255
+ if (body.trim().length === 0) continue;
256
+ if (m[1] === ">" && after === "<" && /^[\s—–-]*$/.test(body)) continue;
257
+ const at = (m.index ?? 0) + 1;
258
+ textNodes.push({ path: file.path, text: body, offset: at });
259
+ rest = rest.slice(0, at) + blank(body) + rest.slice(at + body.length);
260
+ }
261
+ return { textNodes, literals: splitHtml(stringLiterals(rest)) };
262
+ }
263
+ function textNodeIsProse(body) {
264
+ return /[a-zA-Z]{2,}/.test(body) && /\s/.test(body.trim()) || /[a-zA-Z]{4,}[.!?]/.test(body);
265
+ }
266
+ function proseBlocks(file) {
267
+ const masked = file.text.replace(/^(```|~~~)[\s\S]*?^\1/gm, blank).replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length)).replace(/^---\n[\s\S]*?\n---\n/, blank).replace(/<!--[\s\S]*?-->/g, blank);
268
+ return [{ path: file.path, text: masked, offset: 0 }];
269
+ }
270
+ function isData(path) {
271
+ return /\.jsonl?$/i.test(path);
272
+ }
273
+ function scriptLiterals(file) {
274
+ return splitHtml(stringLiterals(isData(file.path) ? file.text : maskScriptComments(file.text)));
275
+ }
276
+ function splitHtml(literals) {
277
+ const out = [];
278
+ for (const l of literals) {
279
+ if (!/<[a-zA-Z][^<>]*>/.test(l.value)) {
280
+ out.push(l);
281
+ continue;
282
+ }
283
+ for (const m of l.value.matchAll(/(?:^|>)([^<>]+)(?=<|$)/g)) {
284
+ const body = m[1];
285
+ if (!body.trim()) continue;
286
+ const lead = m[0].length - body.length;
287
+ out.push({ value: body, offset: l.offset + (m.index ?? 0) + lead });
288
+ }
289
+ }
290
+ return out;
291
+ }
292
+ function extractCopy(file) {
293
+ const kind = fileKind(file.path);
294
+ if (kind === "prose" || file.path.endsWith(".mdx")) return proseBlocks(file);
295
+ if (kind === "markup") {
296
+ const { textNodes, literals } = markupParts(file);
297
+ return [
298
+ ...textNodes.filter((b) => textNodeIsProse(b.text)),
299
+ ...literals.filter((l) => readsAsProse(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
300
+ ];
301
+ }
302
+ if (kind === "script" || isData(file.path)) {
303
+ return scriptLiterals(file).filter((l) => readsAsProse(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
304
+ }
305
+ return [];
306
+ }
307
+ function extractStrings(file) {
308
+ const kind = fileKind(file.path);
309
+ if (kind === "prose" || file.path.endsWith(".mdx")) {
310
+ const [block] = proseBlocks(file);
311
+ const out = [];
312
+ let offset = 0;
313
+ for (const line of block.text.split("\n")) {
314
+ if (line.trim()) out.push({ path: file.path, text: line, offset });
315
+ offset += line.length + 1;
316
+ }
317
+ return out;
318
+ }
319
+ if (kind === "markup") {
320
+ const { textNodes, literals } = markupParts(file);
321
+ return [
322
+ ...textNodes.filter((b) => readsAsText(b.text)),
323
+ ...literals.filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
324
+ ];
325
+ }
326
+ if (kind === "script" || isData(file.path)) {
327
+ return scriptLiterals(file).filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
328
+ }
329
+ return [];
330
+ }
331
+
1
332
  // src/snapshot/types.ts
2
333
  var SNAPSHOT_VERSION = 1;
3
334
 
@@ -1213,7 +1544,7 @@ const inter = Inter({ subsets: ["latin"] });`),
1213
1544
  severity: "warn",
1214
1545
  surface: "source",
1215
1546
  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.",
1216
- fix: "Same as reflex-font. If this face really is the right one, declare an exception with the reason and the evidence.",
1547
+ fix: "Pick the display face from the client's world (signage, packaging, the trade's lettering) and record why in art-direction.json. If this face really is the right one, declare an exception with the reason and the evidence.",
1217
1548
  detect: fontTell(REFLEX_FONTS_2),
1218
1549
  fixtures: {
1219
1550
  flag: [f2("src/styles.css", `@theme {
@@ -1398,495 +1729,164 @@ const inter = Inter({ subsets: ["latin"] });`),
1398
1729
  }
1399
1730
  },
1400
1731
  {
1401
- id: "italic-serif-display",
1402
- name: "Italic serif display",
1403
- generation: 2,
1404
- severity: "warn",
1405
- surface: "source",
1406
- why: "A serif heading with one italic word ('Beauty, reimagined') is the second wave's signature move.",
1407
- fix: "Use emphasis only where the sentence needs it, and set it the way the display face wants, not by reflex.",
1408
- detect: italicSerifHits,
1409
- fixtures: {
1410
- flag: [
1411
- f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
1412
- f2("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
1413
- f2("app/globals.css", `.hero h1 { font-style: italic; }`)
1414
- ],
1415
- pass: [f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
1416
- <p><em>Walk-ins</em> welcome.</p>`)]
1417
- }
1418
- },
1419
- {
1420
- id: "hero-eyebrow-chip",
1421
- name: "Eyebrow chip above the hero",
1422
- generation: 2,
1423
- severity: "warn",
1424
- surface: "source",
1425
- why: "A small bordered pill ('New \xB7 Now booking') above the headline is in almost every generated hero.",
1426
- fix: "Drop it. If the fact matters, put it in the headline or the first line of copy.",
1427
- detect: eyebrowChipHits,
1428
- fixtures: {
1429
- flag: [
1430
- f2("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
1431
- <h1>Nails in Brackley</h1>`),
1432
- f2("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
1433
- <h1>Nails</h1>`)
1434
- ],
1435
- pass: [f2("components/Hero.tsx", `<h1>Nails in Brackley</h1>
1436
- <span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
1437
- }
1438
- },
1439
- {
1440
- id: "icon-tile-stack",
1441
- name: "Icon in a tinted tile",
1442
- generation: 2,
1443
- severity: "warn",
1444
- surface: "source",
1445
- why: "A library icon in a soft rounded square, stacked above a heading, is the stock card header.",
1446
- fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
1447
- detect: iconTileStackHits,
1448
- fixtures: {
1449
- flag: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
1450
- <div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
1451
- pass: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
1452
- <p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
1453
- }
1454
- },
1455
- {
1456
- id: "bento-grid",
1457
- name: "Bento grid",
1458
- generation: 2,
1459
- severity: "warn",
1460
- surface: "source",
1461
- why: "Mixed-size tiles in a grid became the default 'features' layout of the second wave.",
1462
- fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
1463
- detect: bentoHits,
1464
- fixtures: {
1465
- flag: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
1466
- pass: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
1467
- }
1468
- },
1469
- {
1470
- id: "radial-spotlight-glow",
1471
- name: "Radial glow",
1472
- generation: 2,
1473
- severity: "warn",
1474
- surface: "source",
1475
- why: "A soft blurred blob or radial spotlight behind the hero is atmosphere without a subject.",
1476
- fix: "Light the page with real imagery, or leave the ground flat.",
1477
- detect: glowHits,
1478
- fixtures: {
1479
- flag: [
1480
- f2("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
1481
- f2("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
1482
- ],
1483
- pass: [f2("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
1484
- }
1485
- },
1486
- {
1487
- id: "grid-background",
1488
- name: "Graph-paper background",
1489
- generation: 2,
1490
- severity: "warn",
1491
- surface: "source",
1492
- why: "A faint 1px line or dot grid behind the hero is the developer-tool look, borrowed by everyone.",
1493
- fix: "Remove it. If the brand has a pattern, draw it from the brand.",
1494
- detect: gridBackgroundHits,
1495
- fixtures: {
1496
- flag: [
1497
- f2(
1498
- "app/globals.css",
1499
- `.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
1500
- ),
1501
- f2("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
1502
- ],
1503
- pass: [f2("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
1504
- }
1505
- },
1506
- {
1507
- id: "marquee",
1508
- name: "Scrolling marquee",
1509
- generation: 2,
1510
- severity: "warn",
1511
- surface: "source",
1512
- why: "An endless strip of logos or words is the second wave's trust strip. It moves so it looks alive.",
1513
- fix: "Show proof still and in context: one named review, one accreditation, where the doubt arises.",
1514
- detect: marqueeHits,
1515
- fixtures: {
1516
- flag: [
1517
- f2("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
1518
- f2("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
1519
- ],
1520
- pass: [f2("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
1521
- }
1522
- },
1523
- {
1524
- id: "thin-border-wide-shadow",
1525
- name: "Hairline border, wide shadow",
1732
+ id: "italic-serif-display",
1733
+ name: "Italic serif display",
1526
1734
  generation: 2,
1527
1735
  severity: "warn",
1528
1736
  surface: "source",
1529
- why: "A near-invisible border under a large soft shadow is the stock 'elevated card'.",
1530
- fix: "Pick one: a border that is visible, or a shadow the light source explains. Keep within the four-shadow budget.",
1531
- detect: thinBorderWideShadowHits,
1737
+ why: "A serif heading with one italic word ('Beauty, reimagined') is the second wave's signature move.",
1738
+ fix: "Use emphasis only where the sentence needs it, and set it the way the display face wants, not by reflex.",
1739
+ detect: italicSerifHits,
1532
1740
  fixtures: {
1533
1741
  flag: [
1534
- f2("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
1535
- f2("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
1742
+ f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
1743
+ f2("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
1744
+ f2("app/globals.css", `.hero h1 { font-style: italic; }`)
1536
1745
  ],
1537
- pass: [f2("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
1746
+ pass: [f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
1747
+ <p><em>Walk-ins</em> welcome.</p>`)]
1538
1748
  }
1539
1749
  },
1540
1750
  {
1541
- id: "intro-cinematic",
1542
- name: "Intro cinematic",
1751
+ id: "hero-eyebrow-chip",
1752
+ name: "Eyebrow chip above the hero",
1543
1753
  generation: 2,
1544
1754
  severity: "warn",
1545
1755
  surface: "source",
1546
- 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.",
1547
- fix: "Remove it. Spend the signature moment on something the visitor came for.",
1548
- detect: introHits,
1756
+ why: "A small bordered pill ('New \xB7 Now booking') above the headline is in almost every generated hero.",
1757
+ fix: "Drop it. If the fact matters, put it in the headline or the first line of copy.",
1758
+ detect: eyebrowChipHits,
1549
1759
  fixtures: {
1550
- flag: [
1551
- f2("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
1552
- export function IntroCinematic() { return <AnimatePresence /> }`),
1553
- f2("app/page.tsx", `<><Preloader /><Hero /></>`)
1554
- ],
1555
- pass: [f2("components/Introduction.md", `An introduction to the studio.`)]
1556
- }
1557
- }
1558
- ];
1559
-
1560
- // src/character/parse.ts
1561
- var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
1562
- var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
1563
- var CSS = /\.(css|scss|sass|less|pcss)$/i;
1564
- var PROSE = /\.(md|markdown|txt)$/i;
1565
- function fileKind(path) {
1566
- if (MARKUP.test(path)) return "markup";
1567
- if (CSS.test(path)) return "css";
1568
- if (SCRIPT.test(path)) return "script";
1569
- if (PROSE.test(path)) return "prose";
1570
- return "other";
1571
- }
1572
- function lineAt(text, offset) {
1573
- let line = 1;
1574
- for (let i = 0; i < offset && i < text.length; i += 1) {
1575
- if (text.charCodeAt(i) === 10) line += 1;
1576
- }
1577
- return line;
1578
- }
1579
- function excerptAt(text, offset, max = 140) {
1580
- const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
1581
- const endRaw = text.indexOf("\n", offset);
1582
- const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
1583
- return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
1584
- }
1585
- var LITERAL = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
1586
- function stringLiterals(text, base = 0) {
1587
- const out = [];
1588
- for (const m of text.matchAll(LITERAL)) {
1589
- out.push({ value: m[0].slice(1, -1), offset: base + (m.index ?? 0) });
1590
- }
1591
- return out;
1592
- }
1593
- function matchBracket(text, open) {
1594
- const pairs = { "{": "}", "(": ")", "[": "]" };
1595
- const close = pairs[text[open]];
1596
- const opener = text[open];
1597
- let depth = 0;
1598
- for (let i = open; i < text.length; i += 1) {
1599
- const ch = text[i];
1600
- if (ch === '"' || ch === "'" || ch === "`") {
1601
- const end = skipString(text, i);
1602
- i = end - 1;
1603
- continue;
1604
- }
1605
- if (ch === opener) depth += 1;
1606
- else if (ch === close) {
1607
- depth -= 1;
1608
- if (depth === 0) return i + 1;
1609
- }
1610
- }
1611
- return text.length;
1612
- }
1613
- function skipString(text, start) {
1614
- const quote = text[start];
1615
- for (let i = start + 1; i < text.length; i += 1) {
1616
- if (text[i] === "\\") {
1617
- i += 1;
1618
- continue;
1619
- }
1620
- if (text[i] === quote) return i + 1;
1621
- if (text[i] === "\n" && quote !== "`") return i + 1;
1622
- }
1623
- return text.length;
1624
- }
1625
- function split(classes) {
1626
- return classes.split(/\s+/).filter((c) => c.length > 0 && !c.includes("${"));
1627
- }
1628
- function classUnits(text) {
1629
- const units = [];
1630
- const covered = [];
1631
- const attr = /\b(?:className|class|:class)\s*=\s*/g;
1632
- for (const m of text.matchAll(attr)) {
1633
- const at = (m.index ?? 0) + m[0].length;
1634
- const ch = text[at];
1635
- if (ch === '"' || ch === "'") {
1636
- const end = skipString(text, at);
1637
- units.push({ classes: split(text.slice(at + 1, end - 1)), offset: m.index ?? 0 });
1638
- } else if (ch === "{") {
1639
- const end = matchBracket(text, at);
1640
- const joined = stringLiterals(text.slice(at, end)).map((l) => l.value).join(" ");
1641
- units.push({ classes: split(joined), offset: m.index ?? 0 });
1642
- covered.push([at, end]);
1643
- }
1644
- }
1645
- const call = /\b(cn|clsx|twMerge|cx|cva|tv)\s*\(/g;
1646
- for (const m of text.matchAll(call)) {
1647
- const start = m.index ?? 0;
1648
- if (covered.some(([a, b]) => start >= a && start < b)) continue;
1649
- const open = start + m[0].length - 1;
1650
- const end = matchBracket(text, open);
1651
- const literals = stringLiterals(text.slice(open, end), open);
1652
- if (m[1] === "cva" || m[1] === "tv") {
1653
- for (const l of literals) units.push({ classes: split(l.value), offset: l.offset });
1654
- } else {
1655
- units.push({ classes: split(literals.map((l) => l.value).join(" ")), offset: start });
1656
- }
1657
- covered.push([open, end]);
1658
- }
1659
- return units.filter((u) => u.classes.length > 0);
1660
- }
1661
- function blankComments(css) {
1662
- return css.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " "));
1663
- }
1664
- function cssDeclarations(css, base = 0) {
1665
- const src = blankComments(css);
1666
- const out = [];
1667
- const stack = [];
1668
- let segStart = 0;
1669
- let parens = 0;
1670
- const flush = (end) => {
1671
- const seg = src.slice(segStart, end);
1672
- const colon = seg.indexOf(":");
1673
- if (stack.length === 0 || colon === -1) return;
1674
- const property = seg.slice(0, colon).trim();
1675
- if (!/^(--)?[a-zA-Z-]+$/.test(property)) return;
1676
- const lead = seg.length - seg.trimStart().length;
1677
- out.push({
1678
- property: property.toLowerCase(),
1679
- value: seg.slice(colon + 1).trim(),
1680
- selector: stack[stack.length - 1],
1681
- offset: base + segStart + lead
1682
- });
1683
- };
1684
- for (let i = 0; i < src.length; i += 1) {
1685
- const ch = src[i];
1686
- if (ch === '"' || ch === "'") {
1687
- i = skipString(src, i) - 1;
1688
- continue;
1689
- }
1690
- if (ch === "(") parens += 1;
1691
- else if (ch === ")") parens = Math.max(0, parens - 1);
1692
- if (parens > 0) continue;
1693
- if (ch === "{") {
1694
- stack.push(src.slice(segStart, i).trim());
1695
- segStart = i + 1;
1696
- } else if (ch === ";") {
1697
- flush(i);
1698
- segStart = i + 1;
1699
- } else if (ch === "}") {
1700
- flush(i);
1701
- stack.pop();
1702
- segStart = i + 1;
1703
- }
1704
- }
1705
- return out;
1706
- }
1707
- function styleBlocks(text) {
1708
- const out = [];
1709
- for (const m of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
1710
- const bodyStart = (m.index ?? 0) + m[0].indexOf(">") + 1;
1711
- out.push(...cssDeclarations(m[1], bodyStart));
1712
- }
1713
- return out;
1714
- }
1715
- function parseFile(file) {
1716
- const kind = fileKind(file.path);
1717
- return {
1718
- ...file,
1719
- kind,
1720
- classUnits: kind === "markup" || kind === "script" ? classUnits(file.text) : [],
1721
- declarations: kind === "css" ? cssDeclarations(file.text) : kind === "markup" ? styleBlocks(file.text) : []
1722
- };
1723
- }
1724
-
1725
- // src/character/prose.ts
1726
- var blank = (s) => s.replace(/[^\n]/g, " ");
1727
- function readsAsProse(value) {
1728
- if (value.length < 12 || !/\s/.test(value)) return false;
1729
- if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
1730
- const words = withoutMarkup(value);
1731
- if (looksLikeCode(words) || /[<>]/.test(words)) return false;
1732
- return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words);
1733
- }
1734
- function withoutMarkup(value) {
1735
- return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
1736
- }
1737
- function looksLikeCode(words) {
1738
- return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words);
1739
- }
1740
- var UTILITY = /^(?:flex|grid|block|inline|hidden|contents|relative|absolute|fixed|sticky|static|rounded|border|shadow|underline|italic|uppercase|lowercase|capitalize|truncate|container|group|peer|transition|antialiased|prose|sr-only|outline|ring|grow|shrink|isolate|invisible|visible|collapse)$/;
1741
- function looksLikeClasses(value) {
1742
- const tokens = value.trim().split(/\s+/);
1743
- if (tokens.length === 0 || !tokens.every((t) => /^[!\w@:/[\]().#%,>&*+=~-]+$/.test(t))) return false;
1744
- const classy = tokens.filter((t) => /[-:[\]/]/.test(t) || UTILITY.test(t)).length;
1745
- return classy / tokens.length >= 0.6 && /[a-z]-|:|\[/.test(value);
1746
- }
1747
- function looksLikeAddress(value) {
1748
- return /^(?:\.{0,2}\/|@\/|https?:|mailto:|tel:)/.test(value);
1749
- }
1750
- function readsAsText(value) {
1751
- if (value.trim().length === 0) return false;
1752
- if (looksLikeAddress(value) || looksLikeClasses(value)) return false;
1753
- return /[^\s\w]|[a-zA-Z]/.test(value);
1754
- }
1755
- function literalIsText(value) {
1756
- return readsAsText(value) && !looksLikeCode(withoutMarkup(value));
1757
- }
1758
- function maskScriptComments(text) {
1759
- let out = "";
1760
- let i = 0;
1761
- while (i < text.length) {
1762
- const ch = text[i];
1763
- if (ch === '"' || ch === "'" || ch === "`") {
1764
- const start = i;
1765
- i += 1;
1766
- while (i < text.length) {
1767
- if (text[i] === "\\") {
1768
- i += 2;
1769
- continue;
1770
- }
1771
- if (text[i] === ch) {
1772
- i += 1;
1773
- break;
1774
- }
1775
- if (text[i] === "\n" && ch !== "`") break;
1776
- i += 1;
1777
- }
1778
- out += text.slice(start, i);
1779
- continue;
1760
+ flag: [
1761
+ f2("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
1762
+ <h1>Nails in Brackley</h1>`),
1763
+ f2("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
1764
+ <h1>Nails</h1>`)
1765
+ ],
1766
+ pass: [f2("components/Hero.tsx", `<h1>Nails in Brackley</h1>
1767
+ <span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
1780
1768
  }
1781
- if (ch === "/" && text[i + 1] === "/") {
1782
- const end = text.indexOf("\n", i);
1783
- const stop = end === -1 ? text.length : end;
1784
- out += " ".repeat(stop - i);
1785
- i = stop;
1786
- continue;
1769
+ },
1770
+ {
1771
+ id: "icon-tile-stack",
1772
+ name: "Icon in a tinted tile",
1773
+ generation: 2,
1774
+ severity: "warn",
1775
+ surface: "source",
1776
+ why: "A library icon in a soft rounded square, stacked above a heading, is the stock card header.",
1777
+ fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
1778
+ detect: iconTileStackHits,
1779
+ fixtures: {
1780
+ flag: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
1781
+ <div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
1782
+ pass: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
1783
+ <p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
1787
1784
  }
1788
- if (ch === "/" && text[i + 1] === "*") {
1789
- const end = text.indexOf("*/", i + 2);
1790
- const stop = end === -1 ? text.length : end + 2;
1791
- out += blank(text.slice(i, stop));
1792
- i = stop;
1793
- continue;
1785
+ },
1786
+ {
1787
+ id: "bento-grid",
1788
+ name: "Bento grid",
1789
+ generation: 2,
1790
+ severity: "warn",
1791
+ surface: "source",
1792
+ why: "Mixed-size tiles in a grid became the default 'features' layout of the second wave.",
1793
+ fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
1794
+ detect: bentoHits,
1795
+ fixtures: {
1796
+ flag: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
1797
+ pass: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
1794
1798
  }
1795
- out += ch;
1796
- i += 1;
1797
- }
1798
- return out;
1799
- }
1800
- function maskMarkupComments(text) {
1801
- return text.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, blank).replace(/\{\/\*[\s\S]*?\*\/\}|<!--[\s\S]*?-->/g, blank).replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, blank).replace(/^[ \t]*\/\/[^\n]*/gm, blank).replace(/([;,{}()[\]])([ \t]+)\/\/[^\n]*/g, (m, p, s) => p + s + " ".repeat(m.length - p.length - s.length)).replace(/^\s*import\b[^\n]*$/gm, blank);
1802
- }
1803
- function markupParts(file) {
1804
- const masked = maskMarkupComments(file.text);
1805
- const textNodes = [];
1806
- let rest = masked;
1807
- for (const m of masked.matchAll(/([>}])([^<>{}]+)(?=[<{])/g)) {
1808
- const body = m[2];
1809
- const before = masked[(m.index ?? 0) - 1] ?? "";
1810
- const after = masked[(m.index ?? 0) + m[0].length];
1811
- if (m[1] === ">" && /[=-]/.test(before)) continue;
1812
- if (m[1] === "}" && (/^\s*(?:[)\],;:]|(?:else|catch|finally|return|from|as|if|const|let|export|function)\b)/.test(body) || /;\s*$/.test(body))) continue;
1813
- if (m[1] === "}" && after === "{" && (/[=()`'"]/.test(body) || !/[a-zA-Z]{2,}|[^\s\w,|&?:]/.test(body))) continue;
1814
- if (body.trim().length === 0) continue;
1815
- if (m[1] === ">" && after === "<" && /^[\s—–-]*$/.test(body)) continue;
1816
- const at = (m.index ?? 0) + 1;
1817
- textNodes.push({ path: file.path, text: body, offset: at });
1818
- rest = rest.slice(0, at) + blank(body) + rest.slice(at + body.length);
1819
- }
1820
- return { textNodes, literals: splitHtml(stringLiterals(rest)) };
1821
- }
1822
- function textNodeIsProse(body) {
1823
- return /[a-zA-Z]{2,}/.test(body) && /\s/.test(body.trim()) || /[a-zA-Z]{4,}[.!?]/.test(body);
1824
- }
1825
- function proseBlocks(file) {
1826
- const masked = file.text.replace(/^(```|~~~)[\s\S]*?^\1/gm, blank).replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length)).replace(/^---\n[\s\S]*?\n---\n/, blank).replace(/<!--[\s\S]*?-->/g, blank);
1827
- return [{ path: file.path, text: masked, offset: 0 }];
1828
- }
1829
- function isData(path) {
1830
- return /\.jsonl?$/i.test(path);
1831
- }
1832
- function scriptLiterals(file) {
1833
- return splitHtml(stringLiterals(isData(file.path) ? file.text : maskScriptComments(file.text)));
1834
- }
1835
- function splitHtml(literals) {
1836
- const out = [];
1837
- for (const l of literals) {
1838
- if (!/<[a-zA-Z][^<>]*>/.test(l.value)) {
1839
- out.push(l);
1840
- continue;
1799
+ },
1800
+ {
1801
+ id: "radial-spotlight-glow",
1802
+ name: "Radial glow",
1803
+ generation: 2,
1804
+ severity: "warn",
1805
+ surface: "source",
1806
+ why: "A soft blurred blob or radial spotlight behind the hero is atmosphere without a subject.",
1807
+ fix: "Light the page with real imagery, or leave the ground flat.",
1808
+ detect: glowHits,
1809
+ fixtures: {
1810
+ flag: [
1811
+ f2("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
1812
+ f2("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
1813
+ ],
1814
+ pass: [f2("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
1841
1815
  }
1842
- for (const m of l.value.matchAll(/(?:^|>)([^<>]+)(?=<|$)/g)) {
1843
- const body = m[1];
1844
- if (!body.trim()) continue;
1845
- const lead = m[0].length - body.length;
1846
- out.push({ value: body, offset: l.offset + (m.index ?? 0) + lead });
1816
+ },
1817
+ {
1818
+ id: "grid-background",
1819
+ name: "Graph-paper background",
1820
+ generation: 2,
1821
+ severity: "warn",
1822
+ surface: "source",
1823
+ why: "A faint 1px line or dot grid behind the hero is the developer-tool look, borrowed by everyone.",
1824
+ fix: "Remove it. If the brand has a pattern, draw it from the brand.",
1825
+ detect: gridBackgroundHits,
1826
+ fixtures: {
1827
+ flag: [
1828
+ f2(
1829
+ "app/globals.css",
1830
+ `.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
1831
+ ),
1832
+ f2("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
1833
+ ],
1834
+ pass: [f2("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
1847
1835
  }
1848
- }
1849
- return out;
1850
- }
1851
- function extractCopy(file) {
1852
- const kind = fileKind(file.path);
1853
- if (kind === "prose" || file.path.endsWith(".mdx")) return proseBlocks(file);
1854
- if (kind === "markup") {
1855
- const { textNodes, literals } = markupParts(file);
1856
- return [
1857
- ...textNodes.filter((b) => textNodeIsProse(b.text)),
1858
- ...literals.filter((l) => readsAsProse(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
1859
- ];
1860
- }
1861
- if (kind === "script" || isData(file.path)) {
1862
- return scriptLiterals(file).filter((l) => readsAsProse(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
1863
- }
1864
- return [];
1865
- }
1866
- function extractStrings(file) {
1867
- const kind = fileKind(file.path);
1868
- if (kind === "prose" || file.path.endsWith(".mdx")) {
1869
- const [block] = proseBlocks(file);
1870
- const out = [];
1871
- let offset = 0;
1872
- for (const line of block.text.split("\n")) {
1873
- if (line.trim()) out.push({ path: file.path, text: line, offset });
1874
- offset += line.length + 1;
1836
+ },
1837
+ {
1838
+ id: "marquee",
1839
+ name: "Scrolling marquee",
1840
+ generation: 2,
1841
+ severity: "warn",
1842
+ surface: "source",
1843
+ why: "An endless strip of logos or words is the second wave's trust strip. It moves so it looks alive.",
1844
+ fix: "Show proof still and in context: one named review, one accreditation, where the doubt arises.",
1845
+ detect: marqueeHits,
1846
+ fixtures: {
1847
+ flag: [
1848
+ f2("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
1849
+ f2("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
1850
+ ],
1851
+ pass: [f2("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
1852
+ }
1853
+ },
1854
+ {
1855
+ id: "thin-border-wide-shadow",
1856
+ name: "Hairline border, wide shadow",
1857
+ generation: 2,
1858
+ severity: "warn",
1859
+ surface: "source",
1860
+ why: "A near-invisible border under a large soft shadow is the stock 'elevated card'.",
1861
+ fix: "Pick one: a border that is visible, or a shadow the light source explains. Keep within the four-shadow budget.",
1862
+ detect: thinBorderWideShadowHits,
1863
+ fixtures: {
1864
+ flag: [
1865
+ f2("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
1866
+ f2("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
1867
+ ],
1868
+ pass: [f2("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
1869
+ }
1870
+ },
1871
+ {
1872
+ id: "intro-cinematic",
1873
+ name: "Intro cinematic",
1874
+ generation: 2,
1875
+ severity: "warn",
1876
+ surface: "source",
1877
+ 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.",
1878
+ fix: "Remove it. Spend the signature moment on something the visitor came for.",
1879
+ detect: introHits,
1880
+ fixtures: {
1881
+ flag: [
1882
+ f2("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
1883
+ export function IntroCinematic() { return <AnimatePresence /> }`),
1884
+ f2("app/page.tsx", `<><Preloader /><Hero /></>`)
1885
+ ],
1886
+ pass: [f2("components/Introduction.md", `An introduction to the studio.`)]
1875
1887
  }
1876
- return out;
1877
- }
1878
- if (kind === "markup") {
1879
- const { textNodes, literals } = markupParts(file);
1880
- return [
1881
- ...textNodes.filter((b) => readsAsText(b.text)),
1882
- ...literals.filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
1883
- ];
1884
- }
1885
- if (kind === "script" || isData(file.path)) {
1886
- return scriptLiterals(file).filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
1887
1888
  }
1888
- return [];
1889
- }
1889
+ ];
1890
1890
 
1891
1891
  // src/snapshot/fixture.ts
1892
1892
  function makeSnapshot(patch = {}) {
@@ -1958,15 +1958,16 @@ function makeSnapshot(patch = {}) {
1958
1958
  }
1959
1959
 
1960
1960
  // src/snapshot/fonts.ts
1961
+ var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
1961
1962
  function normaliseFamily(raw) {
1962
1963
  let name = raw.replace(/["']/g, "").trim();
1963
1964
  const next = name.match(/^__(.+?)(?:_Fallback)?_[0-9a-f]{5,8}$/i);
1964
1965
  if (next) name = next[1].replace(/_/g, " ");
1965
1966
  name = name.replace(/\s+Fallback$/i, "").replace(/\s+Variable$/i, "");
1967
+ if (SYSTEM.test(name)) return "system-ui";
1966
1968
  if (!/\s/.test(name) && (/[a-z][A-Z]/.test(name) || /^[a-z]+(?:-[a-z]+)+$/.test(name))) {
1967
1969
  name = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/-/g, " ").replace(/\b([a-z])/g, (c) => c.toUpperCase()).replace(/\bDm\b/, "DM");
1968
1970
  }
1969
- if (/^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|sans-serif|serif|monospace)$/i.test(name)) return "system-ui";
1970
1971
  return name;
1971
1972
  }
1972
1973
  var KNOWN = {
@@ -2258,7 +2259,7 @@ function toFinding(tell, hit2, texts) {
2258
2259
  fix: tell.fix
2259
2260
  };
2260
2261
  }
2261
- function report(tells, files, run2, options) {
2262
+ function report(tells, files, run, options) {
2262
2263
  const { applied, rejected } = validateExceptions(options.exceptions ?? []);
2263
2264
  const texts = new Map(files.map((file) => [file.path, file.text]));
2264
2265
  const selected = options.only ? tells.filter((t) => options.only.includes(t.id)) : tells;
@@ -2272,7 +2273,7 @@ function report(tells, files, run2, options) {
2272
2273
  };
2273
2274
  const excepted = /* @__PURE__ */ new Map();
2274
2275
  for (const tell of selected) {
2275
- for (const hit2 of run2(tell)) {
2276
+ for (const hit2 of run(tell)) {
2276
2277
  const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit2.path.includes(e.path)));
2277
2278
  if (exception) {
2278
2279
  excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
@@ -2328,196 +2329,6 @@ function auditSnapshot(snapshot, options = {}) {
2328
2329
  return result;
2329
2330
  }
2330
2331
 
2331
- // src/character/cli.ts
2332
- import { execFileSync } from "child_process";
2333
- import { existsSync, readFileSync, readdirSync, statSync } from "fs";
2334
- import { join, relative, resolve } from "path";
2335
-
2336
- // src/character/format.ts
2337
- function formatReport(report2, title) {
2338
- const lines = [];
2339
- let current = "";
2340
- for (const f3 of report2.findings) {
2341
- if (f3.path !== current) {
2342
- current = f3.path;
2343
- lines.push("", f3.path);
2344
- }
2345
- lines.push(` ${String(f3.line).padStart(4)} ${f3.tell} (gen ${f3.generation}, ${f3.severity}) ${f3.message}`);
2346
- lines.push(` ${f3.excerpt}`);
2347
- }
2348
- const fixes = new Map(report2.findings.map((f3) => [f3.tell, f3.fix]));
2349
- if (fixes.size > 0) {
2350
- lines.push("", "What to do instead:");
2351
- for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
2352
- }
2353
- for (const r of report2.rejectedExceptions) {
2354
- lines.push("", `Exception not applied (${r.exception.tell}): ${r.reason}`);
2355
- }
2356
- for (const e of report2.excepted) {
2357
- lines.push("", `Excepted: ${e.count} \xD7 ${e.tell}, because ${e.because}`);
2358
- }
2359
- if (report2.suppressed.length > 0) {
2360
- lines.push("", `Suppressed: ${report2.suppressed.length} marked copy-ok or craft-ok (${report2.suppressed.map((s) => `${s.path}:${s.line}`).join(", ")})`);
2361
- }
2362
- const { summary } = report2;
2363
- const gens = `gen 1: ${summary.byGeneration[1]}, gen 2: ${summary.byGeneration[2]}, gen 3: ${summary.byGeneration[3]}`;
2364
- lines.push(
2365
- "",
2366
- `${title}: ${summary.findings} finding${summary.findings === 1 ? "" : "s"} in ${summary.files} file${summary.files === 1 ? "" : "s"} (${gens}). Catalogue ${report2.catalogueVersion}.`
2367
- );
2368
- return lines.join("\n").replace(/^\n/, "");
2369
- }
2370
-
2371
- // src/character/cli.ts
2372
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".turbo", ".vercel", "coverage", "vendor", ".cache"]);
2373
- var MAX_BYTES = 1e6;
2374
- var NOT_SITE_COPY = /^(?:README|CHANGELOG|CLAUDE|AGENTS|CONTRIBUTING|LICENSE|CODE_OF_CONDUCT|SECURITY)(?:\.[a-z]+)?$/i;
2375
- var HELP = `craft: find the AI look and say what to do instead.
2376
-
2377
- Usage
2378
- craft scan [paths...] [--staged] [--json] [--strict] [--direction <file>]
2379
- craft copy <paths...> [--json] [--strict] [--direction <file>]
2380
- craft tells list [--json]
2381
- craft snapshot <url> [--out <file>] [--width <px>] [--height <px>]
2382
- craft audit <url | snapshot.json> [--repo <dir>] [--out <file>] [--json] [--strict]
2383
- craft direction init [--snapshot <file>] [--client <name>] [--out <file>]
2384
- craft direction validate [--snapshot <file>] [--direction <file>] [--json]
2385
- craft direction propose [--snapshot <file>] [--estate <dir>] [--out <file>]
2386
-
2387
- scan Markup, component code and stylesheets (source and compiled CSS).
2388
- --staged reads the git index, for a pre-commit hook.
2389
- copy The prose in Markdown, markup and content files.
2390
- tells The catalogue this build judges against.
2391
- snapshot Render a page in a browser and save what it looks like, as JSON.
2392
- audit Judge a rendered page (live, or a saved snapshot) and fingerprint it.
2393
- --repo also scans that site's source, so one report covers both.
2394
- snapshot and audit need Playwright: npm i -D playwright.
2395
- direction The site's art-direction.json: every choice with a reason from the
2396
- client's world. init writes today's choices with empty reasons;
2397
- validate applies the reason rule; propose reads colours off the
2398
- sources' PNG photos and drafts choices for a person to confirm.
2399
-
2400
- Exceptions come from art-direction.json in the working directory, or --direction.
2401
- Every tell ships as warn: exit 1 only on a block, or on any finding with --strict.`;
2402
- var VALUE_FLAGS = {
2403
- "--direction": "direction",
2404
- "--out": "out",
2405
- "--repo": "repo",
2406
- "--width": "width",
2407
- "--height": "height",
2408
- "--snapshot": "snapshot",
2409
- "--estate": "estate",
2410
- "--client": "client"
2411
- };
2412
- function parseFlags(args) {
2413
- const flags = { positional: [], json: false, strict: false, staged: false };
2414
- for (let i = 0; i < args.length; i += 1) {
2415
- const a = args[i];
2416
- if (a === "--json") flags.json = true;
2417
- else if (a === "--strict") flags.strict = true;
2418
- else if (a === "--staged") flags.staged = true;
2419
- else if (a in VALUE_FLAGS) {
2420
- const value = args[i + 1];
2421
- i += 1;
2422
- if (!value || value.startsWith("--")) return `${a} needs a value`;
2423
- flags[VALUE_FLAGS[a]] = value;
2424
- } else if (a.startsWith("--")) return `unknown option ${a}`;
2425
- else flags.positional.push(a);
2426
- }
2427
- return flags;
2428
- }
2429
- function walk(root, cwd, into, wanted, skipDir = (_) => false) {
2430
- const stat = statSync(root);
2431
- if (stat.isFile()) {
2432
- if (stat.size <= MAX_BYTES) into.push({ path: relative(cwd, root) || root, text: readFileSync(root, "utf8") });
2433
- return;
2434
- }
2435
- for (const entry of readdirSync(root, { withFileTypes: true })) {
2436
- if (entry.isDirectory()) {
2437
- if (!SKIP_DIRS.has(entry.name) && !skipDir(entry.name)) walk(join(root, entry.name), cwd, into, wanted, skipDir);
2438
- } else if (entry.isFile() && wanted(entry.name)) {
2439
- walk(join(root, entry.name), cwd, into, wanted, skipDir);
2440
- }
2441
- }
2442
- }
2443
- function readPaths(paths, cwd, wanted, skipDir) {
2444
- const files = [];
2445
- for (const p of paths) {
2446
- const abs = resolve(cwd, p);
2447
- if (!existsSync(abs)) throw new Error(`no such path: ${p}`);
2448
- walk(abs, cwd, files, wanted, skipDir);
2449
- }
2450
- return files;
2451
- }
2452
- function readStaged(cwd, wanted) {
2453
- const names = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, encoding: "utf8" }).split("\0").filter((p) => p && wanted(p));
2454
- return names.map((path) => ({
2455
- path,
2456
- // The index, not the working tree: the commit is what gets checked.
2457
- text: execFileSync("git", ["show", `:${path}`], { cwd, encoding: "utf8", maxBuffer: MAX_BYTES * 4 })
2458
- }));
2459
- }
2460
- function loadExceptions(flags, cwd) {
2461
- const path = resolve(cwd, flags.direction ?? "art-direction.json");
2462
- if (!existsSync(path)) {
2463
- if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
2464
- return [];
2465
- }
2466
- const data = JSON.parse(readFileSync(path, "utf8"));
2467
- return Array.isArray(data.exceptions) ? data.exceptions : [];
2468
- }
2469
- function finish(report2, flags, title, io) {
2470
- io.out(flags.json ? JSON.stringify(report2, null, 2) : formatReport(report2, title));
2471
- if (report2.summary.blocking > 0) return 1;
2472
- if (flags.strict && report2.summary.findings > 0) return 1;
2473
- return 0;
2474
- }
2475
- var SOURCE_FILE = (p) => ["markup", "script", "css"].includes(fileKind(p)) && !/\.d\.ts$|\.min\.js$/.test(p);
2476
- var COPY_FILE = (p) => (["markup", "prose", "script"].includes(fileKind(p)) || /\.jsonl?$/i.test(p)) && !NOT_SITE_COPY.test(p.split("/").pop() ?? p) && !/(?:^|\/)(?:package(?:-lock)?|tsconfig[\w.-]*|art-direction)\.json$/.test(p);
2477
- var NOT_COPY_DIR = (name) => name.startsWith("_");
2478
- function run(argv, io) {
2479
- const [command, ...rest] = argv;
2480
- if (command === "direction") {
2481
- return import("./cli-F7W4F3ER.js").then((m) => m.runDirection(rest, io));
2482
- }
2483
- if (command === "snapshot" || command === "audit") {
2484
- return import("./cli-7ME4VIRG.js").then((m) => m.runAudit(command, rest, io));
2485
- }
2486
- if (!command || command === "help" || command === "--help" || command === "-h") {
2487
- io.out(HELP);
2488
- return command ? 0 : 2;
2489
- }
2490
- try {
2491
- if (command === "tells") {
2492
- const flags = parseFlags(rest);
2493
- if (typeof flags === "string") throw new Error(flags);
2494
- if (flags.positional[0] !== "list") throw new Error("usage: craft tells list [--json]");
2495
- if (flags.json) {
2496
- const data = CATALOGUE.map(({ id, name, generation, surface, severity, why, fix }) => ({ id, name, generation, surface, severity, why, fix }));
2497
- io.out(JSON.stringify({ version: CATALOGUE_VERSION, tells: data }, null, 2));
2498
- } else {
2499
- io.out(`Catalogue ${CATALOGUE_VERSION}`);
2500
- for (const t of CATALOGUE) io.out(` ${t.id.padEnd(24)} gen ${t.generation} ${t.surface.padEnd(6)} ${t.name}`);
2501
- }
2502
- return 0;
2503
- }
2504
- if (command === "scan" || command === "copy") {
2505
- const flags = parseFlags(rest);
2506
- if (typeof flags === "string") throw new Error(flags);
2507
- const wanted = command === "scan" ? SOURCE_FILE : COPY_FILE;
2508
- if (command === "copy" && flags.positional.length === 0 && !flags.staged) throw new Error("usage: craft copy <paths...>");
2509
- const files = flags.staged ? readStaged(io.cwd, wanted) : readPaths(flags.positional.length > 0 ? flags.positional : ["."], io.cwd, wanted, command === "copy" ? NOT_COPY_DIR : void 0);
2510
- const exceptions = loadExceptions(flags, io.cwd);
2511
- const report2 = command === "scan" ? scanSource(files, { exceptions }) : checkCopy(files, { exceptions });
2512
- return finish(report2, flags, command === "scan" ? "craft scan" : "craft copy", io);
2513
- }
2514
- throw new Error(`unknown command "${command}". Run craft --help.`);
2515
- } catch (error) {
2516
- io.err(`craft: ${error.message}`);
2517
- return 2;
2518
- }
2519
- }
2520
-
2521
2332
  export {
2522
2333
  formatHex,
2523
2334
  hexToOklch,
@@ -2528,15 +2339,14 @@ export {
2528
2339
  isCream,
2529
2340
  REFLEX_FONTS_1,
2530
2341
  REFLEX_FONTS_2,
2342
+ fileKind,
2343
+ extractStrings,
2531
2344
  SNAPSHOT_VERSION,
2532
2345
  normaliseFamily,
2533
2346
  fontClass,
2347
+ CATALOGUE_VERSION,
2348
+ CATALOGUE,
2534
2349
  scanSource,
2535
- auditSnapshot,
2536
- parseFlags,
2537
- readPaths,
2538
- loadExceptions,
2539
- finish,
2540
- SOURCE_FILE,
2541
- run
2350
+ checkCopy,
2351
+ auditSnapshot
2542
2352
  };