@domandigital/craft 0.6.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,112 @@
1
+ import {
2
+ snapshotUrl
3
+ } from "./chunk-OA7SGPN4.js";
4
+ import {
5
+ describeTypicality,
6
+ loadNull,
7
+ typicality
8
+ } from "./chunk-GV3QOTQU.js";
9
+ import {
10
+ SOURCE_FILE,
11
+ finish,
12
+ loadExceptions,
13
+ parseFlags,
14
+ readPaths
15
+ } from "./chunk-YYC4EGER.js";
16
+ import {
17
+ fingerprint
18
+ } from "./chunk-Q54JSNC5.js";
19
+ import {
20
+ SNAPSHOT_VERSION,
21
+ auditSnapshot,
22
+ scanSource
23
+ } from "./chunk-3AP4PJMX.js";
24
+
25
+ // src/audit/cli.ts
26
+ import { existsSync, readFileSync, writeFileSync } from "fs";
27
+ import { resolve } from "path";
28
+ var fmt = (o) => o ? `oklch(${o.l.toFixed(3)} ${o.c.toFixed(3)} ${o.h.toFixed(1)})` : "none";
29
+ function describeFingerprint(fp) {
30
+ return [
31
+ "Fingerprint",
32
+ ` accent ${fmt(fp.accent)}`,
33
+ ` ground ${fmt(fp.ground)}`,
34
+ ` display ${fp.display.family} (${fp.display.class})`,
35
+ ` body ${fp.body.family} (${fp.body.class})`,
36
+ ` roundness ${fp.roundness === null ? "no buttons" : fp.roundness.toFixed(2)}`,
37
+ ` motion ${Math.round(fp.motion * 100)}% of sections below the fold reveal on scroll`,
38
+ ` effects ${fp.effects.join(", ") || "none"}`,
39
+ ` layout ${fp.layout.join(" > ") || "none"}`
40
+ ].join("\n");
41
+ }
42
+ function merge(a, b) {
43
+ const byTell = { ...a.summary.byTell };
44
+ for (const [k, v] of Object.entries(b.summary.byTell)) byTell[k] = (byTell[k] ?? 0) + v;
45
+ return {
46
+ catalogueVersion: a.catalogueVersion,
47
+ findings: [...a.findings, ...b.findings],
48
+ rejectedExceptions: a.rejectedExceptions,
49
+ excepted: [...a.excepted, ...b.excepted],
50
+ suppressed: [...a.suppressed, ...b.suppressed],
51
+ summary: {
52
+ files: a.summary.files + b.summary.files,
53
+ findings: a.summary.findings + b.summary.findings,
54
+ 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] },
55
+ byTell,
56
+ blocking: a.summary.blocking + b.summary.blocking
57
+ }
58
+ };
59
+ }
60
+ function readSnapshot(path) {
61
+ const data = JSON.parse(readFileSync(path, "utf8"));
62
+ if (data.version !== SNAPSHOT_VERSION) throw new Error(`${path} is snapshot version ${String(data.version)}; this craft reads ${SNAPSHOT_VERSION}`);
63
+ return data;
64
+ }
65
+ async function runAudit(command, args, io) {
66
+ try {
67
+ const flags = parseFlags(args);
68
+ if (typeof flags === "string") throw new Error(flags);
69
+ const target = flags.positional[0];
70
+ if (!target) throw new Error(`usage: craft ${command} <url${command === "audit" ? " | snapshot.json" : ""}>`);
71
+ const viewport = flags.width || flags.height ? { width: Number(flags.width ?? 1440), height: Number(flags.height ?? 900) } : void 0;
72
+ const local = resolve(io.cwd, target);
73
+ const snapshot = command === "audit" && /\.json$/i.test(target) && existsSync(local) ? readSnapshot(local) : await snapshotUrl(target, { viewport });
74
+ if (flags.out) writeFileSync(resolve(io.cwd, flags.out), `${JSON.stringify(snapshot, null, 2)}
75
+ `);
76
+ if (command === "snapshot") {
77
+ if (!flags.out) io.out(JSON.stringify(snapshot, null, 2));
78
+ else io.out(`craft snapshot: ${snapshot.url} saved to ${flags.out}`);
79
+ return 0;
80
+ }
81
+ const exceptions = loadExceptions(flags, flags.repo ? resolve(io.cwd, flags.repo) : io.cwd);
82
+ let report = auditSnapshot(snapshot, { exceptions });
83
+ if (flags.repo) {
84
+ const repo = resolve(io.cwd, flags.repo);
85
+ report = merge(scanSource(readPaths(["."], repo, SOURCE_FILE), { exceptions }), report);
86
+ }
87
+ const fp = fingerprint(snapshot);
88
+ const nulls = flags.null ? flags.null.split(",").map((p) => loadNull(p.trim(), io.cwd)) : [];
89
+ const typical = nulls.length ? typicality(fp, nulls.flatMap((m, i) => m.runs.map((r) => nulls.length > 1 ? { ...r, id: `${i + 1}/${r.id}` } : r))) : null;
90
+ if (flags.json) {
91
+ io.out(JSON.stringify({ report, fingerprint: fp, typicality: typical, snapshot: flags.out ?? null }, null, 2));
92
+ return report.summary.blocking > 0 || flags.strict && report.summary.findings > 0 ? 1 : 0;
93
+ }
94
+ const code = finish(report, { ...flags, json: false }, `craft audit ${snapshot.url}`, io);
95
+ io.out("");
96
+ io.out(describeFingerprint(fp));
97
+ if (typical) {
98
+ io.out("");
99
+ io.out(describeTypicality(typical, nulls.reduce((s, m) => s + m.runs.length, 0), nulls.length));
100
+ }
101
+ return code;
102
+ } catch (error) {
103
+ io.err(`craft: ${error.message}`);
104
+ return 2;
105
+ }
106
+ }
107
+
108
+ export {
109
+ describeFingerprint,
110
+ merge,
111
+ runAudit
112
+ };
@@ -0,0 +1,175 @@
1
+ import {
2
+ REFLEX_FONTS_1,
3
+ REFLEX_FONTS_2,
4
+ deltaEOk,
5
+ isAiViolet,
6
+ isCream,
7
+ normaliseFamily,
8
+ parseColour
9
+ } from "./chunk-3AP4PJMX.js";
10
+
11
+ // src/direction/types.ts
12
+ var DIRECTION_VERSION = 1;
13
+ var SOURCE_KINDS = [
14
+ "livery",
15
+ "shopfront",
16
+ "signage",
17
+ "packaging",
18
+ "product",
19
+ "uniform",
20
+ "interior",
21
+ "place",
22
+ "material",
23
+ "trade",
24
+ "print",
25
+ "photo",
26
+ "founder"
27
+ ];
28
+ var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
29
+
30
+ // src/direction/validate.ts
31
+ var PREFERENCE = /\b(?:client (?:likes|liked|wants|wanted|asked|chose|prefers?)|(?:we|they|i) (?:like|liked|love|loved|prefer)|on[- ]trend|trendy|popular|what (?:everyone|competitors) (?:uses?|does)|brand (?:colou?r|font|guidelines?) (?:is|are|says?))\b/i;
32
+ var MOOD = /\b(?:modern|clean|fresh|professional|trustworthy|premium|elegant|sleek|timeless|minimal(?:ist)?|bold|vibrant|friendly|approachable|luxur(?:y|ious)|sophisticated|contemporary|stylish|warm|inviting|high[- ]end)\b/gi;
33
+ var VALUE_WORDS = /* @__PURE__ */ new Set(["green", "blue", "navy", "black", "white", "cream", "grey", "gray", "gold", "yellow", "orange", "purple", "violet", "pink", "brown", "teal", "bottle", "dark", "light", "deep", "pale", "bright", "colour", "color", "font", "face", "serif", "sans", "type", "lettering"]);
34
+ var STOP = /* @__PURE__ */ new Set(["with", "that", "this", "from", "their", "they", "have", "been", "were", "which", "what", "when", "where", "there", "about", "into", "onto", "over", "same", "each", "every"]);
35
+ function words(text) {
36
+ return new Set((text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).map((w) => w.replace(/'s$/, "")).filter((w) => !STOP.has(w) && !VALUE_WORDS.has(w)));
37
+ }
38
+ function tellFor(key, value) {
39
+ if (key === "accent") {
40
+ const c = parseColour(value);
41
+ return c && isAiViolet(c.oklch) ? "ai-violet" : null;
42
+ }
43
+ if (key === "ground") {
44
+ const c = parseColour(value);
45
+ return c && isCream(c.oklch) ? "cream-palette" : null;
46
+ }
47
+ if (key === "display" || key === "body") {
48
+ const name = normaliseFamily(value).toLowerCase();
49
+ if (REFLEX_FONTS_1.some((f) => f.toLowerCase() === name)) return "reflex-font";
50
+ if (REFLEX_FONTS_2.some((f) => f.toLowerCase() === name)) return "reflex-font-2";
51
+ }
52
+ return null;
53
+ }
54
+ function validateDirection(input, ctx = {}) {
55
+ const problems = [];
56
+ const err = (at, message) => problems.push({ severity: "error", at, message });
57
+ const warn = (at, message) => problems.push({ severity: "warn", at, message });
58
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
59
+ err("", "art-direction.json must be a JSON object");
60
+ return { valid: false, problems, decided: 0 };
61
+ }
62
+ const d = input;
63
+ const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
64
+ if (legacy) {
65
+ warn("", "exceptions only: run craft direction init to record the choices and their reasons");
66
+ return { valid: true, problems, decided: 0 };
67
+ }
68
+ if (d.version !== DIRECTION_VERSION) err("version", `version must be ${DIRECTION_VERSION}`);
69
+ if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
70
+ if (typeof d.brief !== "string" || d.brief.trim().split(/\s+/).length < 8) err("brief", "the brief needs a sentence: who, where, and what they do");
71
+ const sources = Array.isArray(d.sources) ? d.sources : [];
72
+ if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
73
+ if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
74
+ const ids = /* @__PURE__ */ new Set();
75
+ sources.forEach((s, i) => {
76
+ const at = `sources[${i}]`;
77
+ if (!s || typeof s !== "object") return err(at, "a source must be an object");
78
+ if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
79
+ else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
80
+ else ids.add(s.id);
81
+ if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
82
+ if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
83
+ if (s.path && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
84
+ for (const [j, c] of (s.colours ?? []).entries()) if (!parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
85
+ });
86
+ const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
87
+ if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
88
+ const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
89
+ let decided = 0;
90
+ for (const key of Object.keys(choices)) {
91
+ if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Layout and navigation stay conventional.`);
92
+ }
93
+ for (const key of CHOICE_KEYS) {
94
+ const c = choices[key];
95
+ const at = `choices.${key}`;
96
+ if (!c) {
97
+ warn(at, "not decided yet");
98
+ continue;
99
+ }
100
+ let ok = true;
101
+ if (typeof c.value !== "string" || !c.value.trim()) {
102
+ err(`${at}.value`, "no value");
103
+ ok = false;
104
+ } else if ((key === "accent" || key === "ground") && !parseColour(c.value)) {
105
+ err(`${at}.value`, `"${c.value}" is not a colour`);
106
+ ok = false;
107
+ }
108
+ const because = typeof c.because === "string" ? c.because.trim() : "";
109
+ const evidence = Array.isArray(c.evidence) ? c.evidence : [];
110
+ if (because.split(/\s+/).filter(Boolean).length < 8) {
111
+ err(`${at}.because`, "a reason is at least a sentence. Without one this is a default, however good it looks.");
112
+ ok = false;
113
+ } else if (because.startsWith("PROPOSED:")) {
114
+ err(`${at}.because`, "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
115
+ ok = false;
116
+ } else {
117
+ if (PREFERENCE.test(because)) {
118
+ err(`${at}.because`, "that is a preference, not a reason. Say what in the client's world this comes from.");
119
+ ok = false;
120
+ }
121
+ const moods = because.match(MOOD) ?? [];
122
+ if (moods.length >= 2) {
123
+ err(`${at}.because`, `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
124
+ ok = false;
125
+ }
126
+ }
127
+ if (evidence.length === 0) {
128
+ err(`${at}.evidence`, "cite at least one source");
129
+ ok = false;
130
+ }
131
+ for (const id of evidence) {
132
+ if (!ids.has(id)) {
133
+ err(`${at}.evidence`, `no source called "${id}"`);
134
+ ok = false;
135
+ }
136
+ }
137
+ if (ok) {
138
+ const cited = sources.filter((s) => evidence.includes(s.id));
139
+ const reasonWords = words(because);
140
+ const tied = cited.some((s) => reasonWords.has(s.id) || [...words(`${s.note} ${s.lettering ?? ""}`)].some((w) => reasonWords.has(w)));
141
+ if (!tied) {
142
+ err(`${at}.because`, `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
143
+ ok = false;
144
+ }
145
+ }
146
+ if (typeof c.value === "string") {
147
+ const tell = tellFor(key, c.value);
148
+ if (tell && !exceptions.some((e) => e.tell === tell)) {
149
+ err(`${at}.value`, `${c.value} is on the tell catalogue (${tell}). Keep it only with an exception for ${tell}, carrying the same reason.`);
150
+ ok = false;
151
+ }
152
+ }
153
+ const fp = ctx.fingerprint;
154
+ if (fp && typeof c.value === "string") {
155
+ if ((key === "accent" || key === "ground") && parseColour(c.value)) {
156
+ const declared = parseColour(c.value).oklch;
157
+ const measured = key === "accent" ? fp.accent : fp.ground;
158
+ if (measured && deltaEOk(declared, measured) > 0.08) {
159
+ warn(`${at}.value`, `the page does not show it: its ${key} measures oklch(${measured.l.toFixed(2)} ${measured.c.toFixed(3)} ${measured.h.toFixed(0)})`);
160
+ }
161
+ }
162
+ if (key === "display" || key === "body") {
163
+ const measured = key === "display" ? fp.display.family : fp.body.family;
164
+ if (normaliseFamily(c.value).toLowerCase() !== measured.toLowerCase()) warn(`${at}.value`, `the page sets ${measured}, not ${c.value}`);
165
+ }
166
+ }
167
+ if (ok) decided += 1;
168
+ }
169
+ return { valid: problems.every((p) => p.severity !== "error"), problems, decided };
170
+ }
171
+
172
+ export {
173
+ DIRECTION_VERSION,
174
+ validateDirection
175
+ };
@@ -1,20 +1,10 @@
1
1
  import {
2
2
  fingerprint
3
- } from "./chunk-HYGFP3LB.js";
3
+ } from "./chunk-Q54JSNC5.js";
4
4
  import {
5
5
  SNAPSHOT_VERSION,
6
- SOURCE_FILE,
7
- auditSnapshot,
8
- finish,
9
- loadExceptions,
10
- parseFlags,
11
- readPaths,
12
- scanSource
13
- } from "./chunk-GWMSSRIQ.js";
14
-
15
- // src/audit/cli.ts
16
- import { existsSync, readFileSync, writeFileSync } from "fs";
17
- import { resolve } from "path";
6
+ auditSnapshot
7
+ } from "./chunk-3AP4PJMX.js";
18
8
 
19
9
  // src/audit/collect.ts
20
10
  function collectInPage() {
@@ -167,13 +157,18 @@ function collectInPage() {
167
157
  pushBlocks(root, 0);
168
158
  const header = document.querySelector("body > header, header");
169
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
+ };
170
165
  const hiddenAtLoad = (el) => {
171
- const check = [el, ...Array.from(el.children).slice(0, 6)];
172
- return check.some((c) => {
173
- const cs = style(c);
174
- const op = parseFloat(cs.opacity);
175
- 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 : "");
176
- });
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);
177
172
  };
178
173
  const cardsIn = (section) => {
179
174
  let best = { cards: 0, iconCards: 0 };
@@ -372,99 +367,53 @@ async function snapshotPage(page, url, options = {}) {
372
367
  motion: { ...data.motion, introOverlay: early && !late }
373
368
  };
374
369
  }
375
- async function snapshotUrl(url, options = {}) {
376
- const pw = await loadPlaywright();
370
+ async function launch(pw, url, options) {
377
371
  const env = globalThis.process?.env ?? {};
378
372
  const executablePath = options.executablePath ?? env.CRAFT_CHROMIUM ?? void 0;
379
373
  const host = new URL(url).hostname;
380
374
  const noProxy = [`localhost`, `127.0.0.1`, `::1`, ...(env.NO_PROXY ?? env.no_proxy ?? "").split(",")].map((h) => h.trim().replace(/^\*?\./, "")).filter(Boolean);
381
375
  const direct = /^(?:localhost|127\.|\[?::1\]?$)/.test(host) || noProxy.some((h) => host === h || host.endsWith(`.${h}`));
382
376
  const proxy = direct ? void 0 : env.HTTPS_PROXY ?? env.https_proxy;
383
- const browser = await pw.chromium.launch({ headless: true, ...executablePath ? { executablePath } : {}, ...proxy ? { proxy: { server: proxy } } : {} });
384
- try {
385
- const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
386
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
387
- return await snapshotPage(page, url, options);
388
- } finally {
389
- await browser.close();
390
- }
391
- }
392
-
393
- // src/audit/cli.ts
394
- var fmt = (o) => o ? `oklch(${o.l.toFixed(3)} ${o.c.toFixed(3)} ${o.h.toFixed(1)})` : "none";
395
- function describeFingerprint(fp) {
396
- return [
397
- "Fingerprint",
398
- ` accent ${fmt(fp.accent)}`,
399
- ` ground ${fmt(fp.ground)}`,
400
- ` display ${fp.display.family} (${fp.display.class})`,
401
- ` body ${fp.body.family} (${fp.body.class})`,
402
- ` roundness ${fp.roundness === null ? "no buttons" : fp.roundness.toFixed(2)}`,
403
- ` motion ${Math.round(fp.motion * 100)}% of sections below the fold reveal on scroll`,
404
- ` effects ${fp.effects.join(", ") || "none"}`,
405
- ` layout ${fp.layout.join(" > ") || "none"}`
406
- ].join("\n");
407
- }
408
- function merge(a, b) {
409
- const byTell = { ...a.summary.byTell };
410
- for (const [k, v] of Object.entries(b.summary.byTell)) byTell[k] = (byTell[k] ?? 0) + v;
411
- return {
412
- catalogueVersion: a.catalogueVersion,
413
- findings: [...a.findings, ...b.findings],
414
- rejectedExceptions: a.rejectedExceptions,
415
- excepted: [...a.excepted, ...b.excepted],
416
- suppressed: [...a.suppressed, ...b.suppressed],
417
- summary: {
418
- files: a.summary.files + b.summary.files,
419
- findings: a.summary.findings + b.summary.findings,
420
- 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] },
421
- byTell,
422
- blocking: a.summary.blocking + b.summary.blocking
423
- }
424
- };
377
+ return pw.chromium.launch({ headless: true, ...executablePath ? { executablePath } : {}, ...proxy ? { proxy: { server: proxy } } : {} });
425
378
  }
426
- function readSnapshot(path) {
427
- const data = JSON.parse(readFileSync(path, "utf8"));
428
- if (data.version !== SNAPSHOT_VERSION) throw new Error(`${path} is snapshot version ${String(data.version)}; this craft reads ${SNAPSHOT_VERSION}`);
429
- return data;
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;
430
383
  }
431
- async function runAudit(command, args, io) {
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 = [];
432
389
  try {
433
- const flags = parseFlags(args);
434
- if (typeof flags === "string") throw new Error(flags);
435
- const target = flags.positional[0];
436
- if (!target) throw new Error(`usage: craft ${command} <url${command === "audit" ? " | snapshot.json" : ""}>`);
437
- const viewport = flags.width || flags.height ? { width: Number(flags.width ?? 1440), height: Number(flags.height ?? 900) } : void 0;
438
- const local = resolve(io.cwd, target);
439
- const snapshot = command === "audit" && /\.json$/i.test(target) && existsSync(local) ? readSnapshot(local) : await snapshotUrl(target, { viewport });
440
- if (flags.out) writeFileSync(resolve(io.cwd, flags.out), `${JSON.stringify(snapshot, null, 2)}
441
- `);
442
- if (command === "snapshot") {
443
- if (!flags.out) io.out(JSON.stringify(snapshot, null, 2));
444
- else io.out(`craft snapshot: ${snapshot.url} saved to ${flags.out}`);
445
- return 0;
446
- }
447
- const exceptions = loadExceptions(flags, flags.repo ? resolve(io.cwd, flags.repo) : io.cwd);
448
- let report = auditSnapshot(snapshot, { exceptions });
449
- if (flags.repo) {
450
- const repo = resolve(io.cwd, flags.repo);
451
- report = merge(scanSource(readPaths(["."], repo, SOURCE_FILE), { exceptions }), report);
452
- }
453
- const fp = fingerprint(snapshot);
454
- if (flags.json) {
455
- io.out(JSON.stringify({ report, fingerprint: fp, snapshot: flags.out ?? null }, null, 2));
456
- return report.summary.blocking > 0 || flags.strict && report.summary.findings > 0 ? 1 : 0;
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
+ }
457
400
  }
458
- const code = finish(report, { ...flags, json: false }, `craft audit ${snapshot.url}`, io);
459
- io.out("");
460
- io.out(describeFingerprint(fp));
461
- return code;
462
- } catch (error) {
463
- io.err(`craft: ${error.message}`);
464
- return 2;
401
+ return out;
402
+ } finally {
403
+ await browser.close();
465
404
  }
466
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
+
467
411
  export {
468
- describeFingerprint,
469
- runAudit
412
+ collectInPage,
413
+ overlayInPage,
414
+ loadPlaywright,
415
+ snapshotPage,
416
+ snapshotUrl,
417
+ snapshotUrls,
418
+ audit
470
419
  };
@@ -1,12 +1,14 @@
1
1
  import {
2
+ deltaEOk,
2
3
  fontClass,
3
4
  normaliseFamily,
4
5
  parseColour
5
- } from "./chunk-GWMSSRIQ.js";
6
+ } from "./chunk-3AP4PJMX.js";
6
7
 
7
8
  // src/fingerprint/index.ts
8
9
  var FINGERPRINT_VERSION = 1;
9
10
  var CHROMATIC = 0.03;
11
+ var MIN_ACCENT_AREA = 2e4;
10
12
  var oklch = (value) => {
11
13
  const parsed = parseColour(value);
12
14
  return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
@@ -19,7 +21,7 @@ function accentOf(s) {
19
21
  }
20
22
  const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
21
23
  if (topButton) return oklch(topButton[0]);
22
- const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[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];
23
25
  if (bg) return bg.o;
24
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];
25
27
  return text ? text.o : null;
@@ -62,7 +64,42 @@ function fingerprint(s) {
62
64
  layout: s.sections.map((x) => x.kind)
63
65
  };
64
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
+ }
65
101
 
66
102
  export {
67
- fingerprint
103
+ fingerprint,
104
+ fingerprintDistance
68
105
  };