@domandigital/craft 0.11.0 → 0.12.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/audit.d.ts CHANGED
@@ -11,7 +11,13 @@
11
11
  * to a report and judged again later without a browser. Capturing one needs a
12
12
  * browser (see `@domandigital/craft/audit`); judging one does not.
13
13
  */
14
- declare const SNAPSHOT_VERSION = 1;
14
+ /**
15
+ * 2 adds `role` and `geometry` per section, `rhythmVariance` for the page and
16
+ * the screenshot measures in `visual`. All four are optional: a version 1
17
+ * snapshot reads as version 2 without them (see `readSnapshot`), and nothing
18
+ * that judged a v1 snapshot judges it differently.
19
+ */
20
+ declare const SNAPSHOT_VERSION = 2;
15
21
  interface SnapshotColour {
16
22
  /** `rgb(r, g, b)` or `rgba(r, g, b, a)`, as the browser computed it. */
17
23
  value: string;
@@ -50,6 +56,27 @@ interface SnapshotControl {
50
56
  text: string;
51
57
  }
52
58
  type SectionKind = "hero" | "logos" | "stats" | "cards" | "marquee" | "text" | "other";
59
+ /**
60
+ * What a section is for, finer than `kind`. `kind` keeps its version 1
61
+ * meaning because the rendered tells, the null models and the estate were
62
+ * calibrated on it; `role` is what component-level comparison reads.
63
+ */
64
+ type SectionRole = SectionKind | "cta-band" | "footer-cta" | "pricing" | "testimonials" | "faq" | "process" | "features" | "team" | "contact";
65
+ /** How a section is laid out, measured, not judged. */
66
+ interface SectionGeometry {
67
+ /** Share of the section's text blocks that are centred, 0 to 1. */
68
+ centredShare: number;
69
+ /** Content area balance about the vertical centre line: 1 mirror-even, 0 all on one side. */
70
+ mirrorSymmetry: number;
71
+ /** Share of the section's area with no text, image or control in it, 0 to 1. */
72
+ whitespaceRatio: number;
73
+ /** Width the content spans, over the viewport width. */
74
+ contentWidthRatio: number;
75
+ /** The painted background behind the section, as computed. */
76
+ background: string;
77
+ /** Buttons and button-styled links in the section. */
78
+ controls: number;
79
+ }
53
80
  interface SnapshotSection {
54
81
  top: number;
55
82
  height: number;
@@ -62,6 +89,26 @@ interface SnapshotSection {
62
89
  iconCards: number;
63
90
  /** Hidden at load, waiting for a scroll to reveal it. */
64
91
  hiddenAtLoad: boolean;
92
+ /** Version 2. */
93
+ role?: SectionRole;
94
+ /** Version 2. */
95
+ geometry?: SectionGeometry;
96
+ }
97
+ /**
98
+ * Measures of the rendered pixels, from a full-page screenshot (capped at
99
+ * `height`). None of them passes or fails anything: they describe.
100
+ */
101
+ interface SnapshotVisual {
102
+ /** Hasler and Süsstrunk (2003) colourfulness, M = σ_rgyb + 0.3 μ_rgyb, on 0-255 channels. Grey is 0. */
103
+ colourfulness: number;
104
+ /** Share of sampled pixels on a luminance edge: a complexity proxy, 0 to 1. */
105
+ edgeDensity: number;
106
+ /** 1 minus the mean luminance difference between each pixel and its left-right mirror, 0 to 1. */
107
+ symmetry: number;
108
+ width: number;
109
+ height: number;
110
+ /** Pixels actually read: large pages are sampled on a grid. */
111
+ sampled: number;
65
112
  }
66
113
  interface SnapshotGradient {
67
114
  /** Colour stops, as computed. */
@@ -116,6 +163,12 @@ interface Snapshot {
116
163
  sections: SnapshotSection[];
117
164
  effects: SnapshotEffects;
118
165
  motion: SnapshotMotion;
166
+ /** Version 2: coefficient of variation of the section heights. Null with fewer than two sections. */
167
+ rhythmVariance?: number | null;
168
+ /** Version 2: absent when the page could not be screenshotted. */
169
+ visual?: SnapshotVisual;
170
+ /** Set when a version 1 snapshot was read and upgraded in memory. */
171
+ migratedFrom?: 1;
119
172
  /** The design-system measures trawl has collected since v7, kept so it can switch to this. */
120
173
  metrics: {
121
174
  fontSizes: number[];
@@ -266,9 +319,16 @@ type FontClass = "serif" | "sans" | "mono" | "script" | "system";
266
319
  * Pure. Built from a Snapshot, so it describes what a visitor gets.
267
320
  */
268
321
 
269
- declare const FINGERPRINT_VERSION = 1;
322
+ /** One section as layout comparison sees it. */
323
+ interface FingerprintSection {
324
+ role: SectionRole;
325
+ /** Share of centred text, 0 to 1. */
326
+ centred: number;
327
+ /** Content width over viewport width, 0 to 1. */
328
+ width: number;
329
+ }
270
330
  interface Fingerprint {
271
- version: typeof FINGERPRINT_VERSION;
331
+ version: 1 | 2;
272
332
  /** The most prominent chromatic colour: button fills first, then painted area, then text. */
273
333
  accent: Oklch | null;
274
334
  ground: Oklch;
@@ -288,6 +348,8 @@ interface Fingerprint {
288
348
  effects: string[];
289
349
  /** Section kinds in running order, hero first. */
290
350
  layout: SectionKind[];
351
+ /** Version 2: present when every section carries a role and geometry. */
352
+ sections?: FingerprintSection[];
291
353
  }
292
354
 
293
355
  /**
@@ -329,6 +391,8 @@ interface SnapshotOptions {
329
391
  /** A Chromium to launch instead of Playwright's own. Also read from CRAFT_CHROMIUM. */
330
392
  executablePath?: string;
331
393
  timeoutMs?: number;
394
+ /** Take a screenshot and measure its pixels (`visual`). Default true. */
395
+ visual?: boolean;
332
396
  }
333
397
  interface MinimalPage {
334
398
  close?(): Promise<void>;
@@ -338,7 +402,20 @@ interface MinimalPage {
338
402
  }): Promise<unknown>;
339
403
  evaluate<R>(fn: () => R): Promise<R>;
340
404
  waitForTimeout(ms: number): Promise<void>;
405
+ /** Optional: a driver without it gets a snapshot with no `visual` measures. */
406
+ screenshot?(options: {
407
+ fullPage?: boolean;
408
+ type?: "png";
409
+ clip?: {
410
+ x: number;
411
+ y: number;
412
+ width: number;
413
+ height: number;
414
+ };
415
+ }): Promise<Uint8Array>;
341
416
  }
417
+ /** The most of a page the screenshot measures read, in CSS px. A long page is judged on its opening. */
418
+ declare const VISUAL_MAX_HEIGHT = 6000;
342
419
  interface MinimalBrowser {
343
420
  newPage(options?: {
344
421
  viewport?: {
@@ -388,4 +465,4 @@ interface AuditResult {
388
465
  /** Snapshot a URL, judge it against the rendered tells, and fingerprint it. */
389
466
  declare function audit(url: string, options?: SnapshotOptions & CheckOptions): Promise<AuditResult>;
390
467
 
391
- export { type AuditResult, type SnapshotOptions, type SnapshotResult, audit, collectInPage, loadPlaywright, overlayInPage, snapshotPage, snapshotUrl, snapshotUrls };
468
+ export { type AuditResult, type SnapshotOptions, type SnapshotResult, VISUAL_MAX_HEIGHT, audit, collectInPage, loadPlaywright, overlayInPage, snapshotPage, snapshotUrl, snapshotUrls };
package/dist/audit.js CHANGED
@@ -1535,6 +1535,7 @@ var REFLEX_FONTS_2 = [
1535
1535
  "Plus Jakarta Sans",
1536
1536
  "Outfit",
1537
1537
  "Geist",
1538
+ "Cal Sans",
1538
1539
  "Sora",
1539
1540
  "Instrument Sans",
1540
1541
  "Instrument Serif",
@@ -1756,7 +1757,7 @@ function iconTiles(file) {
1756
1757
  return sized && has(unit, /^rounded-(?:md|lg|xl|2xl|full)$/) && has(unit, /^(?:items-center|place-items-center|place-content-center)$/) && has(unit, /^bg-/);
1757
1758
  });
1758
1759
  }
1759
- var ICON_LIBRARY = /from\s+["'](?:lucide-react|@heroicons\/react[^"']*|react-icons[^"']*|@phosphor-icons\/react|@tabler\/icons-react|@radix-ui\/react-icons)["']/;
1760
+ 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[^"']*)["']/;
1760
1761
  function iconTileStackHits(ctx) {
1761
1762
  const hits = [];
1762
1763
  for (const file of ctx.files) {
@@ -1903,6 +1904,12 @@ function shadcnHits(ctx) {
1903
1904
  const detail = unused.length > 0 ? `; ${unused.length} imported nowhere` : "";
1904
1905
  return [{ path: ui[0].path, offset: 0, message: `${ui.length} stock shadcn primitives${detail}` }];
1905
1906
  }
1907
+ function shadcnCardHits(ctx) {
1908
+ return eachUnit(
1909
+ ctx,
1910
+ (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
1911
+ );
1912
+ }
1906
1913
  function marqueeHits(ctx) {
1907
1914
  const hits = [];
1908
1915
  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;
@@ -2120,6 +2127,23 @@ const inter = Inter({ subsets: ["latin"] });`),
2120
2127
  pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
2121
2128
  }
2122
2129
  },
2130
+ {
2131
+ id: "shadcn-card-stock",
2132
+ name: "Stock shadcn Card",
2133
+ generation: 1,
2134
+ severity: "warn",
2135
+ surface: "source",
2136
+ 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.",
2137
+ 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.",
2138
+ detect: shadcnCardHits,
2139
+ fixtures: {
2140
+ flag: [
2141
+ f3("components/ui/card.tsx", `className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}`),
2142
+ 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)}`)
2143
+ ],
2144
+ pass: [f3("components/ui/card.tsx", `className={cn("rounded-sm border-2 border-ink bg-card text-card-foreground", className)}`)]
2145
+ }
2146
+ },
2123
2147
  {
2124
2148
  id: "cream-palette",
2125
2149
  name: "Cream ground",
@@ -2363,7 +2387,7 @@ function excerptAt(text, offset, max = 140) {
2363
2387
  }
2364
2388
 
2365
2389
  // src/snapshot/types.ts
2366
- var SNAPSHOT_VERSION = 1;
2390
+ var SNAPSHOT_VERSION = 2;
2367
2391
 
2368
2392
  // src/snapshot/fixture.ts
2369
2393
  function makeSnapshot(patch = {}) {
@@ -2697,7 +2721,7 @@ var RENDERED_PATHS = {
2697
2721
  };
2698
2722
 
2699
2723
  // src/character/check.ts
2700
- var CATALOGUE_VERSION = "2026.09.8";
2724
+ var CATALOGUE_VERSION = "2026.09.9";
2701
2725
  var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2702
2726
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2703
2727
  );
@@ -2795,7 +2819,7 @@ function auditSnapshot(snapshot, options = {}) {
2795
2819
  }
2796
2820
 
2797
2821
  // src/fingerprint/index.ts
2798
- var FINGERPRINT_VERSION = 1;
2822
+ var FINGERPRINT_VERSION = 2;
2799
2823
  var CHROMATIC = 0.03;
2800
2824
  var MIN_ACCENT_AREA = 2e4;
2801
2825
  var oklch = (value) => {
@@ -2850,7 +2874,8 @@ function fingerprint(s) {
2850
2874
  roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
2851
2875
  motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
2852
2876
  effects: effects.sort(),
2853
- layout: s.sections.map((x) => x.kind)
2877
+ layout: s.sections.map((x) => x.kind),
2878
+ ...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? { sections: s.sections.map((x) => ({ role: x.role, centred: x.geometry.centredShare, width: x.geometry.contentWidthRatio })) } : {}
2854
2879
  };
2855
2880
  }
2856
2881
 
@@ -2951,18 +2976,26 @@ function collectInPage() {
2951
2976
  top: Math.round(box(h).top)
2952
2977
  };
2953
2978
  });
2979
+ const isButtonEl = (el) => el.tagName === "BUTTON" || el.tagName === "INPUT" && /^(submit|button)$/i.test(el.type);
2980
+ const isControl = (el) => {
2981
+ const isButton = isButtonEl(el);
2982
+ if (!isButton && el.tagName !== "A") return false;
2983
+ if (!shown(el)) return false;
2984
+ const cs = style(el);
2985
+ const b = box(el);
2986
+ const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
2987
+ if (!label || b.height < 24 || b.height > 90) return false;
2988
+ if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) return false;
2989
+ if (!isButton && px(cs.paddingLeft) < 8) return false;
2990
+ return true;
2991
+ };
2954
2992
  const controls = [];
2955
2993
  for (const el of all) {
2956
- const tag = el.tagName;
2957
- const isButton = tag === "BUTTON" || tag === "INPUT" && /^(submit|button)$/i.test(el.type);
2958
- if (!isButton && tag !== "A") continue;
2959
- if (!shown(el)) continue;
2994
+ if (!isControl(el)) continue;
2995
+ const isButton = isButtonEl(el);
2960
2996
  const cs = style(el);
2961
2997
  const b = box(el);
2962
2998
  const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
2963
- if (!label || b.height < 24 || b.height > 90) continue;
2964
- if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) continue;
2965
- if (!isButton && px(cs.paddingLeft) < 8) continue;
2966
2999
  const r = cs.borderTopLeftRadius;
2967
3000
  const radius = r.endsWith("%") ? parseFloat(r) / 100 * b.height : px(r);
2968
3001
  controls.push({
@@ -3042,7 +3075,80 @@ function collectInPage() {
3042
3075
  }
3043
3076
  return best;
3044
3077
  };
3045
- const sections = blocks.slice(0, 40).map((el, index) => {
3078
+ const backgroundOf = (el) => {
3079
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
3080
+ const c = style(n).backgroundColor;
3081
+ if (painted(c)) return c;
3082
+ }
3083
+ return ground;
3084
+ };
3085
+ const geometryOf = (el, b) => {
3086
+ const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter((n) => (ownText(n).length > 1 || /^(IMG|SVG|VIDEO|PICTURE|BUTTON|INPUT|TEXTAREA|SELECT)$/i.test(n.tagName)) && shown(n)).slice(0, 400);
3087
+ const centreX = b.left + b.width / 2;
3088
+ let left = 0;
3089
+ let right = 0;
3090
+ let covered = 0;
3091
+ let minX = Infinity;
3092
+ let maxX = -Infinity;
3093
+ let textBlocks = 0;
3094
+ let centred = 0;
3095
+ for (const n of leaves) {
3096
+ const r = box(n);
3097
+ const area = r.width * r.height;
3098
+ covered += area;
3099
+ minX = Math.min(minX, r.left);
3100
+ maxX = Math.max(maxX, r.left + r.width);
3101
+ const split = Math.max(0, Math.min(r.width, centreX - r.left));
3102
+ left += split * r.height;
3103
+ right += (r.width - split) * r.height;
3104
+ if (ownText(n).length > 1) {
3105
+ textBlocks += 1;
3106
+ const mid = r.left + r.width / 2;
3107
+ if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
3108
+ }
3109
+ }
3110
+ const round = (v) => Math.round(v * 1e3) / 1e3;
3111
+ return {
3112
+ centredShare: textBlocks ? round(centred / textBlocks) : 0,
3113
+ mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
3114
+ whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
3115
+ contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
3116
+ background: backgroundOf(el),
3117
+ controls: Array.from(el.querySelectorAll("a, button, input")).slice(0, 200).filter(isControl).length
3118
+ };
3119
+ };
3120
+ const CURRENCY = /[£$€]\s?\d/;
3121
+ const roleOf = (el, kind, geo, b, cards, iconCards, last) => {
3122
+ if (kind === "marquee" || kind === "hero") return kind;
3123
+ const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
3124
+ const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
3125
+ const own = leaves.map(ownText);
3126
+ const questions = own.filter((t) => /\?$/.test(t) && t.length > 8).length;
3127
+ if (el.querySelectorAll("details").length >= 3 || el.querySelectorAll("[aria-expanded]").length >= 3 || /\bfaq|frequently asked|questions\b/.test(heading) && questions >= 3) return "faq";
3128
+ const prices = own.filter((t) => CURRENCY.test(t) && t.length < 40).length;
3129
+ if (prices >= 2 && (cards >= 2 || /pric|plan|package/.test(heading))) return "pricing";
3130
+ const quotes = el.querySelectorAll("blockquote, q").length;
3131
+ if (quotes >= 2 || /testimonial|review|what (?:our )?(?:clients|customers|people) say/.test(heading)) return "testimonials";
3132
+ const steps = Math.max(0, ...Array.from(el.querySelectorAll("ol")).map((ol) => ol.children.length));
3133
+ const numbered = own.filter((t) => /^(?:step\s*)?0?[1-9][.:]?$/i.test(t)).length;
3134
+ if (steps >= 3 || numbered >= 3 || /how it works|our process|\bsteps?\b/.test(heading)) return "process";
3135
+ const form = el.querySelector("form");
3136
+ const fields = form ? form.querySelectorAll("input:not([type=hidden]):not([type=submit]), textarea, select").length : 0;
3137
+ if (fields >= 2 || /contact|get in touch/.test(heading)) return "contact";
3138
+ const portraits = Array.from(el.querySelectorAll("img")).filter((i) => {
3139
+ const r = box(i);
3140
+ return r.width >= 80 && r.width <= 420 && Math.abs(r.width - r.height) < r.width * 0.25;
3141
+ }).length;
3142
+ if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
3143
+ if (kind === "stats" || kind === "logos") return kind;
3144
+ const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
3145
+ const setApart = geo.background !== ground || geo.centredShare >= 0.6;
3146
+ if (setApart && heading && b.height <= vh * 0.9 && geo.controls >= 1 && geo.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
3147
+ if (cards >= 3 && iconCards >= 2) return "features";
3148
+ return kind;
3149
+ };
3150
+ const kept = blocks.slice(0, 40);
3151
+ const sections = kept.map((el, index) => {
3046
3152
  const b = box(el);
3047
3153
  const heading = el.querySelector("h1, h2, h3");
3048
3154
  const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
@@ -3056,6 +3162,7 @@ function collectInPage() {
3056
3162
  else if (images.length >= 4 && textChars < 300) kind = "logos";
3057
3163
  else if (numbers >= 3 && textChars < 600) kind = "stats";
3058
3164
  else if (cards >= 3) kind = "cards";
3165
+ const geometry = geometryOf(el, b);
3059
3166
  return {
3060
3167
  top: Math.round(b.top),
3061
3168
  height: Math.round(b.height),
@@ -3063,7 +3170,9 @@ function collectInPage() {
3063
3170
  label,
3064
3171
  cards,
3065
3172
  iconCards,
3066
- hiddenAtLoad: b.top > vh && hiddenAtLoad(el)
3173
+ hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
3174
+ role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
3175
+ geometry
3067
3176
  };
3068
3177
  });
3069
3178
  if (header && sections.length === 0) {
@@ -3156,6 +3265,13 @@ function collectInPage() {
3156
3265
  headings: headings2,
3157
3266
  controls,
3158
3267
  sections,
3268
+ rhythmVariance: (() => {
3269
+ const hs = sections.map((x) => x.height);
3270
+ if (hs.length < 2) return null;
3271
+ const mean = hs.reduce((t, v) => t + v, 0) / hs.length;
3272
+ const sd = Math.sqrt(hs.reduce((t, v) => t + (v - mean) ** 2, 0) / hs.length);
3273
+ return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
3274
+ })(),
3159
3275
  effects,
3160
3276
  motion: {
3161
3277
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
@@ -3189,7 +3305,139 @@ function overlayInPage() {
3189
3305
  return false;
3190
3306
  }
3191
3307
 
3308
+ // src/direction/png.ts
3309
+ import { inflateSync } from "zlib";
3310
+ function decodePng(buf) {
3311
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
3312
+ if (!sig.every((b, i) => buf[i] === b)) throw new Error("not a PNG");
3313
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
3314
+ let pos = 8;
3315
+ let width = 0;
3316
+ let height = 0;
3317
+ let depth = 0;
3318
+ let colour = 0;
3319
+ let interlace = 0;
3320
+ const idat = [];
3321
+ while (pos < buf.length) {
3322
+ const len = view.getUint32(pos);
3323
+ const type = String.fromCharCode(...buf.subarray(pos + 4, pos + 8));
3324
+ const data = buf.subarray(pos + 8, pos + 8 + len);
3325
+ if (type === "IHDR") {
3326
+ width = view.getUint32(pos + 8);
3327
+ height = view.getUint32(pos + 12);
3328
+ depth = data[8];
3329
+ colour = data[9];
3330
+ interlace = data[12];
3331
+ } else if (type === "IDAT") idat.push(data);
3332
+ else if (type === "IEND") break;
3333
+ pos += 12 + len;
3334
+ }
3335
+ const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colour];
3336
+ if (depth !== 8 || !channels || interlace !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour type ${colour}${interlace ? ", interlaced" : ""}); export it as 8-bit RGB`);
3337
+ const raw = inflateSync(Buffer.concat(idat.map((d) => Buffer.from(d))));
3338
+ const stride = width * channels;
3339
+ const out = new Uint8Array(width * height * 4);
3340
+ const prev = new Uint8Array(stride);
3341
+ const line = new Uint8Array(stride);
3342
+ for (let y = 0; y < height; y += 1) {
3343
+ const filter = raw[y * (stride + 1)];
3344
+ const src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
3345
+ for (let x = 0; x < stride; x += 1) {
3346
+ const a = x >= channels ? line[x - channels] : 0;
3347
+ const b = prev[x];
3348
+ const c = x >= channels ? prev[x - channels] : 0;
3349
+ let v = src[x];
3350
+ if (filter === 1) v += a;
3351
+ else if (filter === 2) v += b;
3352
+ else if (filter === 3) v += a + b >> 1;
3353
+ else if (filter === 4) {
3354
+ const p = a + b - c;
3355
+ const pa = Math.abs(p - a);
3356
+ const pb = Math.abs(p - b);
3357
+ const pc = Math.abs(p - c);
3358
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
3359
+ }
3360
+ line[x] = v & 255;
3361
+ }
3362
+ for (let x = 0; x < width; x += 1) {
3363
+ const i = x * channels;
3364
+ const o = (y * width + x) * 4;
3365
+ if (channels >= 3) {
3366
+ out[o] = line[i];
3367
+ out[o + 1] = line[i + 1];
3368
+ out[o + 2] = line[i + 2];
3369
+ out[o + 3] = channels === 4 ? line[i + 3] : 255;
3370
+ } else {
3371
+ out[o] = out[o + 1] = out[o + 2] = line[i];
3372
+ out[o + 3] = channels === 2 ? line[i + 1] : 255;
3373
+ }
3374
+ }
3375
+ prev.set(line);
3376
+ }
3377
+ return { width, height, rgba: out };
3378
+ }
3379
+
3380
+ // src/snapshot/visual.ts
3381
+ var EDGE_STEP = 32;
3382
+ function visualMeasures(img, maxSamples = 1e6) {
3383
+ const { width: w, height: h, rgba } = img;
3384
+ if (w < 2 || h < 2 || rgba.length < w * h * 4) throw new Error(`visualMeasures: ${w}x${h} image with ${rgba.length} bytes`);
3385
+ const step = Math.max(1, Math.ceil(Math.sqrt(w * h / maxSamples)));
3386
+ const lum = (x, y) => {
3387
+ const i = (y * w + x) * 4;
3388
+ return 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
3389
+ };
3390
+ let n = 0;
3391
+ let sumRg = 0;
3392
+ let sumYb = 0;
3393
+ let sumRg2 = 0;
3394
+ let sumYb2 = 0;
3395
+ let edges = 0;
3396
+ let edgeN = 0;
3397
+ let asym = 0;
3398
+ let asymN = 0;
3399
+ for (let y = 0; y < h; y += step) {
3400
+ for (let x = 0; x < w; x += step) {
3401
+ const i = (y * w + x) * 4;
3402
+ const r = rgba[i];
3403
+ const g = rgba[i + 1];
3404
+ const b = rgba[i + 2];
3405
+ const rg = r - g;
3406
+ const yb = 0.5 * (r + g) - b;
3407
+ sumRg += rg;
3408
+ sumYb += yb;
3409
+ sumRg2 += rg * rg;
3410
+ sumYb2 += yb * yb;
3411
+ n += 1;
3412
+ const l = lum(x, y);
3413
+ if (x + step < w && y + step < h) {
3414
+ if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
3415
+ edgeN += 1;
3416
+ }
3417
+ if (x < w / 2) {
3418
+ asym += Math.abs(l - lum(w - 1 - x, y));
3419
+ asymN += 1;
3420
+ }
3421
+ }
3422
+ }
3423
+ const meanRg = sumRg / n;
3424
+ const meanYb = sumYb / n;
3425
+ const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
3426
+ const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
3427
+ const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
3428
+ const round = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
3429
+ return {
3430
+ colourfulness: round(colourfulness, 1),
3431
+ edgeDensity: round(edgeN ? edges / edgeN : 0),
3432
+ symmetry: round(asymN ? 1 - asym / asymN / 255 : 1),
3433
+ width: w,
3434
+ height: h,
3435
+ sampled: n
3436
+ };
3437
+ }
3438
+
3192
3439
  // src/audit/index.ts
3440
+ var VISUAL_MAX_HEIGHT = 6e3;
3193
3441
  async function loadPlaywright() {
3194
3442
  for (const name of ["playwright", "playwright-core"]) {
3195
3443
  try {
@@ -3207,12 +3455,23 @@ async function snapshotPage(page, url, options = {}) {
3207
3455
  await page.waitForTimeout(options.introWindowMs ?? 5e3);
3208
3456
  const late = await page.evaluate(overlayInPage).catch(() => false);
3209
3457
  const data = await page.evaluate(collectInPage);
3458
+ let visual;
3459
+ if (page.screenshot && options.visual !== false) {
3460
+ try {
3461
+ const height = Math.min(data.pageHeight, VISUAL_MAX_HEIGHT);
3462
+ const png = await page.screenshot({ fullPage: true, type: "png", clip: { x: 0, y: 0, width: data.viewport.width, height } });
3463
+ visual = visualMeasures(decodePng(png));
3464
+ } catch {
3465
+ visual = void 0;
3466
+ }
3467
+ }
3210
3468
  return {
3211
3469
  version: SNAPSHOT_VERSION,
3212
3470
  url,
3213
3471
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
3214
3472
  ...data,
3215
- motion: { ...data.motion, introOverlay: early && !late }
3473
+ motion: { ...data.motion, introOverlay: early && !late },
3474
+ ...visual ? { visual } : {}
3216
3475
  };
3217
3476
  }
3218
3477
  async function launch(pw, url, options) {
@@ -3258,6 +3517,7 @@ async function audit(url, options = {}) {
3258
3517
  return { snapshot, report: auditSnapshot(snapshot, options), fingerprint: fingerprint(snapshot) };
3259
3518
  }
3260
3519
  export {
3520
+ VISUAL_MAX_HEIGHT,
3261
3521
  audit,
3262
3522
  collectInPage,
3263
3523
  loadPlaywright,
@@ -330,7 +330,7 @@ function extractStrings(file) {
330
330
  }
331
331
 
332
332
  // src/snapshot/types.ts
333
- var SNAPSHOT_VERSION = 1;
333
+ var SNAPSHOT_VERSION = 2;
334
334
 
335
335
  // src/character/tells/copy.ts
336
336
  var f = (path, text) => ({ path, text });
@@ -1902,6 +1902,7 @@ var REFLEX_FONTS_2 = [
1902
1902
  "Plus Jakarta Sans",
1903
1903
  "Outfit",
1904
1904
  "Geist",
1905
+ "Cal Sans",
1905
1906
  "Sora",
1906
1907
  "Instrument Sans",
1907
1908
  "Instrument Serif",
@@ -2123,7 +2124,7 @@ function iconTiles(file) {
2123
2124
  return sized && has(unit, /^rounded-(?:md|lg|xl|2xl|full)$/) && has(unit, /^(?:items-center|place-items-center|place-content-center)$/) && has(unit, /^bg-/);
2124
2125
  });
2125
2126
  }
2126
- var ICON_LIBRARY = /from\s+["'](?:lucide-react|@heroicons\/react[^"']*|react-icons[^"']*|@phosphor-icons\/react|@tabler\/icons-react|@radix-ui\/react-icons)["']/;
2127
+ 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[^"']*)["']/;
2127
2128
  function iconTileStackHits(ctx) {
2128
2129
  const hits = [];
2129
2130
  for (const file of ctx.files) {
@@ -2270,6 +2271,12 @@ function shadcnHits(ctx) {
2270
2271
  const detail = unused.length > 0 ? `; ${unused.length} imported nowhere` : "";
2271
2272
  return [{ path: ui[0].path, offset: 0, message: `${ui.length} stock shadcn primitives${detail}` }];
2272
2273
  }
2274
+ function shadcnCardHits(ctx) {
2275
+ return eachUnit(
2276
+ ctx,
2277
+ (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
2278
+ );
2279
+ }
2273
2280
  function marqueeHits(ctx) {
2274
2281
  const hits = [];
2275
2282
  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;
@@ -2487,6 +2494,23 @@ const inter = Inter({ subsets: ["latin"] });`),
2487
2494
  pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
2488
2495
  }
2489
2496
  },
2497
+ {
2498
+ id: "shadcn-card-stock",
2499
+ name: "Stock shadcn Card",
2500
+ generation: 1,
2501
+ severity: "warn",
2502
+ surface: "source",
2503
+ 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.",
2504
+ 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.",
2505
+ detect: shadcnCardHits,
2506
+ fixtures: {
2507
+ flag: [
2508
+ f3("components/ui/card.tsx", `className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}`),
2509
+ 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)}`)
2510
+ ],
2511
+ pass: [f3("components/ui/card.tsx", `className={cn("rounded-sm border-2 border-ink bg-card text-card-foreground", className)}`)]
2512
+ }
2513
+ },
2490
2514
  {
2491
2515
  id: "cream-palette",
2492
2516
  name: "Cream ground",
@@ -2713,6 +2737,17 @@ var HOUSE = {
2713
2737
  function houseRule(id, name = id) {
2714
2738
  return HOUSE[id] ?? { tier: "review", label: name };
2715
2739
  }
2740
+ 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";
2741
+ function holdHouseBlocks(exceptions, severity) {
2742
+ const blocks = (tell) => HOUSE[tell]?.tier === "block";
2743
+ const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
2744
+ return {
2745
+ exceptions: exceptions.filter((e) => !blocks(e.tell)),
2746
+ refused: exceptions.filter((e) => blocks(e.tell)),
2747
+ severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
2748
+ refusedSeverity
2749
+ };
2750
+ }
2716
2751
 
2717
2752
  // src/snapshot/fixture.ts
2718
2753
  function makeSnapshot(patch = {}) {
@@ -3046,7 +3081,7 @@ var RENDERED_PATHS = {
3046
3081
  };
3047
3082
 
3048
3083
  // src/character/check.ts
3049
- var CATALOGUE_VERSION = "2026.09.8";
3084
+ var CATALOGUE_VERSION = "2026.09.9";
3050
3085
  var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
3051
3086
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
3052
3087
  );
@@ -3171,6 +3206,8 @@ export {
3171
3206
  REFLEX_FONTS_1,
3172
3207
  REFLEX_FONTS_2,
3173
3208
  houseRule,
3209
+ HOUSE_LOCKED,
3210
+ holdHouseBlocks,
3174
3211
  fileKind,
3175
3212
  extractCopy,
3176
3213
  extractStrings,