@domandigital/craft 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2250,14 +2250,14 @@ function contractionHits(ctx) {
2250
2250
  for (const doc2 of documents(ctx)) {
2251
2251
  if (doc2.words < 800) continue;
2252
2252
  const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
2253
- const count2 = (text.match(CONTRACTION) ?? []).length;
2254
- const rate = count2 * 1e3 / doc2.words;
2253
+ const count3 = (text.match(CONTRACTION) ?? []).length;
2254
+ const rate = count3 * 1e3 / doc2.words;
2255
2255
  if (rate < 1) {
2256
2256
  const first = sentences(doc2)[0];
2257
2257
  hits.push({
2258
2258
  path: doc2.path,
2259
2259
  offset: first?.offset ?? 0,
2260
- message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
2260
+ message: `${count3} contraction${count3 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
2261
2261
  });
2262
2262
  }
2263
2263
  }
@@ -2380,16 +2380,16 @@ function filler(words2, seed = 1, options = {}) {
2380
2380
  return state % n;
2381
2381
  };
2382
2382
  const out = [];
2383
- let count2 = 0;
2383
+ let count3 = 0;
2384
2384
  let i = 0;
2385
- while (count2 < words2) {
2385
+ while (count3 < words2) {
2386
2386
  const length = uniform ?? shape[i % shape.length];
2387
2387
  const s = [];
2388
2388
  for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
2389
2389
  if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
2390
2390
  s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
2391
2391
  out.push(`${s.join(" ")}.`);
2392
- count2 += s.length;
2392
+ count3 += s.length;
2393
2393
  i += 1;
2394
2394
  }
2395
2395
  const paragraphs = [];
@@ -2742,6 +2742,7 @@ var REFLEX_FONTS_2 = [
2742
2742
  "Plus Jakarta Sans",
2743
2743
  "Outfit",
2744
2744
  "Geist",
2745
+ "Cal Sans",
2745
2746
  "Sora",
2746
2747
  "Instrument Sans",
2747
2748
  "Instrument Serif",
@@ -2963,7 +2964,7 @@ function iconTiles(file) {
2963
2964
  return sized && has(unit, /^rounded-(?:md|lg|xl|2xl|full)$/) && has(unit, /^(?:items-center|place-items-center|place-content-center)$/) && has(unit, /^bg-/);
2964
2965
  });
2965
2966
  }
2966
- var ICON_LIBRARY = /from\s+["'](?:lucide-react|@heroicons\/react[^"']*|react-icons[^"']*|@phosphor-icons\/react|@tabler\/icons-react|@radix-ui\/react-icons)["']/;
2967
+ var ICON_LIBRARY = /from\s+["'](?:lucide-react|@heroicons\/react[^"']*|react-icons[^"']*|@phosphor-icons\/react|@tabler\/icons-react|@radix-ui\/react-icons|@iconscout\/react-unicons[^"']*|@mui\/icons-material[^"']*|react-material-symbols[^"']*)["']/;
2967
2968
  function iconTileStackHits(ctx) {
2968
2969
  const hits = [];
2969
2970
  for (const file of ctx.files) {
@@ -3110,6 +3111,12 @@ function shadcnHits(ctx) {
3110
3111
  const detail = unused.length > 0 ? `; ${unused.length} imported nowhere` : "";
3111
3112
  return [{ path: ui[0].path, offset: 0, message: `${ui.length} stock shadcn primitives${detail}` }];
3112
3113
  }
3114
+ function shadcnCardHits(ctx) {
3115
+ return eachUnit(
3116
+ ctx,
3117
+ (unit) => has(unit, /^bg-card$/) && has(unit, /^text-card-foreground$/) && has(unit, /^border$/) && has(unit, /^rounded-(?:lg|xl)$/) && has(unit, /^shadow(?:-sm)?$/) ? "the registry Card recipe, unchanged" : null
3118
+ );
3119
+ }
3113
3120
  function marqueeHits(ctx) {
3114
3121
  const hits = [];
3115
3122
  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;
@@ -3327,6 +3334,23 @@ const inter = Inter({ subsets: ["latin"] });`),
3327
3334
  pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
3328
3335
  }
3329
3336
  },
3337
+ {
3338
+ id: "shadcn-card-stock",
3339
+ name: "Stock shadcn Card",
3340
+ generation: 1,
3341
+ severity: "warn",
3342
+ surface: "source",
3343
+ why: "The registry Card (bg-card, a plain border, rounded-lg or rounded-xl, shadow-sm) is the container every generated page is built from. Left as shipped, every card on the site looks like every other shadcn site's.",
3344
+ fix: "Give the card this site's radius and shadow from the restraint budget, or drop the shadow and let a visible border carry it.",
3345
+ detect: shadcnCardHits,
3346
+ fixtures: {
3347
+ flag: [
3348
+ f3("components/ui/card.tsx", `className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}`),
3349
+ f3("components/ui/card.tsx", `className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}`)
3350
+ ],
3351
+ pass: [f3("components/ui/card.tsx", `className={cn("rounded-sm border-2 border-ink bg-card text-card-foreground", className)}`)]
3352
+ }
3353
+ },
3330
3354
  {
3331
3355
  id: "cream-palette",
3332
3356
  name: "Cream ground",
@@ -3553,6 +3577,17 @@ var HOUSE = {
3553
3577
  function houseRule(id, name = id) {
3554
3578
  return HOUSE[id] ?? { tier: "review", label: name };
3555
3579
  }
3580
+ var HOUSE_LOCKED = "the house blocking tier cannot be lowered by a repo. A genuine one-off takes copy-ok on its line; a rule that is wrong is changed in craft's house.ts and COPY.md";
3581
+ function holdHouseBlocks(exceptions, severity) {
3582
+ const blocks = (tell) => HOUSE[tell]?.tier === "block";
3583
+ const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
3584
+ return {
3585
+ exceptions: exceptions.filter((e) => !blocks(e.tell)),
3586
+ refused: exceptions.filter((e) => blocks(e.tell)),
3587
+ severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
3588
+ refusedSeverity
3589
+ };
3590
+ }
3556
3591
 
3557
3592
  // src/character/parse.ts
3558
3593
  var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
@@ -3886,7 +3921,7 @@ function extractStrings(file) {
3886
3921
  }
3887
3922
 
3888
3923
  // src/snapshot/types.ts
3889
- var SNAPSHOT_VERSION = 1;
3924
+ var SNAPSHOT_VERSION = 2;
3890
3925
 
3891
3926
  // src/snapshot/fixture.ts
3892
3927
  function makeSnapshot(patch = {}) {
@@ -4219,188 +4254,988 @@ var RENDERED_PATHS = {
4219
4254
  }
4220
4255
  };
4221
4256
 
4222
- // src/character/check.ts
4223
- var CATALOGUE_VERSION = "2026.09.8";
4224
- var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
4225
- (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
4226
- );
4227
- function tellById(id) {
4228
- return CATALOGUE.find((t) => t.id === id);
4229
- }
4230
- function validateExceptions(exceptions) {
4231
- const applied = [];
4232
- const rejected = [];
4233
- for (const exception of exceptions) {
4234
- if (!tellById(exception.tell)) {
4235
- rejected.push({ exception, reason: `no tell called "${exception.tell}"` });
4236
- } else if (typeof exception.because !== "string" || exception.because.trim().length < 12) {
4237
- rejected.push({ exception, reason: "an exception needs a because of at least a sentence" });
4238
- } else {
4239
- applied.push(exception);
4240
- }
4257
+ // src/fingerprint/index.ts
4258
+ var FINGERPRINT_VERSION = 2;
4259
+ var CHROMATIC = 0.03;
4260
+ var MIN_ACCENT_AREA = 2e4;
4261
+ var oklch = (value) => {
4262
+ const parsed = parseColour(value);
4263
+ return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
4264
+ };
4265
+ function accentOf(s) {
4266
+ const buttons = /* @__PURE__ */ new Map();
4267
+ for (const c of s.controls) {
4268
+ const o = oklch(c.background);
4269
+ if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
4241
4270
  }
4242
- return { applied, rejected };
4243
- }
4244
- var MARKER = /\b(?:copy|craft)-ok\b/;
4245
- function marked(lines, line) {
4246
- return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
4271
+ const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
4272
+ if (topButton) return oklch(topButton[0]);
4273
+ const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC && x.w >= MIN_ACCENT_AREA).sort((a, b) => b.w - a.w)[0];
4274
+ if (bg) return bg.o;
4275
+ 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];
4276
+ return text ? text.o : null;
4247
4277
  }
4248
- function toFinding(tell, hit2, texts) {
4249
- const text = texts.get(hit2.path) ?? "";
4278
+ function faces(s) {
4279
+ const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
4280
+ const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
4281
+ const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
4282
+ const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
4250
4283
  return {
4251
- tell: tell.id,
4252
- name: tell.name,
4253
- generation: tell.generation,
4254
- severity: tell.severity,
4255
- path: hit2.path,
4256
- line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
4257
- excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
4258
- message: hit2.message,
4259
- fix: tell.fix,
4260
- ...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
4284
+ display: { family: displayName, class: fontClass(displayName) },
4285
+ body: { family: bodyName, class: fontClass(bodyName) }
4261
4286
  };
4262
4287
  }
4263
- function report(tells, files, run, options) {
4264
- const { applied, rejected } = validateExceptions(options.exceptions ?? []);
4265
- const texts = new Map(files.map((file) => [file.path, file.text]));
4266
- const selected = options.only ? tells.filter((t) => options.only.includes(t.id)) : tells;
4267
- const findings = [];
4268
- const suppressed = [];
4269
- const lineCache = /* @__PURE__ */ new Map();
4270
- const linesOf = (path) => {
4271
- let lines = lineCache.get(path);
4272
- if (!lines) lineCache.set(path, lines = (texts.get(path) ?? "").split("\n"));
4273
- return lines;
4274
- };
4275
- const excepted = /* @__PURE__ */ new Map();
4276
- for (const tell of selected) {
4277
- for (const hit2 of run(tell)) {
4278
- const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit2.path.includes(e.path)));
4279
- if (exception) {
4280
- excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
4281
- continue;
4282
- }
4283
- const finding = toFinding(tell, hit2, texts);
4284
- if (marked(linesOf(hit2.path), finding.line)) {
4285
- suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
4286
- continue;
4287
- }
4288
- findings.push(finding);
4289
- }
4290
- }
4291
- findings.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.tell.localeCompare(b.tell));
4292
- const byGeneration = { 1: 0, 2: 0, 3: 0 };
4293
- const byTell = {};
4294
- for (const finding of findings) {
4295
- byGeneration[finding.generation] += 1;
4296
- byTell[finding.tell] = (byTell[finding.tell] ?? 0) + 1;
4297
- }
4288
+ function median(values) {
4289
+ if (values.length === 0) return null;
4290
+ const sorted = [...values].sort((a, b) => a - b);
4291
+ const mid = Math.floor(sorted.length / 2);
4292
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
4293
+ }
4294
+ function fingerprint(s) {
4295
+ const effects = [];
4296
+ if (s.effects.glass > 0) effects.push("glass-panel");
4297
+ if (s.effects.gradientText > 0) effects.push("gradient-text");
4298
+ if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
4299
+ if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
4300
+ if (s.effects.marquees > 0) effects.push("marquee");
4301
+ if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
4302
+ if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
4303
+ if (s.motion.introOverlay) effects.push("intro-cinematic");
4304
+ const below = s.sections.filter((x) => x.top > s.viewport.height).length;
4298
4305
  return {
4299
- catalogueVersion: CATALOGUE_VERSION,
4300
- findings,
4301
- rejectedExceptions: rejected,
4302
- excepted: [...excepted].map(([e, count2]) => ({ tell: e.tell, because: e.because, count: count2 })),
4303
- suppressed,
4304
- summary: {
4305
- files: files.length,
4306
- findings: findings.length,
4307
- byGeneration,
4308
- byTell,
4309
- blocking: findings.filter((x) => x.severity === "block").length
4310
- }
4306
+ version: FINGERPRINT_VERSION,
4307
+ accent: accentOf(s),
4308
+ ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
4309
+ ...faces(s),
4310
+ roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
4311
+ motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
4312
+ effects: effects.sort(),
4313
+ layout: s.sections.map((x) => x.kind),
4314
+ ...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? {
4315
+ sections: s.sections.map((x) => ({
4316
+ role: x.role,
4317
+ centred: x.geometry.centredShare,
4318
+ width: x.geometry.contentWidthRatio,
4319
+ symmetry: x.geometry.mirrorSymmetry,
4320
+ controls: x.geometry.controls,
4321
+ cards: x.cards,
4322
+ background: x.geometry.background === s.ground ? null : oklch(x.geometry.background),
4323
+ badges: (x.badges ?? []).map((b) => b.toLowerCase()),
4324
+ avatars: x.avatars ?? 0,
4325
+ carousel: x.carousel ?? false,
4326
+ figures: x.figures ?? 0
4327
+ }))
4328
+ } : {}
4311
4329
  };
4312
4330
  }
4313
- function applyHouseGate(result) {
4314
- const findings = result.findings.map((f4) => f4.house === "block" ? { ...f4, severity: "block" } : f4);
4315
- return { ...result, findings, summary: { ...result.summary, blocking: findings.filter((f4) => f4.severity === "block").length } };
4331
+ var DISTANCE_WEIGHTS = { accent: 0.25, type: 0.25, ground: 0.1, shape: 0.1, motion: 0.1, effects: 0.1, layout: 0.1 };
4332
+ function levenshtein(a, b, cost = (x, y) => x === y ? 0 : 1) {
4333
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
4334
+ for (let i = 1; i <= a.length; i += 1) {
4335
+ let prev = row[0];
4336
+ row[0] = i;
4337
+ for (let j = 1; j <= b.length; j += 1) {
4338
+ const cur = row[j];
4339
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost(a[i - 1], b[j - 1]));
4340
+ prev = cur;
4341
+ }
4342
+ }
4343
+ return row[b.length];
4316
4344
  }
4317
- function scanSource(files, options = {}) {
4318
- const ctx = { files: files.map(parseFile) };
4319
- const tells = CATALOGUE.filter((t) => t.surface === "source");
4320
- return report(tells, files, (tell) => tell.surface === "source" ? tell.detect(ctx) : [], options);
4345
+ function sectionCost(x, y) {
4346
+ if (x.role !== y.role) return 1;
4347
+ return Math.min(0.5, (Math.abs(x.centred - y.centred) + Math.abs(x.width - y.width)) / 2);
4321
4348
  }
4322
- function copyContext(files) {
4323
- return { files, blocks: files.flatMap(extractCopy), strings: files.flatMap(extractStrings) };
4349
+ function layoutDistance(a, b) {
4350
+ if (a.sections && b.sections) {
4351
+ const longest2 = Math.max(a.sections.length, b.sections.length);
4352
+ return longest2 === 0 ? 0 : levenshtein(a.sections, b.sections, sectionCost) / longest2;
4353
+ }
4354
+ const longest = Math.max(a.layout.length, b.layout.length);
4355
+ return longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
4324
4356
  }
4325
- function checkCopy(files, options = {}) {
4326
- const ctx = copyContext(files);
4327
- const tells = CATALOGUE.filter((t) => t.surface === "copy");
4328
- return report(tells, files, (tell) => tell.surface === "copy" ? tell.detect(ctx) : [], options);
4357
+ var clamp = (n) => Math.max(0, Math.min(1, n));
4358
+ function faceDistance(a, b) {
4359
+ if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
4360
+ return a.class === b.class ? 0.5 : 1;
4329
4361
  }
4330
- function auditSnapshot(snapshot, options = {}) {
4331
- const tells = CATALOGUE.filter((t) => t.rendered);
4332
- const result = report(tells, [], (tell) => tell.rendered.detect(snapshot), options);
4333
- result.summary.files = 1;
4334
- return result;
4362
+ function fingerprintDistance(a, b) {
4363
+ const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / 0.3) : a.accent || b.accent ? 1 : 0;
4364
+ const ground = clamp(deltaEOk(a.ground, b.ground) / 0.15);
4365
+ const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
4366
+ const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
4367
+ const motion = clamp(Math.abs(a.motion - b.motion));
4368
+ const union = /* @__PURE__ */ new Set([...a.effects, ...b.effects]);
4369
+ const shared = a.effects.filter((e) => b.effects.includes(e)).length;
4370
+ const effects = union.size === 0 ? 0 : 1 - shared / union.size;
4371
+ const layout = layoutDistance(a, b);
4372
+ const parts2 = { accent, ground, type, shape, motion, effects, layout };
4373
+ const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts2[k], 0);
4374
+ return { total: Math.round(total * 1e3) / 1e3, parts: parts2 };
4335
4375
  }
4336
- function runTell(tell, files) {
4337
- if (tell.surface === "source") return tell.detect({ files: files.map(parseFile) });
4338
- return tell.detect(copyContext(files));
4376
+ function nearest(target2, set, k = set.length) {
4377
+ return set.map((item) => ({ id: item.id, distance: fingerprintDistance(target2, item.fingerprint) })).sort((x, y) => x.distance.total - y.distance.total).slice(0, k);
4339
4378
  }
4340
- function catalogueTable() {
4341
- const rows = CATALOGUE.map(
4342
- (t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
4343
- );
4379
+
4380
+ // src/null/index.ts
4381
+ var NULL_VERSION = 1;
4382
+ function nullPrompt(brief) {
4344
4383
  return [
4345
- `Catalogue version \`${CATALOGUE_VERSION}\`, ${CATALOGUE.length} tells.`,
4384
+ `Build the home page for this business: ${brief.trim()}`,
4346
4385
  "",
4347
- "| Id | Gen | Surface | Severity | Tell | Why it is a default |",
4348
- "| --- | --- | --- | --- | --- | --- |",
4349
- ...rows
4386
+ "Write it as one complete, self-contained HTML file. You may load fonts and libraries from a CDN. Use real copy, not placeholder text. Reply with the HTML only."
4350
4387
  ].join("\n");
4351
4388
  }
4352
- function wordListsJson() {
4353
- return JSON.stringify(
4354
- {
4355
- catalogueVersion: CATALOGUE_VERSION,
4356
- lists: {
4357
- aiWords: AI_WORDS,
4358
- stockPhrases: STOCK_PHRASES,
4359
- aiPhrases: AI_PHRASES,
4360
- plainerWords: PLAINER_WORDS,
4361
- buzzwords: BUZZWORDS,
4362
- negativeReassurance: NEGATIVE_REASSURANCE,
4363
- vagueWords: VAGUE_WORDS,
4364
- reviewPhrases: REVIEW_PHRASES
4365
- }
4366
- },
4367
- null,
4368
- 2
4369
- ) + "\n";
4389
+ function extractHtml(reply) {
4390
+ const start = reply.search(/<!doctype html|<html[\s>]/i);
4391
+ if (start < 0) return null;
4392
+ const end = reply.toLowerCase().lastIndexOf("</html>");
4393
+ const html = end > start ? reply.slice(start, end + "</html>".length) : reply.slice(start).replace(/\s*```\s*$/, "");
4394
+ return html.length > 200 ? html : null;
4370
4395
  }
4371
-
4372
- // src/character/format.ts
4373
- function formatReport(report2, title) {
4374
- const lines = [];
4375
- let current = "";
4376
- for (const f4 of report2.findings) {
4377
- if (f4.path !== current) {
4378
- current = f4.path;
4379
- lines.push("", f4.path);
4380
- }
4381
- lines.push(` ${String(f4.line).padStart(4)} ${f4.tell} (gen ${f4.generation}, ${f4.severity}) ${f4.message}`);
4382
- lines.push(` ${f4.excerpt}`);
4383
- }
4384
- const fixes = new Map(report2.findings.map((f4) => [f4.tell, f4.fix]));
4385
- if (fixes.size > 0) {
4386
- lines.push("", "What to do instead:");
4387
- for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
4388
- }
4389
- for (const r of report2.rejectedExceptions) {
4390
- lines.push("", `Exception not applied (${r.exception.tell}): ${r.reason}`);
4391
- }
4392
- for (const e of report2.excepted) {
4393
- lines.push("", `Excepted: ${e.count} \xD7 ${e.tell}, because ${e.because}`);
4394
- }
4395
- if (report2.suppressed.length > 0) {
4396
- lines.push("", `Suppressed: ${report2.suppressed.length} marked copy-ok or craft-ok (${report2.suppressed.map((s) => `${s.path}:${s.line}`).join(", ")})`);
4397
- }
4398
- const { summary } = report2;
4399
- const gens = `gen 1: ${summary.byGeneration[1]}, gen 2: ${summary.byGeneration[2]}, gen 3: ${summary.byGeneration[3]}`;
4400
- lines.push(
4401
- "",
4402
- `${title}: ${summary.findings} finding${summary.findings === 1 ? "" : "s"} in ${summary.files} file${summary.files === 1 ? "" : "s"} (${gens}). Catalogue ${report2.catalogueVersion}.`
4403
- );
4396
+ var MIN_RUNS = 5;
4397
+ var TYPICAL_AT = 0.1;
4398
+ function meanNearest(distances, k) {
4399
+ const sorted = [...distances].sort((a, b) => a - b).slice(0, k);
4400
+ return sorted.reduce((s, d) => s + d, 0) / sorted.length;
4401
+ }
4402
+ function median2(values) {
4403
+ const sorted = [...values].sort((a, b) => a - b);
4404
+ const mid = Math.floor(sorted.length / 2);
4405
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
4406
+ }
4407
+ function typicality(target2, runs, k = 3) {
4408
+ if (runs.length < MIN_RUNS) throw new Error(`a null model needs at least ${MIN_RUNS} runs; this one has ${runs.length}`);
4409
+ const kk = Math.min(k, runs.length - 1);
4410
+ const toTarget = runs.map((r) => ({ id: r.id, distance: fingerprintDistance(target2, r.fingerprint).total }));
4411
+ const distance = meanNearest(
4412
+ toTarget.map((t) => t.distance),
4413
+ kk
4414
+ );
4415
+ const loo = runs.map(
4416
+ (r, i) => meanNearest(
4417
+ runs.filter((_, j) => j !== i).map((o) => fingerprintDistance(r.fingerprint, o.fingerprint).total),
4418
+ kk
4419
+ )
4420
+ );
4421
+ const score = loo.filter((d) => d >= distance).length / loo.length;
4422
+ return {
4423
+ score: Math.round(score * 100) / 100,
4424
+ typical: score >= TYPICAL_AT,
4425
+ distance: Math.round(distance * 1e3) / 1e3,
4426
+ baseline: Math.round(median2(loo) * 1e3) / 1e3,
4427
+ nearest: toTarget.sort((a, b) => a.distance - b.distance).slice(0, kk),
4428
+ shared: sharedChoices(target2, runs)
4429
+ };
4430
+ }
4431
+ var HUES = [
4432
+ ["red", 0, 45],
4433
+ ["orange", 45, 75],
4434
+ ["yellow", 75, 110],
4435
+ ["green", 110, 165],
4436
+ ["teal", 165, 215],
4437
+ ["blue", 215, 265],
4438
+ ["violet", 265, 315],
4439
+ ["pink", 315, 360]
4440
+ ];
4441
+ function hueName(o) {
4442
+ if (!o) return "none";
4443
+ if (o.c < 0.03) return "neutral";
4444
+ return HUES.find(([, lo, hi]) => o.h >= lo && o.h < hi)?.[0] ?? "red";
4445
+ }
4446
+ function groundName(o) {
4447
+ if (o.l < 0.3) return "dark";
4448
+ if (isCream(o)) return "cream";
4449
+ if (o.l >= 0.97 && o.c < 0.012) return "white";
4450
+ return o.c < 0.02 ? "grey" : "tinted";
4451
+ }
4452
+ function shapeName(roundness) {
4453
+ if (roundness === null) return "no buttons";
4454
+ if (roundness >= 0.45) return "pill";
4455
+ return roundness >= 0.15 ? "soft" : "square";
4456
+ }
4457
+ function choicesOf(fp) {
4458
+ return {
4459
+ display: [fp.display.family],
4460
+ body: [fp.body.family],
4461
+ accent: [hueName(fp.accent)],
4462
+ ground: [groundName(fp.ground)],
4463
+ shape: [shapeName(fp.roundness)],
4464
+ motion: [fp.motion >= 0.6 ? "reveals most sections" : fp.motion > 0 ? "reveals some sections" : "no scroll reveals"],
4465
+ effects: fp.effects,
4466
+ opening: [fp.layout.slice(0, 3).join(" > ") || "none"]
4467
+ };
4468
+ }
4469
+ function sharedChoices(target2, runs) {
4470
+ const mine = choicesOf(target2);
4471
+ const all = runs.map((r) => choicesOf(r.fingerprint));
4472
+ const out = [];
4473
+ for (const [part, values] of Object.entries(mine)) {
4474
+ for (const value of values) {
4475
+ const count3 = all.filter((c) => c[part].includes(value)).length;
4476
+ if (count3 / runs.length >= 0.25) out.push({ part, value, runs: count3, of: runs.length });
4477
+ }
4478
+ }
4479
+ return out.sort((a, b) => b.runs - a.runs);
4480
+ }
4481
+ var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f4) => f4.toLowerCase()));
4482
+ var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f4) => f4.toLowerCase()));
4483
+ function knownFor(kind, value, sample) {
4484
+ if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
4485
+ if (kind === "accent" && value === "violet") return sample.some((f4) => f4.accent && isAiViolet(f4.accent)) ? "ai-violet" : null;
4486
+ if (kind === "ground" && value === "cream") return "cream-palette";
4487
+ if (kind === "shape" && value === "pill") return "pill-everything";
4488
+ if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
4489
+ if (kind === "tell") return value;
4490
+ if (kind === "opening" && /^hero > (?:logos|stats)/.test(value)) return "hero-then-proof";
4491
+ return null;
4492
+ }
4493
+ var STOP = new Set(
4494
+ "a an the and or but of to in on for with at by from as is are be we our you your us it its this that these those will can all more get has have not no any".split(" ")
4495
+ );
4496
+ function grams(text) {
4497
+ const out = /* @__PURE__ */ new Set();
4498
+ for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
4499
+ const words2 = clause.match(/[a-z][a-z'-]*/g) ?? [];
4500
+ for (let n = 2; n <= 4; n += 1) {
4501
+ for (let i = 0; i + n <= words2.length; i += 1) {
4502
+ const g = words2.slice(i, i + n);
4503
+ if (STOP.has(g[0]) || STOP.has(g[n - 1])) continue;
4504
+ if (g.filter((w) => !STOP.has(w)).length < 2) continue;
4505
+ out.add(g.join(" "));
4506
+ }
4507
+ }
4508
+ }
4509
+ return out;
4510
+ }
4511
+ var GRAMMAR = /* @__PURE__ */ new Set([
4512
+ "privacy policy",
4513
+ "cookie policy",
4514
+ "rights reserved",
4515
+ "all rights reserved",
4516
+ "terms of service",
4517
+ "terms and conditions",
4518
+ "terms conditions",
4519
+ "how it works",
4520
+ "what we do",
4521
+ "about us",
4522
+ "contact us",
4523
+ "get in touch",
4524
+ "opening hours",
4525
+ "read more",
4526
+ "learn more",
4527
+ "book now",
4528
+ "free quote",
4529
+ "our services",
4530
+ "quick links",
4531
+ "email address",
4532
+ "phone number",
4533
+ "full name",
4534
+ "first name",
4535
+ "last name",
4536
+ "your name",
4537
+ "your email",
4538
+ "send message"
4539
+ ]);
4540
+ var LEGAL = /©|&copy;|\bregistered (?:in|office|number)\b|\bcompany (?:no|number|reg)|\bvat (?:no|number|reg)/i;
4541
+ var NOISE = /\S+@\S+|https?:\/\/\S+|\b[\w-]+(?:\.[\w-]+)*\.(?:co\.uk|com|org|net|uk|io)\b|\b\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm)\b|\d+/gi;
4542
+ function textGrams(text) {
4543
+ if (LEGAL.test(text)) return /* @__PURE__ */ new Set();
4544
+ const out = grams(text.replace(NOISE, " | "));
4545
+ for (const g of out) if (GRAMMAR.has(g)) out.delete(g);
4546
+ return out;
4547
+ }
4548
+ function properNouns(brief) {
4549
+ return new Set((brief.match(/\b[A-Z][a-z]+/g) ?? []).map((w) => w.toLowerCase()).filter((w) => !STOP.has(w)));
4550
+ }
4551
+ function harvest(models, options = {}) {
4552
+ const minShare = options.minShare ?? 0.25;
4553
+ const runs = models.flatMap((m, b) => m.runs.map((r) => ({ ...r, brief: b })));
4554
+ const of = runs.length;
4555
+ if (of === 0) return [];
4556
+ const multiBrief = models.length > 1;
4557
+ const sample = runs.map((r) => r.fingerprint);
4558
+ const tally = /* @__PURE__ */ new Map();
4559
+ const add = (kind, value, run, brief, example) => {
4560
+ const key = `${kind}\0${value}`;
4561
+ const t = tally.get(key) ?? { kind, value, runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example };
4562
+ t.runs.add(run);
4563
+ t.briefs.add(brief);
4564
+ tally.set(key, t);
4565
+ };
4566
+ runs.forEach((r, i) => {
4567
+ const c = choicesOf(r.fingerprint);
4568
+ for (const f4 of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f4, i, r.brief);
4569
+ add("accent", c.accent[0], i, r.brief);
4570
+ add("ground", c.ground[0], i, r.brief);
4571
+ add("shape", c.shape[0], i, r.brief);
4572
+ add("motion", c.motion[0], i, r.brief);
4573
+ add("opening", c.opening[0], i, r.brief);
4574
+ for (const t of r.tells) add("tell", t, i, r.brief);
4575
+ });
4576
+ const out = [];
4577
+ for (const t of tally.values()) {
4578
+ if (t.runs.size / of < minShare) continue;
4579
+ if (t.kind === "accent" && (t.value === "none" || t.value === "neutral")) continue;
4580
+ if (t.kind === "shape" && t.value === "no buttons") continue;
4581
+ if (t.kind === "ground" && (t.value === "white" || t.value === "grey")) continue;
4582
+ if (t.kind === "motion" && t.value === "no scroll reveals") continue;
4583
+ out.push({ kind: t.kind, value: t.value, runs: t.runs.size, of, briefs: t.briefs.size, known: knownFor(t.kind, t.value, sample) });
4584
+ }
4585
+ const phraseMin = Math.max(3, Math.ceil(of * minShare * 0.4));
4586
+ const names = models.map((m) => properNouns(m.brief));
4587
+ const phrases2 = /* @__PURE__ */ new Map();
4588
+ runs.forEach((r, i) => {
4589
+ for (const text of r.copy) {
4590
+ for (const g of textGrams(text)) {
4591
+ if (g.split(" ").some((w) => names[r.brief].has(w))) continue;
4592
+ const p = phrases2.get(g) ?? { runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example: text.trim().slice(0, 140) };
4593
+ p.runs.add(i);
4594
+ p.briefs.add(r.brief);
4595
+ phrases2.set(g, p);
4596
+ }
4597
+ }
4598
+ });
4599
+ const kept = [...phrases2].filter(([, p]) => p.runs.size >= phraseMin && (!multiBrief || p.briefs.size >= 2)).sort((a, b) => b[1].runs.size - a[1].runs.size || b[0].length - a[0].length);
4600
+ const maximal = kept.filter(([g, p]) => !kept.some(([h, q]) => h !== g && h.includes(g) && q.runs.size >= 0.8 * p.runs.size));
4601
+ for (const [g, p] of maximal.slice(0, 40)) {
4602
+ out.push({ kind: "phrase", value: g, runs: p.runs.size, of, briefs: p.briefs.size, known: options.copyTell?.(g) ?? null, example: p.example });
4603
+ }
4604
+ const order = ["face", "accent", "ground", "shape", "motion", "opening", "tell", "phrase"];
4605
+ return out.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.runs - a.runs);
4606
+ }
4607
+ function hueSwatch(runs, hue) {
4608
+ const hits = runs.map((r) => r.fingerprint.accent).filter((o) => !!o && hueName(o) === hue);
4609
+ if (hits.length === 0) return null;
4610
+ const centre = hits.reduce((best, o) => hits.reduce((s, p) => s + deltaEOk(o, p), 0) < hits.reduce((s, p) => s + deltaEOk(best, p), 0) ? o : best);
4611
+ return formatHex(oklchToRgb(centre));
4612
+ }
4613
+
4614
+ // src/fingerprint/component.ts
4615
+ var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
4616
+ function componentsOf(fp, role) {
4617
+ return (fp.sections ?? []).filter((s) => s.role === role);
4618
+ }
4619
+ var clamp2 = (n) => Math.max(0, Math.min(1, n));
4620
+ var diff = (a, b, scale) => a === void 0 || b === void 0 ? null : clamp2(Math.abs(a - b) / scale);
4621
+ function componentDistance(a, b) {
4622
+ if (a.role !== b.role) return 1;
4623
+ const promo = (s) => (s.badges ?? []).some((t) => PROMO_BADGE.test(t));
4624
+ const parts2 = [
4625
+ diff(a.centred, b.centred, 1),
4626
+ diff(a.width, b.width, 0.5),
4627
+ diff(a.symmetry, b.symmetry, 0.5),
4628
+ diff(a.controls, b.controls, 2),
4629
+ diff(a.cards, b.cards, 3),
4630
+ diff(a.figures, b.figures, 3),
4631
+ diff(a.avatars, b.avatars, 3),
4632
+ a.carousel === void 0 || b.carousel === void 0 ? null : a.carousel === b.carousel ? 0 : 1,
4633
+ a.badges === void 0 || b.badges === void 0 ? null : promo(a) === promo(b) ? 0 : 1,
4634
+ a.background === void 0 || b.background === void 0 ? null : a.background === null || b.background === null ? a.background === b.background ? 0 : 1 : clamp2(deltaEOk(a.background, b.background) / 0.3)
4635
+ ];
4636
+ const known = parts2.filter((p) => p !== null);
4637
+ return known.length === 0 ? 0 : Math.round(known.reduce((s, p) => s + p, 0) / known.length * 1e3) / 1e3;
4638
+ }
4639
+ function componentShared(a, b) {
4640
+ if (a.role !== b.role) return [];
4641
+ const out = [];
4642
+ const both = (f4) => f4(a) && f4(b);
4643
+ if (both((s) => s.centred >= 0.7)) out.push("centred");
4644
+ else if (both((s) => s.centred < 0.3)) out.push("left-aligned");
4645
+ if (both((s) => s.width <= 0.6)) out.push("narrow column");
4646
+ else if (both((s) => s.width >= 0.8)) out.push("full width");
4647
+ if (a.controls !== void 0 && a.controls === b.controls && a.controls > 0) out.push(`${a.controls} button${a.controls === 1 ? "" : "s"}`);
4648
+ if (a.cards !== void 0 && a.cards === b.cards && a.cards >= 2) out.push(`${a.cards} cards`);
4649
+ if (both((s) => (s.figures ?? 0) >= 3)) out.push("a row of figures");
4650
+ if (both((s) => (s.avatars ?? 0) >= 2)) out.push("avatars");
4651
+ if (both((s) => s.carousel === true)) out.push("carousel");
4652
+ if (both((s) => (s.badges ?? []).some((t) => PROMO_BADGE.test(t)))) out.push("promo badge");
4653
+ if (a.background && b.background && deltaEOk(a.background, b.background) <= 0.06) out.push("same coloured band");
4654
+ else if (a.background === null && b.background === null) out.push("on the page ground");
4655
+ return out;
4656
+ }
4657
+ function closestComponents(a, b, role) {
4658
+ let best = null;
4659
+ for (const x of componentsOf(a, role)) {
4660
+ for (const y of componentsOf(b, role)) {
4661
+ const distance = componentDistance(x, y);
4662
+ if (!best || distance < best.distance) best = { distance, shared: componentShared(x, y) };
4663
+ }
4664
+ }
4665
+ return best;
4666
+ }
4667
+ function estateComponentPairs(register, role) {
4668
+ const out = [];
4669
+ const s = register.sites;
4670
+ for (let i = 0; i < s.length; i += 1) {
4671
+ for (let j = i + 1; j < s.length; j += 1) {
4672
+ const c = closestComponents(s[i].fingerprint, s[j].fingerprint, role);
4673
+ if (c) out.push({ a: s[i].id, b: s[j].id, ...c });
4674
+ }
4675
+ }
4676
+ return out.sort((x, y) => x.distance - y.distance);
4677
+ }
4678
+ var meanNearest2 = (d, k) => {
4679
+ const s = [...d].sort((a, b) => a - b).slice(0, k);
4680
+ return s.reduce((t, v) => t + v, 0) / s.length;
4681
+ };
4682
+ function componentTypicality(target2, runs, k = 3) {
4683
+ const pool = runs.flatMap((r) => componentsOf(r.fingerprint, target2.role).slice(0, 1));
4684
+ if (pool.length < MIN_RUNS) return null;
4685
+ const kk = Math.min(k, pool.length - 1);
4686
+ const distance = meanNearest2(pool.map((p) => componentDistance(target2, p)), kk);
4687
+ const loo = pool.map((p, i) => meanNearest2(pool.filter((_, j) => j !== i).map((o) => componentDistance(p, o)), kk));
4688
+ const score = loo.filter((d) => d >= distance).length / loo.length;
4689
+ const sorted = [...loo].sort((a, b) => a - b);
4690
+ const mid = Math.floor(sorted.length / 2);
4691
+ const baseline = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
4692
+ return {
4693
+ role: target2.role,
4694
+ score: Math.round(score * 100) / 100,
4695
+ typical: score >= TYPICAL_AT,
4696
+ distance: Math.round(distance * 1e3) / 1e3,
4697
+ baseline: Math.round(baseline * 1e3) / 1e3,
4698
+ runs: pool.length
4699
+ };
4700
+ }
4701
+ var COMPONENT_ROLES = ["cta-band", "footer-cta", "pricing", "testimonials", "faq", "process", "features", "team", "contact", "stats"];
4702
+ function componentTypicalities(fp, runs, k = 3) {
4703
+ const out = [];
4704
+ for (const role of COMPONENT_ROLES) {
4705
+ const first = componentsOf(fp, role)[0];
4706
+ const t = first ? componentTypicality(first, runs, k) : null;
4707
+ if (t) out.push(t);
4708
+ }
4709
+ return out;
4710
+ }
4711
+
4712
+ // src/snapshot/rendered-tells.ts
4713
+ var hit2 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
4714
+ var GROUND = "rgb(255, 255, 255)";
4715
+ var BAND = "rgb(31, 58, 46)";
4716
+ var geo = (patch = {}) => ({
4717
+ centredShare: 0.1,
4718
+ mirrorSymmetry: 0.6,
4719
+ whitespaceRatio: 0.7,
4720
+ contentWidthRatio: 0.85,
4721
+ background: GROUND,
4722
+ controls: 0,
4723
+ ...patch
4724
+ });
4725
+ function withSection(index, patch, roles = []) {
4726
+ const base = makeSnapshot();
4727
+ return {
4728
+ ...base,
4729
+ sections: base.sections.map((s, i) => ({ ...s, role: roles[i] ?? s.kind, geometry: geo(), badges: [], avatars: 0, carousel: false, figures: 0, ...i === index ? patch : {} }))
4730
+ };
4731
+ }
4732
+ var measured = (s) => s.geometry !== void 0 && s.role !== void 0;
4733
+ var RENDERED_TELLS = [
4734
+ {
4735
+ id: "cta-band-stock",
4736
+ name: "Stock call-to-action band",
4737
+ generation: 1,
4738
+ severity: "warn",
4739
+ surface: "rendered",
4740
+ why: "A full-width coloured band with a centred heading, one line and one or two buttons is what a model closes every section run with. It asks for the booking the same way on every site.",
4741
+ fix: "Keep the ask, change the band: put it where the decision is made (next to the prices, after the proof), in the client's own words, laid out like the rest of the page rather than as a centred slab.",
4742
+ rendered: {
4743
+ detect: (s) => s.sections.filter((x) => measured(x) && (x.role === "cta-band" || x.role === "footer-cta")).filter((x) => {
4744
+ const g = x.geometry;
4745
+ return g.centredShare >= 0.8 && g.background !== s.ground && g.controls >= 1 && g.controls <= 2 && g.contentWidthRatio <= 0.7;
4746
+ }).map((x) => hit2(s, `a centred ${x.role === "footer-cta" ? "closing " : ""}band on its own colour with ${x.geometry.controls === 1 ? "one button" : "two buttons"}`, x.label)),
4747
+ fixtures: {
4748
+ flag: [
4749
+ withSection(4, { role: "footer-cta", label: "Boiler out today?", geometry: geo({ centredShare: 1, background: BAND, controls: 1, contentWidthRatio: 0.45 }) }),
4750
+ withSection(3, { role: "cta-band", label: "Ready to book?", geometry: geo({ centredShare: 0.9, background: BAND, controls: 2, contentWidthRatio: 0.5 }) })
4751
+ ],
4752
+ pass: [
4753
+ makeSnapshot(),
4754
+ // The same ask, laid out like the page: left-aligned on the ground.
4755
+ withSection(4, { role: "footer-cta", label: "Book a visit", geometry: geo({ centredShare: 0.1, background: GROUND, controls: 1, contentWidthRatio: 0.85 }) })
4756
+ ]
4757
+ }
4758
+ }
4759
+ },
4760
+ {
4761
+ id: "pricing-trio-popular",
4762
+ name: "Three-tier pricing with a popular badge",
4763
+ generation: 1,
4764
+ severity: "warn",
4765
+ surface: "rendered",
4766
+ why: "Three price cards with the middle one badged 'Most popular' is the SaaS pricing page, applied to a plumber or a salon whether or not anyone chose the middle option.",
4767
+ fix: "Keep the prices. Show them the way the trade quotes (a price list, per job, per visit), and drop the badge unless it is true and the client can say how many people chose it.",
4768
+ rendered: {
4769
+ detect: (s) => s.sections.filter((x) => measured(x) && x.role === "pricing" && x.cards === 3).filter((x) => (x.badges ?? []).some((b) => PROMO_BADGE.test(b))).map((x) => hit2(s, `three price cards, one badged "${(x.badges ?? []).find((b) => PROMO_BADGE.test(b))}"`, x.label)),
4770
+ fixtures: {
4771
+ flag: [withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: ["Most popular"], label: "Plans" })],
4772
+ pass: [
4773
+ makeSnapshot(),
4774
+ withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: [], label: "Prices" }),
4775
+ withSection(3, { role: "pricing", kind: "text", cards: 0, badges: [], label: "Price list" })
4776
+ ]
4777
+ }
4778
+ }
4779
+ },
4780
+ {
4781
+ id: "testimonial-avatar-carousel",
4782
+ name: "Avatar testimonial carousel",
4783
+ generation: 1,
4784
+ severity: "warn",
4785
+ surface: "rendered",
4786
+ why: "Round headshots over quotes in a sliding carousel is the stock testimonial block. Most visitors see the first slide only, and stock or AI faces make every quote read as invented.",
4787
+ fix: "Show the reviews all at once, where they come from (the Google rating, the named job, the town), with real photos of the work rather than of the reviewer.",
4788
+ rendered: {
4789
+ detect: (s) => s.sections.filter((x) => measured(x) && x.role === "testimonials" && x.carousel === true && (x.avatars ?? 0) >= 2).map((x) => hit2(s, `${x.avatars} round portraits in a sliding testimonial carousel`, x.label)),
4790
+ fixtures: {
4791
+ flag: [withSection(2, { role: "testimonials", avatars: 3, carousel: true, label: "What our clients say" })],
4792
+ pass: [
4793
+ makeSnapshot(),
4794
+ withSection(2, { role: "testimonials", avatars: 3, carousel: false, label: "Reviews" }),
4795
+ withSection(2, { role: "testimonials", avatars: 0, carousel: true, label: "Reviews" })
4796
+ ]
4797
+ }
4798
+ }
4799
+ },
4800
+ {
4801
+ id: "faq-accordion-closer",
4802
+ name: "FAQ accordion as the closer",
4803
+ generation: 2,
4804
+ severity: "warn",
4805
+ surface: "rendered",
4806
+ why: "Ending the page on a collapsed FAQ, often followed only by a band, is the model's default page ending. The answers a buyer needs are hidden behind clicks at the point they decide.",
4807
+ fix: "Answer the questions that block a booking where they arise (price next to prices, area next to the map), and end on the call to action or the proof.",
4808
+ rendered: {
4809
+ detect: (s) => {
4810
+ const body = s.sections.filter((x) => x.role !== "footer-cta" && x.role !== "cta-band");
4811
+ const last = body[body.length - 1];
4812
+ return last && measured(last) && last.role === "faq" ? [hit2(s, "the page ends on a question-and-answer list", last.label)] : [];
4813
+ },
4814
+ fixtures: {
4815
+ flag: [
4816
+ withSection(4, { role: "faq", label: "Common questions" }),
4817
+ {
4818
+ ...withSection(3, { role: "faq", label: "FAQ" }),
4819
+ sections: withSection(3, { role: "faq", label: "FAQ" }).sections.map((x, i) => i === 4 ? { ...x, role: "footer-cta" } : x)
4820
+ }
4821
+ ],
4822
+ pass: [makeSnapshot(), withSection(2, { role: "faq", label: "Common questions" })]
4823
+ }
4824
+ }
4825
+ },
4826
+ {
4827
+ id: "stats-row",
4828
+ name: "Row of big numbers",
4829
+ generation: 1,
4830
+ severity: "warn",
4831
+ surface: "rendered",
4832
+ why: "Three or four large figures in a row (500+ clients, 98% satisfaction, 10 years) is the stock proof block, and the numbers are often round, unsourced or invented.",
4833
+ fix: "Keep a figure only if it is true and sourced, and put it in a sentence next to what it proves. One real number in context beats four in a row.",
4834
+ rendered: {
4835
+ detect: (s) => s.sections.filter((x, i) => measured(x) && (x.role === "stats" || x.kind === "stats") && (x.figures ?? 0) >= 3 && (x.figures ?? 0) <= 6 && !(i === 1 && s.sections[0]?.kind === "hero")).map((x) => hit2(s, `${x.figures} large figures in a row`, x.label)),
4836
+ fixtures: {
4837
+ flag: [withSection(3, { role: "stats", kind: "stats", figures: 4, label: "500+" })],
4838
+ pass: [
4839
+ makeSnapshot(),
4840
+ withSection(3, { role: "text", kind: "text", figures: 1, label: "Since 2009" }),
4841
+ withSection(1, { role: "stats", kind: "stats", figures: 4, label: "500+" })
4842
+ ]
4843
+ }
4844
+ }
4845
+ },
4846
+ {
4847
+ id: "centred-everything",
4848
+ name: "Every section centred",
4849
+ generation: 2,
4850
+ severity: "warn",
4851
+ surface: "rendered",
4852
+ why: "When almost every section stacks a centred heading over centred text, the page has no reading line and every block looks like the one before. It is the layout nothing was decided for.",
4853
+ fix: "Pick an alignment and hold it: left-aligned text with a clear edge, keeping centring for the one moment that earns it.",
4854
+ rendered: {
4855
+ detect: (s) => {
4856
+ const m = s.sections.filter(measured);
4857
+ if (m.length < 4) return [];
4858
+ const centred = m.filter((x) => x.geometry.centredShare >= 0.7).length;
4859
+ return centred / m.length >= 0.8 ? [hit2(s, `${centred} of ${m.length} sections centre their text`)] : [];
4860
+ },
4861
+ fixtures: {
4862
+ flag: [{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x) => ({ ...x, role: x.kind, geometry: geo({ centredShare: 0.9 }) })) }],
4863
+ pass: [
4864
+ makeSnapshot(),
4865
+ { ...makeSnapshot(), sections: makeSnapshot().sections.map((x, i) => ({ ...x, role: x.kind, geometry: geo({ centredShare: i < 2 ? 0.9 : 0.1 }) })) }
4866
+ ]
4867
+ }
4868
+ }
4869
+ }
4870
+ ];
4871
+
4872
+ // src/snapshot/imagery-tells.ts
4873
+ var hit3 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
4874
+ var STOCK_PHOTO_HOSTS = {
4875
+ "unsplash.com": "Unsplash",
4876
+ "pexels.com": "Pexels",
4877
+ "pixabay.com": "Pixabay",
4878
+ "istockphoto.com": "iStock",
4879
+ "gettyimages.com": "Getty Images",
4880
+ "shutterstock.com": "Shutterstock",
4881
+ "ftcdn.net": "Adobe Stock",
4882
+ "dreamstime.com": "Dreamstime",
4883
+ "depositphotos.com": "Depositphotos",
4884
+ "freepik.com": "Freepik",
4885
+ "rawpixel.com": "rawpixel",
4886
+ "stocksnap.io": "StockSnap",
4887
+ "burst.shopifycdn.com": "Burst",
4888
+ "picsum.photos": "Lorem Picsum",
4889
+ "loremflickr.com": "LoremFlickr",
4890
+ "placehold.co": "a placeholder service",
4891
+ "placeholder.com": "a placeholder service",
4892
+ "dummyimage.com": "a placeholder service"
4893
+ };
4894
+ var STOCK_AVATAR_HOSTS = {
4895
+ "randomuser.me": "randomuser.me",
4896
+ "pravatar.cc": "Pravatar",
4897
+ "dicebear.com": "DiceBear",
4898
+ "xsgames.co": "a random-face service",
4899
+ "thispersondoesnotexist.com": "a generated face",
4900
+ "generated.photos": "a generated face"
4901
+ };
4902
+ var STOCK_FILENAMES = [
4903
+ [/(?:^|[/_-])shutterstock[_-]?\d{4,}/i, "Shutterstock"],
4904
+ [/(?:^|[/_-])istock(?:photo)?[_-]?\d{4,}/i, "iStock"],
4905
+ [/(?:^|[/_-])adobestock[_-]?\d{4,}/i, "Adobe Stock"],
4906
+ [/(?:^|[/_-])gettyimages[_-]?\d{4,}/i, "Getty Images"],
4907
+ [/(?:^|[/_-])depositphotos[_-]?\d{4,}/i, "Depositphotos"],
4908
+ [/(?:^|[/_-])dreamstime[_-]/i, "Dreamstime"],
4909
+ [/(?:^|[/_-])pexels-[a-z0-9-]*\d{4,}/i, "Pexels"],
4910
+ [/-unsplash\.(?:jpe?g|png|webp|avif)(?:[?#]|$)/i, "Unsplash"],
4911
+ // Unsplash's photo ids: photo-<13-digit timestamp>-<12 hex>.
4912
+ [/photo-1\d{12}-[0-9a-f]{12}/, "Unsplash"]
4913
+ ];
4914
+ var byHost = (host, table) => {
4915
+ if (!host) return null;
4916
+ const h = host.toLowerCase();
4917
+ for (const [domain, name] of Object.entries(table)) if (h === domain || h.endsWith(`.${domain}`)) return name;
4918
+ return null;
4919
+ };
4920
+ function stockSource(image) {
4921
+ const host = byHost(image.host, STOCK_PHOTO_HOSTS);
4922
+ if (host) return host;
4923
+ let path = image.src.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, "").replace(/[?#].*$/, "");
4924
+ try {
4925
+ path = decodeURIComponent(path);
4926
+ } catch {
4927
+ }
4928
+ for (const [re, name] of STOCK_FILENAMES) if (re.test(path)) return name;
4929
+ return null;
4930
+ }
4931
+ function stockAvatarSource(image) {
4932
+ return byHost(image.host, STOCK_AVATAR_HOSTS) ?? stockSource(image);
4933
+ }
4934
+ var picture = (patch = {}) => ({
4935
+ src: "https://example.test/images/boiler-swap-hinton.jpg",
4936
+ host: "example.test",
4937
+ width: 640,
4938
+ height: 480,
4939
+ top: 1800,
4940
+ role: "photo",
4941
+ alt: "The new combi boiler fitted in a kitchen cupboard in Hinton",
4942
+ decorative: false,
4943
+ section: 2,
4944
+ background: false,
4945
+ ...patch
4946
+ });
4947
+ var withImages = (images) => ({ ...makeSnapshot(), images });
4948
+ var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
4949
+ var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
4950
+ var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
4951
+ var IMAGERY_TELLS = [
4952
+ {
4953
+ id: "stock-photo",
4954
+ name: "Stock photography",
4955
+ generation: 1,
4956
+ severity: "warn",
4957
+ surface: "rendered",
4958
+ why: "A picture from a stock library is the picture every other site in the trade can use. It shows a job the client did not do, in a kitchen that is not theirs, and a visitor who has seen it before stops believing the rest.",
4959
+ fix: "Use a photo of the client's own work, premises or people, even a plain one taken on a phone. Where there is none yet, use fewer pictures rather than a library one.",
4960
+ rendered: {
4961
+ detect: (s) => {
4962
+ const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
4963
+ if (found.length === 0) return [];
4964
+ const names = [...new Set(found.map((x) => x.from))].join(", ");
4965
+ return [hit3(s, `${count2(found.length, "picture")} from ${names}`, found[0].image.src)];
4966
+ },
4967
+ fixtures: {
4968
+ flag: [
4969
+ withImages([picture({ src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=1600", host: "images.unsplash.com", alt: "Plumber fixing a sink" })]),
4970
+ // Downloaded and self-hosted, found by the library's own file name.
4971
+ withImages([picture({ src: "https://example.test/img/shutterstock_1234567890.jpg" })])
4972
+ ],
4973
+ pass: [makeSnapshot(), withImages([picture()]), withImages([picture({ role: "icon", width: 48, height: 48, src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952", host: "images.unsplash.com" })])]
4974
+ }
4975
+ }
4976
+ },
4977
+ {
4978
+ id: "stock-avatar",
4979
+ name: "Stock or placeholder faces",
4980
+ generation: 1,
4981
+ severity: "warn",
4982
+ surface: "rendered",
4983
+ why: "Round portraits from a placeholder-face service or a stock library next to reviews make every quote read as invented, and some of those faces are generated people who do not exist.",
4984
+ fix: "Drop the portraits. Name the reviewer, the town and the job, and link the review to where it was left.",
4985
+ rendered: {
4986
+ detect: (s) => {
4987
+ const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
4988
+ if (found.length === 0) return [];
4989
+ const names = [...new Set(found.map((x) => x.from))].join(", ");
4990
+ return [hit3(s, `${count2(found.length, "portrait")} from ${names}`, found[0].image.src)];
4991
+ },
4992
+ fixtures: {
4993
+ flag: [
4994
+ withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://i.pravatar.cc/150?img=12", host: "i.pravatar.cc", alt: "Sarah" })]),
4995
+ withImages([picture({ role: "avatar", width: 64, height: 64, src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?crop=faces", host: "images.unsplash.com" })])
4996
+ ],
4997
+ pass: [makeSnapshot(), withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://example.test/team/sam.jpg", alt: "Sam, engineer" })])]
4998
+ }
4999
+ }
5000
+ },
5001
+ {
5002
+ id: "ai-image",
5003
+ name: "Generated image",
5004
+ generation: 2,
5005
+ severity: "warn",
5006
+ surface: "rendered",
5007
+ why: "The file itself says a model made it (its XMP or Content Credentials name a trained-model source). A generated picture stands in for work the client did not photograph, and anyone can read the label.",
5008
+ fix: "Replace it with a photo of the real thing: the work, the premises, the people. Where there is none yet, use fewer pictures.",
5009
+ rendered: {
5010
+ detect: (s) => {
5011
+ const found = (s.images ?? []).filter((i) => i.provenance?.digitalSourceType);
5012
+ if (found.length === 0) return [];
5013
+ const first = found[0].provenance;
5014
+ const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
5015
+ return [hit3(s, `${count2(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
5016
+ },
5017
+ fixtures: {
5018
+ flag: [
5019
+ withImages([picture({ provenance: AI_XMP })]),
5020
+ withImages([picture({ provenance: { digitalSourceType: "compositeWithTrainedAlgorithmicMedia", source: "c2pa", c2pa: true, bytes: 12e4 } })])
5021
+ ],
5022
+ // Credentials that say nothing about a model, and an image never read, are not findings.
5023
+ pass: [makeSnapshot(), withImages([picture({ provenance: NOTHING })]), withImages([picture({ provenance: { ...NOTHING, c2pa: true } })]), withImages([picture()])]
5024
+ }
5025
+ }
5026
+ },
5027
+ {
5028
+ id: "no-real-imagery",
5029
+ name: "No photograph on the page",
5030
+ generation: 1,
5031
+ severity: "warn",
5032
+ surface: "rendered",
5033
+ why: "A page with no photograph at all, only icons and illustrations, is the page a model builds when it has no assets. Icon tiles stand in for the evidence a buyer looks for: the work, the premises, the people.",
5034
+ fix: "Add one real photo of the work where the claim is made: a finished job next to the service, the team next to the booking. One plain photo beats any number of icons.",
5035
+ rendered: {
5036
+ detect: (s) => {
5037
+ if (!s.images || s.sections.length < 4) return [];
5038
+ if (s.images.some((i) => i.role === "photo")) return [];
5039
+ const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
5040
+ return [hit3(s, `no photograph on the page${icons ? `: ${count2(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
5041
+ },
5042
+ fixtures: {
5043
+ flag: [
5044
+ withImages([picture({ role: "icon", width: 48, height: 48, src: "https://example.test/icons/wrench.svg" }), picture({ role: "illustration", width: 480, height: 360, src: "https://example.test/hero.svg" })]),
5045
+ withImages([])
5046
+ ],
5047
+ // A snapshot with no image record is never judged; nor is a page too short to be a site.
5048
+ pass: [makeSnapshot(), withImages([picture()]), { ...withImages([]), sections: makeSnapshot().sections.slice(0, 3) }]
5049
+ }
5050
+ }
5051
+ }
5052
+ ];
5053
+
5054
+ // src/character/check.ts
5055
+ var CATALOGUE_VERSION = "2026.09.11";
5056
+ var CATALOGUE = [
5057
+ ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
5058
+ ...RENDERED_TELLS,
5059
+ ...IMAGERY_TELLS
5060
+ ];
5061
+ function tellById(id) {
5062
+ return CATALOGUE.find((t) => t.id === id);
5063
+ }
5064
+ function validateExceptions(exceptions) {
5065
+ const applied = [];
5066
+ const rejected = [];
5067
+ for (const exception of exceptions) {
5068
+ if (!tellById(exception.tell)) {
5069
+ rejected.push({ exception, reason: `no tell called "${exception.tell}"` });
5070
+ } else if (typeof exception.because !== "string" || exception.because.trim().length < 12) {
5071
+ rejected.push({ exception, reason: "an exception needs a because of at least a sentence" });
5072
+ } else {
5073
+ applied.push(exception);
5074
+ }
5075
+ }
5076
+ return { applied, rejected };
5077
+ }
5078
+ var MARKER = /\b(?:copy|craft)-ok\b/;
5079
+ function marked(lines, line) {
5080
+ return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
5081
+ }
5082
+ function toFinding(tell, hit4, texts) {
5083
+ const text = texts.get(hit4.path) ?? "";
5084
+ return {
5085
+ tell: tell.id,
5086
+ name: tell.name,
5087
+ generation: tell.generation,
5088
+ severity: tell.severity,
5089
+ path: hit4.path,
5090
+ line: texts.has(hit4.path) ? lineAt(text, hit4.offset) : 0,
5091
+ excerpt: hit4.excerpt ?? excerptAt(text, hit4.offset),
5092
+ message: hit4.message,
5093
+ fix: tell.fix,
5094
+ ...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
5095
+ };
5096
+ }
5097
+ function report(tells, files, run, options) {
5098
+ const { applied, rejected } = validateExceptions(options.exceptions ?? []);
5099
+ const texts = new Map(files.map((file) => [file.path, file.text]));
5100
+ const selected = options.only ? tells.filter((t) => options.only.includes(t.id)) : tells;
5101
+ const findings = [];
5102
+ const suppressed = [];
5103
+ const lineCache = /* @__PURE__ */ new Map();
5104
+ const linesOf = (path) => {
5105
+ let lines = lineCache.get(path);
5106
+ if (!lines) lineCache.set(path, lines = (texts.get(path) ?? "").split("\n"));
5107
+ return lines;
5108
+ };
5109
+ const excepted = /* @__PURE__ */ new Map();
5110
+ for (const tell of selected) {
5111
+ for (const hit4 of run(tell)) {
5112
+ const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit4.path.includes(e.path)));
5113
+ if (exception) {
5114
+ excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
5115
+ continue;
5116
+ }
5117
+ const finding = toFinding(tell, hit4, texts);
5118
+ if (marked(linesOf(hit4.path), finding.line)) {
5119
+ suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
5120
+ continue;
5121
+ }
5122
+ findings.push(finding);
5123
+ }
5124
+ }
5125
+ findings.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.tell.localeCompare(b.tell));
5126
+ const byGeneration = { 1: 0, 2: 0, 3: 0 };
5127
+ const byTell = {};
5128
+ for (const finding of findings) {
5129
+ byGeneration[finding.generation] += 1;
5130
+ byTell[finding.tell] = (byTell[finding.tell] ?? 0) + 1;
5131
+ }
5132
+ return {
5133
+ catalogueVersion: CATALOGUE_VERSION,
5134
+ findings,
5135
+ rejectedExceptions: rejected,
5136
+ excepted: [...excepted].map(([e, count3]) => ({ tell: e.tell, because: e.because, count: count3 })),
5137
+ suppressed,
5138
+ summary: {
5139
+ files: files.length,
5140
+ findings: findings.length,
5141
+ byGeneration,
5142
+ byTell,
5143
+ blocking: findings.filter((x) => x.severity === "block").length
5144
+ }
5145
+ };
5146
+ }
5147
+ function applyHouseGate(result) {
5148
+ const findings = result.findings.map((f4) => f4.house === "block" ? { ...f4, severity: "block" } : f4);
5149
+ return { ...result, findings, summary: { ...result.summary, blocking: findings.filter((f4) => f4.severity === "block").length } };
5150
+ }
5151
+ function scanSource(files, options = {}) {
5152
+ const ctx = { files: files.map(parseFile) };
5153
+ const tells = CATALOGUE.filter((t) => t.surface === "source");
5154
+ return report(tells, files, (tell) => tell.surface === "source" ? tell.detect(ctx) : [], options);
5155
+ }
5156
+ function copyContext(files) {
5157
+ return { files, blocks: files.flatMap(extractCopy), strings: files.flatMap(extractStrings) };
5158
+ }
5159
+ function checkCopy(files, options = {}) {
5160
+ const ctx = copyContext(files);
5161
+ const tells = CATALOGUE.filter((t) => t.surface === "copy");
5162
+ return report(tells, files, (tell) => tell.surface === "copy" ? tell.detect(ctx) : [], options);
5163
+ }
5164
+ function auditSnapshot(snapshot, options = {}) {
5165
+ const tells = CATALOGUE.filter((t) => t.rendered);
5166
+ const result = report(tells, [], (tell) => tell.rendered.detect(snapshot), options);
5167
+ result.summary.files = 1;
5168
+ return result;
5169
+ }
5170
+ function runTell(tell, files) {
5171
+ if (tell.surface === "source") return tell.detect({ files: files.map(parseFile) });
5172
+ if (tell.surface === "rendered") throw new Error(`${tell.id} is seen on a rendered page only; run tell.rendered.detect(snapshot)`);
5173
+ return tell.detect(copyContext(files));
5174
+ }
5175
+ function catalogueTable() {
5176
+ const rows = CATALOGUE.map(
5177
+ (t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
5178
+ );
5179
+ return [
5180
+ `Catalogue version \`${CATALOGUE_VERSION}\`, ${CATALOGUE.length} tells.`,
5181
+ "",
5182
+ "| Id | Gen | Surface | Severity | Tell | Why it is a default |",
5183
+ "| --- | --- | --- | --- | --- | --- |",
5184
+ ...rows
5185
+ ].join("\n");
5186
+ }
5187
+ function wordListsJson() {
5188
+ return JSON.stringify(
5189
+ {
5190
+ catalogueVersion: CATALOGUE_VERSION,
5191
+ lists: {
5192
+ aiWords: AI_WORDS,
5193
+ stockPhrases: STOCK_PHRASES,
5194
+ aiPhrases: AI_PHRASES,
5195
+ plainerWords: PLAINER_WORDS,
5196
+ buzzwords: BUZZWORDS,
5197
+ negativeReassurance: NEGATIVE_REASSURANCE,
5198
+ vagueWords: VAGUE_WORDS,
5199
+ reviewPhrases: REVIEW_PHRASES
5200
+ }
5201
+ },
5202
+ null,
5203
+ 2
5204
+ ) + "\n";
5205
+ }
5206
+
5207
+ // src/character/format.ts
5208
+ function formatReport(report2, title) {
5209
+ const lines = [];
5210
+ let current = "";
5211
+ for (const f4 of report2.findings) {
5212
+ if (f4.path !== current) {
5213
+ current = f4.path;
5214
+ lines.push("", f4.path);
5215
+ }
5216
+ lines.push(` ${String(f4.line).padStart(4)} ${f4.tell} (gen ${f4.generation}, ${f4.severity}) ${f4.message}`);
5217
+ lines.push(` ${f4.excerpt}`);
5218
+ }
5219
+ const fixes = new Map(report2.findings.map((f4) => [f4.tell, f4.fix]));
5220
+ if (fixes.size > 0) {
5221
+ lines.push("", "What to do instead:");
5222
+ for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
5223
+ }
5224
+ for (const r of report2.rejectedExceptions) {
5225
+ lines.push("", `Exception not applied (${r.exception.tell}): ${r.reason}`);
5226
+ }
5227
+ for (const e of report2.excepted) {
5228
+ lines.push("", `Excepted: ${e.count} \xD7 ${e.tell}, because ${e.because}`);
5229
+ }
5230
+ if (report2.suppressed.length > 0) {
5231
+ lines.push("", `Suppressed: ${report2.suppressed.length} marked copy-ok or craft-ok (${report2.suppressed.map((s) => `${s.path}:${s.line}`).join(", ")})`);
5232
+ }
5233
+ const { summary } = report2;
5234
+ const gens = `gen 1: ${summary.byGeneration[1]}, gen 2: ${summary.byGeneration[2]}, gen 3: ${summary.byGeneration[3]}`;
5235
+ lines.push(
5236
+ "",
5237
+ `${title}: ${summary.findings} finding${summary.findings === 1 ? "" : "s"} in ${summary.files} file${summary.files === 1 ? "" : "s"} (${gens}). Catalogue ${report2.catalogueVersion}.`
5238
+ );
4404
5239
  return lines.join("\n").replace(/^\n/, "");
4405
5240
  }
4406
5241
 
@@ -4787,101 +5622,110 @@ function toJson(data) {
4787
5622
  return JSON.stringify({ schemaVersion: JSON_SCHEMA_VERSION, ...data }, null, 2);
4788
5623
  }
4789
5624
 
4790
- // src/fingerprint/index.ts
4791
- var FINGERPRINT_VERSION = 1;
4792
- var CHROMATIC = 0.03;
4793
- var MIN_ACCENT_AREA = 2e4;
4794
- var oklch = (value) => {
4795
- const parsed = parseColour(value);
4796
- return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
4797
- };
4798
- function accentOf(s) {
4799
- const buttons = /* @__PURE__ */ new Map();
4800
- for (const c of s.controls) {
4801
- const o = oklch(c.background);
4802
- if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
5625
+ // src/snapshot/provenance.ts
5626
+ var PROVENANCE_SCAN_BYTES = 256 * 1024;
5627
+ var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
5628
+ var MARKER2 = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
5629
+ var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
5630
+ var C2PA_PNG = /caBX/;
5631
+ function latin1(bytes) {
5632
+ let out = "";
5633
+ for (let i = 0; i < bytes.length; i += 8192) {
5634
+ out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
4803
5635
  }
4804
- const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
4805
- if (topButton) return oklch(topButton[0]);
4806
- const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC && x.w >= MIN_ACCENT_AREA).sort((a, b) => b.w - a.w)[0];
4807
- if (bg) return bg.o;
4808
- 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];
4809
- return text ? text.o : null;
5636
+ return out;
4810
5637
  }
4811
- function faces(s) {
4812
- const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
4813
- const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
4814
- const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
4815
- const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
4816
- return {
4817
- display: { family: displayName, class: fontClass(displayName) },
4818
- body: { family: bodyName, class: fontClass(bodyName) }
4819
- };
5638
+ function xmpPackets(text) {
5639
+ const out = [];
5640
+ const open = /<x:xmpmeta\b|<\?xpacket begin/g;
5641
+ let m;
5642
+ while (m = open.exec(text)) {
5643
+ const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
5644
+ out.push([m.index, close === -1 ? text.length : close]);
5645
+ if (close === -1) break;
5646
+ open.lastIndex = close;
5647
+ }
5648
+ return out;
4820
5649
  }
4821
- function median(values) {
4822
- if (values.length === 0) return null;
4823
- const sorted = [...values].sort((a, b) => a - b);
4824
- const mid = Math.floor(sorted.length / 2);
4825
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
5650
+ function scanProvenance(bytes) {
5651
+ const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
5652
+ const text = latin1(head);
5653
+ const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
5654
+ const m = MARKER2.exec(text);
5655
+ if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
5656
+ const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
5657
+ return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
4826
5658
  }
4827
- function fingerprint(s) {
4828
- const effects = [];
4829
- if (s.effects.glass > 0) effects.push("glass-panel");
4830
- if (s.effects.gradientText > 0) effects.push("gradient-text");
4831
- if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
4832
- if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
4833
- if (s.effects.marquees > 0) effects.push("marquee");
4834
- if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
4835
- if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
4836
- if (s.motion.introOverlay) effects.push("intro-cinematic");
4837
- const below = s.sections.filter((x) => x.top > s.viewport.height).length;
4838
- return {
4839
- version: FINGERPRINT_VERSION,
4840
- accent: accentOf(s),
4841
- ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
4842
- ...faces(s),
4843
- roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
4844
- motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
4845
- effects: effects.sort(),
4846
- layout: s.sections.map((x) => x.kind)
4847
- };
5659
+
5660
+ // src/snapshot/migrate.ts
5661
+ var READABLE_SNAPSHOT_VERSIONS = [1, 2];
5662
+ function readSnapshot(data, source = "snapshot") {
5663
+ const v = data?.version;
5664
+ if (typeof data !== "object" || data === null || !READABLE_SNAPSHOT_VERSIONS.includes(v)) {
5665
+ throw new Error(`${source} is not a snapshot this craft reads (version ${String(v)}; reads ${READABLE_SNAPSHOT_VERSIONS.join(" and ")})`);
5666
+ }
5667
+ const snap = data;
5668
+ if (snap.version === 1) return { ...snap, version: SNAPSHOT_VERSION, migratedFrom: 1 };
5669
+ return snap;
4848
5670
  }
4849
- var DISTANCE_WEIGHTS = { accent: 0.25, type: 0.25, ground: 0.1, shape: 0.1, motion: 0.1, effects: 0.1, layout: 0.1 };
4850
- function levenshtein(a, b) {
4851
- const row = Array.from({ length: b.length + 1 }, (_, i) => i);
4852
- for (let i = 1; i <= a.length; i += 1) {
4853
- let prev = row[0];
4854
- row[0] = i;
4855
- for (let j = 1; j <= b.length; j += 1) {
4856
- const cur = row[j];
4857
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
4858
- prev = cur;
5671
+
5672
+ // src/snapshot/visual.ts
5673
+ var EDGE_STEP = 32;
5674
+ function visualMeasures(img, maxSamples = 1e6) {
5675
+ const { width: w, height: h, rgba } = img;
5676
+ if (w < 2 || h < 2 || rgba.length < w * h * 4) throw new Error(`visualMeasures: ${w}x${h} image with ${rgba.length} bytes`);
5677
+ const step = Math.max(1, Math.ceil(Math.sqrt(w * h / maxSamples)));
5678
+ const lum = (x, y) => {
5679
+ const i = (y * w + x) * 4;
5680
+ return 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
5681
+ };
5682
+ let n = 0;
5683
+ let sumRg = 0;
5684
+ let sumYb = 0;
5685
+ let sumRg2 = 0;
5686
+ let sumYb2 = 0;
5687
+ let edges = 0;
5688
+ let edgeN = 0;
5689
+ let asym = 0;
5690
+ let asymN = 0;
5691
+ for (let y = 0; y < h; y += step) {
5692
+ for (let x = 0; x < w; x += step) {
5693
+ const i = (y * w + x) * 4;
5694
+ const r = rgba[i];
5695
+ const g = rgba[i + 1];
5696
+ const b = rgba[i + 2];
5697
+ const rg = r - g;
5698
+ const yb = 0.5 * (r + g) - b;
5699
+ sumRg += rg;
5700
+ sumYb += yb;
5701
+ sumRg2 += rg * rg;
5702
+ sumYb2 += yb * yb;
5703
+ n += 1;
5704
+ const l = lum(x, y);
5705
+ if (x + step < w && y + step < h) {
5706
+ if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
5707
+ edgeN += 1;
5708
+ }
5709
+ if (x < w / 2) {
5710
+ asym += Math.abs(l - lum(w - 1 - x, y));
5711
+ asymN += 1;
5712
+ }
4859
5713
  }
4860
5714
  }
4861
- return row[b.length];
4862
- }
4863
- var clamp = (n) => Math.max(0, Math.min(1, n));
4864
- function faceDistance(a, b) {
4865
- if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
4866
- return a.class === b.class ? 0.5 : 1;
4867
- }
4868
- function fingerprintDistance(a, b) {
4869
- const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / 0.3) : a.accent || b.accent ? 1 : 0;
4870
- const ground = clamp(deltaEOk(a.ground, b.ground) / 0.15);
4871
- const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
4872
- const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
4873
- const motion = clamp(Math.abs(a.motion - b.motion));
4874
- const union = /* @__PURE__ */ new Set([...a.effects, ...b.effects]);
4875
- const shared = a.effects.filter((e) => b.effects.includes(e)).length;
4876
- const effects = union.size === 0 ? 0 : 1 - shared / union.size;
4877
- const longest = Math.max(a.layout.length, b.layout.length);
4878
- const layout = longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
4879
- const parts2 = { accent, ground, type, shape, motion, effects, layout };
4880
- const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts2[k], 0);
4881
- return { total: Math.round(total * 1e3) / 1e3, parts: parts2 };
4882
- }
4883
- function nearest(target2, set, k = set.length) {
4884
- return set.map((item) => ({ id: item.id, distance: fingerprintDistance(target2, item.fingerprint) })).sort((x, y) => x.distance.total - y.distance.total).slice(0, k);
5715
+ const meanRg = sumRg / n;
5716
+ const meanYb = sumYb / n;
5717
+ const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
5718
+ const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
5719
+ const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
5720
+ const round2 = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
5721
+ return {
5722
+ colourfulness: round2(colourfulness, 1),
5723
+ edgeDensity: round2(edgeN ? edges / edgeN : 0),
5724
+ symmetry: round2(asymN ? 1 - asym / asymN / 255 : 1),
5725
+ width: w,
5726
+ height: h,
5727
+ sampled: n
5728
+ };
4885
5729
  }
4886
5730
 
4887
5731
  // src/direction/types.ts
@@ -4903,13 +5747,124 @@ var SOURCE_KINDS = [
4903
5747
  ];
4904
5748
  var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
4905
5749
 
5750
+ // src/direction/licences.ts
5751
+ var LICENCES = [
5752
+ {
5753
+ name: "Basteleur",
5754
+ subject: "face",
5755
+ licence: "OFL-1.1",
5756
+ multiClient: "yes",
5757
+ checked: "2026-09-24",
5758
+ source: "https://velvetyne.fr/fonts/basteleur/",
5759
+ note: "Velvetyne, designed by Keussel. Keep the OFL notice with the font files."
5760
+ },
5761
+ {
5762
+ name: "Gulax",
5763
+ subject: "face",
5764
+ licence: "OFL-1.1",
5765
+ multiClient: "yes",
5766
+ checked: "2026-09-24",
5767
+ source: "https://velvetyne.fr/fonts/gulax/",
5768
+ note: "Velvetyne. Keep the OFL notice with the font files."
5769
+ },
5770
+ {
5771
+ name: "Le Murmure",
5772
+ subject: "face",
5773
+ licence: "OFL-1.1",
5774
+ multiClient: "yes",
5775
+ checked: "2026-09-24",
5776
+ source: "https://velvetyne.fr/fonts/le-murmure/",
5777
+ note: "Velvetyne. Keep the OFL notice with the font files."
5778
+ },
5779
+ {
5780
+ name: "Blaze Type",
5781
+ subject: "foundry",
5782
+ licence: "commercial",
5783
+ multiClient: "per-site",
5784
+ cap: 10,
5785
+ checked: "2026-09-24",
5786
+ source: "https://blazetype.eu/eula",
5787
+ note: "The EULA sells a professional licence for 1 to 10 websites, and lets an agency buy in a client's name at the listed price. Its /license page says one licence covers every domain; the EULA is the contract, so buy per client."
5788
+ },
5789
+ {
5790
+ name: "Power Type",
5791
+ subject: "foundry",
5792
+ licence: "commercial",
5793
+ multiClient: "unverified",
5794
+ checked: "2026-09-24",
5795
+ source: "https://power-type.com/eula",
5796
+ note: "Research on 2026-09-11 reported a Super License capped at 10 self-hosted domains. The EULA page did not load when checked; read it before relying on the cap."
5797
+ },
5798
+ {
5799
+ name: "Grilli Type",
5800
+ subject: "foundry",
5801
+ licence: "commercial",
5802
+ multiClient: "per-site",
5803
+ checked: "2026-09-24",
5804
+ source: "https://www.grillitype.com/information",
5805
+ note: "Web licences cover any number of domains controlled by the licensee, priced by monthly visitors, so a client site needs the client's own licence. Unlimited tiers start at $10,000 per style."
5806
+ },
5807
+ {
5808
+ name: "Phosphor",
5809
+ subject: "icons",
5810
+ licence: "MIT",
5811
+ multiClient: "yes",
5812
+ checked: "2026-09-24",
5813
+ source: "https://github.com/phosphor-icons/core/blob/main/LICENSE",
5814
+ note: "Six weights. The icon-tile tells treat it exactly as they treat Lucide: the library is not the tell, the tinted tile is."
5815
+ },
5816
+ {
5817
+ name: "Tabler Icons",
5818
+ subject: "icons",
5819
+ licence: "MIT",
5820
+ multiClient: "yes",
5821
+ checked: "2026-09-24",
5822
+ source: "https://github.com/tabler/tabler-icons/blob/main/LICENSE",
5823
+ note: "One stroke grid across the set."
5824
+ },
5825
+ {
5826
+ name: "Iconoir",
5827
+ subject: "icons",
5828
+ licence: "MIT",
5829
+ multiClient: "yes",
5830
+ checked: "2026-09-24",
5831
+ source: "https://github.com/iconoir-icons/iconoir/blob/main/LICENSE",
5832
+ note: "Smaller set, no paid tier."
5833
+ },
5834
+ {
5835
+ name: "Unicons",
5836
+ subject: "icons",
5837
+ licence: "IconScout Simple License",
5838
+ multiClient: "yes",
5839
+ checked: "2026-09-24",
5840
+ source: "https://github.com/Iconscout/unicons/blob/master/LICENSE",
5841
+ note: "IconScout's free set, personal and commercial use, attribution optional."
5842
+ },
5843
+ {
5844
+ name: "IconScout premium",
5845
+ subject: "icons",
5846
+ licence: "commercial",
5847
+ multiClient: "per-site",
5848
+ checked: "2026-09-24",
5849
+ source: "https://iconscout.com/licenses",
5850
+ note: "Each licence covers a single end product. Never in a tool, template or shared kit (create-site, craft, a house component library), even modified. Record the assets each site uses. Assets come from different contributors, so take one site's set from one contributor."
5851
+ }
5852
+ ];
5853
+ function faceLicence(family) {
5854
+ const name = family.trim().toLowerCase();
5855
+ return LICENCES.find((l) => l.subject === "face" && l.name.toLowerCase() === name);
5856
+ }
5857
+ function licencesJson() {
5858
+ return JSON.stringify({ licences: LICENCES }, null, 2) + "\n";
5859
+ }
5860
+
4906
5861
  // src/direction/validate.ts
4907
5862
  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;
4908
5863
  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;
4909
5864
  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"]);
4910
- 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"]);
5865
+ var STOP2 = /* @__PURE__ */ new Set(["with", "that", "this", "from", "their", "they", "have", "been", "were", "which", "what", "when", "where", "there", "about", "into", "onto", "over", "same", "each", "every"]);
4911
5866
  function words(text) {
4912
- 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)));
5867
+ return new Set((text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).map((w) => w.replace(/'s$/, "")).filter((w) => !STOP2.has(w) && !VALUE_WORDS.has(w)));
4913
5868
  }
4914
5869
  function tellFor(key, value) {
4915
5870
  if (key === "accent") {
@@ -5026,18 +5981,30 @@ function validateDirection(input, ctx = {}) {
5026
5981
  ok = false;
5027
5982
  }
5028
5983
  }
5984
+ if ((key === "display" || key === "body") && typeof c.value === "string" && c.value.trim()) {
5985
+ const family = normaliseFamily(c.value);
5986
+ const entry = faceLicence(family);
5987
+ if (!entry) {
5988
+ warn(`${at}.value`, `${family}: licence not in craft's register. Read the licensor's own terms for use on this client's site, then add it to src/direction/licences.ts.`);
5989
+ } else if (entry.multiClient === "unverified") {
5990
+ warn(`${at}.value`, `${family}: licence unverified (${entry.note})`);
5991
+ } else if (entry.multiClient === "capped" || entry.multiClient === "per-site") {
5992
+ const scope = entry.multiClient === "capped" ? `covers up to ${entry.cap} sites` : "needs a licence for this site, in the client's name";
5993
+ warn(`${at}.value`, `${family}: ${entry.licence} licence ${scope}. Record the purchase before launch.`);
5994
+ }
5995
+ }
5029
5996
  const fp = ctx.fingerprint;
5030
5997
  if (fp && typeof c.value === "string") {
5031
5998
  if ((key === "accent" || key === "ground") && parseColour(c.value)) {
5032
5999
  const declared = parseColour(c.value).oklch;
5033
- const measured = key === "accent" ? fp.accent : fp.ground;
5034
- if (measured && deltaEOk(declared, measured) > 0.08) {
5035
- 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)})`);
6000
+ const measured2 = key === "accent" ? fp.accent : fp.ground;
6001
+ if (measured2 && deltaEOk(declared, measured2) > 0.08) {
6002
+ warn(`${at}.value`, `the page does not show it: its ${key} measures oklch(${measured2.l.toFixed(2)} ${measured2.c.toFixed(3)} ${measured2.h.toFixed(0)})`);
5036
6003
  }
5037
6004
  }
5038
6005
  if (key === "display" || key === "body") {
5039
- const measured = key === "display" ? fp.display.family : fp.body.family;
5040
- if (normaliseFamily(c.value).toLowerCase() !== measured.toLowerCase()) warn(`${at}.value`, `the page sets ${measured}, not ${c.value}`);
6006
+ const measured2 = key === "display" ? fp.display.family : fp.body.family;
6007
+ if (normaliseFamily(c.value).toLowerCase() !== measured2.toLowerCase()) warn(`${at}.value`, `the page sets ${measured2}, not ${c.value}`);
5041
6008
  }
5042
6009
  }
5043
6010
  if (ok) decided += 1;
@@ -5048,8 +6015,8 @@ function validateDirection(input, ctx = {}) {
5048
6015
  // src/direction/init.ts
5049
6016
  var hex = (o) => {
5050
6017
  const { r, g, b } = oklchToRgb(o);
5051
- const clamp2 = (n) => Math.min(1, Math.max(0, n));
5052
- return formatHex({ r: clamp2(r), g: clamp2(g), b: clamp2(b) });
6018
+ const clamp3 = (n) => Math.min(1, Math.max(0, n));
6019
+ return formatHex({ r: clamp3(r), g: clamp3(g), b: clamp3(b) });
5053
6020
  };
5054
6021
  function initDirection(input) {
5055
6022
  const fp = input.current;
@@ -5189,240 +6156,6 @@ function propose(input) {
5189
6156
  };
5190
6157
  }
5191
6158
 
5192
- // src/null/index.ts
5193
- var NULL_VERSION = 1;
5194
- function nullPrompt(brief) {
5195
- return [
5196
- `Build the home page for this business: ${brief.trim()}`,
5197
- "",
5198
- "Write it as one complete, self-contained HTML file. You may load fonts and libraries from a CDN. Use real copy, not placeholder text. Reply with the HTML only."
5199
- ].join("\n");
5200
- }
5201
- function extractHtml(reply) {
5202
- const start = reply.search(/<!doctype html|<html[\s>]/i);
5203
- if (start < 0) return null;
5204
- const end = reply.toLowerCase().lastIndexOf("</html>");
5205
- const html = end > start ? reply.slice(start, end + "</html>".length) : reply.slice(start).replace(/\s*```\s*$/, "");
5206
- return html.length > 200 ? html : null;
5207
- }
5208
- var MIN_RUNS = 5;
5209
- var TYPICAL_AT = 0.1;
5210
- function meanNearest(distances, k) {
5211
- const sorted = [...distances].sort((a, b) => a - b).slice(0, k);
5212
- return sorted.reduce((s, d) => s + d, 0) / sorted.length;
5213
- }
5214
- function median2(values) {
5215
- const sorted = [...values].sort((a, b) => a - b);
5216
- const mid = Math.floor(sorted.length / 2);
5217
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
5218
- }
5219
- function typicality(target2, runs, k = 3) {
5220
- if (runs.length < MIN_RUNS) throw new Error(`a null model needs at least ${MIN_RUNS} runs; this one has ${runs.length}`);
5221
- const kk = Math.min(k, runs.length - 1);
5222
- const toTarget = runs.map((r) => ({ id: r.id, distance: fingerprintDistance(target2, r.fingerprint).total }));
5223
- const distance = meanNearest(
5224
- toTarget.map((t) => t.distance),
5225
- kk
5226
- );
5227
- const loo = runs.map(
5228
- (r, i) => meanNearest(
5229
- runs.filter((_, j) => j !== i).map((o) => fingerprintDistance(r.fingerprint, o.fingerprint).total),
5230
- kk
5231
- )
5232
- );
5233
- const score = loo.filter((d) => d >= distance).length / loo.length;
5234
- return {
5235
- score: Math.round(score * 100) / 100,
5236
- typical: score >= TYPICAL_AT,
5237
- distance: Math.round(distance * 1e3) / 1e3,
5238
- baseline: Math.round(median2(loo) * 1e3) / 1e3,
5239
- nearest: toTarget.sort((a, b) => a.distance - b.distance).slice(0, kk),
5240
- shared: sharedChoices(target2, runs)
5241
- };
5242
- }
5243
- var HUES = [
5244
- ["red", 0, 45],
5245
- ["orange", 45, 75],
5246
- ["yellow", 75, 110],
5247
- ["green", 110, 165],
5248
- ["teal", 165, 215],
5249
- ["blue", 215, 265],
5250
- ["violet", 265, 315],
5251
- ["pink", 315, 360]
5252
- ];
5253
- function hueName(o) {
5254
- if (!o) return "none";
5255
- if (o.c < 0.03) return "neutral";
5256
- return HUES.find(([, lo, hi]) => o.h >= lo && o.h < hi)?.[0] ?? "red";
5257
- }
5258
- function groundName(o) {
5259
- if (o.l < 0.3) return "dark";
5260
- if (isCream(o)) return "cream";
5261
- if (o.l >= 0.97 && o.c < 0.012) return "white";
5262
- return o.c < 0.02 ? "grey" : "tinted";
5263
- }
5264
- function shapeName(roundness) {
5265
- if (roundness === null) return "no buttons";
5266
- if (roundness >= 0.45) return "pill";
5267
- return roundness >= 0.15 ? "soft" : "square";
5268
- }
5269
- function choicesOf(fp) {
5270
- return {
5271
- display: [fp.display.family],
5272
- body: [fp.body.family],
5273
- accent: [hueName(fp.accent)],
5274
- ground: [groundName(fp.ground)],
5275
- shape: [shapeName(fp.roundness)],
5276
- motion: [fp.motion >= 0.6 ? "reveals most sections" : fp.motion > 0 ? "reveals some sections" : "no scroll reveals"],
5277
- effects: fp.effects,
5278
- opening: [fp.layout.slice(0, 3).join(" > ") || "none"]
5279
- };
5280
- }
5281
- function sharedChoices(target2, runs) {
5282
- const mine = choicesOf(target2);
5283
- const all = runs.map((r) => choicesOf(r.fingerprint));
5284
- const out = [];
5285
- for (const [part, values] of Object.entries(mine)) {
5286
- for (const value of values) {
5287
- const count2 = all.filter((c) => c[part].includes(value)).length;
5288
- if (count2 / runs.length >= 0.25) out.push({ part, value, runs: count2, of: runs.length });
5289
- }
5290
- }
5291
- return out.sort((a, b) => b.runs - a.runs);
5292
- }
5293
- var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f4) => f4.toLowerCase()));
5294
- var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f4) => f4.toLowerCase()));
5295
- function knownFor(kind, value, sample) {
5296
- if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
5297
- if (kind === "accent" && value === "violet") return sample.some((f4) => f4.accent && isAiViolet(f4.accent)) ? "ai-violet" : null;
5298
- if (kind === "ground" && value === "cream") return "cream-palette";
5299
- if (kind === "shape" && value === "pill") return "pill-everything";
5300
- if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
5301
- if (kind === "tell") return value;
5302
- if (kind === "opening" && /^hero > (?:logos|stats)/.test(value)) return "hero-then-proof";
5303
- return null;
5304
- }
5305
- var STOP2 = new Set(
5306
- "a an the and or but of to in on for with at by from as is are be we our you your us it its this that these those will can all more get has have not no any".split(" ")
5307
- );
5308
- function grams(text) {
5309
- const out = /* @__PURE__ */ new Set();
5310
- for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
5311
- const words2 = clause.match(/[a-z][a-z'-]*/g) ?? [];
5312
- for (let n = 2; n <= 4; n += 1) {
5313
- for (let i = 0; i + n <= words2.length; i += 1) {
5314
- const g = words2.slice(i, i + n);
5315
- if (STOP2.has(g[0]) || STOP2.has(g[n - 1])) continue;
5316
- if (g.filter((w) => !STOP2.has(w)).length < 2) continue;
5317
- out.add(g.join(" "));
5318
- }
5319
- }
5320
- }
5321
- return out;
5322
- }
5323
- var GRAMMAR = /* @__PURE__ */ new Set([
5324
- "privacy policy",
5325
- "cookie policy",
5326
- "rights reserved",
5327
- "all rights reserved",
5328
- "terms of service",
5329
- "terms and conditions",
5330
- "terms conditions",
5331
- "how it works",
5332
- "what we do",
5333
- "about us",
5334
- "contact us",
5335
- "get in touch",
5336
- "opening hours",
5337
- "read more",
5338
- "learn more",
5339
- "book now",
5340
- "free quote",
5341
- "our services",
5342
- "quick links",
5343
- "email address",
5344
- "phone number",
5345
- "full name",
5346
- "first name",
5347
- "last name",
5348
- "your name",
5349
- "your email",
5350
- "send message"
5351
- ]);
5352
- var LEGAL = /©|&copy;|\bregistered (?:in|office|number)\b|\bcompany (?:no|number|reg)|\bvat (?:no|number|reg)/i;
5353
- var NOISE = /\S+@\S+|https?:\/\/\S+|\b[\w-]+(?:\.[\w-]+)*\.(?:co\.uk|com|org|net|uk|io)\b|\b\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm)\b|\d+/gi;
5354
- function textGrams(text) {
5355
- if (LEGAL.test(text)) return /* @__PURE__ */ new Set();
5356
- const out = grams(text.replace(NOISE, " | "));
5357
- for (const g of out) if (GRAMMAR.has(g)) out.delete(g);
5358
- return out;
5359
- }
5360
- function properNouns(brief) {
5361
- return new Set((brief.match(/\b[A-Z][a-z]+/g) ?? []).map((w) => w.toLowerCase()).filter((w) => !STOP2.has(w)));
5362
- }
5363
- function harvest(models, options = {}) {
5364
- const minShare = options.minShare ?? 0.25;
5365
- const runs = models.flatMap((m, b) => m.runs.map((r) => ({ ...r, brief: b })));
5366
- const of = runs.length;
5367
- if (of === 0) return [];
5368
- const multiBrief = models.length > 1;
5369
- const sample = runs.map((r) => r.fingerprint);
5370
- const tally = /* @__PURE__ */ new Map();
5371
- const add = (kind, value, run, brief, example) => {
5372
- const key = `${kind}\0${value}`;
5373
- const t = tally.get(key) ?? { kind, value, runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example };
5374
- t.runs.add(run);
5375
- t.briefs.add(brief);
5376
- tally.set(key, t);
5377
- };
5378
- runs.forEach((r, i) => {
5379
- const c = choicesOf(r.fingerprint);
5380
- for (const f4 of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f4, i, r.brief);
5381
- add("accent", c.accent[0], i, r.brief);
5382
- add("ground", c.ground[0], i, r.brief);
5383
- add("shape", c.shape[0], i, r.brief);
5384
- add("motion", c.motion[0], i, r.brief);
5385
- add("opening", c.opening[0], i, r.brief);
5386
- for (const t of r.tells) add("tell", t, i, r.brief);
5387
- });
5388
- const out = [];
5389
- for (const t of tally.values()) {
5390
- if (t.runs.size / of < minShare) continue;
5391
- if (t.kind === "accent" && (t.value === "none" || t.value === "neutral")) continue;
5392
- if (t.kind === "shape" && t.value === "no buttons") continue;
5393
- if (t.kind === "ground" && (t.value === "white" || t.value === "grey")) continue;
5394
- if (t.kind === "motion" && t.value === "no scroll reveals") continue;
5395
- out.push({ kind: t.kind, value: t.value, runs: t.runs.size, of, briefs: t.briefs.size, known: knownFor(t.kind, t.value, sample) });
5396
- }
5397
- const phraseMin = Math.max(3, Math.ceil(of * minShare * 0.4));
5398
- const names = models.map((m) => properNouns(m.brief));
5399
- const phrases2 = /* @__PURE__ */ new Map();
5400
- runs.forEach((r, i) => {
5401
- for (const text of r.copy) {
5402
- for (const g of textGrams(text)) {
5403
- if (g.split(" ").some((w) => names[r.brief].has(w))) continue;
5404
- const p = phrases2.get(g) ?? { runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example: text.trim().slice(0, 140) };
5405
- p.runs.add(i);
5406
- p.briefs.add(r.brief);
5407
- phrases2.set(g, p);
5408
- }
5409
- }
5410
- });
5411
- const kept = [...phrases2].filter(([, p]) => p.runs.size >= phraseMin && (!multiBrief || p.briefs.size >= 2)).sort((a, b) => b[1].runs.size - a[1].runs.size || b[0].length - a[0].length);
5412
- const maximal = kept.filter(([g, p]) => !kept.some(([h, q]) => h !== g && h.includes(g) && q.runs.size >= 0.8 * p.runs.size));
5413
- for (const [g, p] of maximal.slice(0, 40)) {
5414
- out.push({ kind: "phrase", value: g, runs: p.runs.size, of, briefs: p.briefs.size, known: options.copyTell?.(g) ?? null, example: p.example });
5415
- }
5416
- const order = ["face", "accent", "ground", "shape", "motion", "opening", "tell", "phrase"];
5417
- return out.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.runs - a.runs);
5418
- }
5419
- function hueSwatch(runs, hue) {
5420
- const hits = runs.map((r) => r.fingerprint.accent).filter((o) => !!o && hueName(o) === hue);
5421
- if (hits.length === 0) return null;
5422
- const centre = hits.reduce((best, o) => hits.reduce((s, p) => s + deltaEOk(o, p), 0) < hits.reduce((s, p) => s + deltaEOk(best, p), 0) ? o : best);
5423
- return formatHex(oklchToRgb(centre));
5424
- }
5425
-
5426
6159
  // src/estate/index.ts
5427
6160
  var ESTATE_VERSION = 1;
5428
6161
  var SIBLING_AT = 0.31;
@@ -5679,6 +6412,7 @@ function retrofitPlan(report2, direction) {
5679
6412
  }
5680
6413
  export {
5681
6414
  AI_PHRASES,
6415
+ AI_SOURCE_TYPES,
5682
6416
  AI_VIOLET,
5683
6417
  AI_WORDS,
5684
6418
  BASELINE_VERSION,
@@ -5686,6 +6420,7 @@ export {
5686
6420
  CATALOGUE,
5687
6421
  CATALOGUE_VERSION,
5688
6422
  CHOICE_KEYS,
6423
+ COMPONENT_ROLES,
5689
6424
  DEFAULT_IGNORE,
5690
6425
  DENSITIES,
5691
6426
  DENSITY_NAMES,
@@ -5695,13 +6430,17 @@ export {
5695
6430
  DURATION_S,
5696
6431
  EASE,
5697
6432
  EASE_TUPLE,
6433
+ EDGE_STEP,
5698
6434
  ESTATE_VERSION,
5699
6435
  EXIT_RATIO,
5700
6436
  FINGERPRINT_VERSION,
5701
6437
  HOUSE,
5702
6438
  HOUSE_BUDGET,
6439
+ HOUSE_LOCKED,
5703
6440
  HOUSE_TYPE,
6441
+ IMAGERY_TELLS,
5704
6442
  JSON_SCHEMA_VERSION,
6443
+ LICENCES,
5705
6444
  LIGHTNESS_CURVE,
5706
6445
  MIN_DECIDED,
5707
6446
  MIN_RUNS,
@@ -5709,11 +6448,15 @@ export {
5709
6448
  NULL_VERSION,
5710
6449
  PILL_LIMIT,
5711
6450
  PLAINER_WORDS,
6451
+ PROMO_BADGE,
5712
6452
  PROPOSED,
6453
+ PROVENANCE_SCAN_BYTES,
5713
6454
  RAMP_STEPS,
6455
+ READABLE_SNAPSHOT_VERSIONS,
5714
6456
  REFLEX_FONTS_1,
5715
6457
  REFLEX_FONTS_2,
5716
6458
  RENDERED_PATHS,
6459
+ RENDERED_TELLS,
5717
6460
  REVEAL_LIMIT,
5718
6461
  REVIEW_PHRASES,
5719
6462
  SCALE,
@@ -5724,6 +6467,8 @@ export {
5724
6467
  SOURCE_KINDS,
5725
6468
  SPACE_STEPS,
5726
6469
  SPRING,
6470
+ STOCK_AVATAR_HOSTS,
6471
+ STOCK_PHOTO_HOSTS,
5727
6472
  STOCK_PHRASES,
5728
6473
  TELL_HEAVY,
5729
6474
  TYPE_STEPS,
@@ -5742,8 +6487,14 @@ export {
5742
6487
  checkPair,
5743
6488
  checkRestraint,
5744
6489
  choicesOf,
6490
+ closestComponents,
5745
6491
  compareFacts,
5746
6492
  compareToEstate,
6493
+ componentDistance,
6494
+ componentShared,
6495
+ componentTypicalities,
6496
+ componentTypicality,
6497
+ componentsOf,
5747
6498
  craftTokens,
5748
6499
  createBaseline,
5749
6500
  deltaEOk,
@@ -5752,9 +6503,11 @@ export {
5752
6503
  densityTokens,
5753
6504
  emitCss,
5754
6505
  emptyEstate,
6506
+ estateComponentPairs,
5755
6507
  estatePairs,
5756
6508
  exitDuration,
5757
6509
  extractHtml,
6510
+ faceLicence,
5758
6511
  findClaims,
5759
6512
  findColours,
5760
6513
  findingKey,
@@ -5772,6 +6525,7 @@ export {
5772
6525
  groundName,
5773
6526
  harvest,
5774
6527
  hexToOklch,
6528
+ holdHouseBlocks,
5775
6529
  houseRule,
5776
6530
  hueName,
5777
6531
  hueSwatch,
@@ -5781,6 +6535,8 @@ export {
5781
6535
  initDirection,
5782
6536
  isAiViolet,
5783
6537
  isCream,
6538
+ layoutDistance,
6539
+ licencesJson,
5784
6540
  makeSnapshot,
5785
6541
  motionTokens,
5786
6542
  namedSources,
@@ -5798,8 +6554,10 @@ export {
5798
6554
  protectedFacts,
5799
6555
  ramp,
5800
6556
  rampFromAnchors,
6557
+ readSnapshot,
5801
6558
  retrofitPlan,
5802
6559
  runTell,
6560
+ scanProvenance,
5803
6561
  scanSource,
5804
6562
  sectionRhythm,
5805
6563
  semantic,
@@ -5808,6 +6566,8 @@ export {
5808
6566
  sharedParts,
5809
6567
  shouldAnimate,
5810
6568
  siblingLine,
6569
+ stockAvatarSource,
6570
+ stockSource,
5811
6571
  tailwindV3Preset,
5812
6572
  tailwindV4Theme,
5813
6573
  tellById,
@@ -5818,6 +6578,7 @@ export {
5818
6578
  typicality,
5819
6579
  validateDirection,
5820
6580
  visibleText,
6581
+ visualMeasures,
5821
6582
  wcagContrast,
5822
6583
  wordListsJson
5823
6584
  };