@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/audit.js CHANGED
@@ -1000,14 +1000,14 @@ function contractionHits(ctx) {
1000
1000
  for (const doc2 of documents(ctx)) {
1001
1001
  if (doc2.words < 800) continue;
1002
1002
  const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
1003
- const count2 = (text.match(CONTRACTION) ?? []).length;
1004
- const rate = count2 * 1e3 / doc2.words;
1003
+ const count3 = (text.match(CONTRACTION) ?? []).length;
1004
+ const rate = count3 * 1e3 / doc2.words;
1005
1005
  if (rate < 1) {
1006
1006
  const first = sentences(doc2)[0];
1007
1007
  hits.push({
1008
1008
  path: doc2.path,
1009
1009
  offset: first?.offset ?? 0,
1010
- message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
1010
+ message: `${count3} contraction${count3 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
1011
1011
  });
1012
1012
  }
1013
1013
  }
@@ -1130,16 +1130,16 @@ function filler(words, seed = 1, options = {}) {
1130
1130
  return state % n;
1131
1131
  };
1132
1132
  const out = [];
1133
- let count2 = 0;
1133
+ let count3 = 0;
1134
1134
  let i = 0;
1135
- while (count2 < words) {
1135
+ while (count3 < words) {
1136
1136
  const length = uniform ?? shape[i % shape.length];
1137
1137
  const s = [];
1138
1138
  for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
1139
1139
  if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
1140
1140
  s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
1141
1141
  out.push(`${s.join(" ")}.`);
1142
- count2 += s.length;
1142
+ count3 += s.length;
1143
1143
  i += 1;
1144
1144
  }
1145
1145
  const paragraphs = [];
@@ -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 = {}) {
@@ -2696,11 +2720,433 @@ var RENDERED_PATHS = {
2696
2720
  }
2697
2721
  };
2698
2722
 
2723
+ // src/fingerprint/index.ts
2724
+ var FINGERPRINT_VERSION = 2;
2725
+ var CHROMATIC = 0.03;
2726
+ var MIN_ACCENT_AREA = 2e4;
2727
+ var oklch = (value) => {
2728
+ const parsed = parseColour(value);
2729
+ return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
2730
+ };
2731
+ function accentOf(s) {
2732
+ const buttons = /* @__PURE__ */ new Map();
2733
+ for (const c of s.controls) {
2734
+ const o = oklch(c.background);
2735
+ if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
2736
+ }
2737
+ const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
2738
+ if (topButton) return oklch(topButton[0]);
2739
+ 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];
2740
+ if (bg) return bg.o;
2741
+ 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];
2742
+ return text ? text.o : null;
2743
+ }
2744
+ function faces(s) {
2745
+ const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
2746
+ const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
2747
+ const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
2748
+ const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
2749
+ return {
2750
+ display: { family: displayName, class: fontClass(displayName) },
2751
+ body: { family: bodyName, class: fontClass(bodyName) }
2752
+ };
2753
+ }
2754
+ function median(values) {
2755
+ if (values.length === 0) return null;
2756
+ const sorted = [...values].sort((a, b) => a - b);
2757
+ const mid = Math.floor(sorted.length / 2);
2758
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
2759
+ }
2760
+ function fingerprint(s) {
2761
+ const effects = [];
2762
+ if (s.effects.glass > 0) effects.push("glass-panel");
2763
+ if (s.effects.gradientText > 0) effects.push("gradient-text");
2764
+ if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
2765
+ if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
2766
+ if (s.effects.marquees > 0) effects.push("marquee");
2767
+ if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
2768
+ if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
2769
+ if (s.motion.introOverlay) effects.push("intro-cinematic");
2770
+ const below = s.sections.filter((x) => x.top > s.viewport.height).length;
2771
+ return {
2772
+ version: FINGERPRINT_VERSION,
2773
+ accent: accentOf(s),
2774
+ ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
2775
+ ...faces(s),
2776
+ roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
2777
+ motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
2778
+ effects: effects.sort(),
2779
+ layout: s.sections.map((x) => x.kind),
2780
+ ...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? {
2781
+ sections: s.sections.map((x) => ({
2782
+ role: x.role,
2783
+ centred: x.geometry.centredShare,
2784
+ width: x.geometry.contentWidthRatio,
2785
+ symmetry: x.geometry.mirrorSymmetry,
2786
+ controls: x.geometry.controls,
2787
+ cards: x.cards,
2788
+ background: x.geometry.background === s.ground ? null : oklch(x.geometry.background),
2789
+ badges: (x.badges ?? []).map((b) => b.toLowerCase()),
2790
+ avatars: x.avatars ?? 0,
2791
+ carousel: x.carousel ?? false,
2792
+ figures: x.figures ?? 0
2793
+ }))
2794
+ } : {}
2795
+ };
2796
+ }
2797
+
2798
+ // src/fingerprint/component.ts
2799
+ var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
2800
+
2801
+ // src/snapshot/rendered-tells.ts
2802
+ var hit2 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
2803
+ var GROUND = "rgb(255, 255, 255)";
2804
+ var BAND = "rgb(31, 58, 46)";
2805
+ var geo = (patch = {}) => ({
2806
+ centredShare: 0.1,
2807
+ mirrorSymmetry: 0.6,
2808
+ whitespaceRatio: 0.7,
2809
+ contentWidthRatio: 0.85,
2810
+ background: GROUND,
2811
+ controls: 0,
2812
+ ...patch
2813
+ });
2814
+ function withSection(index, patch, roles = []) {
2815
+ const base = makeSnapshot();
2816
+ return {
2817
+ ...base,
2818
+ sections: base.sections.map((s, i) => ({ ...s, role: roles[i] ?? s.kind, geometry: geo(), badges: [], avatars: 0, carousel: false, figures: 0, ...i === index ? patch : {} }))
2819
+ };
2820
+ }
2821
+ var measured = (s) => s.geometry !== void 0 && s.role !== void 0;
2822
+ var RENDERED_TELLS = [
2823
+ {
2824
+ id: "cta-band-stock",
2825
+ name: "Stock call-to-action band",
2826
+ generation: 1,
2827
+ severity: "warn",
2828
+ surface: "rendered",
2829
+ 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.",
2830
+ 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.",
2831
+ rendered: {
2832
+ detect: (s) => s.sections.filter((x) => measured(x) && (x.role === "cta-band" || x.role === "footer-cta")).filter((x) => {
2833
+ const g = x.geometry;
2834
+ return g.centredShare >= 0.8 && g.background !== s.ground && g.controls >= 1 && g.controls <= 2 && g.contentWidthRatio <= 0.7;
2835
+ }).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)),
2836
+ fixtures: {
2837
+ flag: [
2838
+ withSection(4, { role: "footer-cta", label: "Boiler out today?", geometry: geo({ centredShare: 1, background: BAND, controls: 1, contentWidthRatio: 0.45 }) }),
2839
+ withSection(3, { role: "cta-band", label: "Ready to book?", geometry: geo({ centredShare: 0.9, background: BAND, controls: 2, contentWidthRatio: 0.5 }) })
2840
+ ],
2841
+ pass: [
2842
+ makeSnapshot(),
2843
+ // The same ask, laid out like the page: left-aligned on the ground.
2844
+ withSection(4, { role: "footer-cta", label: "Book a visit", geometry: geo({ centredShare: 0.1, background: GROUND, controls: 1, contentWidthRatio: 0.85 }) })
2845
+ ]
2846
+ }
2847
+ }
2848
+ },
2849
+ {
2850
+ id: "pricing-trio-popular",
2851
+ name: "Three-tier pricing with a popular badge",
2852
+ generation: 1,
2853
+ severity: "warn",
2854
+ surface: "rendered",
2855
+ 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.",
2856
+ 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.",
2857
+ rendered: {
2858
+ 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)),
2859
+ fixtures: {
2860
+ flag: [withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: ["Most popular"], label: "Plans" })],
2861
+ pass: [
2862
+ makeSnapshot(),
2863
+ withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: [], label: "Prices" }),
2864
+ withSection(3, { role: "pricing", kind: "text", cards: 0, badges: [], label: "Price list" })
2865
+ ]
2866
+ }
2867
+ }
2868
+ },
2869
+ {
2870
+ id: "testimonial-avatar-carousel",
2871
+ name: "Avatar testimonial carousel",
2872
+ generation: 1,
2873
+ severity: "warn",
2874
+ surface: "rendered",
2875
+ 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.",
2876
+ 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.",
2877
+ rendered: {
2878
+ 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)),
2879
+ fixtures: {
2880
+ flag: [withSection(2, { role: "testimonials", avatars: 3, carousel: true, label: "What our clients say" })],
2881
+ pass: [
2882
+ makeSnapshot(),
2883
+ withSection(2, { role: "testimonials", avatars: 3, carousel: false, label: "Reviews" }),
2884
+ withSection(2, { role: "testimonials", avatars: 0, carousel: true, label: "Reviews" })
2885
+ ]
2886
+ }
2887
+ }
2888
+ },
2889
+ {
2890
+ id: "faq-accordion-closer",
2891
+ name: "FAQ accordion as the closer",
2892
+ generation: 2,
2893
+ severity: "warn",
2894
+ surface: "rendered",
2895
+ 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.",
2896
+ 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.",
2897
+ rendered: {
2898
+ detect: (s) => {
2899
+ const body = s.sections.filter((x) => x.role !== "footer-cta" && x.role !== "cta-band");
2900
+ const last = body[body.length - 1];
2901
+ return last && measured(last) && last.role === "faq" ? [hit2(s, "the page ends on a question-and-answer list", last.label)] : [];
2902
+ },
2903
+ fixtures: {
2904
+ flag: [
2905
+ withSection(4, { role: "faq", label: "Common questions" }),
2906
+ {
2907
+ ...withSection(3, { role: "faq", label: "FAQ" }),
2908
+ sections: withSection(3, { role: "faq", label: "FAQ" }).sections.map((x, i) => i === 4 ? { ...x, role: "footer-cta" } : x)
2909
+ }
2910
+ ],
2911
+ pass: [makeSnapshot(), withSection(2, { role: "faq", label: "Common questions" })]
2912
+ }
2913
+ }
2914
+ },
2915
+ {
2916
+ id: "stats-row",
2917
+ name: "Row of big numbers",
2918
+ generation: 1,
2919
+ severity: "warn",
2920
+ surface: "rendered",
2921
+ 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.",
2922
+ 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.",
2923
+ rendered: {
2924
+ 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)),
2925
+ fixtures: {
2926
+ flag: [withSection(3, { role: "stats", kind: "stats", figures: 4, label: "500+" })],
2927
+ pass: [
2928
+ makeSnapshot(),
2929
+ withSection(3, { role: "text", kind: "text", figures: 1, label: "Since 2009" }),
2930
+ withSection(1, { role: "stats", kind: "stats", figures: 4, label: "500+" })
2931
+ ]
2932
+ }
2933
+ }
2934
+ },
2935
+ {
2936
+ id: "centred-everything",
2937
+ name: "Every section centred",
2938
+ generation: 2,
2939
+ severity: "warn",
2940
+ surface: "rendered",
2941
+ 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.",
2942
+ fix: "Pick an alignment and hold it: left-aligned text with a clear edge, keeping centring for the one moment that earns it.",
2943
+ rendered: {
2944
+ detect: (s) => {
2945
+ const m = s.sections.filter(measured);
2946
+ if (m.length < 4) return [];
2947
+ const centred = m.filter((x) => x.geometry.centredShare >= 0.7).length;
2948
+ return centred / m.length >= 0.8 ? [hit2(s, `${centred} of ${m.length} sections centre their text`)] : [];
2949
+ },
2950
+ fixtures: {
2951
+ flag: [{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x) => ({ ...x, role: x.kind, geometry: geo({ centredShare: 0.9 }) })) }],
2952
+ pass: [
2953
+ makeSnapshot(),
2954
+ { ...makeSnapshot(), sections: makeSnapshot().sections.map((x, i) => ({ ...x, role: x.kind, geometry: geo({ centredShare: i < 2 ? 0.9 : 0.1 }) })) }
2955
+ ]
2956
+ }
2957
+ }
2958
+ }
2959
+ ];
2960
+
2961
+ // src/snapshot/imagery-tells.ts
2962
+ var hit3 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
2963
+ var STOCK_PHOTO_HOSTS = {
2964
+ "unsplash.com": "Unsplash",
2965
+ "pexels.com": "Pexels",
2966
+ "pixabay.com": "Pixabay",
2967
+ "istockphoto.com": "iStock",
2968
+ "gettyimages.com": "Getty Images",
2969
+ "shutterstock.com": "Shutterstock",
2970
+ "ftcdn.net": "Adobe Stock",
2971
+ "dreamstime.com": "Dreamstime",
2972
+ "depositphotos.com": "Depositphotos",
2973
+ "freepik.com": "Freepik",
2974
+ "rawpixel.com": "rawpixel",
2975
+ "stocksnap.io": "StockSnap",
2976
+ "burst.shopifycdn.com": "Burst",
2977
+ "picsum.photos": "Lorem Picsum",
2978
+ "loremflickr.com": "LoremFlickr",
2979
+ "placehold.co": "a placeholder service",
2980
+ "placeholder.com": "a placeholder service",
2981
+ "dummyimage.com": "a placeholder service"
2982
+ };
2983
+ var STOCK_AVATAR_HOSTS = {
2984
+ "randomuser.me": "randomuser.me",
2985
+ "pravatar.cc": "Pravatar",
2986
+ "dicebear.com": "DiceBear",
2987
+ "xsgames.co": "a random-face service",
2988
+ "thispersondoesnotexist.com": "a generated face",
2989
+ "generated.photos": "a generated face"
2990
+ };
2991
+ var STOCK_FILENAMES = [
2992
+ [/(?:^|[/_-])shutterstock[_-]?\d{4,}/i, "Shutterstock"],
2993
+ [/(?:^|[/_-])istock(?:photo)?[_-]?\d{4,}/i, "iStock"],
2994
+ [/(?:^|[/_-])adobestock[_-]?\d{4,}/i, "Adobe Stock"],
2995
+ [/(?:^|[/_-])gettyimages[_-]?\d{4,}/i, "Getty Images"],
2996
+ [/(?:^|[/_-])depositphotos[_-]?\d{4,}/i, "Depositphotos"],
2997
+ [/(?:^|[/_-])dreamstime[_-]/i, "Dreamstime"],
2998
+ [/(?:^|[/_-])pexels-[a-z0-9-]*\d{4,}/i, "Pexels"],
2999
+ [/-unsplash\.(?:jpe?g|png|webp|avif)(?:[?#]|$)/i, "Unsplash"],
3000
+ // Unsplash's photo ids: photo-<13-digit timestamp>-<12 hex>.
3001
+ [/photo-1\d{12}-[0-9a-f]{12}/, "Unsplash"]
3002
+ ];
3003
+ var byHost = (host, table) => {
3004
+ if (!host) return null;
3005
+ const h = host.toLowerCase();
3006
+ for (const [domain, name] of Object.entries(table)) if (h === domain || h.endsWith(`.${domain}`)) return name;
3007
+ return null;
3008
+ };
3009
+ function stockSource(image) {
3010
+ const host = byHost(image.host, STOCK_PHOTO_HOSTS);
3011
+ if (host) return host;
3012
+ let path = image.src.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, "").replace(/[?#].*$/, "");
3013
+ try {
3014
+ path = decodeURIComponent(path);
3015
+ } catch {
3016
+ }
3017
+ for (const [re, name] of STOCK_FILENAMES) if (re.test(path)) return name;
3018
+ return null;
3019
+ }
3020
+ function stockAvatarSource(image) {
3021
+ return byHost(image.host, STOCK_AVATAR_HOSTS) ?? stockSource(image);
3022
+ }
3023
+ var picture = (patch = {}) => ({
3024
+ src: "https://example.test/images/boiler-swap-hinton.jpg",
3025
+ host: "example.test",
3026
+ width: 640,
3027
+ height: 480,
3028
+ top: 1800,
3029
+ role: "photo",
3030
+ alt: "The new combi boiler fitted in a kitchen cupboard in Hinton",
3031
+ decorative: false,
3032
+ section: 2,
3033
+ background: false,
3034
+ ...patch
3035
+ });
3036
+ var withImages = (images) => ({ ...makeSnapshot(), images });
3037
+ var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
3038
+ var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
3039
+ var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3040
+ var IMAGERY_TELLS = [
3041
+ {
3042
+ id: "stock-photo",
3043
+ name: "Stock photography",
3044
+ generation: 1,
3045
+ severity: "warn",
3046
+ surface: "rendered",
3047
+ 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.",
3048
+ 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.",
3049
+ rendered: {
3050
+ detect: (s) => {
3051
+ const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
3052
+ if (found.length === 0) return [];
3053
+ const names = [...new Set(found.map((x) => x.from))].join(", ");
3054
+ return [hit3(s, `${count2(found.length, "picture")} from ${names}`, found[0].image.src)];
3055
+ },
3056
+ fixtures: {
3057
+ flag: [
3058
+ withImages([picture({ src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=1600", host: "images.unsplash.com", alt: "Plumber fixing a sink" })]),
3059
+ // Downloaded and self-hosted, found by the library's own file name.
3060
+ withImages([picture({ src: "https://example.test/img/shutterstock_1234567890.jpg" })])
3061
+ ],
3062
+ pass: [makeSnapshot(), withImages([picture()]), withImages([picture({ role: "icon", width: 48, height: 48, src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952", host: "images.unsplash.com" })])]
3063
+ }
3064
+ }
3065
+ },
3066
+ {
3067
+ id: "stock-avatar",
3068
+ name: "Stock or placeholder faces",
3069
+ generation: 1,
3070
+ severity: "warn",
3071
+ surface: "rendered",
3072
+ 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.",
3073
+ fix: "Drop the portraits. Name the reviewer, the town and the job, and link the review to where it was left.",
3074
+ rendered: {
3075
+ detect: (s) => {
3076
+ const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
3077
+ if (found.length === 0) return [];
3078
+ const names = [...new Set(found.map((x) => x.from))].join(", ");
3079
+ return [hit3(s, `${count2(found.length, "portrait")} from ${names}`, found[0].image.src)];
3080
+ },
3081
+ fixtures: {
3082
+ flag: [
3083
+ withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://i.pravatar.cc/150?img=12", host: "i.pravatar.cc", alt: "Sarah" })]),
3084
+ withImages([picture({ role: "avatar", width: 64, height: 64, src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?crop=faces", host: "images.unsplash.com" })])
3085
+ ],
3086
+ pass: [makeSnapshot(), withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://example.test/team/sam.jpg", alt: "Sam, engineer" })])]
3087
+ }
3088
+ }
3089
+ },
3090
+ {
3091
+ id: "ai-image",
3092
+ name: "Generated image",
3093
+ generation: 2,
3094
+ severity: "warn",
3095
+ surface: "rendered",
3096
+ 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.",
3097
+ fix: "Replace it with a photo of the real thing: the work, the premises, the people. Where there is none yet, use fewer pictures.",
3098
+ rendered: {
3099
+ detect: (s) => {
3100
+ const found = (s.images ?? []).filter((i) => i.provenance?.digitalSourceType);
3101
+ if (found.length === 0) return [];
3102
+ const first = found[0].provenance;
3103
+ const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
3104
+ return [hit3(s, `${count2(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
3105
+ },
3106
+ fixtures: {
3107
+ flag: [
3108
+ withImages([picture({ provenance: AI_XMP })]),
3109
+ withImages([picture({ provenance: { digitalSourceType: "compositeWithTrainedAlgorithmicMedia", source: "c2pa", c2pa: true, bytes: 12e4 } })])
3110
+ ],
3111
+ // Credentials that say nothing about a model, and an image never read, are not findings.
3112
+ pass: [makeSnapshot(), withImages([picture({ provenance: NOTHING })]), withImages([picture({ provenance: { ...NOTHING, c2pa: true } })]), withImages([picture()])]
3113
+ }
3114
+ }
3115
+ },
3116
+ {
3117
+ id: "no-real-imagery",
3118
+ name: "No photograph on the page",
3119
+ generation: 1,
3120
+ severity: "warn",
3121
+ surface: "rendered",
3122
+ 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.",
3123
+ 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.",
3124
+ rendered: {
3125
+ detect: (s) => {
3126
+ if (!s.images || s.sections.length < 4) return [];
3127
+ if (s.images.some((i) => i.role === "photo")) return [];
3128
+ const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
3129
+ return [hit3(s, `no photograph on the page${icons ? `: ${count2(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
3130
+ },
3131
+ fixtures: {
3132
+ flag: [
3133
+ 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" })]),
3134
+ withImages([])
3135
+ ],
3136
+ // A snapshot with no image record is never judged; nor is a page too short to be a site.
3137
+ pass: [makeSnapshot(), withImages([picture()]), { ...withImages([]), sections: makeSnapshot().sections.slice(0, 3) }]
3138
+ }
3139
+ }
3140
+ }
3141
+ ];
3142
+
2699
3143
  // src/character/check.ts
2700
- var CATALOGUE_VERSION = "2026.09.8";
2701
- var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2702
- (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2703
- );
3144
+ var CATALOGUE_VERSION = "2026.09.11";
3145
+ var CATALOGUE = [
3146
+ ...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
3147
+ ...RENDERED_TELLS,
3148
+ ...IMAGERY_TELLS
3149
+ ];
2704
3150
  function tellById(id) {
2705
3151
  return CATALOGUE.find((t) => t.id === id);
2706
3152
  }
@@ -2722,17 +3168,17 @@ var MARKER = /\b(?:copy|craft)-ok\b/;
2722
3168
  function marked(lines, line) {
2723
3169
  return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
2724
3170
  }
2725
- function toFinding(tell, hit2, texts) {
2726
- const text = texts.get(hit2.path) ?? "";
3171
+ function toFinding(tell, hit4, texts) {
3172
+ const text = texts.get(hit4.path) ?? "";
2727
3173
  return {
2728
3174
  tell: tell.id,
2729
3175
  name: tell.name,
2730
3176
  generation: tell.generation,
2731
3177
  severity: tell.severity,
2732
- path: hit2.path,
2733
- line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
2734
- excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
2735
- message: hit2.message,
3178
+ path: hit4.path,
3179
+ line: texts.has(hit4.path) ? lineAt(text, hit4.offset) : 0,
3180
+ excerpt: hit4.excerpt ?? excerptAt(text, hit4.offset),
3181
+ message: hit4.message,
2736
3182
  fix: tell.fix,
2737
3183
  ...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
2738
3184
  };
@@ -2751,14 +3197,14 @@ function report(tells, files, run, options) {
2751
3197
  };
2752
3198
  const excepted = /* @__PURE__ */ new Map();
2753
3199
  for (const tell of selected) {
2754
- for (const hit2 of run(tell)) {
2755
- const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit2.path.includes(e.path)));
3200
+ for (const hit4 of run(tell)) {
3201
+ const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit4.path.includes(e.path)));
2756
3202
  if (exception) {
2757
3203
  excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
2758
3204
  continue;
2759
3205
  }
2760
- const finding = toFinding(tell, hit2, texts);
2761
- if (marked(linesOf(hit2.path), finding.line)) {
3206
+ const finding = toFinding(tell, hit4, texts);
3207
+ if (marked(linesOf(hit4.path), finding.line)) {
2762
3208
  suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
2763
3209
  continue;
2764
3210
  }
@@ -2776,7 +3222,7 @@ function report(tells, files, run, options) {
2776
3222
  catalogueVersion: CATALOGUE_VERSION,
2777
3223
  findings,
2778
3224
  rejectedExceptions: rejected,
2779
- excepted: [...excepted].map(([e, count2]) => ({ tell: e.tell, because: e.because, count: count2 })),
3225
+ excepted: [...excepted].map(([e, count3]) => ({ tell: e.tell, because: e.because, count: count3 })),
2780
3226
  suppressed,
2781
3227
  summary: {
2782
3228
  files: files.length,
@@ -2794,66 +3240,6 @@ function auditSnapshot(snapshot, options = {}) {
2794
3240
  return result;
2795
3241
  }
2796
3242
 
2797
- // src/fingerprint/index.ts
2798
- var FINGERPRINT_VERSION = 1;
2799
- var CHROMATIC = 0.03;
2800
- var MIN_ACCENT_AREA = 2e4;
2801
- var oklch = (value) => {
2802
- const parsed = parseColour(value);
2803
- return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
2804
- };
2805
- function accentOf(s) {
2806
- const buttons = /* @__PURE__ */ new Map();
2807
- for (const c of s.controls) {
2808
- const o = oklch(c.background);
2809
- if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
2810
- }
2811
- const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
2812
- if (topButton) return oklch(topButton[0]);
2813
- 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];
2814
- if (bg) return bg.o;
2815
- 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];
2816
- return text ? text.o : null;
2817
- }
2818
- function faces(s) {
2819
- const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
2820
- const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
2821
- const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
2822
- const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
2823
- return {
2824
- display: { family: displayName, class: fontClass(displayName) },
2825
- body: { family: bodyName, class: fontClass(bodyName) }
2826
- };
2827
- }
2828
- function median(values) {
2829
- if (values.length === 0) return null;
2830
- const sorted = [...values].sort((a, b) => a - b);
2831
- const mid = Math.floor(sorted.length / 2);
2832
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
2833
- }
2834
- function fingerprint(s) {
2835
- const effects = [];
2836
- if (s.effects.glass > 0) effects.push("glass-panel");
2837
- if (s.effects.gradientText > 0) effects.push("gradient-text");
2838
- if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
2839
- if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
2840
- if (s.effects.marquees > 0) effects.push("marquee");
2841
- if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
2842
- if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
2843
- if (s.motion.introOverlay) effects.push("intro-cinematic");
2844
- const below = s.sections.filter((x) => x.top > s.viewport.height).length;
2845
- return {
2846
- version: FINGERPRINT_VERSION,
2847
- accent: accentOf(s),
2848
- ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
2849
- ...faces(s),
2850
- roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
2851
- motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
2852
- effects: effects.sort(),
2853
- layout: s.sections.map((x) => x.kind)
2854
- };
2855
- }
2856
-
2857
3243
  // src/audit/collect.ts
2858
3244
  function collectInPage() {
2859
3245
  const vw = window.innerWidth;
@@ -2951,18 +3337,26 @@ function collectInPage() {
2951
3337
  top: Math.round(box(h).top)
2952
3338
  };
2953
3339
  });
3340
+ const isButtonEl = (el) => el.tagName === "BUTTON" || el.tagName === "INPUT" && /^(submit|button)$/i.test(el.type);
3341
+ const isControl = (el) => {
3342
+ const isButton = isButtonEl(el);
3343
+ if (!isButton && el.tagName !== "A") return false;
3344
+ if (!shown(el)) return false;
3345
+ const cs = style(el);
3346
+ const b = box(el);
3347
+ const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
3348
+ if (!label || b.height < 24 || b.height > 90) return false;
3349
+ if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) return false;
3350
+ if (!isButton && px(cs.paddingLeft) < 8) return false;
3351
+ return true;
3352
+ };
2954
3353
  const controls = [];
2955
3354
  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;
3355
+ if (!isControl(el)) continue;
3356
+ const isButton = isButtonEl(el);
2960
3357
  const cs = style(el);
2961
3358
  const b = box(el);
2962
3359
  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
3360
  const r = cs.borderTopLeftRadius;
2967
3361
  const radius = r.endsWith("%") ? parseFloat(r) / 100 * b.height : px(r);
2968
3362
  controls.push({
@@ -3042,20 +3436,122 @@ function collectInPage() {
3042
3436
  }
3043
3437
  return best;
3044
3438
  };
3045
- const sections = blocks.slice(0, 40).map((el, index) => {
3439
+ const backgroundOf = (el) => {
3440
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
3441
+ const c = style(n).backgroundColor;
3442
+ if (painted(c)) return c;
3443
+ }
3444
+ return ground;
3445
+ };
3446
+ const geometryOf = (el, b) => {
3447
+ 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);
3448
+ const centreX = b.left + b.width / 2;
3449
+ let left = 0;
3450
+ let right = 0;
3451
+ let covered = 0;
3452
+ let minX = Infinity;
3453
+ let maxX = -Infinity;
3454
+ let textBlocks = 0;
3455
+ let centred = 0;
3456
+ for (const n of leaves) {
3457
+ const r = box(n);
3458
+ const area = r.width * r.height;
3459
+ covered += area;
3460
+ minX = Math.min(minX, r.left);
3461
+ maxX = Math.max(maxX, r.left + r.width);
3462
+ const split = Math.max(0, Math.min(r.width, centreX - r.left));
3463
+ left += split * r.height;
3464
+ right += (r.width - split) * r.height;
3465
+ if (ownText(n).length > 1) {
3466
+ textBlocks += 1;
3467
+ const mid = r.left + r.width / 2;
3468
+ if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
3469
+ }
3470
+ }
3471
+ const round = (v) => Math.round(v * 1e3) / 1e3;
3472
+ return {
3473
+ centredShare: textBlocks ? round(centred / textBlocks) : 0,
3474
+ mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
3475
+ whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
3476
+ contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
3477
+ background: backgroundOf(el),
3478
+ controls: Array.from(el.querySelectorAll("a, button, input")).slice(0, 200).filter(isControl).length
3479
+ };
3480
+ };
3481
+ const CURRENCY = /[£$€]\s?\d/;
3482
+ const roleOf = (el, kind, geo2, b, cards, iconCards, last) => {
3483
+ if (kind === "marquee" || kind === "hero") return kind;
3484
+ const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
3485
+ const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
3486
+ const own = leaves.map(ownText);
3487
+ const questions = own.filter((t) => /\?$/.test(t) && t.length > 8).length;
3488
+ if (el.querySelectorAll("details").length >= 3 || el.querySelectorAll("[aria-expanded]").length >= 3 || /\bfaq|frequently asked|questions\b/.test(heading) && questions >= 3) return "faq";
3489
+ const prices = own.filter((t) => CURRENCY.test(t) && t.length < 40).length;
3490
+ if (prices >= 2 && (cards >= 2 || /pric|plan|package/.test(heading))) return "pricing";
3491
+ const quotes = el.querySelectorAll("blockquote, q").length;
3492
+ if (quotes >= 2 || /testimonial|review|what (?:our )?(?:clients|customers|people) say/.test(heading)) return "testimonials";
3493
+ const steps = Math.max(0, ...Array.from(el.querySelectorAll("ol")).map((ol) => ol.children.length));
3494
+ const numbered = own.filter((t) => /^(?:step\s*)?0?[1-9][.:]?$/i.test(t)).length;
3495
+ if (steps >= 3 || numbered >= 3 || /how it works|our process|\bsteps?\b/.test(heading)) return "process";
3496
+ const form = el.querySelector("form");
3497
+ const fields = form ? form.querySelectorAll("input:not([type=hidden]):not([type=submit]), textarea, select").length : 0;
3498
+ if (fields >= 2 || /contact|get in touch/.test(heading)) return "contact";
3499
+ const portraits = Array.from(el.querySelectorAll("img")).filter((i) => {
3500
+ const r = box(i);
3501
+ return r.width >= 80 && r.width <= 420 && Math.abs(r.width - r.height) < r.width * 0.25;
3502
+ }).length;
3503
+ if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
3504
+ if (kind === "stats" || kind === "logos") return kind;
3505
+ const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
3506
+ const setApart = geo2.background !== ground || geo2.centredShare >= 0.6;
3507
+ if (setApart && heading && b.height <= vh * 0.9 && geo2.controls >= 1 && geo2.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
3508
+ if (cards >= 3 && iconCards >= 2) return "features";
3509
+ return kind;
3510
+ };
3511
+ const partsOf = (el) => {
3512
+ const inside = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter(shown);
3513
+ const badges = [];
3514
+ for (const n of inside) {
3515
+ const t = ownText(n);
3516
+ if (t.length < 2 || t.length > 24 || badges.length >= 5) continue;
3517
+ const cs = style(n);
3518
+ const r = box(n);
3519
+ const radius = px(cs.borderTopLeftRadius);
3520
+ const drawn = painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1;
3521
+ if (drawn && r.height <= 40 && r.width <= 240 && radius >= r.height / 3 && !isControl(n)) badges.push(t);
3522
+ }
3523
+ const avatars = inside.filter((n) => {
3524
+ if (n.tagName !== "IMG") return false;
3525
+ const r = box(n);
3526
+ const rad = style(n).borderTopLeftRadius;
3527
+ const round = rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= r.width * 0.4;
3528
+ return r.width <= 96 && r.width >= 16 && Math.abs(r.width - r.height) <= r.width * 0.15 && round;
3529
+ }).length;
3530
+ const carousel = inside.some((n) => {
3531
+ const cs = style(n);
3532
+ if (n.getAttribute("aria-roledescription") === "carousel" || cs.scrollSnapType && cs.scrollSnapType !== "none") return true;
3533
+ if (/^(auto|scroll|hidden)$/.test(cs.overflowX) && n.children.length >= 3 && n.scrollWidth > n.clientWidth * 1.2) return true;
3534
+ return /^(BUTTON|A)$/.test(n.tagName) && /\b(prev|previous|next)\b/i.test(n.getAttribute("aria-label") ?? "");
3535
+ });
3536
+ return { badges, avatars, carousel };
3537
+ };
3538
+ const kept = blocks.slice(0, 40);
3539
+ const sections = kept.map((el, index) => {
3046
3540
  const b = box(el);
3047
3541
  const heading = el.querySelector("h1, h2, h3");
3048
3542
  const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
3049
- const images = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
3543
+ const images2 = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
3050
3544
  const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
3051
3545
  const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
3052
3546
  const { cards, iconCards } = cardsIn(el);
3053
3547
  let kind = "text";
3054
3548
  if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
3055
3549
  else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
3056
- else if (images.length >= 4 && textChars < 300) kind = "logos";
3550
+ else if (images2.length >= 4 && textChars < 300) kind = "logos";
3057
3551
  else if (numbers >= 3 && textChars < 600) kind = "stats";
3058
3552
  else if (cards >= 3) kind = "cards";
3553
+ const geometry = geometryOf(el, b);
3554
+ const parts2 = partsOf(el);
3059
3555
  return {
3060
3556
  top: Math.round(b.top),
3061
3557
  height: Math.round(b.height),
@@ -3063,7 +3559,11 @@ function collectInPage() {
3063
3559
  label,
3064
3560
  cards,
3065
3561
  iconCards,
3066
- hiddenAtLoad: b.top > vh && hiddenAtLoad(el)
3562
+ hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
3563
+ role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
3564
+ geometry,
3565
+ ...parts2,
3566
+ figures: numbers
3067
3567
  };
3068
3568
  });
3069
3569
  if (header && sections.length === 0) {
@@ -3145,6 +3645,81 @@ function collectInPage() {
3145
3645
  const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
3146
3646
  if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
3147
3647
  }
3648
+ const unwrap = (raw) => {
3649
+ if (raw.startsWith("data:")) return raw.slice(0, 64);
3650
+ try {
3651
+ const u = new URL(raw, location.href);
3652
+ const inner = /\/_(?:next|vercel)\/image$/.test(u.pathname) ? u.searchParams.get("url") : null;
3653
+ if (inner) return new URL(inner, location.href).href;
3654
+ const cf = u.pathname.match(/^\/cdn-cgi\/image\/[^/]+\/(.+)$/);
3655
+ if (cf) return /^https?:\/\//.test(cf[1]) ? cf[1] : new URL(`/${cf[1]}`, location.href).href;
3656
+ return u.href;
3657
+ } catch {
3658
+ return raw.slice(0, 200);
3659
+ }
3660
+ };
3661
+ const roundBox = (el, w) => {
3662
+ const rad = style(el).borderTopLeftRadius;
3663
+ return rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= w * 0.4;
3664
+ };
3665
+ const sectionOf = (el) => {
3666
+ const i = kept.findIndex((k) => k.contains(el));
3667
+ return i === -1 ? null : i;
3668
+ };
3669
+ const roleOfImage = (el, src, w, h, alt) => {
3670
+ const vector = /\.svg(?:[?#]|$)/i.test(src) || src.startsWith("data:image/svg");
3671
+ const square = Math.abs(w - h) <= w * 0.15;
3672
+ if (w >= 16 && w <= 96 && square && (roundBox(el, w) || el.parentElement !== null && roundBox(el.parentElement, w))) return "avatar";
3673
+ const section = sectionOf(el);
3674
+ const hint = `${alt ?? ""} ${src} ${typeof el.className === "string" ? el.className : ""}`;
3675
+ if (/\blogo\b/i.test(hint) || section !== null && sections[section]?.kind === "logos" || el.closest("header, footer, nav") && h <= 120) return "logo";
3676
+ if (Math.max(w, h) <= (vector ? 120 : 72)) return "icon";
3677
+ if (vector) return "illustration";
3678
+ return w >= 160 && h >= 120 ? "photo" : "icon";
3679
+ };
3680
+ const images = [];
3681
+ const seenImage = /* @__PURE__ */ new Set();
3682
+ const addImage = (el, raw, alt, decorative, background) => {
3683
+ const b = box(el);
3684
+ if (!raw || b.width < 8 || b.height < 8) return;
3685
+ const src = unwrap(raw);
3686
+ const key = `${src} ${Math.round(b.top)} ${Math.round(b.left)}`;
3687
+ if (seenImage.has(key)) return;
3688
+ seenImage.add(key);
3689
+ let host = null;
3690
+ try {
3691
+ host = src.startsWith("data:") ? null : new URL(src).hostname;
3692
+ } catch {
3693
+ host = null;
3694
+ }
3695
+ images.push({
3696
+ src,
3697
+ host,
3698
+ width: Math.round(b.width),
3699
+ height: Math.round(b.height),
3700
+ top: Math.round(b.top),
3701
+ role: roleOfImage(el, src, b.width, b.height, alt),
3702
+ alt,
3703
+ decorative,
3704
+ section: sectionOf(el),
3705
+ background
3706
+ });
3707
+ };
3708
+ for (const img of Array.from(document.querySelectorAll("img")).slice(0, 400)) {
3709
+ if (!shown(img)) continue;
3710
+ const alt = img.getAttribute("alt");
3711
+ const decorative = alt === "" || img.getAttribute("aria-hidden") === "true" || /^(?:presentation|none)$/.test(img.getAttribute("role") ?? "");
3712
+ addImage(img, img.currentSrc || img.getAttribute("src") || "", alt, decorative, false);
3713
+ }
3714
+ for (const el of all) {
3715
+ const bgImage = style(el).backgroundImage || "";
3716
+ if (!bgImage.includes("url(")) continue;
3717
+ const b = box(el);
3718
+ if (b.width < 160 || b.height < 120 || !shown(el)) continue;
3719
+ const m = bgImage.match(/url\(\s*["']?([^"')]+)["']?\s*\)/);
3720
+ if (m) addImage(el, m[1], null, true, true);
3721
+ }
3722
+ images.sort((a, b) => b.width * b.height - a.width * a.height);
3148
3723
  const toList = (m) => Array.from(m, ([value, v]) => ({ value, area: Math.round(v.area), chars: v.chars })).sort((a, b) => b.area - a.area || b.chars - a.chars);
3149
3724
  const nums = (s) => Array.from(s).sort((a, b) => a - b);
3150
3725
  return {
@@ -3156,7 +3731,15 @@ function collectInPage() {
3156
3731
  headings: headings2,
3157
3732
  controls,
3158
3733
  sections,
3734
+ rhythmVariance: (() => {
3735
+ const hs = sections.map((x) => x.height);
3736
+ if (hs.length < 2) return null;
3737
+ const mean = hs.reduce((t, v) => t + v, 0) / hs.length;
3738
+ const sd = Math.sqrt(hs.reduce((t, v) => t + (v - mean) ** 2, 0) / hs.length);
3739
+ return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
3740
+ })(),
3159
3741
  effects,
3742
+ images: images.slice(0, 60),
3160
3743
  motion: {
3161
3744
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
3162
3745
  sections: sections.length,
@@ -3189,7 +3772,192 @@ function overlayInPage() {
3189
3772
  return false;
3190
3773
  }
3191
3774
 
3775
+ // src/direction/png.ts
3776
+ import { inflateSync } from "zlib";
3777
+ function decodePng(buf) {
3778
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
3779
+ if (!sig.every((b, i) => buf[i] === b)) throw new Error("not a PNG");
3780
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
3781
+ let pos = 8;
3782
+ let width = 0;
3783
+ let height = 0;
3784
+ let depth = 0;
3785
+ let colour = 0;
3786
+ let interlace = 0;
3787
+ const idat = [];
3788
+ while (pos < buf.length) {
3789
+ const len = view.getUint32(pos);
3790
+ const type = String.fromCharCode(...buf.subarray(pos + 4, pos + 8));
3791
+ const data = buf.subarray(pos + 8, pos + 8 + len);
3792
+ if (type === "IHDR") {
3793
+ width = view.getUint32(pos + 8);
3794
+ height = view.getUint32(pos + 12);
3795
+ depth = data[8];
3796
+ colour = data[9];
3797
+ interlace = data[12];
3798
+ } else if (type === "IDAT") idat.push(data);
3799
+ else if (type === "IEND") break;
3800
+ pos += 12 + len;
3801
+ }
3802
+ const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colour];
3803
+ if (depth !== 8 || !channels || interlace !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour type ${colour}${interlace ? ", interlaced" : ""}); export it as 8-bit RGB`);
3804
+ const raw = inflateSync(Buffer.concat(idat.map((d) => Buffer.from(d))));
3805
+ const stride = width * channels;
3806
+ const out = new Uint8Array(width * height * 4);
3807
+ const prev = new Uint8Array(stride);
3808
+ const line = new Uint8Array(stride);
3809
+ for (let y = 0; y < height; y += 1) {
3810
+ const filter = raw[y * (stride + 1)];
3811
+ const src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
3812
+ for (let x = 0; x < stride; x += 1) {
3813
+ const a = x >= channels ? line[x - channels] : 0;
3814
+ const b = prev[x];
3815
+ const c = x >= channels ? prev[x - channels] : 0;
3816
+ let v = src[x];
3817
+ if (filter === 1) v += a;
3818
+ else if (filter === 2) v += b;
3819
+ else if (filter === 3) v += a + b >> 1;
3820
+ else if (filter === 4) {
3821
+ const p = a + b - c;
3822
+ const pa = Math.abs(p - a);
3823
+ const pb = Math.abs(p - b);
3824
+ const pc = Math.abs(p - c);
3825
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
3826
+ }
3827
+ line[x] = v & 255;
3828
+ }
3829
+ for (let x = 0; x < width; x += 1) {
3830
+ const i = x * channels;
3831
+ const o = (y * width + x) * 4;
3832
+ if (channels >= 3) {
3833
+ out[o] = line[i];
3834
+ out[o + 1] = line[i + 1];
3835
+ out[o + 2] = line[i + 2];
3836
+ out[o + 3] = channels === 4 ? line[i + 3] : 255;
3837
+ } else {
3838
+ out[o] = out[o + 1] = out[o + 2] = line[i];
3839
+ out[o + 3] = channels === 2 ? line[i + 1] : 255;
3840
+ }
3841
+ }
3842
+ prev.set(line);
3843
+ }
3844
+ return { width, height, rgba: out };
3845
+ }
3846
+
3847
+ // src/snapshot/visual.ts
3848
+ var EDGE_STEP = 32;
3849
+ function visualMeasures(img, maxSamples = 1e6) {
3850
+ const { width: w, height: h, rgba } = img;
3851
+ if (w < 2 || h < 2 || rgba.length < w * h * 4) throw new Error(`visualMeasures: ${w}x${h} image with ${rgba.length} bytes`);
3852
+ const step = Math.max(1, Math.ceil(Math.sqrt(w * h / maxSamples)));
3853
+ const lum = (x, y) => {
3854
+ const i = (y * w + x) * 4;
3855
+ return 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
3856
+ };
3857
+ let n = 0;
3858
+ let sumRg = 0;
3859
+ let sumYb = 0;
3860
+ let sumRg2 = 0;
3861
+ let sumYb2 = 0;
3862
+ let edges = 0;
3863
+ let edgeN = 0;
3864
+ let asym = 0;
3865
+ let asymN = 0;
3866
+ for (let y = 0; y < h; y += step) {
3867
+ for (let x = 0; x < w; x += step) {
3868
+ const i = (y * w + x) * 4;
3869
+ const r = rgba[i];
3870
+ const g = rgba[i + 1];
3871
+ const b = rgba[i + 2];
3872
+ const rg = r - g;
3873
+ const yb = 0.5 * (r + g) - b;
3874
+ sumRg += rg;
3875
+ sumYb += yb;
3876
+ sumRg2 += rg * rg;
3877
+ sumYb2 += yb * yb;
3878
+ n += 1;
3879
+ const l = lum(x, y);
3880
+ if (x + step < w && y + step < h) {
3881
+ if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
3882
+ edgeN += 1;
3883
+ }
3884
+ if (x < w / 2) {
3885
+ asym += Math.abs(l - lum(w - 1 - x, y));
3886
+ asymN += 1;
3887
+ }
3888
+ }
3889
+ }
3890
+ const meanRg = sumRg / n;
3891
+ const meanYb = sumYb / n;
3892
+ const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
3893
+ const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
3894
+ const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
3895
+ const round = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
3896
+ return {
3897
+ colourfulness: round(colourfulness, 1),
3898
+ edgeDensity: round(edgeN ? edges / edgeN : 0),
3899
+ symmetry: round(asymN ? 1 - asym / asymN / 255 : 1),
3900
+ width: w,
3901
+ height: h,
3902
+ sampled: n
3903
+ };
3904
+ }
3905
+
3906
+ // src/snapshot/provenance.ts
3907
+ var PROVENANCE_SCAN_BYTES = 256 * 1024;
3908
+ var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
3909
+ var MARKER2 = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
3910
+ var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
3911
+ var C2PA_PNG = /caBX/;
3912
+ function latin1(bytes) {
3913
+ let out = "";
3914
+ for (let i = 0; i < bytes.length; i += 8192) {
3915
+ out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
3916
+ }
3917
+ return out;
3918
+ }
3919
+ function xmpPackets(text) {
3920
+ const out = [];
3921
+ const open = /<x:xmpmeta\b|<\?xpacket begin/g;
3922
+ let m;
3923
+ while (m = open.exec(text)) {
3924
+ const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
3925
+ out.push([m.index, close === -1 ? text.length : close]);
3926
+ if (close === -1) break;
3927
+ open.lastIndex = close;
3928
+ }
3929
+ return out;
3930
+ }
3931
+ function scanProvenance(bytes) {
3932
+ const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
3933
+ const text = latin1(head);
3934
+ const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
3935
+ const m = MARKER2.exec(text);
3936
+ if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
3937
+ const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
3938
+ return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
3939
+ }
3940
+
3192
3941
  // src/audit/index.ts
3942
+ var PROVENANCE_MAX_IMAGES = 24;
3943
+ var PROVENANCE_BUDGET_MS = 2e4;
3944
+ async function readProvenance(page, images, timeoutMs) {
3945
+ if (!page.request) return;
3946
+ const deadline = Date.now() + PROVENANCE_BUDGET_MS;
3947
+ const wanted = [...new Set(images.filter((i) => i.role !== "icon" && i.role !== "logo" && /^https?:\/\//.test(i.src)).map((i) => i.src))].slice(0, PROVENANCE_MAX_IMAGES);
3948
+ for (const src of wanted) {
3949
+ const left = deadline - Date.now();
3950
+ if (left < 500) break;
3951
+ try {
3952
+ const res = await page.request.get(src, { headers: { Range: `bytes=0-${PROVENANCE_SCAN_BYTES - 1}` }, timeout: Math.min(timeoutMs, left) });
3953
+ if (!res.ok()) continue;
3954
+ const provenance = scanProvenance(await res.body());
3955
+ for (const image of images) if (image.src === src) image.provenance = provenance;
3956
+ } catch {
3957
+ }
3958
+ }
3959
+ }
3960
+ var VISUAL_MAX_HEIGHT = 6e3;
3193
3961
  async function loadPlaywright() {
3194
3962
  for (const name of ["playwright", "playwright-core"]) {
3195
3963
  try {
@@ -3207,12 +3975,24 @@ async function snapshotPage(page, url, options = {}) {
3207
3975
  await page.waitForTimeout(options.introWindowMs ?? 5e3);
3208
3976
  const late = await page.evaluate(overlayInPage).catch(() => false);
3209
3977
  const data = await page.evaluate(collectInPage);
3978
+ if (data.images && options.provenance !== false) await readProvenance(page, data.images, 8e3);
3979
+ let visual;
3980
+ if (page.screenshot && options.visual !== false) {
3981
+ try {
3982
+ const height = Math.min(data.pageHeight, VISUAL_MAX_HEIGHT);
3983
+ const png = await page.screenshot({ fullPage: true, type: "png", clip: { x: 0, y: 0, width: data.viewport.width, height } });
3984
+ visual = visualMeasures(decodePng(png));
3985
+ } catch {
3986
+ visual = void 0;
3987
+ }
3988
+ }
3210
3989
  return {
3211
3990
  version: SNAPSHOT_VERSION,
3212
3991
  url,
3213
3992
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
3214
3993
  ...data,
3215
- motion: { ...data.motion, introOverlay: early && !late }
3994
+ motion: { ...data.motion, introOverlay: early && !late },
3995
+ ...visual ? { visual } : {}
3216
3996
  };
3217
3997
  }
3218
3998
  async function launch(pw, url, options) {
@@ -3258,6 +4038,9 @@ async function audit(url, options = {}) {
3258
4038
  return { snapshot, report: auditSnapshot(snapshot, options), fingerprint: fingerprint(snapshot) };
3259
4039
  }
3260
4040
  export {
4041
+ PROVENANCE_BUDGET_MS,
4042
+ PROVENANCE_MAX_IMAGES,
4043
+ VISUAL_MAX_HEIGHT,
3261
4044
  audit,
3262
4045
  collectInPage,
3263
4046
  loadPlaywright,