@domandigital/craft 0.12.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 = [];
@@ -2720,11 +2720,433 @@ var RENDERED_PATHS = {
2720
2720
  }
2721
2721
  };
2722
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
+
2723
3143
  // src/character/check.ts
2724
- var CATALOGUE_VERSION = "2026.09.9";
2725
- var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2726
- (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2727
- );
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
+ ];
2728
3150
  function tellById(id) {
2729
3151
  return CATALOGUE.find((t) => t.id === id);
2730
3152
  }
@@ -2746,17 +3168,17 @@ var MARKER = /\b(?:copy|craft)-ok\b/;
2746
3168
  function marked(lines, line) {
2747
3169
  return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
2748
3170
  }
2749
- function toFinding(tell, hit2, texts) {
2750
- const text = texts.get(hit2.path) ?? "";
3171
+ function toFinding(tell, hit4, texts) {
3172
+ const text = texts.get(hit4.path) ?? "";
2751
3173
  return {
2752
3174
  tell: tell.id,
2753
3175
  name: tell.name,
2754
3176
  generation: tell.generation,
2755
3177
  severity: tell.severity,
2756
- path: hit2.path,
2757
- line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
2758
- excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
2759
- 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,
2760
3182
  fix: tell.fix,
2761
3183
  ...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
2762
3184
  };
@@ -2775,14 +3197,14 @@ function report(tells, files, run, options) {
2775
3197
  };
2776
3198
  const excepted = /* @__PURE__ */ new Map();
2777
3199
  for (const tell of selected) {
2778
- for (const hit2 of run(tell)) {
2779
- 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)));
2780
3202
  if (exception) {
2781
3203
  excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
2782
3204
  continue;
2783
3205
  }
2784
- const finding = toFinding(tell, hit2, texts);
2785
- if (marked(linesOf(hit2.path), finding.line)) {
3206
+ const finding = toFinding(tell, hit4, texts);
3207
+ if (marked(linesOf(hit4.path), finding.line)) {
2786
3208
  suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
2787
3209
  continue;
2788
3210
  }
@@ -2800,7 +3222,7 @@ function report(tells, files, run, options) {
2800
3222
  catalogueVersion: CATALOGUE_VERSION,
2801
3223
  findings,
2802
3224
  rejectedExceptions: rejected,
2803
- 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 })),
2804
3226
  suppressed,
2805
3227
  summary: {
2806
3228
  files: files.length,
@@ -2818,67 +3240,6 @@ function auditSnapshot(snapshot, options = {}) {
2818
3240
  return result;
2819
3241
  }
2820
3242
 
2821
- // src/fingerprint/index.ts
2822
- var FINGERPRINT_VERSION = 2;
2823
- var CHROMATIC = 0.03;
2824
- var MIN_ACCENT_AREA = 2e4;
2825
- var oklch = (value) => {
2826
- const parsed = parseColour(value);
2827
- return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
2828
- };
2829
- function accentOf(s) {
2830
- const buttons = /* @__PURE__ */ new Map();
2831
- for (const c of s.controls) {
2832
- const o = oklch(c.background);
2833
- if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
2834
- }
2835
- const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
2836
- if (topButton) return oklch(topButton[0]);
2837
- 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];
2838
- if (bg) return bg.o;
2839
- 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];
2840
- return text ? text.o : null;
2841
- }
2842
- function faces(s) {
2843
- const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
2844
- const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
2845
- const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
2846
- const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
2847
- return {
2848
- display: { family: displayName, class: fontClass(displayName) },
2849
- body: { family: bodyName, class: fontClass(bodyName) }
2850
- };
2851
- }
2852
- function median(values) {
2853
- if (values.length === 0) return null;
2854
- const sorted = [...values].sort((a, b) => a - b);
2855
- const mid = Math.floor(sorted.length / 2);
2856
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
2857
- }
2858
- function fingerprint(s) {
2859
- const effects = [];
2860
- if (s.effects.glass > 0) effects.push("glass-panel");
2861
- if (s.effects.gradientText > 0) effects.push("gradient-text");
2862
- if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
2863
- if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
2864
- if (s.effects.marquees > 0) effects.push("marquee");
2865
- if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
2866
- if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
2867
- if (s.motion.introOverlay) effects.push("intro-cinematic");
2868
- const below = s.sections.filter((x) => x.top > s.viewport.height).length;
2869
- return {
2870
- version: FINGERPRINT_VERSION,
2871
- accent: accentOf(s),
2872
- ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
2873
- ...faces(s),
2874
- roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
2875
- motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
2876
- effects: effects.sort(),
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 })) } : {}
2879
- };
2880
- }
2881
-
2882
3243
  // src/audit/collect.ts
2883
3244
  function collectInPage() {
2884
3245
  const vw = window.innerWidth;
@@ -3118,7 +3479,7 @@ function collectInPage() {
3118
3479
  };
3119
3480
  };
3120
3481
  const CURRENCY = /[£$€]\s?\d/;
3121
- const roleOf = (el, kind, geo, b, cards, iconCards, last) => {
3482
+ const roleOf = (el, kind, geo2, b, cards, iconCards, last) => {
3122
3483
  if (kind === "marquee" || kind === "hero") return kind;
3123
3484
  const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
3124
3485
  const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
@@ -3142,27 +3503,55 @@ function collectInPage() {
3142
3503
  if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
3143
3504
  if (kind === "stats" || kind === "logos") return kind;
3144
3505
  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";
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";
3147
3508
  if (cards >= 3 && iconCards >= 2) return "features";
3148
3509
  return kind;
3149
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
+ };
3150
3538
  const kept = blocks.slice(0, 40);
3151
3539
  const sections = kept.map((el, index) => {
3152
3540
  const b = box(el);
3153
3541
  const heading = el.querySelector("h1, h2, h3");
3154
3542
  const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
3155
- 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);
3156
3544
  const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
3157
3545
  const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
3158
3546
  const { cards, iconCards } = cardsIn(el);
3159
3547
  let kind = "text";
3160
3548
  if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
3161
3549
  else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
3162
- else if (images.length >= 4 && textChars < 300) kind = "logos";
3550
+ else if (images2.length >= 4 && textChars < 300) kind = "logos";
3163
3551
  else if (numbers >= 3 && textChars < 600) kind = "stats";
3164
3552
  else if (cards >= 3) kind = "cards";
3165
3553
  const geometry = geometryOf(el, b);
3554
+ const parts2 = partsOf(el);
3166
3555
  return {
3167
3556
  top: Math.round(b.top),
3168
3557
  height: Math.round(b.height),
@@ -3172,7 +3561,9 @@ function collectInPage() {
3172
3561
  iconCards,
3173
3562
  hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
3174
3563
  role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
3175
- geometry
3564
+ geometry,
3565
+ ...parts2,
3566
+ figures: numbers
3176
3567
  };
3177
3568
  });
3178
3569
  if (header && sections.length === 0) {
@@ -3254,6 +3645,81 @@ function collectInPage() {
3254
3645
  const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
3255
3646
  if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
3256
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);
3257
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);
3258
3724
  const nums = (s) => Array.from(s).sort((a, b) => a - b);
3259
3725
  return {
@@ -3273,6 +3739,7 @@ function collectInPage() {
3273
3739
  return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
3274
3740
  })(),
3275
3741
  effects,
3742
+ images: images.slice(0, 60),
3276
3743
  motion: {
3277
3744
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
3278
3745
  sections: sections.length,
@@ -3436,7 +3903,60 @@ function visualMeasures(img, maxSamples = 1e6) {
3436
3903
  };
3437
3904
  }
3438
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
+
3439
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
+ }
3440
3960
  var VISUAL_MAX_HEIGHT = 6e3;
3441
3961
  async function loadPlaywright() {
3442
3962
  for (const name of ["playwright", "playwright-core"]) {
@@ -3455,6 +3975,7 @@ async function snapshotPage(page, url, options = {}) {
3455
3975
  await page.waitForTimeout(options.introWindowMs ?? 5e3);
3456
3976
  const late = await page.evaluate(overlayInPage).catch(() => false);
3457
3977
  const data = await page.evaluate(collectInPage);
3978
+ if (data.images && options.provenance !== false) await readProvenance(page, data.images, 8e3);
3458
3979
  let visual;
3459
3980
  if (page.screenshot && options.visual !== false) {
3460
3981
  try {
@@ -3517,6 +4038,8 @@ async function audit(url, options = {}) {
3517
4038
  return { snapshot, report: auditSnapshot(snapshot, options), fingerprint: fingerprint(snapshot) };
3518
4039
  }
3519
4040
  export {
4041
+ PROVENANCE_BUDGET_MS,
4042
+ PROVENANCE_MAX_IMAGES,
3520
4043
  VISUAL_MAX_HEIGHT,
3521
4044
  audit,
3522
4045
  collectInPage,