@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/dist/index.cjs CHANGED
@@ -20,23 +20,51 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ AI_PHRASES: () => AI_PHRASES,
24
+ AI_VIOLET: () => AI_VIOLET,
25
+ AI_WORDS: () => AI_WORDS,
26
+ BUZZWORDS: () => BUZZWORDS,
27
+ CATALOGUE: () => CATALOGUE,
28
+ CATALOGUE_VERSION: () => CATALOGUE_VERSION,
29
+ CHOICE_KEYS: () => CHOICE_KEYS,
23
30
  DENSITIES: () => DENSITIES,
24
31
  DENSITY_NAMES: () => DENSITY_NAMES,
32
+ DIRECTION_VERSION: () => DIRECTION_VERSION,
33
+ DISTANCE_WEIGHTS: () => DISTANCE_WEIGHTS,
25
34
  DURATION_MS: () => DURATION_MS,
26
35
  DURATION_S: () => DURATION_S,
27
36
  EASE: () => EASE,
28
37
  EASE_TUPLE: () => EASE_TUPLE,
29
38
  EXIT_RATIO: () => EXIT_RATIO,
39
+ FINGERPRINT_VERSION: () => FINGERPRINT_VERSION,
30
40
  HOUSE_BUDGET: () => HOUSE_BUDGET,
31
41
  HOUSE_TYPE: () => HOUSE_TYPE,
32
42
  LIGHTNESS_CURVE: () => LIGHTNESS_CURVE,
43
+ NEGATIVE_REASSURANCE: () => NEGATIVE_REASSURANCE,
44
+ PILL_LIMIT: () => PILL_LIMIT,
45
+ PLAINER_WORDS: () => PLAINER_WORDS,
46
+ PROPOSED: () => PROPOSED,
33
47
  RAMP_STEPS: () => RAMP_STEPS,
48
+ REFLEX_FONTS_1: () => REFLEX_FONTS_1,
49
+ REFLEX_FONTS_2: () => REFLEX_FONTS_2,
50
+ RENDERED_PATHS: () => RENDERED_PATHS,
51
+ REVEAL_LIMIT: () => REVEAL_LIMIT,
52
+ REVIEW_PHRASES: () => REVIEW_PHRASES,
34
53
  SCALE: () => SCALE,
54
+ SHADCN_LIMIT: () => SHADCN_LIMIT,
55
+ SHADCN_PRIMITIVES: () => SHADCN_PRIMITIVES,
56
+ SNAPSHOT_VERSION: () => SNAPSHOT_VERSION,
57
+ SOURCE_KINDS: () => SOURCE_KINDS,
35
58
  SPACE_STEPS: () => SPACE_STEPS,
36
59
  SPRING: () => SPRING,
60
+ STOCK_PHRASES: () => STOCK_PHRASES,
37
61
  TYPE_STEPS: () => TYPE_STEPS,
62
+ VAGUE_WORDS: () => VAGUE_WORDS,
38
63
  accentFork: () => accentFork,
39
64
  apcaContrast: () => apcaContrast,
65
+ auditSnapshot: () => auditSnapshot,
66
+ catalogueTable: () => catalogueTable,
67
+ checkCopy: () => checkCopy,
40
68
  checkPair: () => checkPair,
41
69
  checkRestraint: () => checkRestraint,
42
70
  craftTokens: () => craftTokens,
@@ -46,26 +74,44 @@ __export(index_exports, {
46
74
  densityTokens: () => densityTokens,
47
75
  emitCss: () => emitCss,
48
76
  exitDuration: () => exitDuration,
77
+ findColours: () => findColours,
78
+ fingerprint: () => fingerprint,
79
+ fingerprintDistance: () => fingerprintDistance,
49
80
  fluidClamp: () => fluidClamp,
50
81
  fluidSpace: () => fluidSpace,
51
82
  fluidType: () => fluidType,
83
+ fontClass: () => fontClass,
52
84
  formatHex: () => formatHex,
85
+ formatReport: () => formatReport,
53
86
  hexToOklch: () => hexToOklch,
54
87
  inP3Gamut: () => inP3Gamut,
55
88
  inSrgbGamut: () => inSrgbGamut,
89
+ initDirection: () => initDirection,
90
+ isAiViolet: () => isAiViolet,
91
+ isCream: () => isCream,
92
+ makeSnapshot: () => makeSnapshot,
56
93
  motionTokens: () => motionTokens,
94
+ nearest: () => nearest,
95
+ normaliseFamily: () => normaliseFamily,
57
96
  oklchToHex: () => oklchToHex,
58
97
  oklchToRgb: () => oklchToRgb,
98
+ paletteFromPixels: () => paletteFromPixels,
99
+ parseColour: () => parseColour,
59
100
  parseHex: () => parseHex,
101
+ propose: () => propose,
60
102
  ramp: () => ramp,
61
103
  rampFromAnchors: () => rampFromAnchors,
104
+ runTell: () => runTell,
105
+ scanSource: () => scanSource,
62
106
  sectionRhythm: () => sectionRhythm,
63
107
  semantic: () => semantic,
64
108
  shouldAnimate: () => shouldAnimate,
65
109
  tailwindV3Preset: () => tailwindV3Preset,
66
110
  tailwindV4Theme: () => tailwindV4Theme,
111
+ tellById: () => tellById,
67
112
  toGamut: () => toGamut,
68
113
  typeFeatureTokens: () => typeFeatureTokens,
114
+ validateDirection: () => validateDirection,
69
115
  wcagContrast: () => wcagContrast
70
116
  });
71
117
  module.exports = __toCommonJS(index_exports);
@@ -78,11 +124,11 @@ function srgbToLinear(c) {
78
124
  function linearToSrgb(c) {
79
125
  return c <= 31308e-7 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
80
126
  }
81
- function parseHex(hex) {
82
- const raw = hex.trim().replace(/^#/, "");
127
+ function parseHex(hex2) {
128
+ const raw = hex2.trim().replace(/^#/, "");
83
129
  const expanded = raw.length === 3 || raw.length === 4 ? raw.slice(0, 3).split("").map((ch) => ch + ch).join("") : raw.slice(0, 6);
84
130
  if (!/^[0-9a-fA-F]{6}$/.test(expanded)) {
85
- throw new Error(`craft: not a hex colour: ${JSON.stringify(hex)}`);
131
+ throw new Error(`craft: not a hex colour: ${JSON.stringify(hex2)}`);
86
132
  }
87
133
  return {
88
134
  r: parseInt(expanded.slice(0, 2), 16) / 255,
@@ -141,8 +187,8 @@ function xyzToLinearP3(x, y, z) {
141
187
  ];
142
188
  }
143
189
  var RAD = 180 / Math.PI;
144
- function hexToOklch(hex) {
145
- const { r, g, b } = parseHex(hex);
190
+ function hexToOklch(hex2) {
191
+ const { r, g, b } = parseHex(hex2);
146
192
  const [L, A, B] = linearRgbToOklab(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b));
147
193
  const c = Math.sqrt(A * A + B * B);
148
194
  const h = c < 1e-7 ? 0 : (Math.atan2(B, A) * RAD % 360 + 360) % 360;
@@ -289,11 +335,11 @@ function rampFromAnchors(anchors, options = {}) {
289
335
  h: ((lo.h + hueDelta(lo.h, hi.h) * t) % 360 + 360) % 360
290
336
  };
291
337
  } else {
292
- const nearest = below ?? above;
293
- const base = parsed.get(nearest);
338
+ const nearest2 = below ?? above;
339
+ const base = parsed.get(nearest2);
294
340
  const lightness = warpLightness(
295
341
  LIGHTNESS_CURVE[step],
296
- LIGHTNESS_CURVE[nearest],
342
+ LIGHTNESS_CURVE[nearest2],
297
343
  base.l
298
344
  );
299
345
  const baseEnvelope = chromaEnvelope(base.l);
@@ -306,8 +352,8 @@ function rampFromAnchors(anchors, options = {}) {
306
352
  }
307
353
 
308
354
  // src/color/contrast.ts
309
- function relativeLuminance(hex) {
310
- const { r, g, b } = parseHex(hex);
355
+ function relativeLuminance(hex2) {
356
+ const { r, g, b } = parseHex(hex2);
311
357
  const channel = (c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
312
358
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
313
359
  }
@@ -336,8 +382,8 @@ var APCA = {
336
382
  deltaYmin: 5e-4,
337
383
  loClip: 0.1
338
384
  };
339
- function apcaLuminance(hex) {
340
- const { r, g, b } = parseHex(hex);
385
+ function apcaLuminance(hex2) {
386
+ const { r, g, b } = parseHex(hex2);
341
387
  return APCA.Rco * Math.pow(r, APCA.mainTRC) + APCA.Gco * Math.pow(g, APCA.mainTRC) + APCA.Bco * Math.pow(b, APCA.mainTRC);
342
388
  }
343
389
  function softClamp(y) {
@@ -382,12 +428,12 @@ function accentFork(rampSteps, on, options = {}) {
382
428
  const order = darkBackgrounds ? [...RAMP_STEPS].reverse() : [...RAMP_STEPS];
383
429
  let best = null;
384
430
  for (const step of order) {
385
- const hex = rampSteps[step];
386
- const checks = on.map((bg) => checkPair(hex, bg));
431
+ const hex2 = rampSteps[step];
432
+ const checks = on.map((bg) => checkPair(hex2, bg));
387
433
  const clears = checks.every((c) => c.wcag >= minWcag && Math.abs(c.lc) >= minLc);
388
434
  if (clears) {
389
435
  return {
390
- hex,
436
+ hex: hex2,
391
437
  step,
392
438
  checks,
393
439
  note: checks.map((c) => c.note).join("; "),
@@ -906,8 +952,8 @@ function resolveValue(value, props, depth = 0) {
906
952
  return fallback === void 0 || fallback === "" ? null : resolveValue(fallback, props, depth + 1);
907
953
  }
908
954
  function isVarOnlyList(value) {
909
- const parts = value.split(",");
910
- return parts.length > 1 && parts.every((part) => /^\s*var\(\s*--[\w-]+\s*\)\s*$/.test(part));
955
+ const parts2 = value.split(",");
956
+ return parts2.length > 1 && parts2.every((part) => /^\s*var\(\s*--[\w-]+\s*\)\s*$/.test(part));
911
957
  }
912
958
  function distinctValues(css, property, props, unresolved) {
913
959
  const pattern = new RegExp(`(?:^|[;{\\s])${property}\\s*:\\s*([^;}]+)`, "gi");
@@ -1002,7 +1048,7 @@ function checkRestraint(input) {
1002
1048
  );
1003
1049
  }
1004
1050
  const families = distinctValues(css, "font-family", props, unresolved).filter(
1005
- (f) => !/\bmonospace\b|\bmono\b/.test(f)
1051
+ (f3) => !/\bmonospace\b|\bmono\b/.test(f3)
1006
1052
  );
1007
1053
  if (families.length > budget.maxFontFamilies) {
1008
1054
  push(
@@ -1049,9 +1095,9 @@ function checkRestraint(input) {
1049
1095
  ...css.matchAll(/--[a-z0-9-]*accent[a-z0-9-]*\s*:\s*(#[0-9a-fA-F]{3,8})/g)
1050
1096
  ].map((m) => m[1]);
1051
1097
  if (accentHexes.length > 1) {
1052
- const hues = accentHexes.map((hex) => {
1098
+ const hues = accentHexes.map((hex2) => {
1053
1099
  try {
1054
- const { c, h } = hexToOklch(hex);
1100
+ const { c, h } = hexToOklch(hex2);
1055
1101
  return c > 0.02 ? h : null;
1056
1102
  } catch {
1057
1103
  return null;
@@ -1319,25 +1365,2759 @@ function tailwindV4Theme() {
1319
1365
  `;
1320
1366
  }
1321
1367
  var DENSITY_NAMES = Object.keys(DENSITIES);
1368
+
1369
+ // src/character/tells/copy.ts
1370
+ var f = (path, text) => ({ path, text });
1371
+ var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
1372
+ function allText(ctx) {
1373
+ return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
1374
+ }
1375
+ function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
1376
+ return (ctx) => {
1377
+ const hits = [];
1378
+ for (const block of source(ctx)) {
1379
+ const spans = [];
1380
+ for (const pattern of patterns) {
1381
+ const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
1382
+ for (const m of block.text.matchAll(global)) {
1383
+ if (skip(block, m[0], m.index ?? 0)) continue;
1384
+ spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
1385
+ }
1386
+ }
1387
+ spans.sort((a, b) => a[0] - b[0]);
1388
+ let end = -1;
1389
+ for (const [start, stop, text] of spans) {
1390
+ if (start < end) continue;
1391
+ end = stop;
1392
+ const lead = text.length - text.trimStart().length;
1393
+ hits.push({ path: block.path, offset: block.offset + start + lead, message: label(text.trim()) });
1394
+ }
1395
+ }
1396
+ return hits;
1397
+ };
1398
+ }
1399
+ var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/'/g, "['\u2019]?").replace(/ /g, "\\s+");
1400
+ var wordList = (words2) => new RegExp(`\\b(?:${[...words2].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
1401
+ var quoted = (m) => `"${m}"`;
1402
+ var AI_WORDS = [
1403
+ "tapestry",
1404
+ "testament",
1405
+ "realm",
1406
+ "embark",
1407
+ "embarking",
1408
+ "unleash",
1409
+ "unlocks",
1410
+ "unlocking",
1411
+ "elevate",
1412
+ "elevates",
1413
+ "elevated",
1414
+ "elevating",
1415
+ "bustling",
1416
+ "nestled",
1417
+ "meticulous",
1418
+ "meticulously",
1419
+ "paramount",
1420
+ "furthermore",
1421
+ "moreover",
1422
+ "navigating",
1423
+ "ever-evolving",
1424
+ "unparalleled"
1425
+ ];
1426
+ var STOCK_PHRASES = [
1427
+ "in today's digital age",
1428
+ "to the next level",
1429
+ "one-stop shop",
1430
+ "second to none",
1431
+ "we pride ourselves",
1432
+ "passionate about",
1433
+ "your journey",
1434
+ "our journey",
1435
+ "curated",
1436
+ "crafted with care",
1437
+ "tailored to your",
1438
+ "peace of mind"
1439
+ ];
1440
+ var AI_PHRASES = [
1441
+ "let's dive in",
1442
+ "dive into",
1443
+ "let's unpack",
1444
+ "let's break this down",
1445
+ "let's break it down",
1446
+ "here's the thing",
1447
+ "it's important to note",
1448
+ "it's important to remember",
1449
+ "it is worth noting that",
1450
+ "at its core",
1451
+ "the key takeaway",
1452
+ "the reality is",
1453
+ "the truth is",
1454
+ "increasingly digital",
1455
+ "seamless",
1456
+ "seamlessly",
1457
+ "unlock your",
1458
+ "unlock the power",
1459
+ "meaningful impact",
1460
+ "drive meaningful",
1461
+ "foster collaboration",
1462
+ "fosters collaboration",
1463
+ "fostering collaboration",
1464
+ "comprehensive guide to",
1465
+ "look no further",
1466
+ "in the event that",
1467
+ "at no additional cost to you",
1468
+ "rest assured",
1469
+ "when it comes to",
1470
+ "we have got you covered",
1471
+ "we've got you covered"
1472
+ ];
1473
+ var PLAINER_WORDS = [
1474
+ "empower",
1475
+ "empowers",
1476
+ "empowered",
1477
+ "empowering",
1478
+ "harness",
1479
+ "harnesses",
1480
+ "harnessed",
1481
+ "harnessing",
1482
+ "delve",
1483
+ "delves",
1484
+ "delved",
1485
+ "delving",
1486
+ "leverage",
1487
+ "leverages",
1488
+ "leveraged",
1489
+ "leveraging"
1490
+ ];
1491
+ var BUZZWORDS = [
1492
+ "synergy",
1493
+ "synergise",
1494
+ "synergize",
1495
+ "cutting-edge",
1496
+ "cutting edge",
1497
+ "best-in-class",
1498
+ "best in class",
1499
+ "best of breed",
1500
+ "game-changer",
1501
+ "game changer",
1502
+ "paradigm shift",
1503
+ "world-class",
1504
+ "state of the art",
1505
+ "state-of-the-art",
1506
+ "mission-critical",
1507
+ "low-hanging fruit",
1508
+ "move the needle",
1509
+ "circle back",
1510
+ "touch base",
1511
+ "reach out",
1512
+ "value-add",
1513
+ "robust",
1514
+ "turnkey",
1515
+ "holistic",
1516
+ "disruptive"
1517
+ ];
1518
+ var NEGATIVE_REASSURANCE = [
1519
+ "no risk",
1520
+ "never locked in",
1521
+ "nothing goes wrong",
1522
+ "no hidden fees",
1523
+ "never pay",
1524
+ "without the hassle",
1525
+ "no surprises"
1526
+ ];
1527
+ var VAGUE_WORDS = ["innovative", "scalable", "end-to-end", "streamline", "solutions"];
1528
+ var REVIEW_PHRASES = [
1529
+ "great question",
1530
+ "of course!",
1531
+ "absolutely!",
1532
+ "absolutely,",
1533
+ "truly",
1534
+ "genuinely",
1535
+ "deeply",
1536
+ "ultimately",
1537
+ "one-size-fits-all",
1538
+ "one size fits all",
1539
+ "let me know if you'd",
1540
+ "the single most",
1541
+ "by far the most",
1542
+ "hands down the",
1543
+ "arguably the best",
1544
+ "bar none"
1545
+ ];
1546
+ var NEGATION = [
1547
+ // "It's not just a haircut, it's an experience."
1548
+ /\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,
1549
+ /\bmore\s+than\s+just\b/i,
1550
+ // copular: "It's not a website, it's a growth engine"
1551
+ /\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,
1552
+ // verbal: "I don't teach the treatment you'd choose: I teach the standard"
1553
+ /\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,
1554
+ /\bnot\s+(?:just|merely|simply)\b/i,
1555
+ // "X isn't a job, it's art"
1556
+ /\b(?:is|are|was|were)(?:n’t|n't)\b[^.;]{2,60},\s*(?:it|they|that|this)(?:’s|'s|’re|'re)\b/i,
1557
+ /\bless\s+about\b[^.;]{2,50}\bmore\s+about\b/i,
1558
+ // parallel participle, no repeated subject: "dispatched from here, not shipped in from Seoul"
1559
+ /\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i
1560
+ ];
1561
+ 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;
1562
+ var SIGNED_IN = /(?:^|\/)(?:admin|portal|dashboard|embedded)\//;
1563
+ function lineAround(text, index) {
1564
+ const start = text.lastIndexOf("\n", index) + 1;
1565
+ const end = text.indexOf("\n", index);
1566
+ return text.slice(start, end === -1 ? text.length : end);
1567
+ }
1568
+ var emptyState = (block, match, index = 0) => SIGNED_IN.test(block.path) || DATA_ABSENCE.test(isProsePath(block.path) ? lineAround(block.text, index) : block.text) || DATA_ABSENCE.test(match);
1569
+ var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
1570
+ var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
1571
+ var EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}]+/u;
1572
+ var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
1573
+ function badgeHits(ctx) {
1574
+ const hits = [];
1575
+ for (const block of ctx.strings) {
1576
+ let text = block.text;
1577
+ let lead = text.length - text.trimStart().length;
1578
+ if (isProsePath(block.path)) {
1579
+ if (/^\s*#{1,6}\s/.test(text) || /[<>{}=;]/.test(text)) continue;
1580
+ const bullet = text.trimStart().match(/^[-*]\s+/);
1581
+ if (bullet) lead += bullet[0].length;
1582
+ text = text.slice(lead);
1583
+ }
1584
+ const candidate = text.trim();
1585
+ if (NO_X_BADGE.test(candidate) && !emptyState(block, candidate)) {
1586
+ hits.push({ path: block.path, offset: block.offset + lead, message: quoted(candidate) });
1587
+ }
1588
+ }
1589
+ return hits;
1590
+ }
1591
+ var COPY_TELLS = [
1592
+ {
1593
+ id: "ai-vocabulary",
1594
+ name: "AI vocabulary",
1595
+ generation: 1,
1596
+ severity: "warn",
1597
+ surface: "copy",
1598
+ why: "Tapestry, elevate, nestled, unparalleled: words that appear in generated copy far more than in anything a business owner writes.",
1599
+ fix: "Say the plain thing. 'Elevate your look' becomes what actually happens: 'Gel that lasts three weeks'.",
1600
+ detect: phrases([wordList(AI_WORDS), /\bunlock\b(?!\s+(?:your|the power))/i], quoted),
1601
+ fixtures: {
1602
+ flag: [f("content/home.md", "Elevate your nails in our bustling studio."), f("content/home.md", "We unlock it for you.")],
1603
+ pass: [f("content/home.md", "Gel nails that last three weeks. Book online in a minute.")]
1604
+ }
1605
+ },
1606
+ {
1607
+ id: "stock-phrase",
1608
+ name: "Stock phrase",
1609
+ generation: 1,
1610
+ severity: "warn",
1611
+ surface: "copy",
1612
+ why: "Phrases every generated services page uses. They fill space where a fact should be.",
1613
+ fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
1614
+ detect: phrases([wordList(STOCK_PHRASES)], quoted),
1615
+ fixtures: {
1616
+ flag: [f("content/home.md", "Take your plumbing to the next level with peace of mind.")],
1617
+ pass: [f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley.")]
1618
+ }
1619
+ },
1620
+ {
1621
+ id: "ai-phrase",
1622
+ name: "AI phrase",
1623
+ generation: 1,
1624
+ severity: "warn",
1625
+ surface: "copy",
1626
+ 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.",
1627
+ fix: "Cut the phrase and start with the point it was introducing. 'Seamless booking' becomes 'Book in three taps'.",
1628
+ detect: phrases(
1629
+ [
1630
+ wordList(AI_PHRASES),
1631
+ /\bin today['’]?s (?:rapidly )?(?:evolving|changing|fast-paced)\b/i,
1632
+ /\bwhether you['’]re an? [^.,;]{2,30} or\b/i
1633
+ ],
1634
+ quoted
1635
+ ),
1636
+ fixtures: {
1637
+ flag: [
1638
+ f("content/home.md", "Let's dive in to what we do."),
1639
+ f("content/home.md", "In today's rapidly evolving market, we help."),
1640
+ f("content/home.md", "Whether you're a landlord or a tenant, call us.")
1641
+ ],
1642
+ pass: [f("content/home.md", "Book in three taps. We confirm by text within the hour.")]
1643
+ }
1644
+ },
1645
+ {
1646
+ id: "plainer-word",
1647
+ name: "A plainer word exists",
1648
+ generation: 1,
1649
+ severity: "warn",
1650
+ surface: "copy",
1651
+ why: "Empower, leverage, harness, delve: each stands in for a plainer verb, and the swap is a reliable sign nobody chose the word.",
1652
+ fix: "Name the mechanism instead. 'Empower your team' becomes 'Your team can publish without us'.",
1653
+ detect: phrases([wordList(PLAINER_WORDS)], quoted),
1654
+ fixtures: {
1655
+ flag: [f("content/home.md", "We empower small firms to grow.")],
1656
+ pass: [f("content/home.md", "Small firms can publish without waiting for us.")]
1657
+ }
1658
+ },
1659
+ {
1660
+ id: "plain-english",
1661
+ name: "'Plain English'",
1662
+ generation: 2,
1663
+ severity: "warn",
1664
+ surface: "copy",
1665
+ why: "Told to avoid jargon, a model announces that it is avoiding jargon. 'Explained plainly' swaps a synonym and keeps the tell.",
1666
+ fix: "Use the house replacement, 'properly explained', or drop the claim and let the copy show it.",
1667
+ detect: phrases([/\b(?:plain english|explained plainly|plainly explained)\b/i], quoted),
1668
+ fixtures: {
1669
+ flag: [f("content/home.md", "Your options, in plain English.")],
1670
+ pass: [f("content/home.md", "Your options, properly explained.")]
1671
+ }
1672
+ },
1673
+ {
1674
+ id: "buzzword",
1675
+ name: "Buzzword",
1676
+ generation: 1,
1677
+ severity: "warn",
1678
+ surface: "copy",
1679
+ 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.",
1680
+ fix: "Say what is actually true. 'World-class service' becomes 'We answer the phone until 8pm'. 'Reach out' becomes 'get in touch'.",
1681
+ detect: phrases([wordList(BUZZWORDS)], quoted),
1682
+ fixtures: {
1683
+ flag: [f("content/home.md", "Reach out for world-class plumbing.")],
1684
+ pass: [f("content/home.md", "Get in touch. We answer until 8pm.")]
1685
+ }
1686
+ },
1687
+ {
1688
+ id: "negative-reassurance",
1689
+ name: "Negative reassurance",
1690
+ generation: 1,
1691
+ severity: "warn",
1692
+ surface: "copy",
1693
+ 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.",
1694
+ fix: "Say what they get: 'The price on the quote is the price you pay', 'Cancel any month'.",
1695
+ detect: phrases([wordList(NEGATIVE_REASSURANCE)], quoted),
1696
+ fixtures: {
1697
+ flag: [f("content/home.md", "Clear pricing with no hidden fees.")],
1698
+ pass: [f("content/home.md", "The price on the quote is the price you pay.")]
1699
+ }
1700
+ },
1701
+ {
1702
+ id: "vague-word",
1703
+ name: "Vague word",
1704
+ generation: 1,
1705
+ severity: "warn",
1706
+ surface: "copy",
1707
+ why: "Innovative, scalable, end-to-end, solutions: each can be true, and each is used where the writer had nothing specific to say.",
1708
+ fix: "Replace it with the specific thing. 'Innovative solutions' becomes the actual product and what it does.",
1709
+ detect: phrases([wordList(VAGUE_WORDS)], quoted),
1710
+ fixtures: {
1711
+ flag: [f("content/home.md", "Innovative solutions for every business.")],
1712
+ pass: [f("content/home.md", "Rewires, fuse boards and EV chargers across Northamptonshire.")]
1713
+ }
1714
+ },
1715
+ {
1716
+ id: "review-phrase",
1717
+ name: "Review-tier phrase",
1718
+ generation: 1,
1719
+ severity: "warn",
1720
+ surface: "copy",
1721
+ 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.",
1722
+ fix: "Keep the one that lands hardest and cut the rest. 'What is genuinely excellent' reads better as 'what is excellent'.",
1723
+ 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),
1724
+ fixtures: {
1725
+ flag: [f("content/home.md", "We are truly proud of it.")],
1726
+ pass: [f("content/home.md", "We have fitted 400 boilers since 2009.")]
1727
+ }
1728
+ },
1729
+ {
1730
+ id: "hollow-imperative",
1731
+ name: "Hollow imperative",
1732
+ generation: 1,
1733
+ severity: "warn",
1734
+ surface: "copy",
1735
+ why: "'Discover', 'Experience', 'Transform your' as the opening verb of a heading or button is a call to action with nothing in it.",
1736
+ fix: "Name the action and the outcome: 'Book a 45-minute facial', not 'Experience the difference'.",
1737
+ detect: phrases(
1738
+ [/(?:^|[.!?]\s+|\n\s*|>\s*)(?:Discover|Experience|Explore|Transform|Indulge in|Embrace|Unlock|Elevate)\s+(?:the|your|our|a|true|real)\b[^.!?\n<]{0,40}/],
1739
+ (m) => `"${m.replace(/^[.!?>\s]+/, "")}"`
1740
+ ),
1741
+ fixtures: {
1742
+ flag: [f("content/home.md", "Experience the difference today.")],
1743
+ pass: [f("content/home.md", "Book a 45-minute facial.")]
1744
+ }
1745
+ },
1746
+ {
1747
+ id: "rhetorical-opener",
1748
+ name: "Rhetorical question opener",
1749
+ generation: 1,
1750
+ severity: "warn",
1751
+ surface: "copy",
1752
+ why: "'Looking for...?', 'Tired of...?', 'Ready to...?' opens with the model guessing at the reader instead of telling them something.",
1753
+ fix: "Open with the answer.",
1754
+ 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]+/, "")}"`),
1755
+ fixtures: {
1756
+ flag: [f("content/home.md", "Looking for a reliable plumber?")],
1757
+ pass: [f("content/home.md", "A plumber in Brackley, on call until 10pm.")]
1758
+ }
1759
+ },
1760
+ {
1761
+ id: "em-dash",
1762
+ name: "Em dash",
1763
+ generation: 2,
1764
+ severity: "warn",
1765
+ surface: "copy",
1766
+ why: "Generated copy leans on the em dash to join clauses. House copy does not use it.",
1767
+ fix: "Use a full stop, a comma, or a colon. Two sentences are clearer than one sentence with a dash in it.",
1768
+ detect: phrases([/\S{0,20}\s?—\s?\S{0,20}/], quoted, (ctx) => ctx.strings, (block) => placeholderDash(block)),
1769
+ fixtures: {
1770
+ flag: [
1771
+ f("content/home.md", "Walk-ins welcome \u2014 just ring first."),
1772
+ f("app/hours.tsx", `export const hours = { weekdays: "Mon \u2014 Fri" };`)
1773
+ ],
1774
+ pass: [
1775
+ f("content/home.md", "Walk-ins welcome. Ring first. Open 9-5."),
1776
+ f("app/row.tsx", `export const Cell = ({ v }) => <td>{v ?? "\u2014"}</td>;`),
1777
+ f("app/row.tsx", `export const Empty = () => <td className="muted">\u2014</td>;`)
1778
+ ]
1779
+ }
1780
+ },
1781
+ {
1782
+ id: "emoji",
1783
+ name: "Emoji in copy",
1784
+ generation: 1,
1785
+ severity: "warn",
1786
+ surface: "copy",
1787
+ 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.",
1788
+ fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
1789
+ detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
1790
+ fixtures: {
1791
+ flag: [f("content/home.md", "New treatments this month \u2728\u{1F680}"), f("app/cta.tsx", `export const label = "Book \u{1F680}";`)],
1792
+ pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192")]
1793
+ }
1794
+ },
1795
+ {
1796
+ id: "not-just-but",
1797
+ name: "Contrastive negation",
1798
+ generation: 2,
1799
+ severity: "warn",
1800
+ surface: "copy",
1801
+ 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.",
1802
+ fix: "Say the positive claim and drop the contrast.",
1803
+ detect: phrases(NEGATION, quoted, allText),
1804
+ fixtures: {
1805
+ flag: [
1806
+ f("content/home.md", "It's not just a haircut, it's an experience."),
1807
+ f("content/home.md", "It's not a website, it's a growth engine."),
1808
+ f("content/home.md", "I don't teach the treatment you'd choose: I teach the standard."),
1809
+ f("content/home.md", "Hairdressing isn't a job, it's art."),
1810
+ f("content/home.md", "It is less about the tools and more about the eye."),
1811
+ f("content/home.md", "Order today and it's dispatched from here, not shipped in from Seoul."),
1812
+ f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`)
1813
+ ],
1814
+ pass: [
1815
+ f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
1816
+ f("content/home.md", "They are cosmetics, not medicines."),
1817
+ f("content/home.md", "The box arrived red, not led astray by a dented corner.")
1818
+ ]
1819
+ }
1820
+ },
1821
+ {
1822
+ id: "no-x-no-y",
1823
+ name: "'No X, no Y' list",
1824
+ generation: 2,
1825
+ severity: "warn",
1826
+ surface: "copy",
1827
+ why: "'No obligation, no spam.' Defining the business by what it is not, itemised, is the same template as contrastive negation.",
1828
+ fix: "Say what they get: 'We reply within a day and only about your enquiry'.",
1829
+ detect: phrases([NO_X_NO_Y], quoted, allText, emptyState),
1830
+ fixtures: {
1831
+ flag: [
1832
+ f("content/home.md", "We reply within a day. No obligation, no spam."),
1833
+ f("app/hero.tsx", `export const Hero = () => <p className="mt-4">We reply within a day. No
1834
+ obligation, no spam.</p>;`)
1835
+ ],
1836
+ pass: [
1837
+ f("content/home.md", "We reply within a day, and only about your enquiry."),
1838
+ f("app/admin/list.tsx", `export const Empty = () => <p>No clients, no bookings.</p>;`),
1839
+ f("app/list.tsx", `export const Empty = () => <p>No orders yet, no invoices found.</p>;`)
1840
+ ]
1841
+ }
1842
+ },
1843
+ {
1844
+ id: "no-x-badge",
1845
+ name: "'No X' badge",
1846
+ generation: 2,
1847
+ severity: "warn",
1848
+ surface: "copy",
1849
+ 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.",
1850
+ fix: "Replace it with the positive fact it hints at, or cut it.",
1851
+ detect: badgeHits,
1852
+ fixtures: {
1853
+ flag: [f("app/badge.tsx", `export const kicker = "No catch.";`), f("content/home.md", "Straight answers.\n\nNo jargon guide\n")],
1854
+ pass: [
1855
+ f("app/list.tsx", `export const empty = "No results found.";`),
1856
+ f("app/portal/list.tsx", `export const empty = "No bookings";`),
1857
+ f("content/home.md", "### No clarity on ideal client\n")
1858
+ ]
1859
+ }
1860
+ },
1861
+ {
1862
+ id: "staccato-triplet",
1863
+ name: "Staccato triplet",
1864
+ generation: 2,
1865
+ severity: "warn",
1866
+ surface: "copy",
1867
+ why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
1868
+ fix: "Write one sentence that says which of the three is true, and how you know.",
1869
+ detect: phrases([/(?:\b[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!]\s+){2}[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
1870
+ fixtures: {
1871
+ flag: [f("content/home.md", "No fuss. No jargon. Just results.")],
1872
+ pass: [f("content/home.md", "We fix boilers the same day, and we tell you the price before we start.")]
1873
+ }
1874
+ },
1875
+ {
1876
+ id: "where-x-meets-y",
1877
+ name: "'Where X meets Y'",
1878
+ generation: 2,
1879
+ severity: "warn",
1880
+ surface: "copy",
1881
+ why: "'Where luxury meets comfort' is a tagline shape that fits every business and so describes none.",
1882
+ fix: "Say what the place is and where it is.",
1883
+ detect: phrases([/\bwhere\s+[\w'’-]+(?:\s+[\w'’-]+)?\s+meets?\s+[\w'’-]+/i], quoted),
1884
+ fixtures: {
1885
+ flag: [f("content/home.md", "Where luxury meets comfort.")],
1886
+ pass: [f("content/home.md", "A two-chair studio above the bakery on Market Place.")]
1887
+ }
1888
+ }
1889
+ ];
1890
+
1891
+ // src/character/color.ts
1892
+ 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;
1893
+ function num(token, percentOf) {
1894
+ const t = token.trim();
1895
+ if (t.endsWith("%")) return parseFloat(t) / 100 * percentOf;
1896
+ return parseFloat(t);
1897
+ }
1898
+ function parts(fn) {
1899
+ const inner = fn.slice(fn.indexOf("(") + 1, -1).trim();
1900
+ const [main, slashAlpha] = inner.split("/");
1901
+ const channels = main.split(/[\s,]+/).filter(Boolean);
1902
+ let alpha = 1;
1903
+ if (slashAlpha !== void 0) alpha = num(slashAlpha, 1);
1904
+ else if (channels.length === 4) alpha = num(channels.pop(), 1);
1905
+ return { channels, alpha: Number.isFinite(alpha) ? alpha : 1 };
1906
+ }
1907
+ function hslToRgb(h, s, l) {
1908
+ const k = (n) => (n + h / 30) % 12;
1909
+ const a = s * Math.min(l, 1 - l);
1910
+ const f3 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
1911
+ return { r: f3(0), g: f3(8), b: f3(4) };
1912
+ }
1913
+ var clamp012 = (n) => Math.min(1, Math.max(0, n));
1914
+ function parseColour(raw) {
1915
+ try {
1916
+ if (raw.startsWith("#")) {
1917
+ const hex2 = raw.slice(1);
1918
+ const alpha2 = hex2.length === 8 ? parseInt(hex2.slice(6), 16) / 255 : hex2.length === 4 ? parseInt(hex2[3] + hex2[3], 16) / 255 : 1;
1919
+ return { oklch: hexToOklch(raw), alpha: alpha2 };
1920
+ }
1921
+ const fn = raw.slice(0, raw.indexOf("(")).toLowerCase();
1922
+ if (fn === "color") {
1923
+ const { channels: channels2, alpha: alpha2 } = parts(raw.replace(/^color\(\s*srgb\s+/i, "srgb("));
1924
+ const [r, g, b] = channels2.map((c) => clamp012(num(c, 1)));
1925
+ if (![r, g, b].every(Number.isFinite)) return null;
1926
+ return { oklch: hexToOklch(formatHex({ r, g, b })), alpha: alpha2 };
1927
+ }
1928
+ const { channels, alpha } = parts(raw);
1929
+ if (channels.length < 3 || channels.some((c) => c.startsWith("var"))) return null;
1930
+ if (fn === "oklch") {
1931
+ const l = num(channels[0], 1);
1932
+ const c = num(channels[1], 0.4);
1933
+ const h = parseFloat(channels[2]);
1934
+ if (![l, c].every(Number.isFinite)) return null;
1935
+ return { oklch: { l, c, h: Number.isFinite(h) ? h : 0 }, alpha };
1936
+ }
1937
+ if (fn === "oklab") {
1938
+ const l = num(channels[0], 1);
1939
+ const a = num(channels[1], 0.4);
1940
+ const b = num(channels[2], 0.4);
1941
+ if (![l, a, b].every(Number.isFinite)) return null;
1942
+ const c = Math.sqrt(a * a + b * b);
1943
+ const h = c < 1e-7 ? 0 : (Math.atan2(b, a) * 180 / Math.PI % 360 + 360) % 360;
1944
+ return { oklch: { l, c, h }, alpha };
1945
+ }
1946
+ if (fn === "rgb" || fn === "rgba") {
1947
+ const [r, g, b] = channels.map((c) => clamp012(num(c, 255) / 255));
1948
+ if (![r, g, b].every(Number.isFinite)) return null;
1949
+ return { oklch: hexToOklch(formatHex({ r, g, b })), alpha };
1950
+ }
1951
+ if (fn === "hsl" || fn === "hsla") {
1952
+ const h = parseFloat(channels[0]);
1953
+ const s = num(channels[1], 1);
1954
+ const l = num(channels[2], 1);
1955
+ if (![h, s, l].every(Number.isFinite)) return null;
1956
+ const rgb = hslToRgb((h % 360 + 360) % 360, clamp012(s), clamp012(l));
1957
+ return { oklch: hexToOklch(formatHex(rgb)), alpha };
1958
+ }
1959
+ } catch {
1960
+ return null;
1961
+ }
1962
+ return null;
1963
+ }
1964
+ function findColours(value) {
1965
+ const out = [];
1966
+ for (const m of value.matchAll(COLOUR)) {
1967
+ const parsed = parseColour(m[0]);
1968
+ if (parsed) out.push({ raw: m[0], ...parsed, index: m.index ?? 0 });
1969
+ }
1970
+ return out;
1971
+ }
1972
+ var AI_VIOLET = { hueMin: 268, hueMax: 312, minChroma: 0.1, minL: 0.3, maxL: 0.85 };
1973
+ function isAiViolet({ l, c, h }) {
1974
+ return c >= AI_VIOLET.minChroma && h >= AI_VIOLET.hueMin && h <= AI_VIOLET.hueMax && l >= AI_VIOLET.minL && l <= AI_VIOLET.maxL;
1975
+ }
1976
+ function isCream({ l, c, h }) {
1977
+ return l >= 0.9 && l <= 0.985 && c >= 8e-3 && c <= 0.06 && h >= 55 && h <= 105;
1978
+ }
1979
+ function isBlueToPurple({ c, h }) {
1980
+ return c >= 0.08 && h >= 240 && h <= 330;
1981
+ }
1982
+
1983
+ // src/character/tells/source.ts
1984
+ function baseClass(cls) {
1985
+ let depth = 0;
1986
+ let cut = 0;
1987
+ for (let i = 0; i < cls.length; i += 1) {
1988
+ if (cls[i] === "[") depth += 1;
1989
+ else if (cls[i] === "]") depth -= 1;
1990
+ else if (cls[i] === ":" && depth === 0) cut = i + 1;
1991
+ }
1992
+ return cls.slice(cut).replace(/^!/, "");
1993
+ }
1994
+ function bases(unit) {
1995
+ return unit.classes.map(baseClass);
1996
+ }
1997
+ function has(unit, pattern) {
1998
+ return bases(unit).some((c) => pattern.test(c));
1999
+ }
2000
+ function find(unit, pattern) {
2001
+ return bases(unit).find((c) => pattern.test(c));
2002
+ }
2003
+ function arbitrary(cls) {
2004
+ const m = cls.match(/\[(.*)\]$/);
2005
+ return m ? m[1].replace(/_/g, " ") : null;
2006
+ }
2007
+ function eachUnit(ctx, fn) {
2008
+ const hits = [];
2009
+ for (const file of ctx.files) {
2010
+ for (const unit of file.classUnits) {
2011
+ const message = fn(unit, file);
2012
+ if (message) hits.push({ path: file.path, offset: unit.offset, message });
2013
+ }
2014
+ }
2015
+ return hits;
2016
+ }
2017
+ function firstMatch(text, pattern) {
2018
+ const m = pattern.exec(text);
2019
+ pattern.lastIndex = 0;
2020
+ return m ? m.index : -1;
2021
+ }
2022
+ var FRAMEWORK_PALETTE = /^--(?:color-)?(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+$|^--tw-/;
2023
+ var f2 = (path, text) => ({ path, text });
2024
+ function familyNames(value) {
2025
+ return value.split(",").map((v) => v.trim().replace(/^["']|["']$/g, "").trim()).filter(Boolean);
2026
+ }
2027
+ function fontMentions(file) {
2028
+ const out = [];
2029
+ for (const d of file.declarations) {
2030
+ if (d.property === "font-family" || d.property === "font" || d.property.startsWith("--font")) {
2031
+ for (const name of familyNames(d.value)) out.push({ name, offset: d.offset });
2032
+ }
2033
+ }
2034
+ const text = file.text;
2035
+ for (const m of text.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']next\/font\/google["']/g)) {
2036
+ for (const spec of m[1].split(",")) {
2037
+ const name = spec.trim().split(/\s+as\s+/)[0].replace(/_/g, " ").trim();
2038
+ if (name) out.push({ name, offset: m.index ?? 0 });
2039
+ }
2040
+ }
2041
+ for (const m of text.matchAll(/fonts\.googleapis\.com\/css2?\?[^"'\s)>]*/g)) {
2042
+ for (const fam of m[0].matchAll(/family=([^&:;]+)/g)) {
2043
+ out.push({ name: decodeURIComponent(fam[1]).replace(/\+/g, " "), offset: m.index ?? 0 });
2044
+ }
2045
+ }
2046
+ for (const m of text.matchAll(/@fontsource(?:-variable)?\/([a-z0-9-]+)/g)) {
2047
+ out.push({ name: m[1].replace(/-/g, " "), offset: m.index ?? 0 });
2048
+ }
2049
+ for (const m of text.matchAll(/\bfont-\[([^\]]+)\]/g)) {
2050
+ for (const name of familyNames(m[1].replace(/_/g, " "))) out.push({ name, offset: m.index ?? 0 });
2051
+ }
2052
+ if (file.kind === "script" || file.kind === "markup") {
2053
+ for (const m of text.matchAll(/fontFamily\s*[:=]\s*(\{[\s\S]*?\}|\[[^\]]*\]|["'][^"']*["'])/g)) {
2054
+ for (const lit of m[1].matchAll(/["']([^"']+)["']/g)) {
2055
+ for (const name of familyNames(lit[1])) out.push({ name, offset: (m.index ?? 0) + (lit.index ?? 0) });
2056
+ }
2057
+ }
2058
+ }
2059
+ return out;
2060
+ }
2061
+ function fontTell(fonts) {
2062
+ const wanted = new Map(fonts.map((n) => [n.toLowerCase(), n]));
2063
+ return (ctx) => {
2064
+ const hits = [];
2065
+ for (const file of ctx.files) {
2066
+ const seen = /* @__PURE__ */ new Set();
2067
+ for (const { name, offset } of fontMentions(file)) {
2068
+ const canonical = wanted.get(name.toLowerCase());
2069
+ if (!canonical || seen.has(canonical)) continue;
2070
+ seen.add(canonical);
2071
+ hits.push({ path: file.path, offset, message: `${canonical} is a reflex font` });
2072
+ }
2073
+ }
2074
+ return hits;
2075
+ };
2076
+ }
2077
+ var REFLEX_FONTS_1 = ["Inter", "Roboto", "Open Sans", "Poppins", "Montserrat", "Lato"];
2078
+ var REFLEX_FONTS_2 = [
2079
+ "Inter Tight",
2080
+ "DM Sans",
2081
+ "Manrope",
2082
+ "Space Grotesk",
2083
+ "Plus Jakarta Sans",
2084
+ "Outfit",
2085
+ "Geist",
2086
+ "Sora",
2087
+ "Instrument Sans",
2088
+ "Instrument Serif",
2089
+ "Fraunces",
2090
+ "Playfair Display",
2091
+ "Cormorant Garamond",
2092
+ "DM Serif Display"
2093
+ ];
2094
+ 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.[\]]+)?$/;
2095
+ function violetHits(ctx) {
2096
+ const hits = [];
2097
+ for (const file of ctx.files) {
2098
+ const seen = /* @__PURE__ */ new Set();
2099
+ const push = (key, offset, message) => {
2100
+ if (seen.has(key)) return;
2101
+ seen.add(key);
2102
+ hits.push({ path: file.path, offset, message });
2103
+ };
2104
+ for (const unit of file.classUnits) {
2105
+ for (const cls of bases(unit)) {
2106
+ const tw = cls.match(TW_VIOLET);
2107
+ if (tw) push(`${tw[1]}-${tw[2]}`, unit.offset, `${tw[1]}-${tw[2]} is the default AI accent`);
2108
+ const arb = arbitrary(cls);
2109
+ if (arb) {
2110
+ for (const c of findColours(arb)) {
2111
+ 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)`);
2112
+ }
2113
+ }
2114
+ }
2115
+ }
2116
+ for (const d of file.declarations) {
2117
+ if (FRAMEWORK_PALETTE.test(d.property)) continue;
2118
+ for (const c of findColours(d.value)) {
2119
+ if (c.alpha >= 0.5 && isAiViolet(c.oklch)) {
2120
+ push(c.raw.toLowerCase(), d.offset, `${c.raw} sits in the indigo-violet band (h ${c.oklch.h.toFixed(0)}\xB0)`);
2121
+ }
2122
+ }
2123
+ }
2124
+ }
2125
+ return hits;
2126
+ }
2127
+ var BLUEISH = /^(?:blue|indigo|sky|cyan)$/;
2128
+ var PURPLEISH = /^(?:violet|purple|fuchsia|pink)$/;
2129
+ function gradientHits(ctx) {
2130
+ const hits = eachUnit(ctx, (unit) => {
2131
+ if (!has(unit, /^bg-(?:gradient-to|linear|radial|conic)/)) return null;
2132
+ const stops = bases(unit).map((c) => c.match(/^(?:from|via|to)-([a-z]+)-\d+/)?.[1]).filter((x) => Boolean(x));
2133
+ if (stops.some((s) => BLUEISH.test(s)) && stops.some((s) => PURPLEISH.test(s))) {
2134
+ return `gradient from ${stops.join(" to ")}`;
2135
+ }
2136
+ return null;
2137
+ });
2138
+ for (const file of ctx.files) {
2139
+ for (const d of file.declarations) {
2140
+ if (!/gradient\(/.test(d.value) || FRAMEWORK_PALETTE.test(d.property)) continue;
2141
+ const colours = findColours(d.value).filter((c) => isBlueToPurple(c.oklch));
2142
+ if (colours.length < 2) continue;
2143
+ const hues = colours.map((c) => c.oklch.h);
2144
+ if (Math.max(...hues) - Math.min(...hues) >= 25) {
2145
+ hits.push({ path: file.path, offset: d.offset, message: `gradient from ${colours.map((c) => c.raw).join(" to ")}` });
2146
+ }
2147
+ }
2148
+ }
2149
+ return hits;
2150
+ }
2151
+ var CREAM_CLASS = /^bg-(?:cream|ivory|parchment|linen|sand|oat|bone|eggshell)(?:-\d+)?(?:\/\d+)?$/;
2152
+ var BG_PROPERTY = /background|^--(?:color-)?(?:bg|background|surface|canvas|paper|cream|base|page)/;
2153
+ function creamHits(ctx) {
2154
+ const hits = [];
2155
+ for (const file of ctx.files) {
2156
+ const seen = /* @__PURE__ */ new Set();
2157
+ const push = (key, offset, message) => {
2158
+ if (seen.has(key)) return;
2159
+ seen.add(key);
2160
+ hits.push({ path: file.path, offset, message });
2161
+ };
2162
+ for (const unit of file.classUnits) {
2163
+ for (const cls of bases(unit)) {
2164
+ if (CREAM_CLASS.test(cls)) push(cls, unit.offset, `${cls} is a cream ground`);
2165
+ const arb = cls.startsWith("bg-") ? arbitrary(cls) : null;
2166
+ if (arb) {
2167
+ for (const c of findColours(arb)) if (isCream(c.oklch)) push(c.raw.toLowerCase(), unit.offset, `${c.raw} is a cream ground`);
2168
+ }
2169
+ }
2170
+ }
2171
+ for (const d of file.declarations) {
2172
+ if (!BG_PROPERTY.test(d.property) || FRAMEWORK_PALETTE.test(d.property)) continue;
2173
+ for (const c of findColours(d.value)) {
2174
+ if (c.alpha >= 0.9 && isCream(c.oklch)) push(c.raw.toLowerCase(), d.offset, `${c.raw} is a cream ground`);
2175
+ }
2176
+ }
2177
+ }
2178
+ return hits;
2179
+ }
2180
+ function glassHits(ctx) {
2181
+ const hits = eachUnit(
2182
+ ctx,
2183
+ (unit) => has(unit, /^backdrop-blur/) && has(unit, /^bg-[a-z]+(?:-\d+)?\/(?:\d+|\[[\d.]+\])$/) ? "translucent blurred panel" : null
2184
+ );
2185
+ for (const file of ctx.files) {
2186
+ for (const d of file.declarations) {
2187
+ if (/^(?:-webkit-)?backdrop-filter$/.test(d.property) && /blur\(/.test(d.value)) {
2188
+ hits.push({ path: file.path, offset: d.offset, message: "backdrop blur panel" });
2189
+ }
2190
+ }
2191
+ }
2192
+ return hits;
2193
+ }
2194
+ function gradientTextHits(ctx) {
2195
+ const hits = eachUnit(
2196
+ ctx,
2197
+ (unit) => has(unit, /^bg-clip-text$/) && has(unit, /^text-transparent$/) ? "gradient-filled text" : null
2198
+ );
2199
+ for (const file of ctx.files) {
2200
+ for (const d of file.declarations) {
2201
+ if (/^(?:-webkit-)?background-clip$/.test(d.property) && /\btext\b/.test(d.value)) {
2202
+ hits.push({ path: file.path, offset: d.offset, message: "gradient-filled text" });
2203
+ }
2204
+ }
2205
+ }
2206
+ return hits;
2207
+ }
2208
+ function thinBorderWideShadowHits(ctx) {
2209
+ const hits = eachUnit(ctx, (unit) => {
2210
+ const faint = has(unit, /^border-(?:black|white)\/(?:[1-9]|10|\[0?\.\d+\])$|^border-(?:gray|slate|zinc|neutral|stone)-(?:50|100|200)(?:\/\d+)?$/);
2211
+ const wide = has(unit, /^shadow-(?:xl|2xl)$/) || bases(unit).some((c) => c.startsWith("shadow-[") && Number((arbitrary(c) ?? "").split(/\s+/)[2]?.replace("px", "")) >= 24);
2212
+ return faint && wide && has(unit, /^border$/) ? "hairline border under a wide soft shadow" : null;
2213
+ });
2214
+ for (const file of ctx.files) {
2215
+ const bySelector = /* @__PURE__ */ new Map();
2216
+ for (const d of file.declarations) {
2217
+ const entry = bySelector.get(d.selector) ?? { offset: d.offset };
2218
+ if (/^border(?:-top|-bottom)?$/.test(d.property) && /\b1px\b/.test(d.value)) {
2219
+ const c = findColours(d.value)[0];
2220
+ if (c && c.alpha <= 0.12) entry.border = d.offset;
2221
+ }
2222
+ if (d.property === "box-shadow") {
2223
+ 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);
2224
+ if (blur >= 24) entry.shadow = d.offset;
2225
+ }
2226
+ bySelector.set(d.selector, entry);
2227
+ }
2228
+ for (const entry of bySelector.values()) {
2229
+ if (entry.border !== void 0 && entry.shadow !== void 0) {
2230
+ hits.push({ path: file.path, offset: entry.border, message: "hairline border under a wide soft shadow" });
2231
+ }
2232
+ }
2233
+ }
2234
+ return hits;
2235
+ }
2236
+ function glowHits(ctx) {
2237
+ const hits = eachUnit(ctx, (unit) => {
2238
+ if (bases(unit).some((c) => c.startsWith("bg-[radial-gradient"))) return "radial spotlight behind content";
2239
+ 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-)/);
2240
+ return blob ? "blurred glow blob" : null;
2241
+ });
2242
+ for (const file of ctx.files) {
2243
+ for (const d of file.declarations) {
2244
+ if (!/^background(?:-image)?$/.test(d.property) || !/radial-gradient\(/.test(d.value)) continue;
2245
+ const colours = findColours(d.value);
2246
+ if (/transparent/.test(d.value) || colours.some((c) => c.alpha < 0.5)) {
2247
+ hits.push({ path: file.path, offset: d.offset, message: "radial spotlight fading to transparent" });
2248
+ }
2249
+ }
2250
+ }
2251
+ return hits;
2252
+ }
2253
+ function gridBackgroundHits(ctx) {
2254
+ const hits = eachUnit(ctx, (unit) => {
2255
+ if (has(unit, /^bg-(?:grid|dot|dots|graph)(?:-|$)/)) return "decorative grid background";
2256
+ const arb = bases(unit).filter((c) => c.startsWith("bg-[") || c.startsWith("[background")).map((c) => arbitrary(c) ?? "");
2257
+ const lines = arb.join(" ").match(/(?:linear|radial)-gradient\([^)]*\b1px\b/g) ?? [];
2258
+ return lines.length > 0 ? "decorative grid background" : null;
2259
+ });
2260
+ for (const file of ctx.files) {
2261
+ for (const d of file.declarations) {
2262
+ if (!/^background(?:-image)?$/.test(d.property)) continue;
2263
+ const lines = d.value.match(/(?:linear|radial)-gradient\([^;]*?\b1px\b/g) ?? [];
2264
+ if (lines.length >= 1 && /gradient\([\s\S]*gradient\(/.test(d.value)) {
2265
+ hits.push({ path: file.path, offset: d.offset, message: "decorative grid background" });
2266
+ }
2267
+ }
2268
+ }
2269
+ return hits;
2270
+ }
2271
+ function italicSerifHits(ctx) {
2272
+ const hits = eachUnit(
2273
+ ctx,
2274
+ (unit) => has(unit, /^italic$/) && has(unit, /^font-(?:serif|display|heading|title|\[)/) && !has(unit, /^font-(?:sans|mono)$/) ? "italic serif display" : null
2275
+ );
2276
+ for (const file of ctx.files) {
2277
+ for (const m of file.text.matchAll(/<h[1-3]\b[^>]*>(?:(?!<\/h[1-3]>)[\s\S]){0,200}?<(?:em|i)\b/g)) {
2278
+ hits.push({ path: file.path, offset: m.index ?? 0, message: "italic word set inside a display heading" });
2279
+ }
2280
+ for (const d of file.declarations) {
2281
+ if (d.property === "font-style" && d.value === "italic" && /(?:^|[\s,>])(?:h[1-3]|\.?(?:display|hero|headline|title))\b/i.test(d.selector)) {
2282
+ hits.push({ path: file.path, offset: d.offset, message: `italic display type on ${d.selector}` });
2283
+ }
2284
+ }
2285
+ }
2286
+ return hits;
2287
+ }
2288
+ function eyebrowChipHits(ctx) {
2289
+ const hits = [];
2290
+ for (const file of ctx.files) {
2291
+ const h1 = file.text.search(/<h1\b/);
2292
+ if (h1 === -1) continue;
2293
+ for (const unit of file.classUnits) {
2294
+ if (unit.offset > h1 || h1 - unit.offset > 900) continue;
2295
+ 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)$/);
2296
+ if (chip) hits.push({ path: file.path, offset: unit.offset, message: "pill badge above the hero heading" });
2297
+ }
2298
+ }
2299
+ return hits;
2300
+ }
2301
+ function iconTiles(file) {
2302
+ return file.classUnits.filter((unit) => {
2303
+ 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);
2304
+ return sized && has(unit, /^rounded-(?:md|lg|xl|2xl|full)$/) && has(unit, /^(?:items-center|place-items-center|place-content-center)$/) && has(unit, /^bg-/);
2305
+ });
2306
+ }
2307
+ var ICON_LIBRARY = /from\s+["'](?:lucide-react|@heroicons\/react[^"']*|react-icons[^"']*|@phosphor-icons\/react|@tabler\/icons-react|@radix-ui\/react-icons)["']/;
2308
+ function iconTileStackHits(ctx) {
2309
+ const hits = [];
2310
+ for (const file of ctx.files) {
2311
+ if (!ICON_LIBRARY.test(file.text)) continue;
2312
+ const tiles = iconTiles(file);
2313
+ if (tiles.length > 0) {
2314
+ hits.push({ path: file.path, offset: tiles[0].offset, message: `icon in a tinted tile (${tiles.length} in this file)` });
2315
+ }
2316
+ }
2317
+ return hits;
2318
+ }
2319
+ function iconGridHits(ctx) {
2320
+ const hits = [];
2321
+ for (const file of ctx.files) {
2322
+ if (!ICON_LIBRARY.test(file.text) || iconTiles(file).length === 0) continue;
2323
+ const grid = file.classUnits.find((u) => has(u, /^grid-cols-3$/) && has(u, /^grid$/));
2324
+ if (grid) hits.push({ path: file.path, offset: grid.offset, message: "three-column grid of icon cards" });
2325
+ }
2326
+ return hits;
2327
+ }
2328
+ function bentoHits(ctx) {
2329
+ const hits = [];
2330
+ for (const file of ctx.files) {
2331
+ const all = file.classUnits.flatMap(bases);
2332
+ const colSpan = all.some((c) => /^col-span-[2-3]$/.test(c));
2333
+ const rowSpan = file.classUnits.find((u) => has(u, /^row-span-[2-3]$/));
2334
+ if (colSpan && rowSpan) {
2335
+ hits.push({ path: file.path, offset: rowSpan.offset, message: "mixed column and row spans: a bento grid" });
2336
+ }
2337
+ }
2338
+ return hits;
2339
+ }
2340
+ var WRAPPER = /^(?:Section|Container|Reveal|FadeIn|FadeUp|Fade|Animate\w*|Motion\w*|Suspense|Fragment|Wrapper|Layout|Main|ErrorBoundary)$/;
2341
+ var PROOF = /Trust|Logo|Proof|Stat|Badge|Partner|Client|Marquee|Press|Rating|Review|Testimonial|Accredit|Brands/;
2342
+ function heroThenProofHits(ctx) {
2343
+ const hits = [];
2344
+ for (const file of ctx.files) {
2345
+ if (file.kind !== "markup") continue;
2346
+ const tags = [...file.text.matchAll(/<([A-Z][A-Za-z0-9]*)\b/g)].map((m) => ({ name: m[1], offset: m.index ?? 0 }));
2347
+ const hero = tags.findIndex((t) => /Hero/.test(t.name));
2348
+ if (hero === -1) continue;
2349
+ const next = tags.slice(hero + 1).find((t) => !WRAPPER.test(t.name) && !/Hero/.test(t.name));
2350
+ if (next && PROOF.test(next.name)) {
2351
+ hits.push({ path: file.path, offset: next.offset, message: `${tags[hero].name} is followed straight by ${next.name}` });
2352
+ }
2353
+ }
2354
+ return hits;
2355
+ }
2356
+ var REVEAL_LIMIT = 12;
2357
+ var PILL_LIMIT = 30;
2358
+ var SHADCN_LIMIT = 12;
2359
+ var REVEAL = /<(?:Reveal|ScrollReveal|FadeIn|FadeUp|FadeInUp|SlideIn|SlideUp|AnimateIn|AnimateOnScroll|InView|RevealOnScroll)\b|\bwhileInView\b|\bdata-aos=|\buseInView\s*\(|\bscrollTrigger\s*:/g;
2360
+ function countHits(ctx, perFile, limit, label) {
2361
+ let total = 0;
2362
+ let worst = null;
2363
+ for (const file of ctx.files) {
2364
+ const offsets = perFile(file);
2365
+ total += offsets.length;
2366
+ if (!worst || offsets.length > worst.offsets.length) worst = { file, offsets };
2367
+ }
2368
+ if (total <= limit || !worst || worst.offsets.length === 0) return [];
2369
+ return [{ path: worst.file.path, offset: worst.offsets[0], message: label(total, `${worst.file.path} has ${worst.offsets.length}`) }];
2370
+ }
2371
+ function revealHits(ctx) {
2372
+ return countHits(
2373
+ ctx,
2374
+ (file) => file.kind === "markup" || file.kind === "script" ? [...file.text.matchAll(REVEAL)].map((m) => m.index ?? 0) : [],
2375
+ REVEAL_LIMIT,
2376
+ (n, top) => `${n} scroll reveals (limit ${REVEAL_LIMIT}); ${top}`
2377
+ );
2378
+ }
2379
+ var PILL_RADIUS = /\b(?:9{3,}|[1-9]\d{3,})px\b|\b(?:50|100)vh\b|\b100vmax\b|\b9{3,}rem\b/;
2380
+ function pillHits(ctx) {
2381
+ return countHits(
2382
+ ctx,
2383
+ (file) => [
2384
+ ...file.classUnits.filter((u) => has(u, /^rounded-full$/)).map((u) => u.offset),
2385
+ ...file.declarations.filter((d) => /^border(?:-[a-z]+)*-radius$/.test(d.property) && PILL_RADIUS.test(d.value)).map((d) => d.offset)
2386
+ ],
2387
+ PILL_LIMIT,
2388
+ (n, top) => `${n} fully rounded shapes (limit ${PILL_LIMIT}); ${top}`
2389
+ );
2390
+ }
2391
+ var SHADCN_PRIMITIVES = [
2392
+ "accordion",
2393
+ "alert",
2394
+ "alert-dialog",
2395
+ "aspect-ratio",
2396
+ "avatar",
2397
+ "badge",
2398
+ "breadcrumb",
2399
+ "button",
2400
+ "calendar",
2401
+ "card",
2402
+ "carousel",
2403
+ "chart",
2404
+ "checkbox",
2405
+ "collapsible",
2406
+ "command",
2407
+ "context-menu",
2408
+ "dialog",
2409
+ "drawer",
2410
+ "dropdown-menu",
2411
+ "form",
2412
+ "hover-card",
2413
+ "input",
2414
+ "input-otp",
2415
+ "label",
2416
+ "menubar",
2417
+ "navigation-menu",
2418
+ "pagination",
2419
+ "popover",
2420
+ "progress",
2421
+ "radio-group",
2422
+ "resizable",
2423
+ "scroll-area",
2424
+ "select",
2425
+ "separator",
2426
+ "sheet",
2427
+ "sidebar",
2428
+ "skeleton",
2429
+ "slider",
2430
+ "sonner",
2431
+ "switch",
2432
+ "table",
2433
+ "tabs",
2434
+ "textarea",
2435
+ "toast",
2436
+ "toaster",
2437
+ "toggle",
2438
+ "toggle-group",
2439
+ "tooltip"
2440
+ ];
2441
+ function shadcnHits(ctx) {
2442
+ const stock = new Set(SHADCN_PRIMITIVES);
2443
+ const ui = ctx.files.filter((file) => {
2444
+ const m = file.path.match(/(?:^|\/)components\/ui\/([a-z-]+)\.(?:tsx|jsx|ts)$/);
2445
+ return m !== null && stock.has(m[1]);
2446
+ });
2447
+ if (ui.length < SHADCN_LIMIT) return [];
2448
+ const others = ctx.files.filter((file) => !ui.includes(file));
2449
+ const imported = (name) => others.some((o) => new RegExp(`ui/${name}["']`).test(o.text)) || ui.some((o) => new RegExp(`["']\\./${name}["']|ui/${name}["']`).test(o.text));
2450
+ const unused = others.length > 0 ? ui.filter((file) => !imported(file.path.match(/([a-z-]+)\.[a-z]+$/)[1])) : [];
2451
+ const detail = unused.length > 0 ? `; ${unused.length} imported nowhere` : "";
2452
+ return [{ path: ui[0].path, offset: 0, message: `${ui.length} stock shadcn primitives${detail}` }];
2453
+ }
2454
+ function marqueeHits(ctx) {
2455
+ const hits = [];
2456
+ 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;
2457
+ for (const file of ctx.files) {
2458
+ const at = firstMatch(file.text, pattern);
2459
+ if (at !== -1) hits.push({ path: file.path, offset: at, message: "scrolling marquee" });
2460
+ }
2461
+ return hits;
2462
+ }
2463
+ function introHits(ctx) {
2464
+ const hits = [];
2465
+ const name = /(?:^|\/)(?:intro(?:-?(?:cinematic|sequence|animation|overlay))?|preloader|splash(?:-?screen)?|loading-?screen|curtain)\.(?:tsx|jsx|vue|svelte|astro)$/i;
2466
+ for (const file of ctx.files) {
2467
+ if (name.test(file.path) && /sessionStorage|localStorage|AnimatePresence|setTimeout|gsap|@keyframes|animate\(/.test(file.text)) {
2468
+ hits.push({ path: file.path, offset: 0, message: "intro sequence before the page" });
2469
+ continue;
2470
+ }
2471
+ const use = file.text.match(/<(?:IntroCinematic|Intro|Preloader|Splash|SplashScreen|LoadingScreen|Curtain)\b/);
2472
+ if (use && file.kind === "markup") hits.push({ path: file.path, offset: use.index ?? 0, message: `${use[0].slice(1)} runs before the page` });
2473
+ }
2474
+ return hits;
2475
+ }
2476
+ var SOURCE_TELLS = [
2477
+ {
2478
+ id: "reflex-font",
2479
+ name: "Reflex font",
2480
+ generation: 1,
2481
+ severity: "warn",
2482
+ surface: "source",
2483
+ 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.",
2484
+ 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.",
2485
+ detect: fontTell(REFLEX_FONTS_1),
2486
+ fixtures: {
2487
+ flag: [
2488
+ f2("app/layout.tsx", `import { Inter } from "next/font/google";
2489
+ const inter = Inter({ subsets: ["latin"] });`),
2490
+ f2("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
2491
+ f2("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
2492
+ f2("main.ts", `import "@fontsource/montserrat";`),
2493
+ f2("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
2494
+ ],
2495
+ pass: [f2("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
2496
+ }
2497
+ },
2498
+ {
2499
+ id: "reflex-font-2",
2500
+ name: "Second-wave reflex font",
2501
+ generation: 2,
2502
+ severity: "warn",
2503
+ surface: "source",
2504
+ why: "Ban Inter and a model reaches for Inter Tight, DM Sans, Manrope, Space Grotesk or an Instrument/Fraunces serif. A swap inside the reflex list is not a decision.",
2505
+ fix: "Same as reflex-font. If this face really is the right one, declare an exception with the reason and the evidence.",
2506
+ detect: fontTell(REFLEX_FONTS_2),
2507
+ fixtures: {
2508
+ flag: [f2("src/styles.css", `@theme {
2509
+ --font-sans: "DM Sans", system-ui, sans-serif;
2510
+ }`)],
2511
+ pass: [f2("src/styles.css", `@theme {
2512
+ --font-sans: "Hanken Grotesk", system-ui, sans-serif;
2513
+ }`)]
2514
+ }
2515
+ },
2516
+ {
2517
+ id: "ai-violet",
2518
+ name: "Indigo-violet accent",
2519
+ generation: 1,
2520
+ severity: "warn",
2521
+ surface: "source",
2522
+ 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.",
2523
+ fix: 'Derive the accent from something the client owns (livery, shopfront, product). If violet is the brand, declare it: `{ "tell": "ai-violet", "because": "..." }`.',
2524
+ detect: violetHits,
2525
+ fixtures: {
2526
+ flag: [
2527
+ f2("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
2528
+ f2("app/globals.css", `:root { --accent: #5a35d1; }`)
2529
+ ],
2530
+ pass: [f2("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
2531
+ }
2532
+ },
2533
+ {
2534
+ id: "blue-purple-gradient",
2535
+ name: "Blue-to-purple gradient",
2536
+ generation: 1,
2537
+ severity: "warn",
2538
+ surface: "source",
2539
+ why: "The first-wave hero background. It says 'software product' on a plumber's site.",
2540
+ 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.",
2541
+ detect: gradientHits,
2542
+ fixtures: {
2543
+ flag: [
2544
+ f2("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
2545
+ f2("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
2546
+ ],
2547
+ pass: [f2("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
2548
+ }
2549
+ },
2550
+ {
2551
+ id: "gradient-text",
2552
+ name: "Gradient-filled heading",
2553
+ generation: 1,
2554
+ severity: "warn",
2555
+ surface: "source",
2556
+ why: "Gradient text is the model's way of making a heading look designed without deciding what it should look like.",
2557
+ fix: "Set the heading in the display face at a real size and weight. Let type do the work.",
2558
+ detect: gradientTextHits,
2559
+ fixtures: {
2560
+ flag: [
2561
+ f2("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
2562
+ f2("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
2563
+ ],
2564
+ pass: [f2("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
2565
+ }
2566
+ },
2567
+ {
2568
+ id: "glass-panel",
2569
+ name: "Glass panel",
2570
+ generation: 1,
2571
+ severity: "warn",
2572
+ surface: "source",
2573
+ 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.",
2574
+ fix: "Give text a solid ground, or crop the image so the text sits on a quiet area.",
2575
+ detect: glassHits,
2576
+ fixtures: {
2577
+ flag: [
2578
+ f2("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
2579
+ f2("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
2580
+ ],
2581
+ pass: [f2("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
2582
+ }
2583
+ },
2584
+ {
2585
+ id: "hero-then-proof",
2586
+ name: "Hero straight into a trust strip",
2587
+ generation: 1,
2588
+ severity: "warn",
2589
+ surface: "source",
2590
+ 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.",
2591
+ fix: "Follow the hero with the thing only this business has: the work, the place, the person. Put proof where a doubt arises.",
2592
+ detect: heroThenProofHits,
2593
+ fixtures: {
2594
+ flag: [f2("app/page.tsx", `export default function Page() {
2595
+ return (<main><Hero /><TrustStrip /><Services /></main>);
2596
+ }`)],
2597
+ pass: [f2("app/page.tsx", `export default function Page() {
2598
+ return (<main><Hero /><Workshop /><TrustStrip /></main>);
2599
+ }`)]
2600
+ }
2601
+ },
2602
+ {
2603
+ id: "icon-tile-grid",
2604
+ name: "Three-column icon-card grid",
2605
+ generation: 1,
2606
+ severity: "warn",
2607
+ surface: "source",
2608
+ why: "Three cards, each an icon, a heading and two lines, is what a model builds for any list of services.",
2609
+ fix: "Show the services as the client would: a price list, a menu board, photographs of the work, a sequence.",
2610
+ detect: iconGridHits,
2611
+ fixtures: {
2612
+ flag: [
2613
+ f2(
2614
+ "components/Features.tsx",
2615
+ `import { Heart } from "lucide-react";
2616
+ <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
2617
+ <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-rose-100"><Heart /></div>
2618
+ </div>`
2619
+ )
2620
+ ],
2621
+ pass: [f2("components/Features.tsx", `<ul className="divide-y">
2622
+ <li className="flex justify-between py-3">Gel manicure <span>\xA332</span></li>
2623
+ </ul>`)]
2624
+ }
2625
+ },
2626
+ {
2627
+ id: "reveal-everywhere",
2628
+ name: "Reveal on every section",
2629
+ generation: 1,
2630
+ severity: "warn",
2631
+ surface: "source",
2632
+ 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.",
2633
+ fix: `Keep one gated signature moment per page (STANDARD section 7) and let the rest arrive still. The limit here is ${REVEAL_LIMIT} per scan.`,
2634
+ detect: revealHits,
2635
+ fixtures: {
2636
+ flag: [f2("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
2637
+ pass: [f2("app/page.tsx", `<Reveal><Signature /></Reveal>
2638
+ <Block />
2639
+ <Block />`)]
2640
+ }
2641
+ },
2642
+ {
2643
+ id: "pill-everything",
2644
+ name: "Pills everywhere",
2645
+ generation: 1,
2646
+ severity: "warn",
2647
+ surface: "source",
2648
+ 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.",
2649
+ fix: `Choose the shape language on purpose (and record it), then use the pill only where it means something. The limit here is ${PILL_LIMIT} per scan.`,
2650
+ detect: pillHits,
2651
+ fixtures: {
2652
+ flag: [f2("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
2653
+ pass: [f2("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
2654
+ <a className="rounded-sm px-4">Book</a>`)]
2655
+ }
2656
+ },
2657
+ {
2658
+ id: "shadcn-dump",
2659
+ name: "Stock component dump",
2660
+ generation: 1,
2661
+ severity: "warn",
2662
+ surface: "source",
2663
+ 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.",
2664
+ fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
2665
+ detect: shadcnHits,
2666
+ fixtures: {
2667
+ flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f2(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
2668
+ pass: [f2("src/components/ui/button.tsx", `export function Button() { return null; }`)]
2669
+ }
2670
+ },
2671
+ {
2672
+ id: "cream-palette",
2673
+ name: "Cream ground",
2674
+ generation: 2,
2675
+ severity: "warn",
2676
+ surface: "source",
2677
+ 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.",
2678
+ fix: "Take the ground from the client's material world (paper stock, wall colour, tile). If it really is cream, say why.",
2679
+ detect: creamHits,
2680
+ fixtures: {
2681
+ flag: [
2682
+ f2("app/globals.css", `body { background-color: #f5efe6; }`),
2683
+ f2("app/page.tsx", `<main className="bg-cream text-ink">`),
2684
+ f2("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
2685
+ ],
2686
+ pass: [f2("app/globals.css", `body { background-color: #ffffff; }`)]
2687
+ }
2688
+ },
2689
+ {
2690
+ id: "italic-serif-display",
2691
+ name: "Italic serif display",
2692
+ generation: 2,
2693
+ severity: "warn",
2694
+ surface: "source",
2695
+ why: "A serif heading with one italic word ('Beauty, reimagined') is the second wave's signature move.",
2696
+ fix: "Use emphasis only where the sentence needs it, and set it the way the display face wants, not by reflex.",
2697
+ detect: italicSerifHits,
2698
+ fixtures: {
2699
+ flag: [
2700
+ f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
2701
+ f2("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
2702
+ f2("app/globals.css", `.hero h1 { font-style: italic; }`)
2703
+ ],
2704
+ pass: [f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
2705
+ <p><em>Walk-ins</em> welcome.</p>`)]
2706
+ }
2707
+ },
2708
+ {
2709
+ id: "hero-eyebrow-chip",
2710
+ name: "Eyebrow chip above the hero",
2711
+ generation: 2,
2712
+ severity: "warn",
2713
+ surface: "source",
2714
+ why: "A small bordered pill ('New \xB7 Now booking') above the headline is in almost every generated hero.",
2715
+ fix: "Drop it. If the fact matters, put it in the headline or the first line of copy.",
2716
+ detect: eyebrowChipHits,
2717
+ fixtures: {
2718
+ flag: [
2719
+ f2("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
2720
+ <h1>Nails in Brackley</h1>`),
2721
+ f2("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
2722
+ <h1>Nails</h1>`)
2723
+ ],
2724
+ pass: [f2("components/Hero.tsx", `<h1>Nails in Brackley</h1>
2725
+ <span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
2726
+ }
2727
+ },
2728
+ {
2729
+ id: "icon-tile-stack",
2730
+ name: "Icon in a tinted tile",
2731
+ generation: 2,
2732
+ severity: "warn",
2733
+ surface: "source",
2734
+ why: "A library icon in a soft rounded square, stacked above a heading, is the stock card header.",
2735
+ fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
2736
+ detect: iconTileStackHits,
2737
+ fixtures: {
2738
+ flag: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
2739
+ <div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
2740
+ pass: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
2741
+ <p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
2742
+ }
2743
+ },
2744
+ {
2745
+ id: "bento-grid",
2746
+ name: "Bento grid",
2747
+ generation: 2,
2748
+ severity: "warn",
2749
+ surface: "source",
2750
+ why: "Mixed-size tiles in a grid became the default 'features' layout of the second wave.",
2751
+ fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
2752
+ detect: bentoHits,
2753
+ fixtures: {
2754
+ flag: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
2755
+ pass: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
2756
+ }
2757
+ },
2758
+ {
2759
+ id: "radial-spotlight-glow",
2760
+ name: "Radial glow",
2761
+ generation: 2,
2762
+ severity: "warn",
2763
+ surface: "source",
2764
+ why: "A soft blurred blob or radial spotlight behind the hero is atmosphere without a subject.",
2765
+ fix: "Light the page with real imagery, or leave the ground flat.",
2766
+ detect: glowHits,
2767
+ fixtures: {
2768
+ flag: [
2769
+ f2("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
2770
+ f2("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
2771
+ ],
2772
+ pass: [f2("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
2773
+ }
2774
+ },
2775
+ {
2776
+ id: "grid-background",
2777
+ name: "Graph-paper background",
2778
+ generation: 2,
2779
+ severity: "warn",
2780
+ surface: "source",
2781
+ why: "A faint 1px line or dot grid behind the hero is the developer-tool look, borrowed by everyone.",
2782
+ fix: "Remove it. If the brand has a pattern, draw it from the brand.",
2783
+ detect: gridBackgroundHits,
2784
+ fixtures: {
2785
+ flag: [
2786
+ f2(
2787
+ "app/globals.css",
2788
+ `.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
2789
+ ),
2790
+ f2("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
2791
+ ],
2792
+ pass: [f2("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
2793
+ }
2794
+ },
2795
+ {
2796
+ id: "marquee",
2797
+ name: "Scrolling marquee",
2798
+ generation: 2,
2799
+ severity: "warn",
2800
+ surface: "source",
2801
+ why: "An endless strip of logos or words is the second wave's trust strip. It moves so it looks alive.",
2802
+ fix: "Show proof still and in context: one named review, one accreditation, where the doubt arises.",
2803
+ detect: marqueeHits,
2804
+ fixtures: {
2805
+ flag: [
2806
+ f2("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
2807
+ f2("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
2808
+ ],
2809
+ pass: [f2("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
2810
+ }
2811
+ },
2812
+ {
2813
+ id: "thin-border-wide-shadow",
2814
+ name: "Hairline border, wide shadow",
2815
+ generation: 2,
2816
+ severity: "warn",
2817
+ surface: "source",
2818
+ why: "A near-invisible border under a large soft shadow is the stock 'elevated card'.",
2819
+ fix: "Pick one: a border that is visible, or a shadow the light source explains. Keep within the four-shadow budget.",
2820
+ detect: thinBorderWideShadowHits,
2821
+ fixtures: {
2822
+ flag: [
2823
+ f2("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
2824
+ f2("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
2825
+ ],
2826
+ pass: [f2("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
2827
+ }
2828
+ },
2829
+ {
2830
+ id: "intro-cinematic",
2831
+ name: "Intro cinematic",
2832
+ generation: 2,
2833
+ severity: "warn",
2834
+ surface: "source",
2835
+ 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.",
2836
+ fix: "Remove it. Spend the signature moment on something the visitor came for.",
2837
+ detect: introHits,
2838
+ fixtures: {
2839
+ flag: [
2840
+ f2("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
2841
+ export function IntroCinematic() { return <AnimatePresence /> }`),
2842
+ f2("app/page.tsx", `<><Preloader /><Hero /></>`)
2843
+ ],
2844
+ pass: [f2("components/Introduction.md", `An introduction to the studio.`)]
2845
+ }
2846
+ }
2847
+ ];
2848
+
2849
+ // src/character/parse.ts
2850
+ var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
2851
+ var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
2852
+ var CSS = /\.(css|scss|sass|less|pcss)$/i;
2853
+ var PROSE = /\.(md|markdown|txt)$/i;
2854
+ function fileKind(path) {
2855
+ if (MARKUP.test(path)) return "markup";
2856
+ if (CSS.test(path)) return "css";
2857
+ if (SCRIPT.test(path)) return "script";
2858
+ if (PROSE.test(path)) return "prose";
2859
+ return "other";
2860
+ }
2861
+ function lineAt(text, offset) {
2862
+ let line = 1;
2863
+ for (let i = 0; i < offset && i < text.length; i += 1) {
2864
+ if (text.charCodeAt(i) === 10) line += 1;
2865
+ }
2866
+ return line;
2867
+ }
2868
+ function excerptAt(text, offset, max = 140) {
2869
+ const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
2870
+ const endRaw = text.indexOf("\n", offset);
2871
+ const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
2872
+ return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
2873
+ }
2874
+ var LITERAL = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
2875
+ function stringLiterals(text, base = 0) {
2876
+ const out = [];
2877
+ for (const m of text.matchAll(LITERAL)) {
2878
+ out.push({ value: m[0].slice(1, -1), offset: base + (m.index ?? 0) });
2879
+ }
2880
+ return out;
2881
+ }
2882
+ function matchBracket(text, open) {
2883
+ const pairs = { "{": "}", "(": ")", "[": "]" };
2884
+ const close = pairs[text[open]];
2885
+ const opener = text[open];
2886
+ let depth = 0;
2887
+ for (let i = open; i < text.length; i += 1) {
2888
+ const ch = text[i];
2889
+ if (ch === '"' || ch === "'" || ch === "`") {
2890
+ const end = skipString(text, i);
2891
+ i = end - 1;
2892
+ continue;
2893
+ }
2894
+ if (ch === opener) depth += 1;
2895
+ else if (ch === close) {
2896
+ depth -= 1;
2897
+ if (depth === 0) return i + 1;
2898
+ }
2899
+ }
2900
+ return text.length;
2901
+ }
2902
+ function skipString(text, start) {
2903
+ const quote = text[start];
2904
+ for (let i = start + 1; i < text.length; i += 1) {
2905
+ if (text[i] === "\\") {
2906
+ i += 1;
2907
+ continue;
2908
+ }
2909
+ if (text[i] === quote) return i + 1;
2910
+ if (text[i] === "\n" && quote !== "`") return i + 1;
2911
+ }
2912
+ return text.length;
2913
+ }
2914
+ function split(classes) {
2915
+ return classes.split(/\s+/).filter((c) => c.length > 0 && !c.includes("${"));
2916
+ }
2917
+ function classUnits(text) {
2918
+ const units = [];
2919
+ const covered = [];
2920
+ const attr = /\b(?:className|class|:class)\s*=\s*/g;
2921
+ for (const m of text.matchAll(attr)) {
2922
+ const at = (m.index ?? 0) + m[0].length;
2923
+ const ch = text[at];
2924
+ if (ch === '"' || ch === "'") {
2925
+ const end = skipString(text, at);
2926
+ units.push({ classes: split(text.slice(at + 1, end - 1)), offset: m.index ?? 0 });
2927
+ } else if (ch === "{") {
2928
+ const end = matchBracket(text, at);
2929
+ const joined = stringLiterals(text.slice(at, end)).map((l) => l.value).join(" ");
2930
+ units.push({ classes: split(joined), offset: m.index ?? 0 });
2931
+ covered.push([at, end]);
2932
+ }
2933
+ }
2934
+ const call = /\b(cn|clsx|twMerge|cx|cva|tv)\s*\(/g;
2935
+ for (const m of text.matchAll(call)) {
2936
+ const start = m.index ?? 0;
2937
+ if (covered.some(([a, b]) => start >= a && start < b)) continue;
2938
+ const open = start + m[0].length - 1;
2939
+ const end = matchBracket(text, open);
2940
+ const literals = stringLiterals(text.slice(open, end), open);
2941
+ if (m[1] === "cva" || m[1] === "tv") {
2942
+ for (const l of literals) units.push({ classes: split(l.value), offset: l.offset });
2943
+ } else {
2944
+ units.push({ classes: split(literals.map((l) => l.value).join(" ")), offset: start });
2945
+ }
2946
+ covered.push([open, end]);
2947
+ }
2948
+ return units.filter((u) => u.classes.length > 0);
2949
+ }
2950
+ function blankComments(css) {
2951
+ return css.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " "));
2952
+ }
2953
+ function cssDeclarations(css, base = 0) {
2954
+ const src = blankComments(css);
2955
+ const out = [];
2956
+ const stack = [];
2957
+ let segStart = 0;
2958
+ let parens = 0;
2959
+ const flush = (end) => {
2960
+ const seg = src.slice(segStart, end);
2961
+ const colon = seg.indexOf(":");
2962
+ if (stack.length === 0 || colon === -1) return;
2963
+ const property = seg.slice(0, colon).trim();
2964
+ if (!/^(--)?[a-zA-Z-]+$/.test(property)) return;
2965
+ const lead = seg.length - seg.trimStart().length;
2966
+ out.push({
2967
+ property: property.toLowerCase(),
2968
+ value: seg.slice(colon + 1).trim(),
2969
+ selector: stack[stack.length - 1],
2970
+ offset: base + segStart + lead
2971
+ });
2972
+ };
2973
+ for (let i = 0; i < src.length; i += 1) {
2974
+ const ch = src[i];
2975
+ if (ch === '"' || ch === "'") {
2976
+ i = skipString(src, i) - 1;
2977
+ continue;
2978
+ }
2979
+ if (ch === "(") parens += 1;
2980
+ else if (ch === ")") parens = Math.max(0, parens - 1);
2981
+ if (parens > 0) continue;
2982
+ if (ch === "{") {
2983
+ stack.push(src.slice(segStart, i).trim());
2984
+ segStart = i + 1;
2985
+ } else if (ch === ";") {
2986
+ flush(i);
2987
+ segStart = i + 1;
2988
+ } else if (ch === "}") {
2989
+ flush(i);
2990
+ stack.pop();
2991
+ segStart = i + 1;
2992
+ }
2993
+ }
2994
+ return out;
2995
+ }
2996
+ function styleBlocks(text) {
2997
+ const out = [];
2998
+ for (const m of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
2999
+ const bodyStart = (m.index ?? 0) + m[0].indexOf(">") + 1;
3000
+ out.push(...cssDeclarations(m[1], bodyStart));
3001
+ }
3002
+ return out;
3003
+ }
3004
+ function parseFile(file) {
3005
+ const kind = fileKind(file.path);
3006
+ return {
3007
+ ...file,
3008
+ kind,
3009
+ classUnits: kind === "markup" || kind === "script" ? classUnits(file.text) : [],
3010
+ declarations: kind === "css" ? cssDeclarations(file.text) : kind === "markup" ? styleBlocks(file.text) : []
3011
+ };
3012
+ }
3013
+
3014
+ // src/character/prose.ts
3015
+ var blank = (s) => s.replace(/[^\n]/g, " ");
3016
+ function readsAsProse(value) {
3017
+ if (value.length < 12 || !/\s/.test(value)) return false;
3018
+ if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
3019
+ const words2 = withoutMarkup(value);
3020
+ if (looksLikeCode(words2) || /[<>]/.test(words2)) return false;
3021
+ return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words2);
3022
+ }
3023
+ function withoutMarkup(value) {
3024
+ return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
3025
+ }
3026
+ function looksLikeCode(words2) {
3027
+ return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words2);
3028
+ }
3029
+ 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)$/;
3030
+ function looksLikeClasses(value) {
3031
+ const tokens = value.trim().split(/\s+/);
3032
+ if (tokens.length === 0 || !tokens.every((t) => /^[!\w@:/[\]().#%,>&*+=~-]+$/.test(t))) return false;
3033
+ const classy = tokens.filter((t) => /[-:[\]/]/.test(t) || UTILITY.test(t)).length;
3034
+ return classy / tokens.length >= 0.6 && /[a-z]-|:|\[/.test(value);
3035
+ }
3036
+ function looksLikeAddress(value) {
3037
+ return /^(?:\.{0,2}\/|@\/|https?:|mailto:|tel:)/.test(value);
3038
+ }
3039
+ function readsAsText(value) {
3040
+ if (value.trim().length === 0) return false;
3041
+ if (looksLikeAddress(value) || looksLikeClasses(value)) return false;
3042
+ return /[^\s\w]|[a-zA-Z]/.test(value);
3043
+ }
3044
+ function literalIsText(value) {
3045
+ return readsAsText(value) && !looksLikeCode(withoutMarkup(value));
3046
+ }
3047
+ function maskScriptComments(text) {
3048
+ let out = "";
3049
+ let i = 0;
3050
+ while (i < text.length) {
3051
+ const ch = text[i];
3052
+ if (ch === '"' || ch === "'" || ch === "`") {
3053
+ const start = i;
3054
+ i += 1;
3055
+ while (i < text.length) {
3056
+ if (text[i] === "\\") {
3057
+ i += 2;
3058
+ continue;
3059
+ }
3060
+ if (text[i] === ch) {
3061
+ i += 1;
3062
+ break;
3063
+ }
3064
+ if (text[i] === "\n" && ch !== "`") break;
3065
+ i += 1;
3066
+ }
3067
+ out += text.slice(start, i);
3068
+ continue;
3069
+ }
3070
+ if (ch === "/" && text[i + 1] === "/") {
3071
+ const end = text.indexOf("\n", i);
3072
+ const stop = end === -1 ? text.length : end;
3073
+ out += " ".repeat(stop - i);
3074
+ i = stop;
3075
+ continue;
3076
+ }
3077
+ if (ch === "/" && text[i + 1] === "*") {
3078
+ const end = text.indexOf("*/", i + 2);
3079
+ const stop = end === -1 ? text.length : end + 2;
3080
+ out += blank(text.slice(i, stop));
3081
+ i = stop;
3082
+ continue;
3083
+ }
3084
+ out += ch;
3085
+ i += 1;
3086
+ }
3087
+ return out;
3088
+ }
3089
+ function maskMarkupComments(text) {
3090
+ 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);
3091
+ }
3092
+ function markupParts(file) {
3093
+ const masked = maskMarkupComments(file.text);
3094
+ const textNodes = [];
3095
+ let rest = masked;
3096
+ for (const m of masked.matchAll(/([>}])([^<>{}]+)(?=[<{])/g)) {
3097
+ const body = m[2];
3098
+ const before = masked[(m.index ?? 0) - 1] ?? "";
3099
+ const after = masked[(m.index ?? 0) + m[0].length];
3100
+ if (m[1] === ">" && /[=-]/.test(before)) continue;
3101
+ if (m[1] === "}" && (/^\s*(?:[)\],;:]|(?:else|catch|finally|return|from|as|if|const|let|export|function)\b)/.test(body) || /;\s*$/.test(body))) continue;
3102
+ if (m[1] === "}" && after === "{" && (/[=()`'"]/.test(body) || !/[a-zA-Z]{2,}|[^\s\w,|&?:]/.test(body))) continue;
3103
+ if (body.trim().length === 0) continue;
3104
+ if (m[1] === ">" && after === "<" && /^[\s—–-]*$/.test(body)) continue;
3105
+ const at = (m.index ?? 0) + 1;
3106
+ textNodes.push({ path: file.path, text: body, offset: at });
3107
+ rest = rest.slice(0, at) + blank(body) + rest.slice(at + body.length);
3108
+ }
3109
+ return { textNodes, literals: splitHtml(stringLiterals(rest)) };
3110
+ }
3111
+ function textNodeIsProse(body) {
3112
+ return /[a-zA-Z]{2,}/.test(body) && /\s/.test(body.trim()) || /[a-zA-Z]{4,}[.!?]/.test(body);
3113
+ }
3114
+ function proseBlocks(file) {
3115
+ 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);
3116
+ return [{ path: file.path, text: masked, offset: 0 }];
3117
+ }
3118
+ function isData(path) {
3119
+ return /\.jsonl?$/i.test(path);
3120
+ }
3121
+ function scriptLiterals(file) {
3122
+ return splitHtml(stringLiterals(isData(file.path) ? file.text : maskScriptComments(file.text)));
3123
+ }
3124
+ function splitHtml(literals) {
3125
+ const out = [];
3126
+ for (const l of literals) {
3127
+ if (!/<[a-zA-Z][^<>]*>/.test(l.value)) {
3128
+ out.push(l);
3129
+ continue;
3130
+ }
3131
+ for (const m of l.value.matchAll(/(?:^|>)([^<>]+)(?=<|$)/g)) {
3132
+ const body = m[1];
3133
+ if (!body.trim()) continue;
3134
+ const lead = m[0].length - body.length;
3135
+ out.push({ value: body, offset: l.offset + (m.index ?? 0) + lead });
3136
+ }
3137
+ }
3138
+ return out;
3139
+ }
3140
+ function extractCopy(file) {
3141
+ const kind = fileKind(file.path);
3142
+ if (kind === "prose" || file.path.endsWith(".mdx")) return proseBlocks(file);
3143
+ if (kind === "markup") {
3144
+ const { textNodes, literals } = markupParts(file);
3145
+ return [
3146
+ ...textNodes.filter((b) => textNodeIsProse(b.text)),
3147
+ ...literals.filter((l) => readsAsProse(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
3148
+ ];
3149
+ }
3150
+ if (kind === "script" || isData(file.path)) {
3151
+ return scriptLiterals(file).filter((l) => readsAsProse(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
3152
+ }
3153
+ return [];
3154
+ }
3155
+ function extractStrings(file) {
3156
+ const kind = fileKind(file.path);
3157
+ if (kind === "prose" || file.path.endsWith(".mdx")) {
3158
+ const [block] = proseBlocks(file);
3159
+ const out = [];
3160
+ let offset = 0;
3161
+ for (const line of block.text.split("\n")) {
3162
+ if (line.trim()) out.push({ path: file.path, text: line, offset });
3163
+ offset += line.length + 1;
3164
+ }
3165
+ return out;
3166
+ }
3167
+ if (kind === "markup") {
3168
+ const { textNodes, literals } = markupParts(file);
3169
+ return [
3170
+ ...textNodes.filter((b) => readsAsText(b.text)),
3171
+ ...literals.filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
3172
+ ];
3173
+ }
3174
+ if (kind === "script" || isData(file.path)) {
3175
+ return scriptLiterals(file).filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
3176
+ }
3177
+ return [];
3178
+ }
3179
+
3180
+ // src/snapshot/types.ts
3181
+ var SNAPSHOT_VERSION = 1;
3182
+
3183
+ // src/snapshot/fixture.ts
3184
+ function makeSnapshot(patch = {}) {
3185
+ const base = {
3186
+ version: SNAPSHOT_VERSION,
3187
+ url: "https://example.test/",
3188
+ capturedAt: "2026-09-23T00:00:00.000Z",
3189
+ viewport: { width: 1440, height: 900 },
3190
+ pageHeight: 4200,
3191
+ ground: "rgb(255, 255, 255)",
3192
+ colours: {
3193
+ backgrounds: [
3194
+ { value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
3195
+ { value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
3196
+ ],
3197
+ text: [
3198
+ { value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
3199
+ { value: "rgb(31, 58, 46)", area: 0, chars: 300 }
3200
+ ]
3201
+ },
3202
+ fonts: [
3203
+ { family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
3204
+ { family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
3205
+ ],
3206
+ headings: [
3207
+ { level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
3208
+ { level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
3209
+ ],
3210
+ controls: [
3211
+ { kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
3212
+ { kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
3213
+ { kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
3214
+ { kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
3215
+ ],
3216
+ sections: [
3217
+ { top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
3218
+ { top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
3219
+ { top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
3220
+ { top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
3221
+ { top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
3222
+ ],
3223
+ effects: {
3224
+ glass: 0,
3225
+ gradientText: 0,
3226
+ gradients: [],
3227
+ glows: 0,
3228
+ gridBackgrounds: 0,
3229
+ marquees: 0,
3230
+ hairlineShadowCards: 0,
3231
+ eyebrowChip: false
3232
+ },
3233
+ motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
3234
+ metrics: {
3235
+ fontSizes: [14, 16, 20, 36, 64],
3236
+ fontWeights: [400, 500],
3237
+ radii: ["4px"],
3238
+ shadows: [],
3239
+ spacingOnScalePct: 96,
3240
+ bodyLineLengthCh: 68,
3241
+ bodyFontSizePx: 17
3242
+ }
3243
+ };
3244
+ const out = { ...base };
3245
+ for (const [key, value] of Object.entries(patch)) {
3246
+ const current = base[key];
3247
+ out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
3248
+ }
3249
+ return out;
3250
+ }
3251
+
3252
+ // src/snapshot/fonts.ts
3253
+ function normaliseFamily(raw) {
3254
+ let name = raw.replace(/["']/g, "").trim();
3255
+ const next = name.match(/^__(.+?)(?:_Fallback)?_[0-9a-f]{5,8}$/i);
3256
+ if (next) name = next[1].replace(/_/g, " ");
3257
+ name = name.replace(/\s+Fallback$/i, "").replace(/\s+Variable$/i, "");
3258
+ if (!/\s/.test(name) && (/[a-z][A-Z]/.test(name) || /^[a-z]+(?:-[a-z]+)+$/.test(name))) {
3259
+ name = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/-/g, " ").replace(/\b([a-z])/g, (c) => c.toUpperCase()).replace(/\bDm\b/, "DM");
3260
+ }
3261
+ if (/^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|sans-serif|serif|monospace)$/i.test(name)) return "system-ui";
3262
+ return name;
3263
+ }
3264
+ var KNOWN = {
3265
+ fraunces: "serif",
3266
+ "playfair display": "serif",
3267
+ "cormorant garamond": "serif",
3268
+ cormorant: "serif",
3269
+ "eb garamond": "serif",
3270
+ lora: "serif",
3271
+ merriweather: "serif",
3272
+ "libre baskerville": "serif",
3273
+ "instrument serif": "serif",
3274
+ newsreader: "serif",
3275
+ "source serif 4": "serif",
3276
+ "crimson pro": "serif",
3277
+ canela: "serif",
3278
+ "tiempos headline": "serif",
3279
+ "tiempos text": "serif",
3280
+ "gt super": "serif",
3281
+ "freight display pro": "serif",
3282
+ "gt sectra": "serif",
3283
+ georgia: "serif",
3284
+ "times new roman": "serif",
3285
+ "dm serif display": "serif",
3286
+ "bodoni moda": "serif",
3287
+ "libre caslon text": "serif",
3288
+ "young serif": "serif",
3289
+ "caveat": "script",
3290
+ "dancing script": "script",
3291
+ "great vibes": "script",
3292
+ "pinyon script": "script"
3293
+ };
3294
+ function fontClass(family) {
3295
+ const name = normaliseFamily(family);
3296
+ if (name === "system-ui") return "system";
3297
+ const known = KNOWN[name.toLowerCase()];
3298
+ if (known) return known;
3299
+ if (/\bmono\b|code|courier/i.test(name)) return "mono";
3300
+ if (/script|hand/i.test(name)) return "script";
3301
+ if (/serif/i.test(name) && !/sans/i.test(name)) return "serif";
3302
+ return "sans";
3303
+ }
3304
+
3305
+ // src/snapshot/rendered.ts
3306
+ var hit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
3307
+ var pct = (n) => `${Math.round(n * 100)}%`;
3308
+ var oklchOf = (value) => parseColour(value)?.oklch ?? null;
3309
+ function reflexFonts(list) {
3310
+ const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
3311
+ return (s) => {
3312
+ const total = s.fonts.reduce((n, f3) => n + f3.chars, 0) || 1;
3313
+ const display = s.fonts.reduce((n, f3) => n + f3.displayChars, 0) || 1;
3314
+ const hits = [];
3315
+ const seen = /* @__PURE__ */ new Set();
3316
+ const h1 = s.headings.find((h) => h.level === 1);
3317
+ const h1Name = h1 ? wanted.get(normaliseFamily(h1.family).toLowerCase()) : void 0;
3318
+ if (h1 && h1Name) {
3319
+ seen.add(h1Name);
3320
+ hits.push(hit(s, `${h1Name} sets the headline`, h1.text));
3321
+ }
3322
+ for (const font of s.fonts) {
3323
+ const name = wanted.get(normaliseFamily(font.family).toLowerCase());
3324
+ if (!name || seen.has(name)) continue;
3325
+ const share = font.chars / total;
3326
+ const displayShare = font.displayChars / display;
3327
+ if (share >= 0.15 || displayShare >= 0.3) {
3328
+ seen.add(name);
3329
+ hits.push(hit(s, `${name} sets ${pct(share)} of the text on the page${displayShare >= 0.3 ? ` and ${pct(displayShare)} of the headings` : ""}`));
3330
+ }
3331
+ }
3332
+ return hits;
3333
+ };
3334
+ }
3335
+ function violetHits2(s) {
3336
+ const hits = [];
3337
+ const controls = s.controls.filter((c) => {
3338
+ const o = oklchOf(c.background);
3339
+ return o !== null && (parseColour(c.background)?.alpha ?? 1) > 0.5 && isAiViolet(o);
3340
+ });
3341
+ 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}`));
3342
+ const totalChars = s.colours.text.reduce((n, t) => n + t.chars, 0) || 1;
3343
+ const violetText = s.colours.text.filter((t) => {
3344
+ const o = oklchOf(t.value);
3345
+ return o !== null && isAiViolet(o);
3346
+ });
3347
+ const textChars = violetText.reduce((n, t) => n + t.chars, 0);
3348
+ 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));
3349
+ const pageArea = s.pageHeight * s.viewport.width || 1;
3350
+ const violetBg = s.colours.backgrounds.filter((b) => {
3351
+ const o = oklchOf(b.value);
3352
+ return o !== null && (parseColour(b.value)?.alpha ?? 1) > 0.5 && isAiViolet(o);
3353
+ });
3354
+ const bgArea = violetBg.reduce((n, b) => n + b.area, 0);
3355
+ if (bgArea / pageArea >= 0.01 && controls.length === 0) hits.push(hit(s, `indigo-violet covers ${pct(bgArea / pageArea)} of the page`, violetBg[0].value));
3356
+ return hits;
3357
+ }
3358
+ function blueToPurple(s) {
3359
+ const hits = [];
3360
+ for (const g of s.effects.gradients) {
3361
+ const hues = g.stops.map(oklchOf).filter((o) => o !== null && o.c >= 0.08).map((o) => o.h);
3362
+ if (hues.some((h) => h >= 235 && h <= 272) && hues.some((h) => h >= 285 && h <= 330)) {
3363
+ hits.push(hit(s, "a blue-to-purple gradient covers a large area", g.stops.join(" \u2192 ")));
3364
+ }
3365
+ }
3366
+ return hits;
3367
+ }
3368
+ var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3369
+ var RENDERED_PATHS = {
3370
+ "reflex-font": {
3371
+ detect: reflexFonts(REFLEX_FONTS_1),
3372
+ fixtures: {
3373
+ flag: [makeSnapshot({ fonts: [{ family: "__Inter_d65c78", chars: 5e3, displayChars: 900, italicChars: 0, weights: [400, 700] }] })],
3374
+ 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] }] })]
3375
+ }
3376
+ },
3377
+ "reflex-font-2": {
3378
+ detect: reflexFonts(REFLEX_FONTS_2),
3379
+ fixtures: {
3380
+ flag: [
3381
+ makeSnapshot({ fonts: [{ family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400] }, { family: "Fraunces", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }] }),
3382
+ makeSnapshot({ headings: [{ level: 1, family: "Instrument Serif", sizePx: 72, weight: 400, italic: false, italicPart: false, text: "Beauty", top: 200 }] })
3383
+ ],
3384
+ pass: [makeSnapshot()]
3385
+ }
3386
+ },
3387
+ "ai-violet": {
3388
+ detect: violetHits2,
3389
+ fixtures: {
3390
+ flag: [
3391
+ makeSnapshot({ controls: [{ kind: "button", radiusPx: 4, heightPx: 44, background: "oklch(0.541 0.281 293.009)", text: "Get started" }] }),
3392
+ 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 }] } }),
3393
+ 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 }] } })
3394
+ ],
3395
+ pass: [makeSnapshot(), makeSnapshot({ controls: [{ kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(30, 58, 138)", text: "Book" }] })]
3396
+ }
3397
+ },
3398
+ "blue-purple-gradient": {
3399
+ detect: blueToPurple,
3400
+ fixtures: {
3401
+ flag: [makeSnapshot({ effects: { gradients: [{ stops: ["rgb(37, 99, 235)", "rgb(147, 51, 234)"], area: 12e5, radial: false }] } })],
3402
+ pass: [makeSnapshot({ effects: { gradients: [{ stops: ["rgb(250, 247, 240)", "rgb(255, 255, 255)"], area: 12e5, radial: false }] } })]
3403
+ }
3404
+ },
3405
+ "gradient-text": {
3406
+ detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
3407
+ fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
3408
+ },
3409
+ "glass-panel": {
3410
+ detect: (s) => s.effects.glass > 0 ? [hit(s, `${count(s.effects.glass, "translucent blurred panel")}`)] : [],
3411
+ fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
3412
+ },
3413
+ "cream-palette": {
3414
+ detect: (s) => {
3415
+ const ground = oklchOf(s.ground);
3416
+ if (ground && isCream(ground)) return [hit(s, "the page ground is a warm off-white", s.ground)];
3417
+ const pageArea = s.pageHeight * s.viewport.width || 1;
3418
+ const cream = s.colours.backgrounds.find((b) => {
3419
+ const o = oklchOf(b.value);
3420
+ return o !== null && isCream(o) && b.area / pageArea >= 0.3;
3421
+ });
3422
+ return cream ? [hit(s, `a warm off-white covers ${pct(cream.area / pageArea)} of the page`, cream.value)] : [];
3423
+ },
3424
+ fixtures: {
3425
+ flag: [makeSnapshot({ ground: "rgb(250, 246, 238)" }), makeSnapshot({ colours: { text: makeSnapshot().colours.text, backgrounds: [{ value: "rgb(245, 240, 230)", area: 3e6, chars: 0 }] } })],
3426
+ pass: [makeSnapshot(), makeSnapshot({ ground: "rgb(254, 252, 232)" })]
3427
+ }
3428
+ },
3429
+ "italic-serif-display": {
3430
+ detect: (s) => {
3431
+ const h = s.headings.find((x) => x.level <= 2 && fontClass(x.family) === "serif" && (x.italic || x.italicPart));
3432
+ return h ? [hit(s, `a serif ${h.level === 1 ? "headline" : "heading"} with an italic turn`, h.text)] : [];
3433
+ },
3434
+ fixtures: {
3435
+ flag: [makeSnapshot({ headings: [{ level: 1, family: "__Instrument_Serif_1a2b3c", sizePx: 72, weight: 400, italic: false, italicPart: true, text: "Beauty, reimagined", top: 200 }] })],
3436
+ pass: [makeSnapshot(), makeSnapshot({ headings: [{ level: 1, family: "S\xF6hne", sizePx: 64, weight: 600, italic: false, italicPart: true, text: "Boilers, fixed", top: 200 }] })]
3437
+ }
3438
+ },
3439
+ "hero-eyebrow-chip": {
3440
+ detect: (s) => s.effects.eyebrowChip ? [hit(s, "a small bordered pill sits above the headline")] : [],
3441
+ fixtures: { flag: [makeSnapshot({ effects: { eyebrowChip: true } })], pass: [makeSnapshot()] }
3442
+ },
3443
+ "icon-tile-grid": {
3444
+ 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)),
3445
+ fixtures: {
3446
+ flag: [makeSnapshot({ sections: [...makeSnapshot().sections.slice(0, 2), { top: 1700, height: 700, kind: "cards", label: "Our services", cards: 3, iconCards: 3, hiddenAtLoad: false }] })],
3447
+ pass: [makeSnapshot()]
3448
+ }
3449
+ },
3450
+ "hero-then-proof": {
3451
+ detect: (s) => {
3452
+ const [first, second] = s.sections;
3453
+ 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)] : [];
3454
+ },
3455
+ fixtures: {
3456
+ flag: [
3457
+ makeSnapshot({ sections: [makeSnapshot().sections[0], { top: 800, height: 160, kind: "logos", label: "Trusted by", cards: 0, iconCards: 0, hiddenAtLoad: false }, ...makeSnapshot().sections.slice(1)] }),
3458
+ makeSnapshot({ sections: [makeSnapshot().sections[0], { top: 800, height: 220, kind: "stats", label: "500+", cards: 0, iconCards: 0, hiddenAtLoad: false }] })
3459
+ ],
3460
+ pass: [makeSnapshot()]
3461
+ }
3462
+ },
3463
+ "reveal-everywhere": {
3464
+ detect: (s) => {
3465
+ const below = s.sections.filter((x) => x.top > s.viewport.height).length;
3466
+ const hidden = s.motion.hiddenSections;
3467
+ return hidden >= 4 && below > 0 && hidden / below >= 0.6 ? [hit(s, `${hidden} of ${below} sections below the fold wait for a scroll to appear`)] : [];
3468
+ },
3469
+ fixtures: {
3470
+ flag: [makeSnapshot({ motion: { hiddenSections: 4 }, sections: makeSnapshot().sections.map((x, i) => ({ ...x, hiddenAtLoad: i > 0 })) })],
3471
+ pass: [makeSnapshot(), makeSnapshot({ motion: { hiddenSections: 1 } })]
3472
+ }
3473
+ },
3474
+ "pill-everything": {
3475
+ detect: (s) => {
3476
+ const pills = s.controls.filter((c) => c.radiusPx >= c.heightPx / 2 - 1);
3477
+ return s.controls.length >= 4 && pills.length / s.controls.length >= 0.8 ? [hit(s, `${pills.length} of ${s.controls.length} buttons are pills`)] : [];
3478
+ },
3479
+ fixtures: {
3480
+ flag: [makeSnapshot({ controls: makeSnapshot().controls.map((c) => ({ ...c, radiusPx: 9999 })) })],
3481
+ pass: [makeSnapshot(), makeSnapshot({ controls: makeSnapshot().controls.slice(0, 2).map((c) => ({ ...c, radiusPx: 9999 })) })]
3482
+ }
3483
+ },
3484
+ "radial-spotlight-glow": {
3485
+ detect: (s) => {
3486
+ const radial = s.effects.gradients.filter((g) => g.radial).length;
3487
+ const n = s.effects.glows + radial;
3488
+ return n > 0 ? [hit(s, `${count(n, "soft glow")} behind the content`)] : [];
3489
+ },
3490
+ fixtures: {
3491
+ flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
3492
+ pass: [makeSnapshot()]
3493
+ }
3494
+ },
3495
+ "grid-background": {
3496
+ detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
3497
+ fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
3498
+ },
3499
+ marquee: {
3500
+ detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count(s.effects.marquees, "endless scrolling strip")}`)] : [],
3501
+ fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
3502
+ },
3503
+ "thin-border-wide-shadow": {
3504
+ detect: (s) => s.effects.hairlineShadowCards >= 3 ? [hit(s, `${s.effects.hairlineShadowCards} cards with a hairline border under a wide shadow`)] : [],
3505
+ fixtures: { flag: [makeSnapshot({ effects: { hairlineShadowCards: 3 } })], pass: [makeSnapshot(), makeSnapshot({ effects: { hairlineShadowCards: 1 } })] }
3506
+ },
3507
+ "intro-cinematic": {
3508
+ detect: (s) => s.motion.introOverlay ? [hit(s, "a full-screen layer covers the page at load and then leaves")] : [],
3509
+ fixtures: { flag: [makeSnapshot({ motion: { introOverlay: true } })], pass: [makeSnapshot()] }
3510
+ }
3511
+ };
3512
+
3513
+ // src/character/check.ts
3514
+ var CATALOGUE_VERSION = "2026.09.3";
3515
+ var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS].map(
3516
+ (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
3517
+ );
3518
+ function tellById(id) {
3519
+ return CATALOGUE.find((t) => t.id === id);
3520
+ }
3521
+ function validateExceptions(exceptions) {
3522
+ const applied = [];
3523
+ const rejected = [];
3524
+ for (const exception of exceptions) {
3525
+ if (!tellById(exception.tell)) {
3526
+ rejected.push({ exception, reason: `no tell called "${exception.tell}"` });
3527
+ } else if (typeof exception.because !== "string" || exception.because.trim().length < 12) {
3528
+ rejected.push({ exception, reason: "an exception needs a because of at least a sentence" });
3529
+ } else {
3530
+ applied.push(exception);
3531
+ }
3532
+ }
3533
+ return { applied, rejected };
3534
+ }
3535
+ var MARKER = /\b(?:copy|craft)-ok\b/;
3536
+ function marked(lines, line) {
3537
+ return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
3538
+ }
3539
+ function toFinding(tell, hit2, texts) {
3540
+ const text = texts.get(hit2.path) ?? "";
3541
+ return {
3542
+ tell: tell.id,
3543
+ name: tell.name,
3544
+ generation: tell.generation,
3545
+ severity: tell.severity,
3546
+ path: hit2.path,
3547
+ line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
3548
+ excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
3549
+ message: hit2.message,
3550
+ fix: tell.fix
3551
+ };
3552
+ }
3553
+ function report(tells, files, run, options) {
3554
+ const { applied, rejected } = validateExceptions(options.exceptions ?? []);
3555
+ const texts = new Map(files.map((file) => [file.path, file.text]));
3556
+ const selected = options.only ? tells.filter((t) => options.only.includes(t.id)) : tells;
3557
+ const findings = [];
3558
+ const suppressed = [];
3559
+ const lineCache = /* @__PURE__ */ new Map();
3560
+ const linesOf = (path) => {
3561
+ let lines = lineCache.get(path);
3562
+ if (!lines) lineCache.set(path, lines = (texts.get(path) ?? "").split("\n"));
3563
+ return lines;
3564
+ };
3565
+ const excepted = /* @__PURE__ */ new Map();
3566
+ for (const tell of selected) {
3567
+ for (const hit2 of run(tell)) {
3568
+ const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit2.path.includes(e.path)));
3569
+ if (exception) {
3570
+ excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
3571
+ continue;
3572
+ }
3573
+ const finding = toFinding(tell, hit2, texts);
3574
+ if (marked(linesOf(hit2.path), finding.line)) {
3575
+ suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
3576
+ continue;
3577
+ }
3578
+ findings.push(finding);
3579
+ }
3580
+ }
3581
+ findings.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.tell.localeCompare(b.tell));
3582
+ const byGeneration = { 1: 0, 2: 0, 3: 0 };
3583
+ const byTell = {};
3584
+ for (const finding of findings) {
3585
+ byGeneration[finding.generation] += 1;
3586
+ byTell[finding.tell] = (byTell[finding.tell] ?? 0) + 1;
3587
+ }
3588
+ return {
3589
+ catalogueVersion: CATALOGUE_VERSION,
3590
+ findings,
3591
+ rejectedExceptions: rejected,
3592
+ excepted: [...excepted].map(([e, count2]) => ({ tell: e.tell, because: e.because, count: count2 })),
3593
+ suppressed,
3594
+ summary: {
3595
+ files: files.length,
3596
+ findings: findings.length,
3597
+ byGeneration,
3598
+ byTell,
3599
+ blocking: findings.filter((x) => x.severity === "block").length
3600
+ }
3601
+ };
3602
+ }
3603
+ function scanSource(files, options = {}) {
3604
+ const ctx = { files: files.map(parseFile) };
3605
+ const tells = CATALOGUE.filter((t) => t.surface === "source");
3606
+ return report(tells, files, (tell) => tell.surface === "source" ? tell.detect(ctx) : [], options);
3607
+ }
3608
+ function copyContext(files) {
3609
+ return { files, blocks: files.flatMap(extractCopy), strings: files.flatMap(extractStrings) };
3610
+ }
3611
+ function checkCopy(files, options = {}) {
3612
+ const ctx = copyContext(files);
3613
+ const tells = CATALOGUE.filter((t) => t.surface === "copy");
3614
+ return report(tells, files, (tell) => tell.surface === "copy" ? tell.detect(ctx) : [], options);
3615
+ }
3616
+ function auditSnapshot(snapshot, options = {}) {
3617
+ const tells = CATALOGUE.filter((t) => t.rendered);
3618
+ const result = report(tells, [], (tell) => tell.rendered.detect(snapshot), options);
3619
+ result.summary.files = 1;
3620
+ return result;
3621
+ }
3622
+ function runTell(tell, files) {
3623
+ if (tell.surface === "source") return tell.detect({ files: files.map(parseFile) });
3624
+ return tell.detect(copyContext(files));
3625
+ }
3626
+ function catalogueTable() {
3627
+ const rows = CATALOGUE.map(
3628
+ (t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
3629
+ );
3630
+ return [
3631
+ `Catalogue version \`${CATALOGUE_VERSION}\`, ${CATALOGUE.length} tells.`,
3632
+ "",
3633
+ "| Id | Gen | Surface | Severity | Tell | Why it is a default |",
3634
+ "| --- | --- | --- | --- | --- | --- |",
3635
+ ...rows
3636
+ ].join("\n");
3637
+ }
3638
+
3639
+ // src/character/format.ts
3640
+ function formatReport(report2, title) {
3641
+ const lines = [];
3642
+ let current = "";
3643
+ for (const f3 of report2.findings) {
3644
+ if (f3.path !== current) {
3645
+ current = f3.path;
3646
+ lines.push("", f3.path);
3647
+ }
3648
+ lines.push(` ${String(f3.line).padStart(4)} ${f3.tell} (gen ${f3.generation}, ${f3.severity}) ${f3.message}`);
3649
+ lines.push(` ${f3.excerpt}`);
3650
+ }
3651
+ const fixes = new Map(report2.findings.map((f3) => [f3.tell, f3.fix]));
3652
+ if (fixes.size > 0) {
3653
+ lines.push("", "What to do instead:");
3654
+ for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
3655
+ }
3656
+ for (const r of report2.rejectedExceptions) {
3657
+ lines.push("", `Exception not applied (${r.exception.tell}): ${r.reason}`);
3658
+ }
3659
+ for (const e of report2.excepted) {
3660
+ lines.push("", `Excepted: ${e.count} \xD7 ${e.tell}, because ${e.because}`);
3661
+ }
3662
+ if (report2.suppressed.length > 0) {
3663
+ lines.push("", `Suppressed: ${report2.suppressed.length} marked copy-ok or craft-ok (${report2.suppressed.map((s) => `${s.path}:${s.line}`).join(", ")})`);
3664
+ }
3665
+ const { summary } = report2;
3666
+ const gens = `gen 1: ${summary.byGeneration[1]}, gen 2: ${summary.byGeneration[2]}, gen 3: ${summary.byGeneration[3]}`;
3667
+ lines.push(
3668
+ "",
3669
+ `${title}: ${summary.findings} finding${summary.findings === 1 ? "" : "s"} in ${summary.files} file${summary.files === 1 ? "" : "s"} (${gens}). Catalogue ${report2.catalogueVersion}.`
3670
+ );
3671
+ return lines.join("\n").replace(/^\n/, "");
3672
+ }
3673
+
3674
+ // src/fingerprint/index.ts
3675
+ var FINGERPRINT_VERSION = 1;
3676
+ var CHROMATIC = 0.03;
3677
+ var oklch = (value) => {
3678
+ const parsed = parseColour(value);
3679
+ return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
3680
+ };
3681
+ function accentOf(s) {
3682
+ const buttons = /* @__PURE__ */ new Map();
3683
+ for (const c of s.controls) {
3684
+ const o = oklch(c.background);
3685
+ if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
3686
+ }
3687
+ const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
3688
+ if (topButton) return oklch(topButton[0]);
3689
+ const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
3690
+ if (bg) return bg.o;
3691
+ const text = s.colours.text.map((t) => ({ o: oklch(t.value), w: t.chars })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
3692
+ return text ? text.o : null;
3693
+ }
3694
+ function faces(s) {
3695
+ const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
3696
+ const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
3697
+ const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
3698
+ const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
3699
+ return {
3700
+ display: { family: displayName, class: fontClass(displayName) },
3701
+ body: { family: bodyName, class: fontClass(bodyName) }
3702
+ };
3703
+ }
3704
+ function median(values) {
3705
+ if (values.length === 0) return null;
3706
+ const sorted = [...values].sort((a, b) => a - b);
3707
+ const mid = Math.floor(sorted.length / 2);
3708
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
3709
+ }
3710
+ function fingerprint(s) {
3711
+ const effects = [];
3712
+ if (s.effects.glass > 0) effects.push("glass-panel");
3713
+ if (s.effects.gradientText > 0) effects.push("gradient-text");
3714
+ if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
3715
+ if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
3716
+ if (s.effects.marquees > 0) effects.push("marquee");
3717
+ if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
3718
+ if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
3719
+ if (s.motion.introOverlay) effects.push("intro-cinematic");
3720
+ const below = s.sections.filter((x) => x.top > s.viewport.height).length;
3721
+ return {
3722
+ version: FINGERPRINT_VERSION,
3723
+ accent: accentOf(s),
3724
+ ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
3725
+ ...faces(s),
3726
+ roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
3727
+ motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
3728
+ effects: effects.sort(),
3729
+ layout: s.sections.map((x) => x.kind)
3730
+ };
3731
+ }
3732
+ var DISTANCE_WEIGHTS = { accent: 0.25, type: 0.25, ground: 0.1, shape: 0.1, motion: 0.1, effects: 0.1, layout: 0.1 };
3733
+ function levenshtein(a, b) {
3734
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
3735
+ for (let i = 1; i <= a.length; i += 1) {
3736
+ let prev = row[0];
3737
+ row[0] = i;
3738
+ for (let j = 1; j <= b.length; j += 1) {
3739
+ const cur = row[j];
3740
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
3741
+ prev = cur;
3742
+ }
3743
+ }
3744
+ return row[b.length];
3745
+ }
3746
+ var clamp = (n) => Math.max(0, Math.min(1, n));
3747
+ function faceDistance(a, b) {
3748
+ if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
3749
+ return a.class === b.class ? 0.5 : 1;
3750
+ }
3751
+ function fingerprintDistance(a, b) {
3752
+ const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / 0.3) : a.accent || b.accent ? 1 : 0;
3753
+ const ground = clamp(deltaEOk(a.ground, b.ground) / 0.15);
3754
+ const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
3755
+ const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
3756
+ const motion = clamp(Math.abs(a.motion - b.motion));
3757
+ const union = /* @__PURE__ */ new Set([...a.effects, ...b.effects]);
3758
+ const shared = a.effects.filter((e) => b.effects.includes(e)).length;
3759
+ const effects = union.size === 0 ? 0 : 1 - shared / union.size;
3760
+ const longest = Math.max(a.layout.length, b.layout.length);
3761
+ const layout = longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
3762
+ const parts2 = { accent, ground, type, shape, motion, effects, layout };
3763
+ const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts2[k], 0);
3764
+ return { total: Math.round(total * 1e3) / 1e3, parts: parts2 };
3765
+ }
3766
+ function nearest(target, set, k = set.length) {
3767
+ return set.map((item) => ({ id: item.id, distance: fingerprintDistance(target, item.fingerprint) })).sort((x, y) => x.distance.total - y.distance.total).slice(0, k);
3768
+ }
3769
+
3770
+ // src/direction/types.ts
3771
+ var DIRECTION_VERSION = 1;
3772
+ var SOURCE_KINDS = [
3773
+ "livery",
3774
+ "shopfront",
3775
+ "signage",
3776
+ "packaging",
3777
+ "product",
3778
+ "uniform",
3779
+ "interior",
3780
+ "place",
3781
+ "material",
3782
+ "trade",
3783
+ "print",
3784
+ "photo",
3785
+ "founder"
3786
+ ];
3787
+ var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
3788
+
3789
+ // src/direction/validate.ts
3790
+ var PREFERENCE = /\b(?:client (?:likes|liked|wants|wanted|asked|chose|prefers?)|(?:we|they|i) (?:like|liked|love|loved|prefer)|on[- ]trend|trendy|popular|what (?:everyone|competitors) (?:uses?|does)|brand (?:colou?r|font|guidelines?) (?:is|are|says?))\b/i;
3791
+ var MOOD = /\b(?:modern|clean|fresh|professional|trustworthy|premium|elegant|sleek|timeless|minimal(?:ist)?|bold|vibrant|friendly|approachable|luxur(?:y|ious)|sophisticated|contemporary|stylish|warm|inviting|high[- ]end)\b/gi;
3792
+ var VALUE_WORDS = /* @__PURE__ */ new Set(["green", "blue", "navy", "black", "white", "cream", "grey", "gray", "gold", "yellow", "orange", "purple", "violet", "pink", "brown", "teal", "bottle", "dark", "light", "deep", "pale", "bright", "colour", "color", "font", "face", "serif", "sans", "type", "lettering"]);
3793
+ var STOP = /* @__PURE__ */ new Set(["with", "that", "this", "from", "their", "they", "have", "been", "were", "which", "what", "when", "where", "there", "about", "into", "onto", "over", "same", "each", "every"]);
3794
+ function words(text) {
3795
+ return new Set((text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).map((w) => w.replace(/'s$/, "")).filter((w) => !STOP.has(w) && !VALUE_WORDS.has(w)));
3796
+ }
3797
+ function tellFor(key, value) {
3798
+ if (key === "accent") {
3799
+ const c = parseColour(value);
3800
+ return c && isAiViolet(c.oklch) ? "ai-violet" : null;
3801
+ }
3802
+ if (key === "ground") {
3803
+ const c = parseColour(value);
3804
+ return c && isCream(c.oklch) ? "cream-palette" : null;
3805
+ }
3806
+ if (key === "display" || key === "body") {
3807
+ const name = normaliseFamily(value).toLowerCase();
3808
+ if (REFLEX_FONTS_1.some((f3) => f3.toLowerCase() === name)) return "reflex-font";
3809
+ if (REFLEX_FONTS_2.some((f3) => f3.toLowerCase() === name)) return "reflex-font-2";
3810
+ }
3811
+ return null;
3812
+ }
3813
+ function validateDirection(input, ctx = {}) {
3814
+ const problems = [];
3815
+ const err = (at, message) => problems.push({ severity: "error", at, message });
3816
+ const warn = (at, message) => problems.push({ severity: "warn", at, message });
3817
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
3818
+ err("", "art-direction.json must be a JSON object");
3819
+ return { valid: false, problems, decided: 0 };
3820
+ }
3821
+ const d = input;
3822
+ const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
3823
+ if (legacy) {
3824
+ warn("", "exceptions only: run craft direction init to record the choices and their reasons");
3825
+ return { valid: true, problems, decided: 0 };
3826
+ }
3827
+ if (d.version !== DIRECTION_VERSION) err("version", `version must be ${DIRECTION_VERSION}`);
3828
+ if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
3829
+ if (typeof d.brief !== "string" || d.brief.trim().split(/\s+/).length < 8) err("brief", "the brief needs a sentence: who, where, and what they do");
3830
+ const sources = Array.isArray(d.sources) ? d.sources : [];
3831
+ if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
3832
+ if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
3833
+ const ids = /* @__PURE__ */ new Set();
3834
+ sources.forEach((s, i) => {
3835
+ const at = `sources[${i}]`;
3836
+ if (!s || typeof s !== "object") return err(at, "a source must be an object");
3837
+ if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
3838
+ else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
3839
+ else ids.add(s.id);
3840
+ if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
3841
+ if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
3842
+ if (s.path && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
3843
+ for (const [j, c] of (s.colours ?? []).entries()) if (!parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
3844
+ });
3845
+ const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
3846
+ if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
3847
+ const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
3848
+ let decided = 0;
3849
+ for (const key of Object.keys(choices)) {
3850
+ if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Layout and navigation stay conventional.`);
3851
+ }
3852
+ for (const key of CHOICE_KEYS) {
3853
+ const c = choices[key];
3854
+ const at = `choices.${key}`;
3855
+ if (!c) {
3856
+ warn(at, "not decided yet");
3857
+ continue;
3858
+ }
3859
+ let ok = true;
3860
+ if (typeof c.value !== "string" || !c.value.trim()) {
3861
+ err(`${at}.value`, "no value");
3862
+ ok = false;
3863
+ } else if ((key === "accent" || key === "ground") && !parseColour(c.value)) {
3864
+ err(`${at}.value`, `"${c.value}" is not a colour`);
3865
+ ok = false;
3866
+ }
3867
+ const because = typeof c.because === "string" ? c.because.trim() : "";
3868
+ const evidence = Array.isArray(c.evidence) ? c.evidence : [];
3869
+ if (because.split(/\s+/).filter(Boolean).length < 8) {
3870
+ err(`${at}.because`, "a reason is at least a sentence. Without one this is a default, however good it looks.");
3871
+ ok = false;
3872
+ } else if (because.startsWith("PROPOSED:")) {
3873
+ err(`${at}.because`, "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
3874
+ ok = false;
3875
+ } else {
3876
+ if (PREFERENCE.test(because)) {
3877
+ err(`${at}.because`, "that is a preference, not a reason. Say what in the client's world this comes from.");
3878
+ ok = false;
3879
+ }
3880
+ const moods = because.match(MOOD) ?? [];
3881
+ if (moods.length >= 2) {
3882
+ err(`${at}.because`, `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
3883
+ ok = false;
3884
+ }
3885
+ }
3886
+ if (evidence.length === 0) {
3887
+ err(`${at}.evidence`, "cite at least one source");
3888
+ ok = false;
3889
+ }
3890
+ for (const id of evidence) {
3891
+ if (!ids.has(id)) {
3892
+ err(`${at}.evidence`, `no source called "${id}"`);
3893
+ ok = false;
3894
+ }
3895
+ }
3896
+ if (ok) {
3897
+ const cited = sources.filter((s) => evidence.includes(s.id));
3898
+ const reasonWords = words(because);
3899
+ const tied = cited.some((s) => reasonWords.has(s.id) || [...words(`${s.note} ${s.lettering ?? ""}`)].some((w) => reasonWords.has(w)));
3900
+ if (!tied) {
3901
+ err(`${at}.because`, `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
3902
+ ok = false;
3903
+ }
3904
+ }
3905
+ if (typeof c.value === "string") {
3906
+ const tell = tellFor(key, c.value);
3907
+ if (tell && !exceptions.some((e) => e.tell === tell)) {
3908
+ err(`${at}.value`, `${c.value} is on the tell catalogue (${tell}). Keep it only with an exception for ${tell}, carrying the same reason.`);
3909
+ ok = false;
3910
+ }
3911
+ }
3912
+ const fp = ctx.fingerprint;
3913
+ if (fp && typeof c.value === "string") {
3914
+ if ((key === "accent" || key === "ground") && parseColour(c.value)) {
3915
+ const declared = parseColour(c.value).oklch;
3916
+ const measured = key === "accent" ? fp.accent : fp.ground;
3917
+ if (measured && deltaEOk(declared, measured) > 0.08) {
3918
+ warn(`${at}.value`, `the page does not show it: its ${key} measures oklch(${measured.l.toFixed(2)} ${measured.c.toFixed(3)} ${measured.h.toFixed(0)})`);
3919
+ }
3920
+ }
3921
+ if (key === "display" || key === "body") {
3922
+ const measured = key === "display" ? fp.display.family : fp.body.family;
3923
+ if (normaliseFamily(c.value).toLowerCase() !== measured.toLowerCase()) warn(`${at}.value`, `the page sets ${measured}, not ${c.value}`);
3924
+ }
3925
+ }
3926
+ if (ok) decided += 1;
3927
+ }
3928
+ return { valid: problems.every((p) => p.severity !== "error"), problems, decided };
3929
+ }
3930
+
3931
+ // src/direction/init.ts
3932
+ var hex = (o) => {
3933
+ const { r, g, b } = oklchToRgb(o);
3934
+ const clamp2 = (n) => Math.min(1, Math.max(0, n));
3935
+ return formatHex({ r: clamp2(r), g: clamp2(g), b: clamp2(b) });
3936
+ };
3937
+ function initDirection(input) {
3938
+ const fp = input.current;
3939
+ const empty = (value) => ({ value, because: "", evidence: [] });
3940
+ const choices = fp ? {
3941
+ ...fp.accent ? { accent: empty(hex(fp.accent)) } : {},
3942
+ ground: empty(hex(fp.ground)),
3943
+ display: empty(fp.display.family),
3944
+ body: empty(fp.body.family),
3945
+ ...fp.roundness !== null ? { shape: empty(fp.roundness >= 0.45 ? "pill buttons" : fp.roundness <= 0.05 ? "square corners" : `corners at ${Math.round(fp.roundness * 100)}% of button height`) } : {}
3946
+ } : {};
3947
+ return {
3948
+ $schema: "https://unpkg.com/@domandigital/craft/art-direction.schema.json",
3949
+ version: DIRECTION_VERSION,
3950
+ client: input.client ?? "",
3951
+ brief: input.brief ?? "",
3952
+ sources: [],
3953
+ choices,
3954
+ ...input.exceptions?.length ? { exceptions: input.exceptions } : {}
3955
+ };
3956
+ }
3957
+
3958
+ // src/direction/propose.ts
3959
+ var PROPOSED = "PROPOSED:";
3960
+ function srgbToLinear2(c) {
3961
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
3962
+ }
3963
+ function toLab(r, g, b) {
3964
+ const lr = srgbToLinear2(r / 255);
3965
+ const lg = srgbToLinear2(g / 255);
3966
+ const lb = srgbToLinear2(b / 255);
3967
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
3968
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
3969
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
3970
+ return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
3971
+ }
3972
+ function paletteFromPixels(rgba, k = 6, maxSamples = 2e4) {
3973
+ const total = Math.floor(rgba.length / 4);
3974
+ const step = Math.max(1, Math.floor(total / maxSamples));
3975
+ const px = [];
3976
+ for (let i = 0; i < total; i += step) {
3977
+ const o = i * 4;
3978
+ if (rgba[o + 3] < 128) continue;
3979
+ px.push({ lab: toLab(rgba[o], rgba[o + 1], rgba[o + 2]), rgb: [rgba[o], rgba[o + 1], rgba[o + 2]] });
3980
+ }
3981
+ if (px.length === 0) return [];
3982
+ const sorted = [...px].sort((a, b) => a.lab[0] - b.lab[0]);
3983
+ const n = Math.min(k, px.length);
3984
+ let centres = Array.from({ length: n }, (_, i) => sorted[Math.floor((i + 0.5) / n * sorted.length)].lab.slice());
3985
+ let assign = new Array(px.length).fill(0);
3986
+ for (let iter = 0; iter < 12; iter += 1) {
3987
+ assign = px.map((p) => {
3988
+ let best = 0;
3989
+ let bestD = Infinity;
3990
+ centres.forEach((c, j) => {
3991
+ const d = (p.lab[0] - c[0]) ** 2 + (p.lab[1] - c[1]) ** 2 + (p.lab[2] - c[2]) ** 2;
3992
+ if (d < bestD) {
3993
+ bestD = d;
3994
+ best = j;
3995
+ }
3996
+ });
3997
+ return best;
3998
+ });
3999
+ centres = centres.map((c, j) => {
4000
+ const members = px.filter((_, i) => assign[i] === j);
4001
+ if (members.length === 0) return c;
4002
+ return [0, 1, 2].map((d) => members.reduce((s, m) => s + m.lab[d], 0) / members.length);
4003
+ });
4004
+ }
4005
+ return centres.map((_, j) => {
4006
+ const members = px.filter((__, i) => assign[i] === j);
4007
+ if (members.length === 0) return null;
4008
+ const avg = [0, 1, 2].map((d) => members.reduce((s, m) => s + m.rgb[d], 0) / members.length / 255);
4009
+ const hex2 = formatHex({ r: avg[0], g: avg[1], b: avg[2] });
4010
+ return { hex: hex2, share: members.length / px.length, oklch: hexToOklch(hex2) };
4011
+ }).filter((c) => c !== null).sort((a, b) => b.share - a.share);
4012
+ }
4013
+ var chromatic = (o) => o.c >= 0.04 && o.l >= 0.2 && o.l <= 0.85;
4014
+ function accentCandidates(input) {
4015
+ const out = [];
4016
+ for (const s of input.sources) {
4017
+ for (const c of s.colours ?? []) {
4018
+ const parsed = parseColour(c);
4019
+ if (!parsed || !chromatic(parsed.oklch)) continue;
4020
+ const o = parsed.oklch;
4021
+ const siblings = (input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 }));
4022
+ const nearest2 = siblings.sort((a, b) => a.d - b.d)[0];
4023
+ const penalty = isAiViolet(o) ? 1 : 0;
4024
+ out.push({
4025
+ value: c.startsWith("#") ? c.toLowerCase() : c,
4026
+ evidence: [s.id],
4027
+ note: `from ${s.id} (${s.kind})${nearest2 ? `; nearest estate accent ${nearest2.id} at \u0394E ${nearest2.d.toFixed(2)}` : ""}${penalty ? "; in the reflex violet band" : ""}`,
4028
+ score: (nearest2 ? Math.min(nearest2.d, 0.3) : 0.3) - penalty + o.c
4029
+ });
4030
+ }
4031
+ }
4032
+ return out.sort((a, b) => b.score - a.score).map(({ score: _s, ...rest }) => rest);
4033
+ }
4034
+ function groundCandidates(input) {
4035
+ const out = [];
4036
+ for (const s of input.sources) {
4037
+ for (const c of s.colours ?? []) {
4038
+ const parsed = parseColour(c);
4039
+ if (!parsed || parsed.oklch.l < 0.9) continue;
4040
+ out.push({ value: c, evidence: [s.id], note: `a light colour from ${s.id}${isCream(parsed.oklch) ? "; reads as the cream tell, needs its reason" : ""}` });
4041
+ }
4042
+ }
4043
+ return out;
4044
+ }
4045
+ function typeCandidates(input) {
4046
+ return input.sources.filter((s) => s.lettering).map((s) => ({ value: "", evidence: [s.id], note: `find a face with the character of "${s.lettering}" on ${s.id}` }));
4047
+ }
4048
+ function propose(input) {
4049
+ const proposals = [
4050
+ { key: "accent", candidates: accentCandidates(input) },
4051
+ { key: "ground", candidates: groundCandidates(input) },
4052
+ { key: "display", candidates: typeCandidates(input) }
4053
+ ];
4054
+ const choices = {};
4055
+ const draft = (p) => {
4056
+ const top = p.candidates[0];
4057
+ if (!top || !top.value) return void 0;
4058
+ const source = input.sources.find((s) => s.id === top.evidence[0]);
4059
+ return {
4060
+ value: top.value,
4061
+ because: `${PROPOSED} taken from the ${source.id}: ${source.note} Confirm it matches, then rewrite this in your own words.`,
4062
+ evidence: top.evidence
4063
+ };
4064
+ };
4065
+ for (const p of proposals) {
4066
+ const c = draft(p);
4067
+ if (c) choices[p.key] = c;
4068
+ }
4069
+ return {
4070
+ proposals,
4071
+ direction: { version: DIRECTION_VERSION, client: input.client, brief: input.brief, sources: input.sources, choices }
4072
+ };
4073
+ }
1322
4074
  // Annotate the CommonJS export names for ESM import in node:
1323
4075
  0 && (module.exports = {
4076
+ AI_PHRASES,
4077
+ AI_VIOLET,
4078
+ AI_WORDS,
4079
+ BUZZWORDS,
4080
+ CATALOGUE,
4081
+ CATALOGUE_VERSION,
4082
+ CHOICE_KEYS,
1324
4083
  DENSITIES,
1325
4084
  DENSITY_NAMES,
4085
+ DIRECTION_VERSION,
4086
+ DISTANCE_WEIGHTS,
1326
4087
  DURATION_MS,
1327
4088
  DURATION_S,
1328
4089
  EASE,
1329
4090
  EASE_TUPLE,
1330
4091
  EXIT_RATIO,
4092
+ FINGERPRINT_VERSION,
1331
4093
  HOUSE_BUDGET,
1332
4094
  HOUSE_TYPE,
1333
4095
  LIGHTNESS_CURVE,
4096
+ NEGATIVE_REASSURANCE,
4097
+ PILL_LIMIT,
4098
+ PLAINER_WORDS,
4099
+ PROPOSED,
1334
4100
  RAMP_STEPS,
4101
+ REFLEX_FONTS_1,
4102
+ REFLEX_FONTS_2,
4103
+ RENDERED_PATHS,
4104
+ REVEAL_LIMIT,
4105
+ REVIEW_PHRASES,
1335
4106
  SCALE,
4107
+ SHADCN_LIMIT,
4108
+ SHADCN_PRIMITIVES,
4109
+ SNAPSHOT_VERSION,
4110
+ SOURCE_KINDS,
1336
4111
  SPACE_STEPS,
1337
4112
  SPRING,
4113
+ STOCK_PHRASES,
1338
4114
  TYPE_STEPS,
4115
+ VAGUE_WORDS,
1339
4116
  accentFork,
1340
4117
  apcaContrast,
4118
+ auditSnapshot,
4119
+ catalogueTable,
4120
+ checkCopy,
1341
4121
  checkPair,
1342
4122
  checkRestraint,
1343
4123
  craftTokens,
@@ -1347,25 +4127,43 @@ var DENSITY_NAMES = Object.keys(DENSITIES);
1347
4127
  densityTokens,
1348
4128
  emitCss,
1349
4129
  exitDuration,
4130
+ findColours,
4131
+ fingerprint,
4132
+ fingerprintDistance,
1350
4133
  fluidClamp,
1351
4134
  fluidSpace,
1352
4135
  fluidType,
4136
+ fontClass,
1353
4137
  formatHex,
4138
+ formatReport,
1354
4139
  hexToOklch,
1355
4140
  inP3Gamut,
1356
4141
  inSrgbGamut,
4142
+ initDirection,
4143
+ isAiViolet,
4144
+ isCream,
4145
+ makeSnapshot,
1357
4146
  motionTokens,
4147
+ nearest,
4148
+ normaliseFamily,
1358
4149
  oklchToHex,
1359
4150
  oklchToRgb,
4151
+ paletteFromPixels,
4152
+ parseColour,
1360
4153
  parseHex,
4154
+ propose,
1361
4155
  ramp,
1362
4156
  rampFromAnchors,
4157
+ runTell,
4158
+ scanSource,
1363
4159
  sectionRhythm,
1364
4160
  semantic,
1365
4161
  shouldAnimate,
1366
4162
  tailwindV3Preset,
1367
4163
  tailwindV4Theme,
4164
+ tellById,
1368
4165
  toGamut,
1369
4166
  typeFeatureTokens,
4167
+ validateDirection,
1370
4168
  wcagContrast
1371
4169
  });