@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.
- package/CHANGELOG.md +110 -0
- package/CHARACTER.md +578 -0
- package/PRINCIPLES.md +14 -0
- package/README.md +36 -1
- package/STANDARD.md +4 -1
- package/art-direction.schema.json +59 -0
- package/dist/audit-YNODYB3U.js +20 -0
- package/dist/audit.cjs +2472 -0
- package/dist/audit.d.cts +389 -0
- package/dist/audit.d.ts +389 -0
- package/dist/audit.js +2439 -0
- package/dist/chunk-3AP4PJMX.js +2352 -0
- package/dist/chunk-CU5TZHOV.js +163 -0
- package/dist/chunk-GV3QOTQU.js +458 -0
- package/dist/chunk-IQTZWNEK.js +112 -0
- package/dist/chunk-J2BOAIHJ.js +175 -0
- package/dist/chunk-OA7SGPN4.js +419 -0
- package/dist/chunk-Q54JSNC5.js +105 -0
- package/dist/chunk-YYC4EGER.js +249 -0
- package/dist/cli-6Y5E32FS.js +334 -0
- package/dist/cli-IQT6QZ6U.js +16 -0
- package/dist/cli-MW3JAGX3.js +21 -0
- package/dist/cli-PD43TWX7.js +15 -0
- package/dist/cli-TM2HWYN7.js +312 -0
- package/dist/cli.js +22 -0
- package/dist/index.cjs +3385 -27
- package/dist/index.d.cts +1011 -1
- package/dist/index.d.ts +1011 -1
- package/dist/index.js +3311 -24
- package/package.json +28 -4
- package/word-lists.json +155 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CATALOGUE,
|
|
3
|
+
CATALOGUE_VERSION,
|
|
4
|
+
checkCopy,
|
|
5
|
+
fileKind,
|
|
6
|
+
scanSource
|
|
7
|
+
} from "./chunk-3AP4PJMX.js";
|
|
8
|
+
|
|
9
|
+
// src/character/cli.ts
|
|
10
|
+
import { execFileSync } from "child_process";
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
12
|
+
import { join, relative, resolve } from "path";
|
|
13
|
+
|
|
14
|
+
// src/character/format.ts
|
|
15
|
+
function formatReport(report, title) {
|
|
16
|
+
const lines = [];
|
|
17
|
+
let current = "";
|
|
18
|
+
for (const f of report.findings) {
|
|
19
|
+
if (f.path !== current) {
|
|
20
|
+
current = f.path;
|
|
21
|
+
lines.push("", f.path);
|
|
22
|
+
}
|
|
23
|
+
lines.push(` ${String(f.line).padStart(4)} ${f.tell} (gen ${f.generation}, ${f.severity}) ${f.message}`);
|
|
24
|
+
lines.push(` ${f.excerpt}`);
|
|
25
|
+
}
|
|
26
|
+
const fixes = new Map(report.findings.map((f) => [f.tell, f.fix]));
|
|
27
|
+
if (fixes.size > 0) {
|
|
28
|
+
lines.push("", "What to do instead:");
|
|
29
|
+
for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
|
|
30
|
+
}
|
|
31
|
+
for (const r of report.rejectedExceptions) {
|
|
32
|
+
lines.push("", `Exception not applied (${r.exception.tell}): ${r.reason}`);
|
|
33
|
+
}
|
|
34
|
+
for (const e of report.excepted) {
|
|
35
|
+
lines.push("", `Excepted: ${e.count} \xD7 ${e.tell}, because ${e.because}`);
|
|
36
|
+
}
|
|
37
|
+
if (report.suppressed.length > 0) {
|
|
38
|
+
lines.push("", `Suppressed: ${report.suppressed.length} marked copy-ok or craft-ok (${report.suppressed.map((s) => `${s.path}:${s.line}`).join(", ")})`);
|
|
39
|
+
}
|
|
40
|
+
const { summary } = report;
|
|
41
|
+
const gens = `gen 1: ${summary.byGeneration[1]}, gen 2: ${summary.byGeneration[2]}, gen 3: ${summary.byGeneration[3]}`;
|
|
42
|
+
lines.push(
|
|
43
|
+
"",
|
|
44
|
+
`${title}: ${summary.findings} finding${summary.findings === 1 ? "" : "s"} in ${summary.files} file${summary.files === 1 ? "" : "s"} (${gens}). Catalogue ${report.catalogueVersion}.`
|
|
45
|
+
);
|
|
46
|
+
return lines.join("\n").replace(/^\n/, "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/character/cli.ts
|
|
50
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".turbo", ".vercel", "coverage", "vendor", ".cache"]);
|
|
51
|
+
var MAX_BYTES = 1e6;
|
|
52
|
+
var NOT_SITE_COPY = /^(?:README|CHANGELOG|CLAUDE|AGENTS|CONTRIBUTING|LICENSE|CODE_OF_CONDUCT|SECURITY)(?:\.[a-z]+)?$/i;
|
|
53
|
+
var HELP = `craft: find the AI look and say what to do instead.
|
|
54
|
+
|
|
55
|
+
Usage
|
|
56
|
+
craft scan [paths...] [--staged] [--json] [--strict] [--direction <file>]
|
|
57
|
+
craft copy <paths...> [--json] [--strict] [--direction <file>]
|
|
58
|
+
craft tells list [--json]
|
|
59
|
+
craft tells harvest <null.json | dir>... [--share 0.25] [--json]
|
|
60
|
+
craft snapshot <url> [--out <file>] [--width <px>] [--height <px>]
|
|
61
|
+
craft audit <url | snapshot.json> [--repo <dir>] [--null <null.json>] [--out <file>] [--json] [--strict]
|
|
62
|
+
craft direction init [--snapshot <file>] [--client <name>] [--out <file>]
|
|
63
|
+
craft direction validate [--snapshot <file>] [--direction <file>] [--json]
|
|
64
|
+
craft direction propose [--snapshot <file>] [--estate <estate.json | dir>] [--out <file>]
|
|
65
|
+
craft null build --brief "<text>" --out <dir> [--runs 20] [--parallel 4] [--model <name>]
|
|
66
|
+
craft estate add <url | snapshot.json> --id <id> [--client <name>] [--estate <file>]
|
|
67
|
+
craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--json] [--strict]
|
|
68
|
+
craft report <url | snapshot.json> [--repo <dir>] [--null <dir>] [--estate <file>] [--direction <file>] [--json] [--strict]
|
|
69
|
+
craft retrofit <url | snapshot.json> [the same] [--out RETROFIT.md]
|
|
70
|
+
|
|
71
|
+
scan Markup, component code and stylesheets (source and compiled CSS).
|
|
72
|
+
--staged reads the git index, for a pre-commit hook.
|
|
73
|
+
copy The prose in Markdown, markup and content files.
|
|
74
|
+
tells The catalogue this build judges against. harvest reads null models
|
|
75
|
+
for choices the model keeps making that the catalogue does not know.
|
|
76
|
+
snapshot Render a page in a browser and save what it looks like, as JSON.
|
|
77
|
+
audit Judge a rendered page (live, or a saved snapshot) and fingerprint it.
|
|
78
|
+
--repo also scans that site's source, so one report covers both.
|
|
79
|
+
--null scores how typical it is against a null model (comma-separate
|
|
80
|
+
several to pool them).
|
|
81
|
+
snapshot and audit need Playwright: npm i -D playwright.
|
|
82
|
+
direction The site's art-direction.json: every choice with a reason from the
|
|
83
|
+
client's world. init writes today's choices with empty reasons;
|
|
84
|
+
validate applies the reason rule; propose reads colours off the
|
|
85
|
+
sources' PNG photos and drafts choices for a person to confirm.
|
|
86
|
+
null The counterfactual: about 20 pages \`claude -p\` builds from the brief
|
|
87
|
+
alone, snapshotted and fingerprinted into null.json. Resumable.
|
|
88
|
+
estate The register of shipped sites (estate.json). add fingerprints a site;
|
|
89
|
+
compare lists the nearest, and marks siblings: two sites closer than
|
|
90
|
+
two pages Claude builds for one brief. --strict exits 1 on a sibling.
|
|
91
|
+
report Every signal for one site: tells, typicality, the estate, the reasons.
|
|
92
|
+
A verdict (default, mixed, decided, unproven) and what to do, in order.
|
|
93
|
+
Anything not given is "not measured", and never counts as a pass.
|
|
94
|
+
retrofit The report as a checklist: decide, then change type, colour, shape,
|
|
95
|
+
effects, motion and copy. Never the page grammar.
|
|
96
|
+
|
|
97
|
+
Exceptions come from art-direction.json in the working directory, or --direction.
|
|
98
|
+
Every tell ships as warn: exit 1 only on a block, or on any finding with --strict.`;
|
|
99
|
+
var VALUE_FLAGS = {
|
|
100
|
+
"--direction": "direction",
|
|
101
|
+
"--out": "out",
|
|
102
|
+
"--repo": "repo",
|
|
103
|
+
"--width": "width",
|
|
104
|
+
"--height": "height",
|
|
105
|
+
"--snapshot": "snapshot",
|
|
106
|
+
"--estate": "estate",
|
|
107
|
+
"--client": "client",
|
|
108
|
+
"--brief": "brief",
|
|
109
|
+
"--runs": "runs",
|
|
110
|
+
"--parallel": "parallel",
|
|
111
|
+
"--model": "model",
|
|
112
|
+
"--share": "share",
|
|
113
|
+
"--null": "null",
|
|
114
|
+
"--id": "id"
|
|
115
|
+
};
|
|
116
|
+
function parseFlags(args) {
|
|
117
|
+
const flags = { positional: [], json: false, strict: false, staged: false };
|
|
118
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
119
|
+
const a = args[i];
|
|
120
|
+
if (a === "--json") flags.json = true;
|
|
121
|
+
else if (a === "--strict") flags.strict = true;
|
|
122
|
+
else if (a === "--staged") flags.staged = true;
|
|
123
|
+
else if (a in VALUE_FLAGS) {
|
|
124
|
+
const value = args[i + 1];
|
|
125
|
+
i += 1;
|
|
126
|
+
if (!value || value.startsWith("--")) return `${a} needs a value`;
|
|
127
|
+
flags[VALUE_FLAGS[a]] = value;
|
|
128
|
+
} else if (a.startsWith("--")) return `unknown option ${a}`;
|
|
129
|
+
else flags.positional.push(a);
|
|
130
|
+
}
|
|
131
|
+
return flags;
|
|
132
|
+
}
|
|
133
|
+
function walk(root, cwd, into, wanted, skipDir = (_) => false) {
|
|
134
|
+
const stat = statSync(root);
|
|
135
|
+
if (stat.isFile()) {
|
|
136
|
+
if (stat.size <= MAX_BYTES) into.push({ path: relative(cwd, root) || root, text: readFileSync(root, "utf8") });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
if (!SKIP_DIRS.has(entry.name) && !skipDir(entry.name)) walk(join(root, entry.name), cwd, into, wanted, skipDir);
|
|
142
|
+
} else if (entry.isFile() && wanted(entry.name)) {
|
|
143
|
+
walk(join(root, entry.name), cwd, into, wanted, skipDir);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function readPaths(paths, cwd, wanted, skipDir) {
|
|
148
|
+
const files = [];
|
|
149
|
+
for (const p of paths) {
|
|
150
|
+
const abs = resolve(cwd, p);
|
|
151
|
+
if (!existsSync(abs)) throw new Error(`no such path: ${p}`);
|
|
152
|
+
walk(abs, cwd, files, wanted, skipDir);
|
|
153
|
+
}
|
|
154
|
+
return files;
|
|
155
|
+
}
|
|
156
|
+
function readStaged(cwd, wanted) {
|
|
157
|
+
const names = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, encoding: "utf8" }).split("\0").filter((p) => p && wanted(p));
|
|
158
|
+
return names.map((path) => ({
|
|
159
|
+
path,
|
|
160
|
+
// The index, not the working tree: the commit is what gets checked.
|
|
161
|
+
text: execFileSync("git", ["show", `:${path}`], { cwd, encoding: "utf8", maxBuffer: MAX_BYTES * 4 })
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
function loadExceptions(flags, cwd) {
|
|
165
|
+
const path = resolve(cwd, flags.direction ?? "art-direction.json");
|
|
166
|
+
if (!existsSync(path)) {
|
|
167
|
+
if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
171
|
+
return Array.isArray(data.exceptions) ? data.exceptions : [];
|
|
172
|
+
}
|
|
173
|
+
function finish(report, flags, title, io) {
|
|
174
|
+
io.out(flags.json ? JSON.stringify(report, null, 2) : formatReport(report, title));
|
|
175
|
+
if (report.summary.blocking > 0) return 1;
|
|
176
|
+
if (flags.strict && report.summary.findings > 0) return 1;
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
var SOURCE_FILE = (p) => ["markup", "script", "css"].includes(fileKind(p)) && !/\.d\.ts$|\.min\.js$/.test(p);
|
|
180
|
+
var COPY_FILE = (p) => (["markup", "prose", "script"].includes(fileKind(p)) || /\.jsonl?$/i.test(p)) && !NOT_SITE_COPY.test(p.split("/").pop() ?? p) && !/(?:^|\/)(?:package(?:-lock)?|tsconfig[\w.-]*|art-direction)\.json$/.test(p);
|
|
181
|
+
var NOT_COPY_DIR = (name) => name.startsWith("_");
|
|
182
|
+
function run(argv, io) {
|
|
183
|
+
const [command, ...rest] = argv;
|
|
184
|
+
if (command === "direction") {
|
|
185
|
+
return import("./cli-6Y5E32FS.js").then((m) => m.runDirection(rest, io));
|
|
186
|
+
}
|
|
187
|
+
if (command === "report" || command === "retrofit") {
|
|
188
|
+
return import("./cli-TM2HWYN7.js").then((m) => m.runReport(command, rest, io));
|
|
189
|
+
}
|
|
190
|
+
if (command === "estate") {
|
|
191
|
+
return import("./cli-IQT6QZ6U.js").then((m) => m.runEstate(rest, io));
|
|
192
|
+
}
|
|
193
|
+
if (command === "null") {
|
|
194
|
+
return import("./cli-MW3JAGX3.js").then((m) => m.runNull(rest, io));
|
|
195
|
+
}
|
|
196
|
+
if (command === "tells" && rest[0] === "harvest") {
|
|
197
|
+
return import("./cli-MW3JAGX3.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
|
|
198
|
+
io.err(`craft: ${error.message}`);
|
|
199
|
+
return 2;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (command === "snapshot" || command === "audit") {
|
|
203
|
+
return import("./cli-PD43TWX7.js").then((m) => m.runAudit(command, rest, io));
|
|
204
|
+
}
|
|
205
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
206
|
+
io.out(HELP);
|
|
207
|
+
return command ? 0 : 2;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
if (command === "tells") {
|
|
211
|
+
const flags = parseFlags(rest);
|
|
212
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
213
|
+
if (flags.positional[0] !== "list") throw new Error("usage: craft tells list [--json] | craft tells harvest <null.json>...");
|
|
214
|
+
if (flags.json) {
|
|
215
|
+
const data = CATALOGUE.map(({ id, name, generation, surface, severity, why, fix }) => ({ id, name, generation, surface, severity, why, fix }));
|
|
216
|
+
io.out(JSON.stringify({ version: CATALOGUE_VERSION, tells: data }, null, 2));
|
|
217
|
+
} else {
|
|
218
|
+
io.out(`Catalogue ${CATALOGUE_VERSION}`);
|
|
219
|
+
for (const t of CATALOGUE) io.out(` ${t.id.padEnd(24)} gen ${t.generation} ${t.surface.padEnd(6)} ${t.name}`);
|
|
220
|
+
}
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
if (command === "scan" || command === "copy") {
|
|
224
|
+
const flags = parseFlags(rest);
|
|
225
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
226
|
+
const wanted = command === "scan" ? SOURCE_FILE : COPY_FILE;
|
|
227
|
+
if (command === "copy" && flags.positional.length === 0 && !flags.staged) throw new Error("usage: craft copy <paths...>");
|
|
228
|
+
const files = flags.staged ? readStaged(io.cwd, wanted) : readPaths(flags.positional.length > 0 ? flags.positional : ["."], io.cwd, wanted, command === "copy" ? NOT_COPY_DIR : void 0);
|
|
229
|
+
const exceptions = loadExceptions(flags, io.cwd);
|
|
230
|
+
const report = command === "scan" ? scanSource(files, { exceptions }) : checkCopy(files, { exceptions });
|
|
231
|
+
return finish(report, flags, command === "scan" ? "craft scan" : "craft copy", io);
|
|
232
|
+
}
|
|
233
|
+
throw new Error(`unknown command "${command}". Run craft --help.`);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
io.err(`craft: ${error.message}`);
|
|
236
|
+
return 2;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export {
|
|
241
|
+
parseFlags,
|
|
242
|
+
readPaths,
|
|
243
|
+
loadExceptions,
|
|
244
|
+
finish,
|
|
245
|
+
SOURCE_FILE,
|
|
246
|
+
COPY_FILE,
|
|
247
|
+
NOT_COPY_DIR,
|
|
248
|
+
run
|
|
249
|
+
};
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DIRECTION_VERSION,
|
|
3
|
+
validateDirection
|
|
4
|
+
} from "./chunk-J2BOAIHJ.js";
|
|
5
|
+
import {
|
|
6
|
+
parseFlags
|
|
7
|
+
} from "./chunk-YYC4EGER.js";
|
|
8
|
+
import {
|
|
9
|
+
fingerprint
|
|
10
|
+
} from "./chunk-Q54JSNC5.js";
|
|
11
|
+
import {
|
|
12
|
+
deltaEOk,
|
|
13
|
+
formatHex,
|
|
14
|
+
hexToOklch,
|
|
15
|
+
isAiViolet,
|
|
16
|
+
isCream,
|
|
17
|
+
oklchToRgb,
|
|
18
|
+
parseColour
|
|
19
|
+
} from "./chunk-3AP4PJMX.js";
|
|
20
|
+
|
|
21
|
+
// src/direction/cli.ts
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
23
|
+
import { dirname, join, resolve } from "path";
|
|
24
|
+
|
|
25
|
+
// src/direction/init.ts
|
|
26
|
+
var hex = (o) => {
|
|
27
|
+
const { r, g, b } = oklchToRgb(o);
|
|
28
|
+
const clamp = (n) => Math.min(1, Math.max(0, n));
|
|
29
|
+
return formatHex({ r: clamp(r), g: clamp(g), b: clamp(b) });
|
|
30
|
+
};
|
|
31
|
+
function initDirection(input) {
|
|
32
|
+
const fp = input.current;
|
|
33
|
+
const empty = (value) => ({ value, because: "", evidence: [] });
|
|
34
|
+
const choices = fp ? {
|
|
35
|
+
...fp.accent ? { accent: empty(hex(fp.accent)) } : {},
|
|
36
|
+
ground: empty(hex(fp.ground)),
|
|
37
|
+
display: empty(fp.display.family),
|
|
38
|
+
body: empty(fp.body.family),
|
|
39
|
+
...fp.roundness !== null ? { shape: empty(fp.roundness >= 0.45 ? "pill buttons" : fp.roundness <= 0.05 ? "square corners" : `corners at ${Math.round(fp.roundness * 100)}% of button height`) } : {}
|
|
40
|
+
} : {};
|
|
41
|
+
return {
|
|
42
|
+
$schema: "https://unpkg.com/@domandigital/craft/art-direction.schema.json",
|
|
43
|
+
version: DIRECTION_VERSION,
|
|
44
|
+
client: input.client ?? "",
|
|
45
|
+
brief: input.brief ?? "",
|
|
46
|
+
sources: [],
|
|
47
|
+
choices,
|
|
48
|
+
...input.exceptions?.length ? { exceptions: input.exceptions } : {}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/direction/png.ts
|
|
53
|
+
import { inflateSync } from "zlib";
|
|
54
|
+
function decodePng(buf) {
|
|
55
|
+
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
56
|
+
if (!sig.every((b, i) => buf[i] === b)) throw new Error("not a PNG");
|
|
57
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
58
|
+
let pos = 8;
|
|
59
|
+
let width = 0;
|
|
60
|
+
let height = 0;
|
|
61
|
+
let depth = 0;
|
|
62
|
+
let colour = 0;
|
|
63
|
+
let interlace = 0;
|
|
64
|
+
const idat = [];
|
|
65
|
+
while (pos < buf.length) {
|
|
66
|
+
const len = view.getUint32(pos);
|
|
67
|
+
const type = String.fromCharCode(...buf.subarray(pos + 4, pos + 8));
|
|
68
|
+
const data = buf.subarray(pos + 8, pos + 8 + len);
|
|
69
|
+
if (type === "IHDR") {
|
|
70
|
+
width = view.getUint32(pos + 8);
|
|
71
|
+
height = view.getUint32(pos + 12);
|
|
72
|
+
depth = data[8];
|
|
73
|
+
colour = data[9];
|
|
74
|
+
interlace = data[12];
|
|
75
|
+
} else if (type === "IDAT") idat.push(data);
|
|
76
|
+
else if (type === "IEND") break;
|
|
77
|
+
pos += 12 + len;
|
|
78
|
+
}
|
|
79
|
+
const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colour];
|
|
80
|
+
if (depth !== 8 || !channels || interlace !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour type ${colour}${interlace ? ", interlaced" : ""}); export it as 8-bit RGB`);
|
|
81
|
+
const raw = inflateSync(Buffer.concat(idat.map((d) => Buffer.from(d))));
|
|
82
|
+
const stride = width * channels;
|
|
83
|
+
const out = new Uint8Array(width * height * 4);
|
|
84
|
+
const prev = new Uint8Array(stride);
|
|
85
|
+
const line = new Uint8Array(stride);
|
|
86
|
+
for (let y = 0; y < height; y += 1) {
|
|
87
|
+
const filter = raw[y * (stride + 1)];
|
|
88
|
+
const src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
89
|
+
for (let x = 0; x < stride; x += 1) {
|
|
90
|
+
const a = x >= channels ? line[x - channels] : 0;
|
|
91
|
+
const b = prev[x];
|
|
92
|
+
const c = x >= channels ? prev[x - channels] : 0;
|
|
93
|
+
let v = src[x];
|
|
94
|
+
if (filter === 1) v += a;
|
|
95
|
+
else if (filter === 2) v += b;
|
|
96
|
+
else if (filter === 3) v += a + b >> 1;
|
|
97
|
+
else if (filter === 4) {
|
|
98
|
+
const p = a + b - c;
|
|
99
|
+
const pa = Math.abs(p - a);
|
|
100
|
+
const pb = Math.abs(p - b);
|
|
101
|
+
const pc = Math.abs(p - c);
|
|
102
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
103
|
+
}
|
|
104
|
+
line[x] = v & 255;
|
|
105
|
+
}
|
|
106
|
+
for (let x = 0; x < width; x += 1) {
|
|
107
|
+
const i = x * channels;
|
|
108
|
+
const o = (y * width + x) * 4;
|
|
109
|
+
if (channels >= 3) {
|
|
110
|
+
out[o] = line[i];
|
|
111
|
+
out[o + 1] = line[i + 1];
|
|
112
|
+
out[o + 2] = line[i + 2];
|
|
113
|
+
out[o + 3] = channels === 4 ? line[i + 3] : 255;
|
|
114
|
+
} else {
|
|
115
|
+
out[o] = out[o + 1] = out[o + 2] = line[i];
|
|
116
|
+
out[o + 3] = channels === 2 ? line[i + 1] : 255;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
prev.set(line);
|
|
120
|
+
}
|
|
121
|
+
return { width, height, rgba: out };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/direction/propose.ts
|
|
125
|
+
var PROPOSED = "PROPOSED:";
|
|
126
|
+
function srgbToLinear(c) {
|
|
127
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
128
|
+
}
|
|
129
|
+
function toLab(r, g, b) {
|
|
130
|
+
const lr = srgbToLinear(r / 255);
|
|
131
|
+
const lg = srgbToLinear(g / 255);
|
|
132
|
+
const lb = srgbToLinear(b / 255);
|
|
133
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
134
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
135
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
136
|
+
return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
|
|
137
|
+
}
|
|
138
|
+
function paletteFromPixels(rgba, k = 6, maxSamples = 2e4) {
|
|
139
|
+
const total = Math.floor(rgba.length / 4);
|
|
140
|
+
const step = Math.max(1, Math.floor(total / maxSamples));
|
|
141
|
+
const px = [];
|
|
142
|
+
for (let i = 0; i < total; i += step) {
|
|
143
|
+
const o = i * 4;
|
|
144
|
+
if (rgba[o + 3] < 128) continue;
|
|
145
|
+
px.push({ lab: toLab(rgba[o], rgba[o + 1], rgba[o + 2]), rgb: [rgba[o], rgba[o + 1], rgba[o + 2]] });
|
|
146
|
+
}
|
|
147
|
+
if (px.length === 0) return [];
|
|
148
|
+
const sorted = [...px].sort((a, b) => a.lab[0] - b.lab[0]);
|
|
149
|
+
const n = Math.min(k, px.length);
|
|
150
|
+
let centres = Array.from({ length: n }, (_, i) => sorted[Math.floor((i + 0.5) / n * sorted.length)].lab.slice());
|
|
151
|
+
let assign = new Array(px.length).fill(0);
|
|
152
|
+
for (let iter = 0; iter < 12; iter += 1) {
|
|
153
|
+
assign = px.map((p) => {
|
|
154
|
+
let best = 0;
|
|
155
|
+
let bestD = Infinity;
|
|
156
|
+
centres.forEach((c, j) => {
|
|
157
|
+
const d = (p.lab[0] - c[0]) ** 2 + (p.lab[1] - c[1]) ** 2 + (p.lab[2] - c[2]) ** 2;
|
|
158
|
+
if (d < bestD) {
|
|
159
|
+
bestD = d;
|
|
160
|
+
best = j;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
return best;
|
|
164
|
+
});
|
|
165
|
+
centres = centres.map((c, j) => {
|
|
166
|
+
const members = px.filter((_, i) => assign[i] === j);
|
|
167
|
+
if (members.length === 0) return c;
|
|
168
|
+
return [0, 1, 2].map((d) => members.reduce((s, m) => s + m.lab[d], 0) / members.length);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return centres.map((_, j) => {
|
|
172
|
+
const members = px.filter((__, i) => assign[i] === j);
|
|
173
|
+
if (members.length === 0) return null;
|
|
174
|
+
const avg = [0, 1, 2].map((d) => members.reduce((s, m) => s + m.rgb[d], 0) / members.length / 255);
|
|
175
|
+
const hex2 = formatHex({ r: avg[0], g: avg[1], b: avg[2] });
|
|
176
|
+
return { hex: hex2, share: members.length / px.length, oklch: hexToOklch(hex2) };
|
|
177
|
+
}).filter((c) => c !== null).sort((a, b) => b.share - a.share);
|
|
178
|
+
}
|
|
179
|
+
var chromatic = (o) => o.c >= 0.04 && o.l >= 0.2 && o.l <= 0.85;
|
|
180
|
+
function accentCandidates(input) {
|
|
181
|
+
const out = [];
|
|
182
|
+
for (const s of input.sources) {
|
|
183
|
+
for (const c of s.colours ?? []) {
|
|
184
|
+
const parsed = parseColour(c);
|
|
185
|
+
if (!parsed || !chromatic(parsed.oklch)) continue;
|
|
186
|
+
const o = parsed.oklch;
|
|
187
|
+
const siblings = (input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 }));
|
|
188
|
+
const nearest = siblings.sort((a, b) => a.d - b.d)[0];
|
|
189
|
+
const penalty = isAiViolet(o) ? 1 : 0;
|
|
190
|
+
out.push({
|
|
191
|
+
value: c.startsWith("#") ? c.toLowerCase() : c,
|
|
192
|
+
evidence: [s.id],
|
|
193
|
+
note: `from ${s.id} (${s.kind})${nearest ? `; nearest estate accent ${nearest.id} at \u0394E ${nearest.d.toFixed(2)}` : ""}${penalty ? "; in the reflex violet band" : ""}`,
|
|
194
|
+
score: (nearest ? Math.min(nearest.d, 0.3) : 0.3) - penalty + o.c
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out.sort((a, b) => b.score - a.score).map(({ score: _s, ...rest }) => rest);
|
|
199
|
+
}
|
|
200
|
+
function groundCandidates(input) {
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const s of input.sources) {
|
|
203
|
+
for (const c of s.colours ?? []) {
|
|
204
|
+
const parsed = parseColour(c);
|
|
205
|
+
if (!parsed || parsed.oklch.l < 0.9) continue;
|
|
206
|
+
out.push({ value: c, evidence: [s.id], note: `a light colour from ${s.id}${isCream(parsed.oklch) ? "; reads as the cream tell, needs its reason" : ""}` });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
function typeCandidates(input) {
|
|
212
|
+
return input.sources.filter((s) => s.lettering).map((s) => ({ value: "", evidence: [s.id], note: `find a face with the character of "${s.lettering}" on ${s.id}` }));
|
|
213
|
+
}
|
|
214
|
+
function propose(input) {
|
|
215
|
+
const proposals = [
|
|
216
|
+
{ key: "accent", candidates: accentCandidates(input) },
|
|
217
|
+
{ key: "ground", candidates: groundCandidates(input) },
|
|
218
|
+
{ key: "display", candidates: typeCandidates(input) }
|
|
219
|
+
];
|
|
220
|
+
const choices = {};
|
|
221
|
+
const draft = (p) => {
|
|
222
|
+
const top = p.candidates[0];
|
|
223
|
+
if (!top || !top.value) return void 0;
|
|
224
|
+
const source = input.sources.find((s) => s.id === top.evidence[0]);
|
|
225
|
+
return {
|
|
226
|
+
value: top.value,
|
|
227
|
+
because: `${PROPOSED} taken from the ${source.id}: ${source.note} Confirm it matches, then rewrite this in your own words.`,
|
|
228
|
+
evidence: top.evidence
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
for (const p of proposals) {
|
|
232
|
+
const c = draft(p);
|
|
233
|
+
if (c) choices[p.key] = c;
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
proposals,
|
|
237
|
+
direction: { version: DIRECTION_VERSION, client: input.client, brief: input.brief, sources: input.sources, choices }
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/direction/cli.ts
|
|
242
|
+
var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
|
243
|
+
function fingerprintFrom(path, cwd) {
|
|
244
|
+
return path ? fingerprint(readJson(resolve(cwd, path))) : void 0;
|
|
245
|
+
}
|
|
246
|
+
function estateFrom(dir, cwd) {
|
|
247
|
+
if (!dir) return [];
|
|
248
|
+
const root = resolve(cwd, dir);
|
|
249
|
+
if (root.endsWith(".json")) {
|
|
250
|
+
const register = readJson(root);
|
|
251
|
+
return (register.sites ?? []).map((s) => ({ id: s.id, fingerprint: s.fingerprint }));
|
|
252
|
+
}
|
|
253
|
+
return readdirSync(root).filter((f) => f.endsWith(".json")).flatMap((f) => {
|
|
254
|
+
try {
|
|
255
|
+
const snap = readJson(join(root, f));
|
|
256
|
+
return snap.version === 1 && snap.fonts ? [{ id: f.replace(/(\.snapshot)?\.json$/, ""), fingerprint: fingerprint(snap) }] : [];
|
|
257
|
+
} catch {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function runDirection(args, io) {
|
|
263
|
+
try {
|
|
264
|
+
const flags = parseFlags(args);
|
|
265
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
266
|
+
const [sub] = flags.positional;
|
|
267
|
+
const file = resolve(io.cwd, flags.direction ?? "art-direction.json");
|
|
268
|
+
const current = fingerprintFrom(flags.snapshot, io.cwd);
|
|
269
|
+
if (sub === "init") {
|
|
270
|
+
const out = resolve(io.cwd, flags.out ?? "art-direction.json");
|
|
271
|
+
if (existsSync(out) && !flags.out) {
|
|
272
|
+
const existing = readJson(out);
|
|
273
|
+
if (existing.version !== void 0) throw new Error(`${out} already records choices. Pass --out to write elsewhere.`);
|
|
274
|
+
const next = initDirection({ client: flags.client, current, exceptions: existing.exceptions });
|
|
275
|
+
writeFileSync(out, `${JSON.stringify(next, null, 2)}
|
|
276
|
+
`);
|
|
277
|
+
} else {
|
|
278
|
+
writeFileSync(out, `${JSON.stringify(initDirection({ client: flags.client, current }), null, 2)}
|
|
279
|
+
`);
|
|
280
|
+
}
|
|
281
|
+
io.out(`craft direction: wrote ${out}. Add the sources and a reason for every choice, then run craft direction validate.`);
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
if (sub === "validate") {
|
|
285
|
+
if (!existsSync(file)) throw new Error(`no ${file}. Run craft direction init.`);
|
|
286
|
+
const report = validateDirection(readJson(file), {
|
|
287
|
+
fingerprint: current,
|
|
288
|
+
pathExists: (p) => existsSync(resolve(dirname(file), p))
|
|
289
|
+
});
|
|
290
|
+
if (flags.json) io.out(JSON.stringify(report, null, 2));
|
|
291
|
+
else {
|
|
292
|
+
for (const p of report.problems) io.out(` ${p.severity === "error" ? "error" : "warn "} ${p.at || "(file)"} ${p.message}`);
|
|
293
|
+
io.out(`craft direction: ${report.valid ? "valid" : "not valid"}, ${report.decided} of 7 choices decided with a reason.`);
|
|
294
|
+
}
|
|
295
|
+
return report.valid ? 0 : 1;
|
|
296
|
+
}
|
|
297
|
+
if (sub === "propose") {
|
|
298
|
+
if (!existsSync(file)) throw new Error(`no ${file}. Run craft direction init and add the sources first.`);
|
|
299
|
+
const base = readJson(file);
|
|
300
|
+
const sources = (base.sources ?? []).map((s) => {
|
|
301
|
+
if (s.colours?.length || !s.path || !/\.png$/i.test(s.path)) return s;
|
|
302
|
+
const path = resolve(dirname(file), s.path);
|
|
303
|
+
if (!existsSync(path)) return s;
|
|
304
|
+
try {
|
|
305
|
+
const { rgba } = decodePng(readFileSync(path));
|
|
306
|
+
return { ...s, colours: paletteFromPixels(rgba).filter((c) => c.share >= 0.03).map((c) => c.hex) };
|
|
307
|
+
} catch (error) {
|
|
308
|
+
io.err(`craft direction: ${s.path}: ${error.message}`);
|
|
309
|
+
return s;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
const result = propose({ client: base.client, brief: base.brief, sources, current, estate: estateFrom(flags.estate, io.cwd) });
|
|
313
|
+
const merged = { ...base, sources, choices: { ...result.direction.choices, ...stripUndecided(base.choices) }, ...base.exceptions ? { exceptions: base.exceptions } : {} };
|
|
314
|
+
const text = `${JSON.stringify(merged, null, 2)}
|
|
315
|
+
`;
|
|
316
|
+
if (flags.out) writeFileSync(resolve(io.cwd, flags.out), text);
|
|
317
|
+
else io.out(text);
|
|
318
|
+
for (const p of result.proposals) {
|
|
319
|
+
io.err(`${p.key}: ${p.candidates.length ? p.candidates.map((c) => `${c.value || "?"} (${c.note})`).join("; ") : "nothing in the sources to draw on yet"}`);
|
|
320
|
+
}
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
throw new Error("usage: craft direction init | validate | propose");
|
|
324
|
+
} catch (error) {
|
|
325
|
+
io.err(`craft: ${error.message}`);
|
|
326
|
+
return 2;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function stripUndecided(choices = {}) {
|
|
330
|
+
return Object.fromEntries(Object.entries(choices).filter(([, c]) => c && c.because && c.because.trim().length > 0));
|
|
331
|
+
}
|
|
332
|
+
export {
|
|
333
|
+
runDirection
|
|
334
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatMatches,
|
|
3
|
+
formatPairs,
|
|
4
|
+
loadEstate,
|
|
5
|
+
runEstate
|
|
6
|
+
} from "./chunk-CU5TZHOV.js";
|
|
7
|
+
import "./chunk-GV3QOTQU.js";
|
|
8
|
+
import "./chunk-YYC4EGER.js";
|
|
9
|
+
import "./chunk-Q54JSNC5.js";
|
|
10
|
+
import "./chunk-3AP4PJMX.js";
|
|
11
|
+
export {
|
|
12
|
+
formatMatches,
|
|
13
|
+
formatPairs,
|
|
14
|
+
loadEstate,
|
|
15
|
+
runEstate
|
|
16
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describeTypicality,
|
|
3
|
+
formatHarvest,
|
|
4
|
+
loadNull,
|
|
5
|
+
measureRun,
|
|
6
|
+
nullPath,
|
|
7
|
+
runHarvest,
|
|
8
|
+
runNull
|
|
9
|
+
} from "./chunk-GV3QOTQU.js";
|
|
10
|
+
import "./chunk-YYC4EGER.js";
|
|
11
|
+
import "./chunk-Q54JSNC5.js";
|
|
12
|
+
import "./chunk-3AP4PJMX.js";
|
|
13
|
+
export {
|
|
14
|
+
describeTypicality,
|
|
15
|
+
formatHarvest,
|
|
16
|
+
loadNull,
|
|
17
|
+
measureRun,
|
|
18
|
+
nullPath,
|
|
19
|
+
runHarvest,
|
|
20
|
+
runNull
|
|
21
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describeFingerprint,
|
|
3
|
+
merge,
|
|
4
|
+
runAudit
|
|
5
|
+
} from "./chunk-IQTZWNEK.js";
|
|
6
|
+
import "./chunk-OA7SGPN4.js";
|
|
7
|
+
import "./chunk-GV3QOTQU.js";
|
|
8
|
+
import "./chunk-YYC4EGER.js";
|
|
9
|
+
import "./chunk-Q54JSNC5.js";
|
|
10
|
+
import "./chunk-3AP4PJMX.js";
|
|
11
|
+
export {
|
|
12
|
+
describeFingerprint,
|
|
13
|
+
merge,
|
|
14
|
+
runAudit
|
|
15
|
+
};
|