@domandigital/craft 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ import {
2
+ snapshotUrl,
3
+ snapshotUrls
4
+ } from "./chunk-ULPI335S.js";
5
+ import {
6
+ describeTypicality,
7
+ loadNull,
8
+ typicality
9
+ } from "./chunk-LHGY43LL.js";
10
+ import {
11
+ readSnapshot
12
+ } from "./chunk-ZKVJ7K6X.js";
13
+ import {
14
+ SOURCE_FILE,
15
+ finish,
16
+ loadConfig,
17
+ loadExceptions,
18
+ parseFlags,
19
+ prepareReport,
20
+ readPaths,
21
+ toJson
22
+ } from "./chunk-J3QUROJ5.js";
23
+ import {
24
+ fingerprint
25
+ } from "./chunk-PZ4MF2VV.js";
26
+ import {
27
+ auditSnapshot,
28
+ scanSource
29
+ } from "./chunk-AL6MRG6V.js";
30
+
31
+ // src/audit/cli.ts
32
+ import { existsSync, readFileSync, writeFileSync } from "fs";
33
+ import { resolve } from "path";
34
+
35
+ // src/audit/pages.ts
36
+ var decode = (s) => s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'");
37
+ function parseSitemap(xml) {
38
+ const locs = [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)].map((m) => decode(m[1]));
39
+ return /<sitemapindex[\s>]/i.test(xml) ? { urls: [], sitemaps: locs } : { urls: locs, sitemaps: [] };
40
+ }
41
+ function parseUrlList(text) {
42
+ return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
43
+ }
44
+ function parseViewports(value) {
45
+ const widths = value.split(",").map((w) => w.trim());
46
+ return widths.map((w) => {
47
+ const width = Number(w);
48
+ if (!Number.isInteger(width) || width < 240 || width > 3840) throw new Error(`--viewport: "${w}" is not a width in px between 240 and 3840`);
49
+ return { width, height: width < 600 ? 844 : width < 1024 ? 1024 : 900 };
50
+ });
51
+ }
52
+
53
+ // src/audit/cli.ts
54
+ var fmt = (o) => o ? `oklch(${o.l.toFixed(3)} ${o.c.toFixed(3)} ${o.h.toFixed(1)})` : "none";
55
+ function describeFingerprint(fp) {
56
+ return [
57
+ "Fingerprint",
58
+ ` accent ${fmt(fp.accent)}`,
59
+ ` ground ${fmt(fp.ground)}`,
60
+ ` display ${fp.display.family} (${fp.display.class})`,
61
+ ` body ${fp.body.family} (${fp.body.class})`,
62
+ ` roundness ${fp.roundness === null ? "no buttons" : fp.roundness.toFixed(2)}`,
63
+ ` motion ${Math.round(fp.motion * 100)}% of sections below the fold reveal on scroll`,
64
+ ` effects ${fp.effects.join(", ") || "none"}`,
65
+ ` layout ${fp.layout.join(" > ") || "none"}`
66
+ ].join("\n");
67
+ }
68
+ function merge(a, b) {
69
+ const byTell = { ...a.summary.byTell };
70
+ for (const [k, v] of Object.entries(b.summary.byTell)) byTell[k] = (byTell[k] ?? 0) + v;
71
+ return {
72
+ catalogueVersion: a.catalogueVersion,
73
+ findings: [...a.findings, ...b.findings],
74
+ rejectedExceptions: a.rejectedExceptions,
75
+ excepted: [...a.excepted, ...b.excepted],
76
+ suppressed: [...a.suppressed, ...b.suppressed],
77
+ summary: {
78
+ files: a.summary.files + b.summary.files,
79
+ findings: a.summary.findings + b.summary.findings,
80
+ byGeneration: { 1: a.summary.byGeneration[1] + b.summary.byGeneration[1], 2: a.summary.byGeneration[2] + b.summary.byGeneration[2], 3: a.summary.byGeneration[3] + b.summary.byGeneration[3] },
81
+ byTell,
82
+ blocking: a.summary.blocking + b.summary.blocking
83
+ }
84
+ };
85
+ }
86
+ function readSnapshotFile(path) {
87
+ return readSnapshot(JSON.parse(readFileSync(path, "utf8")), path);
88
+ }
89
+ async function readText(source, cwd) {
90
+ if (/^https?:\/\//i.test(source)) {
91
+ const res = await fetch(source);
92
+ if (!res.ok) throw new Error(`${source}: HTTP ${res.status}`);
93
+ return res.text();
94
+ }
95
+ const path = resolve(cwd, source);
96
+ if (!existsSync(path)) throw new Error(`no such file: ${source}`);
97
+ return readFileSync(path, "utf8");
98
+ }
99
+ async function resolvePages(source, cwd) {
100
+ const text = await readText(source, cwd);
101
+ if (!/^\s*<(?:\?xml|urlset|sitemapindex)/i.test(text)) return parseUrlList(text);
102
+ const { urls, sitemaps } = parseSitemap(text);
103
+ const nested = await Promise.all(sitemaps.map(async (s) => parseSitemap(await readText(s, cwd)).urls));
104
+ const all = [...urls, ...nested.flat()];
105
+ if (all.length === 0) throw new Error(`${source}: no <loc> entries`);
106
+ return [...new Set(all)];
107
+ }
108
+ function viewportsOf(flags) {
109
+ if (flags.viewport) return parseViewports(flags.viewport);
110
+ if (flags.width || flags.height) return [{ width: Number(flags.width ?? 1440), height: Number(flags.height ?? 900) }];
111
+ return [void 0];
112
+ }
113
+ async function runAudit(command, args, io) {
114
+ try {
115
+ const flags = parseFlags(args);
116
+ if (typeof flags === "string") throw new Error(flags);
117
+ const target = flags.positional[0];
118
+ if (command === "snapshot" && (flags.pages || flags.viewport)) throw new Error("craft snapshot takes one page; use --width and --height");
119
+ if (flags.pages && target) throw new Error("give a page or --pages, not both");
120
+ if (!target && !flags.pages) throw new Error(`usage: craft ${command} <url${command === "audit" ? " | snapshot.json> | --pages <sitemap.xml | urls.txt>" : ">"}`);
121
+ const viewports = viewportsOf(flags);
122
+ const audited = [];
123
+ const failed = [];
124
+ const local = target ? resolve(io.cwd, target) : "";
125
+ if (target && command === "audit" && /\.json$/i.test(target) && existsSync(local)) {
126
+ audited.push({ url: target, viewport: null, snapshot: readSnapshotFile(local) });
127
+ } else if (target && viewports.length === 1) {
128
+ const snapshot = await snapshotUrl(target, { viewport: viewports[0] });
129
+ audited.push({ url: target, viewport: viewports[0] ?? null, snapshot });
130
+ } else {
131
+ const urls = flags.pages ? await resolvePages(flags.pages, io.cwd) : [target];
132
+ for (const viewport of viewports) {
133
+ for (const r of await snapshotUrls(urls, { viewport })) {
134
+ if (r.snapshot) audited.push({ url: r.url, viewport: viewport ?? null, snapshot: r.snapshot });
135
+ else failed.push({ url: r.url, viewport: viewport ?? null, error: r.error ?? "no snapshot" });
136
+ }
137
+ }
138
+ }
139
+ if (audited.length === 0) throw new Error(`no page could be measured:
140
+ ${failed.map((f) => ` ${f.url}: ${f.error}`).join("\n")}`);
141
+ const single = audited.length === 1 && failed.length === 0;
142
+ if (flags.out) {
143
+ if (!single) throw new Error("--out saves one snapshot; drop --pages and --viewport, or snapshot each page");
144
+ writeFileSync(resolve(io.cwd, flags.out), `${JSON.stringify(audited[0].snapshot, null, 2)}
145
+ `);
146
+ }
147
+ if (command === "snapshot") {
148
+ const { snapshot } = audited[0];
149
+ if (!flags.out) io.out(JSON.stringify(snapshot, null, 2));
150
+ else io.out(`craft snapshot: ${snapshot.url} saved to ${flags.out}`);
151
+ return 0;
152
+ }
153
+ const root = flags.repo ? resolve(io.cwd, flags.repo) : io.cwd;
154
+ const exceptions = loadExceptions(flags, root);
155
+ const config = loadConfig(flags, root);
156
+ const label = (a) => viewports.length > 1 && a.viewport ? `${a.snapshot.url} @ ${a.viewport.width}px` : a.snapshot.url;
157
+ let report = audited.map((a) => {
158
+ const r = auditSnapshot(a.snapshot, { exceptions });
159
+ return { ...r, findings: r.findings.map((f) => ({ ...f, path: label(a) })) };
160
+ }).reduce((acc, r) => merge(acc, r));
161
+ if (flags.repo) {
162
+ report = merge(scanSource(readPaths(["."], root, SOURCE_FILE, void 0, config.ignore), { exceptions }), report);
163
+ }
164
+ const prepared = prepareReport(report, flags, io, config);
165
+ if (!prepared) return 0;
166
+ const nulls = flags.null ? flags.null.split(",").map((p) => loadNull(p.trim(), io.cwd)) : [];
167
+ const pooled = nulls.flatMap((m, i) => m.runs.map((r) => nulls.length > 1 ? { ...r, id: `${i + 1}/${r.id}` } : r));
168
+ const pages = audited.map((a) => {
169
+ const fp = fingerprint(a.snapshot);
170
+ return { url: a.snapshot.url, viewport: a.viewport, fingerprint: fp, typicality: nulls.length ? typicality(fp, pooled) : null };
171
+ });
172
+ const unmeasured = failed.length > 0 && flags.strict ? 1 : 0;
173
+ if (flags.json) {
174
+ const extra = single ? { fingerprint: pages[0].fingerprint, typicality: pages[0].typicality, snapshot: flags.out ?? null } : {};
175
+ io.out(toJson({ report: prepared, ...extra, pages, failed }));
176
+ return Math.max(prepared.summary.blocking > 0 || flags.strict && prepared.summary.findings > 0 ? 1 : 0, unmeasured);
177
+ }
178
+ const title = single ? `craft audit ${audited[0].snapshot.url}` : `craft audit: ${audited.length} page${audited.length === 1 ? "" : "s"}`;
179
+ const code = finish(prepared, { ...flags, json: false }, title, io);
180
+ for (const page of pages) {
181
+ io.out("");
182
+ if (!single) io.out(`${page.url}${page.viewport ? ` @ ${page.viewport.width}px` : ""}`);
183
+ io.out(describeFingerprint(page.fingerprint));
184
+ if (page.typicality) {
185
+ io.out("");
186
+ io.out(describeTypicality(page.typicality, pooled.length, nulls.length));
187
+ }
188
+ }
189
+ if (failed.length) {
190
+ io.out("");
191
+ io.out("Not measured (never counts as a pass):");
192
+ for (const f of failed) io.out(` ${f.url}${f.viewport ? ` @ ${f.viewport.width}px` : ""}: ${f.error}`);
193
+ }
194
+ return Math.max(code, unmeasured);
195
+ } catch (error) {
196
+ io.err(`craft: ${error.message}`);
197
+ return 2;
198
+ }
199
+ }
200
+
201
+ export {
202
+ describeFingerprint,
203
+ merge,
204
+ resolvePages,
205
+ runAudit
206
+ };
@@ -3,10 +3,10 @@ import {
3
3
  fontClass,
4
4
  normaliseFamily,
5
5
  parseColour
6
- } from "./chunk-3NGWXZZE.js";
6
+ } from "./chunk-AL6MRG6V.js";
7
7
 
8
8
  // src/fingerprint/index.ts
9
- var FINGERPRINT_VERSION = 1;
9
+ var FINGERPRINT_VERSION = 2;
10
10
  var CHROMATIC = 0.03;
11
11
  var MIN_ACCENT_AREA = 2e4;
12
12
  var oklch = (value) => {
@@ -61,23 +61,36 @@ function fingerprint(s) {
61
61
  roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
62
62
  motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
63
63
  effects: effects.sort(),
64
- layout: s.sections.map((x) => x.kind)
64
+ layout: s.sections.map((x) => x.kind),
65
+ ...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 })) } : {}
65
66
  };
66
67
  }
67
68
  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
+ function levenshtein(a, b, cost = (x, y) => x === y ? 0 : 1) {
69
70
  const row = Array.from({ length: b.length + 1 }, (_, i) => i);
70
71
  for (let i = 1; i <= a.length; i += 1) {
71
72
  let prev = row[0];
72
73
  row[0] = i;
73
74
  for (let j = 1; j <= b.length; j += 1) {
74
75
  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
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost(a[i - 1], b[j - 1]));
76
77
  prev = cur;
77
78
  }
78
79
  }
79
80
  return row[b.length];
80
81
  }
82
+ function sectionCost(x, y) {
83
+ if (x.role !== y.role) return 1;
84
+ return Math.min(0.5, (Math.abs(x.centred - y.centred) + Math.abs(x.width - y.width)) / 2);
85
+ }
86
+ function layoutDistance(a, b) {
87
+ if (a.sections && b.sections) {
88
+ const longest2 = Math.max(a.sections.length, b.sections.length);
89
+ return longest2 === 0 ? 0 : levenshtein(a.sections, b.sections, sectionCost) / longest2;
90
+ }
91
+ const longest = Math.max(a.layout.length, b.layout.length);
92
+ return longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
93
+ }
81
94
  var clamp = (n) => Math.max(0, Math.min(1, n));
82
95
  function faceDistance(a, b) {
83
96
  if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
@@ -92,8 +105,7 @@ function fingerprintDistance(a, b) {
92
105
  const union = /* @__PURE__ */ new Set([...a.effects, ...b.effects]);
93
106
  const shared = a.effects.filter((e) => b.effects.includes(e)).length;
94
107
  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;
108
+ const layout = layoutDistance(a, b);
97
109
  const parts = { accent, ground, type, shape, motion, effects, layout };
98
110
  const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts[k], 0);
99
111
  return { total: Math.round(total * 1e3) / 1e3, parts };
@@ -6,7 +6,115 @@ import {
6
6
  isCream,
7
7
  normaliseFamily,
8
8
  parseColour
9
- } from "./chunk-3NGWXZZE.js";
9
+ } from "./chunk-AL6MRG6V.js";
10
+
11
+ // src/direction/licences.ts
12
+ var LICENCES = [
13
+ {
14
+ name: "Basteleur",
15
+ subject: "face",
16
+ licence: "OFL-1.1",
17
+ multiClient: "yes",
18
+ checked: "2026-09-24",
19
+ source: "https://velvetyne.fr/fonts/basteleur/",
20
+ note: "Velvetyne, designed by Keussel. Keep the OFL notice with the font files."
21
+ },
22
+ {
23
+ name: "Gulax",
24
+ subject: "face",
25
+ licence: "OFL-1.1",
26
+ multiClient: "yes",
27
+ checked: "2026-09-24",
28
+ source: "https://velvetyne.fr/fonts/gulax/",
29
+ note: "Velvetyne. Keep the OFL notice with the font files."
30
+ },
31
+ {
32
+ name: "Le Murmure",
33
+ subject: "face",
34
+ licence: "OFL-1.1",
35
+ multiClient: "yes",
36
+ checked: "2026-09-24",
37
+ source: "https://velvetyne.fr/fonts/le-murmure/",
38
+ note: "Velvetyne. Keep the OFL notice with the font files."
39
+ },
40
+ {
41
+ name: "Blaze Type",
42
+ subject: "foundry",
43
+ licence: "commercial",
44
+ multiClient: "per-site",
45
+ cap: 10,
46
+ checked: "2026-09-24",
47
+ source: "https://blazetype.eu/eula",
48
+ note: "The EULA sells a professional licence for 1 to 10 websites, and lets an agency buy in a client's name at the listed price. Its /license page says one licence covers every domain; the EULA is the contract, so buy per client."
49
+ },
50
+ {
51
+ name: "Power Type",
52
+ subject: "foundry",
53
+ licence: "commercial",
54
+ multiClient: "unverified",
55
+ checked: "2026-09-24",
56
+ source: "https://power-type.com/eula",
57
+ note: "Research on 2026-09-11 reported a Super License capped at 10 self-hosted domains. The EULA page did not load when checked; read it before relying on the cap."
58
+ },
59
+ {
60
+ name: "Grilli Type",
61
+ subject: "foundry",
62
+ licence: "commercial",
63
+ multiClient: "per-site",
64
+ checked: "2026-09-24",
65
+ source: "https://www.grillitype.com/information",
66
+ note: "Web licences cover any number of domains controlled by the licensee, priced by monthly visitors, so a client site needs the client's own licence. Unlimited tiers start at $10,000 per style."
67
+ },
68
+ {
69
+ name: "Phosphor",
70
+ subject: "icons",
71
+ licence: "MIT",
72
+ multiClient: "yes",
73
+ checked: "2026-09-24",
74
+ source: "https://github.com/phosphor-icons/core/blob/main/LICENSE",
75
+ note: "Six weights. The icon-tile tells treat it exactly as they treat Lucide: the library is not the tell, the tinted tile is."
76
+ },
77
+ {
78
+ name: "Tabler Icons",
79
+ subject: "icons",
80
+ licence: "MIT",
81
+ multiClient: "yes",
82
+ checked: "2026-09-24",
83
+ source: "https://github.com/tabler/tabler-icons/blob/main/LICENSE",
84
+ note: "One stroke grid across the set."
85
+ },
86
+ {
87
+ name: "Iconoir",
88
+ subject: "icons",
89
+ licence: "MIT",
90
+ multiClient: "yes",
91
+ checked: "2026-09-24",
92
+ source: "https://github.com/iconoir-icons/iconoir/blob/main/LICENSE",
93
+ note: "Smaller set, no paid tier."
94
+ },
95
+ {
96
+ name: "Unicons",
97
+ subject: "icons",
98
+ licence: "IconScout Simple License",
99
+ multiClient: "yes",
100
+ checked: "2026-09-24",
101
+ source: "https://github.com/Iconscout/unicons/blob/master/LICENSE",
102
+ note: "IconScout's free set, personal and commercial use, attribution optional."
103
+ },
104
+ {
105
+ name: "IconScout premium",
106
+ subject: "icons",
107
+ licence: "commercial",
108
+ multiClient: "per-site",
109
+ checked: "2026-09-24",
110
+ source: "https://iconscout.com/licenses",
111
+ note: "Each licence covers a single end product. Never in a tool, template or shared kit (create-site, craft, a house component library), even modified. Record the assets each site uses. Assets come from different contributors, so take one site's set from one contributor."
112
+ }
113
+ ];
114
+ function faceLicence(family) {
115
+ const name = family.trim().toLowerCase();
116
+ return LICENCES.find((l) => l.subject === "face" && l.name.toLowerCase() === name);
117
+ }
10
118
 
11
119
  // src/direction/types.ts
12
120
  var DIRECTION_VERSION = 1;
@@ -150,6 +258,18 @@ function validateDirection(input, ctx = {}) {
150
258
  ok = false;
151
259
  }
152
260
  }
261
+ if ((key === "display" || key === "body") && typeof c.value === "string" && c.value.trim()) {
262
+ const family = normaliseFamily(c.value);
263
+ const entry = faceLicence(family);
264
+ if (!entry) {
265
+ warn(`${at}.value`, `${family}: licence not in craft's register. Read the licensor's own terms for use on this client's site, then add it to src/direction/licences.ts.`);
266
+ } else if (entry.multiClient === "unverified") {
267
+ warn(`${at}.value`, `${family}: licence unverified (${entry.note})`);
268
+ } else if (entry.multiClient === "capped" || entry.multiClient === "per-site") {
269
+ const scope = entry.multiClient === "capped" ? `covers up to ${entry.cap} sites` : "needs a licence for this site, in the client's name";
270
+ warn(`${at}.value`, `${family}: ${entry.licence} licence ${scope}. Record the purchase before launch.`);
271
+ }
272
+ }
153
273
  const fp = ctx.fingerprint;
154
274
  if (fp && typeof c.value === "string") {
155
275
  if ((key === "accent" || key === "ground") && parseColour(c.value)) {
@@ -1,10 +1,13 @@
1
+ import {
2
+ decodePng
3
+ } from "./chunk-JYNXGCYZ.js";
1
4
  import {
2
5
  fingerprint
3
- } from "./chunk-QMYERCBB.js";
6
+ } from "./chunk-PZ4MF2VV.js";
4
7
  import {
5
8
  SNAPSHOT_VERSION,
6
9
  auditSnapshot
7
- } from "./chunk-3NGWXZZE.js";
10
+ } from "./chunk-AL6MRG6V.js";
8
11
 
9
12
  // src/audit/collect.ts
10
13
  function collectInPage() {
@@ -103,18 +106,26 @@ function collectInPage() {
103
106
  top: Math.round(box(h).top)
104
107
  };
105
108
  });
109
+ const isButtonEl = (el) => el.tagName === "BUTTON" || el.tagName === "INPUT" && /^(submit|button)$/i.test(el.type);
110
+ const isControl = (el) => {
111
+ const isButton = isButtonEl(el);
112
+ if (!isButton && el.tagName !== "A") return false;
113
+ if (!shown(el)) return false;
114
+ const cs = style(el);
115
+ const b = box(el);
116
+ const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
117
+ if (!label || b.height < 24 || b.height > 90) return false;
118
+ if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) return false;
119
+ if (!isButton && px(cs.paddingLeft) < 8) return false;
120
+ return true;
121
+ };
106
122
  const controls = [];
107
123
  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;
124
+ if (!isControl(el)) continue;
125
+ const isButton = isButtonEl(el);
112
126
  const cs = style(el);
113
127
  const b = box(el);
114
128
  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
129
  const r = cs.borderTopLeftRadius;
119
130
  const radius = r.endsWith("%") ? parseFloat(r) / 100 * b.height : px(r);
120
131
  controls.push({
@@ -194,7 +205,80 @@ function collectInPage() {
194
205
  }
195
206
  return best;
196
207
  };
197
- const sections = blocks.slice(0, 40).map((el, index) => {
208
+ const backgroundOf = (el) => {
209
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
210
+ const c = style(n).backgroundColor;
211
+ if (painted(c)) return c;
212
+ }
213
+ return ground;
214
+ };
215
+ const geometryOf = (el, b) => {
216
+ 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);
217
+ const centreX = b.left + b.width / 2;
218
+ let left = 0;
219
+ let right = 0;
220
+ let covered = 0;
221
+ let minX = Infinity;
222
+ let maxX = -Infinity;
223
+ let textBlocks = 0;
224
+ let centred = 0;
225
+ for (const n of leaves) {
226
+ const r = box(n);
227
+ const area = r.width * r.height;
228
+ covered += area;
229
+ minX = Math.min(minX, r.left);
230
+ maxX = Math.max(maxX, r.left + r.width);
231
+ const split = Math.max(0, Math.min(r.width, centreX - r.left));
232
+ left += split * r.height;
233
+ right += (r.width - split) * r.height;
234
+ if (ownText(n).length > 1) {
235
+ textBlocks += 1;
236
+ const mid = r.left + r.width / 2;
237
+ if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
238
+ }
239
+ }
240
+ const round = (v) => Math.round(v * 1e3) / 1e3;
241
+ return {
242
+ centredShare: textBlocks ? round(centred / textBlocks) : 0,
243
+ mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
244
+ whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
245
+ contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
246
+ background: backgroundOf(el),
247
+ controls: Array.from(el.querySelectorAll("a, button, input")).slice(0, 200).filter(isControl).length
248
+ };
249
+ };
250
+ const CURRENCY = /[£$€]\s?\d/;
251
+ const roleOf = (el, kind, geo, b, cards, iconCards, last) => {
252
+ if (kind === "marquee" || kind === "hero") return kind;
253
+ const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
254
+ const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
255
+ const own = leaves.map(ownText);
256
+ const questions = own.filter((t) => /\?$/.test(t) && t.length > 8).length;
257
+ if (el.querySelectorAll("details").length >= 3 || el.querySelectorAll("[aria-expanded]").length >= 3 || /\bfaq|frequently asked|questions\b/.test(heading) && questions >= 3) return "faq";
258
+ const prices = own.filter((t) => CURRENCY.test(t) && t.length < 40).length;
259
+ if (prices >= 2 && (cards >= 2 || /pric|plan|package/.test(heading))) return "pricing";
260
+ const quotes = el.querySelectorAll("blockquote, q").length;
261
+ if (quotes >= 2 || /testimonial|review|what (?:our )?(?:clients|customers|people) say/.test(heading)) return "testimonials";
262
+ const steps = Math.max(0, ...Array.from(el.querySelectorAll("ol")).map((ol) => ol.children.length));
263
+ const numbered = own.filter((t) => /^(?:step\s*)?0?[1-9][.:]?$/i.test(t)).length;
264
+ if (steps >= 3 || numbered >= 3 || /how it works|our process|\bsteps?\b/.test(heading)) return "process";
265
+ const form = el.querySelector("form");
266
+ const fields = form ? form.querySelectorAll("input:not([type=hidden]):not([type=submit]), textarea, select").length : 0;
267
+ if (fields >= 2 || /contact|get in touch/.test(heading)) return "contact";
268
+ const portraits = Array.from(el.querySelectorAll("img")).filter((i) => {
269
+ const r = box(i);
270
+ return r.width >= 80 && r.width <= 420 && Math.abs(r.width - r.height) < r.width * 0.25;
271
+ }).length;
272
+ if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
273
+ if (kind === "stats" || kind === "logos") return kind;
274
+ const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
275
+ const setApart = geo.background !== ground || geo.centredShare >= 0.6;
276
+ if (setApart && heading && b.height <= vh * 0.9 && geo.controls >= 1 && geo.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
277
+ if (cards >= 3 && iconCards >= 2) return "features";
278
+ return kind;
279
+ };
280
+ const kept = blocks.slice(0, 40);
281
+ const sections = kept.map((el, index) => {
198
282
  const b = box(el);
199
283
  const heading = el.querySelector("h1, h2, h3");
200
284
  const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
@@ -208,6 +292,7 @@ function collectInPage() {
208
292
  else if (images.length >= 4 && textChars < 300) kind = "logos";
209
293
  else if (numbers >= 3 && textChars < 600) kind = "stats";
210
294
  else if (cards >= 3) kind = "cards";
295
+ const geometry = geometryOf(el, b);
211
296
  return {
212
297
  top: Math.round(b.top),
213
298
  height: Math.round(b.height),
@@ -215,7 +300,9 @@ function collectInPage() {
215
300
  label,
216
301
  cards,
217
302
  iconCards,
218
- hiddenAtLoad: b.top > vh && hiddenAtLoad(el)
303
+ hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
304
+ role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
305
+ geometry
219
306
  };
220
307
  });
221
308
  if (header && sections.length === 0) {
@@ -308,6 +395,13 @@ function collectInPage() {
308
395
  headings,
309
396
  controls,
310
397
  sections,
398
+ rhythmVariance: (() => {
399
+ const hs = sections.map((x) => x.height);
400
+ if (hs.length < 2) return null;
401
+ const mean = hs.reduce((t, v) => t + v, 0) / hs.length;
402
+ const sd = Math.sqrt(hs.reduce((t, v) => t + (v - mean) ** 2, 0) / hs.length);
403
+ return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
404
+ })(),
311
405
  effects,
312
406
  motion: {
313
407
  hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
@@ -341,7 +435,67 @@ function overlayInPage() {
341
435
  return false;
342
436
  }
343
437
 
438
+ // src/snapshot/visual.ts
439
+ var EDGE_STEP = 32;
440
+ function visualMeasures(img, maxSamples = 1e6) {
441
+ const { width: w, height: h, rgba } = img;
442
+ if (w < 2 || h < 2 || rgba.length < w * h * 4) throw new Error(`visualMeasures: ${w}x${h} image with ${rgba.length} bytes`);
443
+ const step = Math.max(1, Math.ceil(Math.sqrt(w * h / maxSamples)));
444
+ const lum = (x, y) => {
445
+ const i = (y * w + x) * 4;
446
+ return 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
447
+ };
448
+ let n = 0;
449
+ let sumRg = 0;
450
+ let sumYb = 0;
451
+ let sumRg2 = 0;
452
+ let sumYb2 = 0;
453
+ let edges = 0;
454
+ let edgeN = 0;
455
+ let asym = 0;
456
+ let asymN = 0;
457
+ for (let y = 0; y < h; y += step) {
458
+ for (let x = 0; x < w; x += step) {
459
+ const i = (y * w + x) * 4;
460
+ const r = rgba[i];
461
+ const g = rgba[i + 1];
462
+ const b = rgba[i + 2];
463
+ const rg = r - g;
464
+ const yb = 0.5 * (r + g) - b;
465
+ sumRg += rg;
466
+ sumYb += yb;
467
+ sumRg2 += rg * rg;
468
+ sumYb2 += yb * yb;
469
+ n += 1;
470
+ const l = lum(x, y);
471
+ if (x + step < w && y + step < h) {
472
+ if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
473
+ edgeN += 1;
474
+ }
475
+ if (x < w / 2) {
476
+ asym += Math.abs(l - lum(w - 1 - x, y));
477
+ asymN += 1;
478
+ }
479
+ }
480
+ }
481
+ const meanRg = sumRg / n;
482
+ const meanYb = sumYb / n;
483
+ const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
484
+ const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
485
+ const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
486
+ const round = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
487
+ return {
488
+ colourfulness: round(colourfulness, 1),
489
+ edgeDensity: round(edgeN ? edges / edgeN : 0),
490
+ symmetry: round(asymN ? 1 - asym / asymN / 255 : 1),
491
+ width: w,
492
+ height: h,
493
+ sampled: n
494
+ };
495
+ }
496
+
344
497
  // src/audit/index.ts
498
+ var VISUAL_MAX_HEIGHT = 6e3;
345
499
  async function loadPlaywright() {
346
500
  for (const name of ["playwright", "playwright-core"]) {
347
501
  try {
@@ -359,12 +513,23 @@ async function snapshotPage(page, url, options = {}) {
359
513
  await page.waitForTimeout(options.introWindowMs ?? 5e3);
360
514
  const late = await page.evaluate(overlayInPage).catch(() => false);
361
515
  const data = await page.evaluate(collectInPage);
516
+ let visual;
517
+ if (page.screenshot && options.visual !== false) {
518
+ try {
519
+ const height = Math.min(data.pageHeight, VISUAL_MAX_HEIGHT);
520
+ const png = await page.screenshot({ fullPage: true, type: "png", clip: { x: 0, y: 0, width: data.viewport.width, height } });
521
+ visual = visualMeasures(decodePng(png));
522
+ } catch {
523
+ visual = void 0;
524
+ }
525
+ }
362
526
  return {
363
527
  version: SNAPSHOT_VERSION,
364
528
  url,
365
529
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
366
530
  ...data,
367
- motion: { ...data.motion, introOverlay: early && !late }
531
+ motion: { ...data.motion, introOverlay: early && !late },
532
+ ...visual ? { visual } : {}
368
533
  };
369
534
  }
370
535
  async function launch(pw, url, options) {
@@ -390,7 +555,9 @@ async function snapshotUrls(urls, options = {}) {
390
555
  for (const url of urls) {
391
556
  const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
392
557
  try {
393
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
558
+ const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
559
+ const status = typeof response?.status === "function" ? response.status() : 200;
560
+ if (status >= 400) throw new Error(`HTTP ${status}`);
394
561
  out.push({ url, snapshot: await snapshotPage(page, url, options) });
395
562
  } catch (error) {
396
563
  out.push({ url, error: error.message });
@@ -411,6 +578,7 @@ async function audit(url, options = {}) {
411
578
  export {
412
579
  collectInPage,
413
580
  overlayInPage,
581
+ VISUAL_MAX_HEIGHT,
414
582
  loadPlaywright,
415
583
  snapshotPage,
416
584
  snapshotUrl,