@domandigital/craft 0.5.0 → 0.7.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.
@@ -0,0 +1,419 @@
1
+ import {
2
+ fingerprint
3
+ } from "./chunk-Q54JSNC5.js";
4
+ import {
5
+ SNAPSHOT_VERSION,
6
+ auditSnapshot
7
+ } from "./chunk-3AP4PJMX.js";
8
+
9
+ // src/audit/collect.ts
10
+ function collectInPage() {
11
+ const vw = window.innerWidth;
12
+ const vh = window.innerHeight;
13
+ const scrollY = window.scrollY;
14
+ const LIMIT = 8e3;
15
+ const all = Array.from(document.body.querySelectorAll("*")).slice(0, LIMIT);
16
+ const styles = /* @__PURE__ */ new Map();
17
+ const style = (el) => {
18
+ let cs = styles.get(el);
19
+ if (!cs) styles.set(el, cs = getComputedStyle(el));
20
+ return cs;
21
+ };
22
+ const box = (el) => {
23
+ const r = el.getBoundingClientRect();
24
+ return { top: r.top + scrollY, left: r.left, width: r.width, height: r.height };
25
+ };
26
+ const shown = (el) => {
27
+ const cs = style(el);
28
+ if (cs.display === "none" || cs.visibility === "hidden") return false;
29
+ const r = el.getBoundingClientRect();
30
+ return r.width > 0 && r.height > 0;
31
+ };
32
+ const alphaOf = (c) => {
33
+ const m = c.match(/rgba?\(([^)]+)\)/);
34
+ if (!m) return c === "transparent" ? 0 : 1;
35
+ const parts = m[1].split(/[\s,/]+/).filter(Boolean);
36
+ return parts.length >= 4 ? parseFloat(parts[3]) : 1;
37
+ };
38
+ const painted = (c) => Boolean(c) && alphaOf(c) > 0.02;
39
+ const ownText = (el) => Array.from(el.childNodes).filter((n) => n.nodeType === 3).map((n) => n.nodeValue ?? "").join("").replace(/\s+/g, " ").trim();
40
+ const firstFamily = (stack) => (stack.split(",")[0] ?? "").replace(/["']/g, "").trim();
41
+ const px = (v) => parseFloat(v) || 0;
42
+ const bg = /* @__PURE__ */ new Map();
43
+ const text = /* @__PURE__ */ new Map();
44
+ const fonts = /* @__PURE__ */ new Map();
45
+ const sizes = /* @__PURE__ */ new Set();
46
+ const weights = /* @__PURE__ */ new Set();
47
+ const radii = /* @__PURE__ */ new Set();
48
+ const shadows = /* @__PURE__ */ new Set();
49
+ const spacing = [];
50
+ const pageArea = Math.max(1, document.documentElement.scrollHeight * vw);
51
+ for (const el of all) {
52
+ if (!shown(el)) continue;
53
+ const cs = style(el);
54
+ const b = box(el);
55
+ const area = Math.min(b.width * b.height, pageArea);
56
+ if (painted(cs.backgroundColor)) {
57
+ const e = bg.get(cs.backgroundColor) ?? { area: 0, chars: 0 };
58
+ e.area += area;
59
+ bg.set(cs.backgroundColor, e);
60
+ }
61
+ const own = ownText(el);
62
+ if (own.length > 1) {
63
+ const chars = own.length;
64
+ const t = text.get(cs.color) ?? { area: 0, chars: 0 };
65
+ t.chars += chars;
66
+ text.set(cs.color, t);
67
+ const family = firstFamily(cs.fontFamily);
68
+ const f = fonts.get(family) ?? { chars: 0, displayChars: 0, italicChars: 0, weights: /* @__PURE__ */ new Set() };
69
+ const size = px(cs.fontSize);
70
+ const weight = parseInt(cs.fontWeight, 10) || 400;
71
+ f.chars += chars;
72
+ if (/^H[1-3]$/.test(el.tagName) || size >= 28) f.displayChars += chars;
73
+ if (cs.fontStyle === "italic" || cs.fontStyle.startsWith("oblique")) f.italicChars += chars;
74
+ f.weights.add(weight);
75
+ fonts.set(family, f);
76
+ sizes.add(Math.round(size));
77
+ weights.add(weight);
78
+ }
79
+ if (/^(A|BUTTON|SECTION|ARTICLE|ASIDE)$/.test(el.tagName) || /card|panel|box|tile/i.test(typeof el.className === "string" ? el.className : "")) {
80
+ if (cs.borderRadius && cs.borderRadius !== "0px") radii.add(cs.borderRadius);
81
+ if (cs.boxShadow && cs.boxShadow !== "none") shadows.add(cs.boxShadow);
82
+ }
83
+ for (const prop of ["marginTop", "marginBottom", "paddingTop", "paddingBottom"]) {
84
+ const v = Math.round(px(cs[prop]));
85
+ if (v > 0 && v <= 200) spacing.push(v);
86
+ }
87
+ }
88
+ const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
89
+ const bodyBg = getComputedStyle(document.body).backgroundColor;
90
+ const ground = painted(bodyBg) ? bodyBg : painted(htmlBg) ? htmlBg : "rgb(255, 255, 255)";
91
+ const headings = Array.from(document.querySelectorAll("h1, h2, h3")).filter(shown).slice(0, 60).map((h) => {
92
+ const cs = style(h);
93
+ const italic = cs.fontStyle !== "normal";
94
+ const italicPart = !italic && Array.from(h.querySelectorAll("*")).some((c) => style(c).fontStyle !== "normal" && (c.textContent ?? "").trim().length > 0);
95
+ return {
96
+ level: Number(h.tagName[1]),
97
+ family: firstFamily(cs.fontFamily),
98
+ sizePx: Math.round(px(cs.fontSize)),
99
+ weight: parseInt(cs.fontWeight, 10) || 400,
100
+ italic,
101
+ italicPart,
102
+ text: (h.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120),
103
+ top: Math.round(box(h).top)
104
+ };
105
+ });
106
+ const controls = [];
107
+ for (const el of all) {
108
+ const tag = el.tagName;
109
+ const isButton = tag === "BUTTON" || tag === "INPUT" && /^(submit|button)$/i.test(el.type);
110
+ if (!isButton && tag !== "A") continue;
111
+ if (!shown(el)) continue;
112
+ const cs = style(el);
113
+ const b = box(el);
114
+ const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
115
+ if (!label || b.height < 24 || b.height > 90) continue;
116
+ if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) continue;
117
+ if (!isButton && px(cs.paddingLeft) < 8) continue;
118
+ const r = cs.borderTopLeftRadius;
119
+ const radius = r.endsWith("%") ? parseFloat(r) / 100 * b.height : px(r);
120
+ controls.push({
121
+ kind: isButton ? "button" : "link",
122
+ radiusPx: Math.round(radius),
123
+ heightPx: Math.round(b.height),
124
+ background: cs.backgroundColor,
125
+ text: label.slice(0, 60)
126
+ });
127
+ if (controls.length >= 200) break;
128
+ }
129
+ const anims = [];
130
+ const docAny = document;
131
+ for (const a of docAny.getAnimations ? docAny.getAnimations() : []) {
132
+ const effect = a.effect;
133
+ if (!effect) continue;
134
+ let translateX = false;
135
+ try {
136
+ translateX = effect.getKeyframes().some((k) => /translate(?:X|3d)?\(\s*-?[\d.]+(?:%|px)/.test(String(k.transform ?? "")) && !/translateY/.test(String(k.transform ?? "")));
137
+ } catch {
138
+ translateX = false;
139
+ }
140
+ anims.push({ target: effect.target, infinite: effect.getTiming().iterations === Infinity, translateX });
141
+ }
142
+ const marqueeTargets = anims.filter((a) => a.infinite && a.translateX && a.target).map((a) => a.target);
143
+ const root = document.querySelector("main") ?? document.body;
144
+ const blocks = [];
145
+ const pushBlocks = (parent, depth) => {
146
+ for (const child of Array.from(parent.children)) {
147
+ if (!shown(child) && !(style(child).opacity === "0")) continue;
148
+ const b = box(child);
149
+ if (b.height < 80 || b.width < vw * 0.5) continue;
150
+ const kids = Array.from(child.children).filter((k) => box(k).height >= 80 && box(k).width >= vw * 0.5);
151
+ const covered = kids.reduce((n, k) => n + box(k).height, 0);
152
+ const wrapper = depth < 6 && !/^(SECTION|ARTICLE|HEADER|FOOTER|ASIDE|NAV)$/.test(child.tagName) && (kids.length === 1 && box(kids[0]).height >= b.height * 0.8 || kids.length >= 2 && covered >= b.height * 0.6);
153
+ if (wrapper) pushBlocks(child, depth + 1);
154
+ else blocks.push(child);
155
+ }
156
+ };
157
+ pushBlocks(root, 0);
158
+ const header = document.querySelector("body > header, header");
159
+ const h1 = document.querySelector("h1");
160
+ const waiting = (c) => {
161
+ const cs = style(c);
162
+ const op = parseFloat(cs.opacity);
163
+ return op < 0.1 || op < 1 && cs.transform !== "none" || c.hasAttribute("data-aos") || /\breveal\b|\bfade-?(?:in|up)\b|\banimate-on-scroll\b/i.test(typeof c.className === "string" ? c.className : "");
164
+ };
165
+ const hiddenAtLoad = (el) => {
166
+ if ([el, ...Array.from(el.children).slice(0, 6)].some(waiting)) return true;
167
+ const blocks2 = Array.from(el.querySelectorAll("div, article, li, figure, p, h2, h3")).slice(0, 200).filter((d) => {
168
+ const b = box(d);
169
+ return b.width > 100 && b.height > 40 && (d.textContent ?? "").trim().length > 12 && style(d).position !== "fixed";
170
+ }).slice(0, 12);
171
+ return blocks2.filter(waiting).length >= Math.min(2, blocks2.length) && blocks2.some(waiting);
172
+ };
173
+ const cardsIn = (section) => {
174
+ let best = { cards: 0, iconCards: 0 };
175
+ const containers = [section, ...Array.from(section.querySelectorAll("div, ul, ol"))].slice(0, 300);
176
+ for (const c of containers) {
177
+ const kids = Array.from(c.children).filter(shown);
178
+ if (kids.length < 3) continue;
179
+ const boxes = kids.map(box);
180
+ const row = boxes.filter((b) => Math.abs(b.top - boxes[0].top) < 12);
181
+ if (row.length < 3) continue;
182
+ const w = row.map((b) => b.width);
183
+ if (Math.max(...w) > Math.min(...w) * 1.35 || Math.max(...w) > vw * 0.45) continue;
184
+ const withText = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
185
+ if (withText.length < 3) continue;
186
+ const iconCards = withText.filter((k) => {
187
+ const first = k.querySelector("svg, img");
188
+ if (!first) return false;
189
+ const fb = box(first);
190
+ const kb = box(k);
191
+ return fb.width <= 72 && fb.height <= 72 && fb.top - kb.top < 120;
192
+ }).length;
193
+ if (withText.length > best.cards) best = { cards: withText.length, iconCards };
194
+ }
195
+ return best;
196
+ };
197
+ const sections = blocks.slice(0, 40).map((el, index) => {
198
+ const b = box(el);
199
+ const heading = el.querySelector("h1, h2, h3");
200
+ const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
201
+ const images = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
202
+ const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
203
+ const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
204
+ const { cards, iconCards } = cardsIn(el);
205
+ let kind = "text";
206
+ if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
207
+ else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
208
+ else if (images.length >= 4 && textChars < 300) kind = "logos";
209
+ else if (numbers >= 3 && textChars < 600) kind = "stats";
210
+ else if (cards >= 3) kind = "cards";
211
+ return {
212
+ top: Math.round(b.top),
213
+ height: Math.round(b.height),
214
+ kind,
215
+ label,
216
+ cards,
217
+ iconCards,
218
+ hiddenAtLoad: b.top > vh && hiddenAtLoad(el)
219
+ };
220
+ });
221
+ if (header && sections.length === 0) {
222
+ const b = box(header);
223
+ sections.push({ top: Math.round(b.top), height: Math.round(b.height), kind: "hero", label: "", cards: 0, iconCards: 0, hiddenAtLoad: false });
224
+ }
225
+ const colourStops = (image) => image.match(/rgba?\([^)]*\)|#[0-9a-f]{3,8}\b/gi) ?? [];
226
+ const effects = {
227
+ glass: 0,
228
+ gradientText: 0,
229
+ gradients: [],
230
+ glows: 0,
231
+ gridBackgrounds: 0,
232
+ marquees: new Set(marqueeTargets).size,
233
+ hairlineShadowCards: 0,
234
+ eyebrowChip: false
235
+ };
236
+ for (const el of all) {
237
+ if (!shown(el)) continue;
238
+ const cs = style(el);
239
+ const b = box(el);
240
+ const area = b.width * b.height;
241
+ const backdrop = cs.backdropFilter || cs.webkitBackdropFilter || "";
242
+ if (/blur\(\s*[1-9]/.test(backdrop) && alphaOf(cs.backgroundColor) < 0.95 && b.width >= 200 && b.height >= 120 && cs.position !== "fixed" && cs.position !== "sticky" && !el.closest("header, nav, [role=dialog], dialog")) {
243
+ effects.glass += 1;
244
+ }
245
+ const image = cs.backgroundImage || "";
246
+ const clip = cs.backgroundClip || cs.webkitBackgroundClip || "";
247
+ if (clip === "text" && /gradient/.test(image)) effects.gradientText += 1;
248
+ if (/gradient/.test(image)) {
249
+ const size = cs.backgroundSize || "";
250
+ const small = /(?:^|\s)(?:[1-9]|[1-5]\d|6[0-4])px/.test(size);
251
+ const lines = (image.match(/linear-gradient/g) ?? []).length;
252
+ if (small && (lines >= 2 || /radial-gradient/.test(image)) || /repeating-linear-gradient/.test(image)) {
253
+ effects.gridBackgrounds += 1;
254
+ } else if (area >= vw * vh * 0.05) {
255
+ effects.gradients.push({ stops: colourStops(image), area: Math.round(area), radial: /radial-gradient/.test(image) });
256
+ }
257
+ }
258
+ const blur = (cs.filter || "").match(/blur\(\s*([\d.]+)px/);
259
+ if ((blur && parseFloat(blur[1]) >= 40 || /radial-gradient/.test(image) && !/(?:^|\s)\d{1,2}px/.test(cs.backgroundSize || "")) && area >= 4e4 && (el.textContent ?? "").trim().length === 0) {
260
+ effects.glows += 1;
261
+ }
262
+ const shadow = cs.boxShadow || "";
263
+ const shadowBlur = shadow.match(/(?:-?[\d.]+px\s+){2}([\d.]+)px/);
264
+ if (shadowBlur && parseFloat(shadowBlur[1]) >= 24 && px(cs.borderTopWidth) >= 1 && alphaOf(cs.borderTopColor) <= 0.2 && area >= 2e4) {
265
+ effects.hairlineShadowCards += 1;
266
+ }
267
+ }
268
+ if (h1) {
269
+ let prev = h1.previousElementSibling;
270
+ if (!prev && h1.parentElement) prev = h1.parentElement.previousElementSibling;
271
+ if (prev && shown(prev)) {
272
+ const cs = style(prev);
273
+ const b = box(prev);
274
+ const pill = b.height <= 44 && px(cs.borderTopLeftRadius) >= b.height / 2 - 1;
275
+ const drawn = painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1;
276
+ const chip = pill && drawn ? prev : Array.from(prev.querySelectorAll("*")).find((c) => {
277
+ const ccs = style(c);
278
+ const cb = box(c);
279
+ return cb.height <= 44 && px(ccs.borderTopLeftRadius) >= cb.height / 2 - 1 && (painted(ccs.backgroundColor) || px(ccs.borderTopWidth) >= 1);
280
+ });
281
+ effects.eyebrowChip = Boolean(chip && (chip.textContent ?? "").trim().length > 0 && (chip.textContent ?? "").trim().length <= 48);
282
+ }
283
+ }
284
+ const onScale = spacing.filter((v) => v % 4 === 0).length;
285
+ let bodyLineLengthCh = null;
286
+ let bodyFontSizePx = null;
287
+ const para = Array.from(document.querySelectorAll("p")).find((p) => shown(p) && (p.innerText || "").trim().split(/\s+/).length > 20);
288
+ if (para) {
289
+ const cs = style(para);
290
+ bodyFontSizePx = Math.round(px(cs.fontSize));
291
+ const probe = document.createElement("span");
292
+ probe.style.cssText = `font:${cs.font};visibility:hidden;position:absolute;white-space:pre`;
293
+ probe.textContent = "abcdefghijklmnopqrstuvwxyz";
294
+ document.body.appendChild(probe);
295
+ const avg = probe.getBoundingClientRect().width / 26;
296
+ probe.remove();
297
+ const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
298
+ if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
299
+ }
300
+ 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);
301
+ const nums = (s) => Array.from(s).sort((a, b) => a - b);
302
+ return {
303
+ viewport: { width: vw, height: vh },
304
+ pageHeight: document.documentElement.scrollHeight,
305
+ ground,
306
+ colours: { backgrounds: toList(bg).slice(0, 60), text: toList(text).sort((a, b) => b.chars - a.chars).slice(0, 40) },
307
+ fonts: Array.from(fonts, ([family, f]) => ({ family, chars: f.chars, displayChars: f.displayChars, italicChars: f.italicChars, weights: nums(f.weights) })).sort((a, b) => b.chars - a.chars),
308
+ headings,
309
+ controls,
310
+ sections,
311
+ effects,
312
+ motion: {
313
+ hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
314
+ sections: sections.length,
315
+ animations: anims.length
316
+ },
317
+ metrics: {
318
+ fontSizes: nums(sizes),
319
+ fontWeights: nums(weights),
320
+ radii: Array.from(radii).sort(),
321
+ shadows: Array.from(shadows).sort().slice(0, 20),
322
+ spacingOnScalePct: spacing.length ? Math.round(onScale / spacing.length * 1e3) / 10 : null,
323
+ bodyLineLengthCh,
324
+ bodyFontSizePx
325
+ }
326
+ };
327
+ }
328
+ function overlayInPage() {
329
+ const vw = window.innerWidth;
330
+ const vh = window.innerHeight;
331
+ for (const el of Array.from(document.body.querySelectorAll("*")).slice(0, 4e3)) {
332
+ const cs = getComputedStyle(el);
333
+ if (cs.position !== "fixed" && cs.position !== "absolute") continue;
334
+ if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity) < 0.5) continue;
335
+ const r = el.getBoundingClientRect();
336
+ if (r.width < vw * 0.9 || r.height < vh * 0.9 || r.top > 10) continue;
337
+ const bg = cs.backgroundColor;
338
+ const alpha = /rgba?\(([^)]+)\)/.test(bg) ? parseFloat((bg.match(/[\d.]+/g) ?? [])[3] ?? "1") : 0;
339
+ if (alpha > 0.8 || /gradient|url\(/.test(cs.backgroundImage)) return true;
340
+ }
341
+ return false;
342
+ }
343
+
344
+ // src/audit/index.ts
345
+ async function loadPlaywright() {
346
+ for (const name of ["playwright", "playwright-core"]) {
347
+ try {
348
+ return await import(
349
+ /* @vite-ignore */
350
+ name
351
+ );
352
+ } catch {
353
+ }
354
+ }
355
+ throw new Error("craft audit needs a browser. Install one: npm i -D playwright && npx playwright install chromium");
356
+ }
357
+ async function snapshotPage(page, url, options = {}) {
358
+ const early = await page.evaluate(overlayInPage).catch(() => false);
359
+ await page.waitForTimeout(options.introWindowMs ?? 5e3);
360
+ const late = await page.evaluate(overlayInPage).catch(() => false);
361
+ const data = await page.evaluate(collectInPage);
362
+ return {
363
+ version: SNAPSHOT_VERSION,
364
+ url,
365
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
366
+ ...data,
367
+ motion: { ...data.motion, introOverlay: early && !late }
368
+ };
369
+ }
370
+ async function launch(pw, url, options) {
371
+ const env = globalThis.process?.env ?? {};
372
+ const executablePath = options.executablePath ?? env.CRAFT_CHROMIUM ?? void 0;
373
+ const host = new URL(url).hostname;
374
+ const noProxy = [`localhost`, `127.0.0.1`, `::1`, ...(env.NO_PROXY ?? env.no_proxy ?? "").split(",")].map((h) => h.trim().replace(/^\*?\./, "")).filter(Boolean);
375
+ const direct = /^(?:localhost|127\.|\[?::1\]?$)/.test(host) || noProxy.some((h) => host === h || host.endsWith(`.${h}`));
376
+ const proxy = direct ? void 0 : env.HTTPS_PROXY ?? env.https_proxy;
377
+ return pw.chromium.launch({ headless: true, ...executablePath ? { executablePath } : {}, ...proxy ? { proxy: { server: proxy } } : {} });
378
+ }
379
+ async function snapshotUrl(url, options = {}) {
380
+ const [result] = await snapshotUrls([url], options);
381
+ if (!result.snapshot) throw new Error(result.error);
382
+ return result.snapshot;
383
+ }
384
+ async function snapshotUrls(urls, options = {}) {
385
+ if (urls.length === 0) return [];
386
+ const pw = await loadPlaywright();
387
+ const browser = await launch(pw, urls[0], options);
388
+ const out = [];
389
+ try {
390
+ for (const url of urls) {
391
+ const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
392
+ try {
393
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
394
+ out.push({ url, snapshot: await snapshotPage(page, url, options) });
395
+ } catch (error) {
396
+ out.push({ url, error: error.message });
397
+ } finally {
398
+ await page.close?.();
399
+ }
400
+ }
401
+ return out;
402
+ } finally {
403
+ await browser.close();
404
+ }
405
+ }
406
+ async function audit(url, options = {}) {
407
+ const snapshot = await snapshotUrl(url, options);
408
+ return { snapshot, report: auditSnapshot(snapshot, options), fingerprint: fingerprint(snapshot) };
409
+ }
410
+
411
+ export {
412
+ collectInPage,
413
+ overlayInPage,
414
+ loadPlaywright,
415
+ snapshotPage,
416
+ snapshotUrl,
417
+ snapshotUrls,
418
+ audit
419
+ };
@@ -0,0 +1,105 @@
1
+ import {
2
+ deltaEOk,
3
+ fontClass,
4
+ normaliseFamily,
5
+ parseColour
6
+ } from "./chunk-3AP4PJMX.js";
7
+
8
+ // src/fingerprint/index.ts
9
+ var FINGERPRINT_VERSION = 1;
10
+ var CHROMATIC = 0.03;
11
+ var MIN_ACCENT_AREA = 2e4;
12
+ var oklch = (value) => {
13
+ const parsed = parseColour(value);
14
+ return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
15
+ };
16
+ function accentOf(s) {
17
+ const buttons = /* @__PURE__ */ new Map();
18
+ for (const c of s.controls) {
19
+ const o = oklch(c.background);
20
+ if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
21
+ }
22
+ const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
23
+ if (topButton) return oklch(topButton[0]);
24
+ 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];
25
+ if (bg) return bg.o;
26
+ 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];
27
+ return text ? text.o : null;
28
+ }
29
+ function faces(s) {
30
+ const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
31
+ const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
32
+ const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
33
+ const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
34
+ return {
35
+ display: { family: displayName, class: fontClass(displayName) },
36
+ body: { family: bodyName, class: fontClass(bodyName) }
37
+ };
38
+ }
39
+ function median(values) {
40
+ if (values.length === 0) return null;
41
+ const sorted = [...values].sort((a, b) => a - b);
42
+ const mid = Math.floor(sorted.length / 2);
43
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
44
+ }
45
+ function fingerprint(s) {
46
+ const effects = [];
47
+ if (s.effects.glass > 0) effects.push("glass-panel");
48
+ if (s.effects.gradientText > 0) effects.push("gradient-text");
49
+ if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
50
+ if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
51
+ if (s.effects.marquees > 0) effects.push("marquee");
52
+ if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
53
+ if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
54
+ if (s.motion.introOverlay) effects.push("intro-cinematic");
55
+ const below = s.sections.filter((x) => x.top > s.viewport.height).length;
56
+ return {
57
+ version: FINGERPRINT_VERSION,
58
+ accent: accentOf(s),
59
+ ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
60
+ ...faces(s),
61
+ roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
62
+ motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
63
+ effects: effects.sort(),
64
+ layout: s.sections.map((x) => x.kind)
65
+ };
66
+ }
67
+ var DISTANCE_WEIGHTS = { accent: 0.25, type: 0.25, ground: 0.1, shape: 0.1, motion: 0.1, effects: 0.1, layout: 0.1 };
68
+ function levenshtein(a, b) {
69
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
70
+ for (let i = 1; i <= a.length; i += 1) {
71
+ let prev = row[0];
72
+ row[0] = i;
73
+ for (let j = 1; j <= b.length; j += 1) {
74
+ const cur = row[j];
75
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
76
+ prev = cur;
77
+ }
78
+ }
79
+ return row[b.length];
80
+ }
81
+ var clamp = (n) => Math.max(0, Math.min(1, n));
82
+ function faceDistance(a, b) {
83
+ if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
84
+ return a.class === b.class ? 0.5 : 1;
85
+ }
86
+ function fingerprintDistance(a, b) {
87
+ const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / 0.3) : a.accent || b.accent ? 1 : 0;
88
+ const ground = clamp(deltaEOk(a.ground, b.ground) / 0.15);
89
+ const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
90
+ const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
91
+ const motion = clamp(Math.abs(a.motion - b.motion));
92
+ const union = /* @__PURE__ */ new Set([...a.effects, ...b.effects]);
93
+ const shared = a.effects.filter((e) => b.effects.includes(e)).length;
94
+ const effects = union.size === 0 ? 0 : 1 - shared / union.size;
95
+ const longest = Math.max(a.layout.length, b.layout.length);
96
+ const layout = longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
97
+ const parts = { accent, ground, type, shape, motion, effects, layout };
98
+ const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts[k], 0);
99
+ return { total: Math.round(total * 1e3) / 1e3, parts };
100
+ }
101
+
102
+ export {
103
+ fingerprint,
104
+ fingerprintDistance
105
+ };