@domandigital/craft 0.5.0 → 0.6.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 +85 -0
- package/CHARACTER.md +335 -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.cjs +2444 -0
- package/dist/audit.d.cts +376 -0
- package/dist/audit.d.ts +376 -0
- package/dist/audit.js +2412 -0
- package/dist/chunk-GWMSSRIQ.js +2542 -0
- package/dist/chunk-HYGFP3LB.js +68 -0
- package/dist/cli-7ME4VIRG.js +470 -0
- package/dist/cli-F7W4F3ER.js +488 -0
- package/dist/cli.js +21 -0
- package/dist/index.cjs +2818 -20
- package/dist/index.d.cts +726 -1
- package/dist/index.d.ts +726 -1
- package/dist/index.js +2772 -20
- package/package.json +27 -4
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fingerprint
|
|
3
|
+
} from "./chunk-HYGFP3LB.js";
|
|
4
|
+
import {
|
|
5
|
+
REFLEX_FONTS_1,
|
|
6
|
+
REFLEX_FONTS_2,
|
|
7
|
+
deltaEOk,
|
|
8
|
+
formatHex,
|
|
9
|
+
hexToOklch,
|
|
10
|
+
isAiViolet,
|
|
11
|
+
isCream,
|
|
12
|
+
normaliseFamily,
|
|
13
|
+
oklchToRgb,
|
|
14
|
+
parseColour,
|
|
15
|
+
parseFlags
|
|
16
|
+
} from "./chunk-GWMSSRIQ.js";
|
|
17
|
+
|
|
18
|
+
// src/direction/cli.ts
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
20
|
+
import { dirname, join, resolve } from "path";
|
|
21
|
+
|
|
22
|
+
// src/direction/types.ts
|
|
23
|
+
var DIRECTION_VERSION = 1;
|
|
24
|
+
var SOURCE_KINDS = [
|
|
25
|
+
"livery",
|
|
26
|
+
"shopfront",
|
|
27
|
+
"signage",
|
|
28
|
+
"packaging",
|
|
29
|
+
"product",
|
|
30
|
+
"uniform",
|
|
31
|
+
"interior",
|
|
32
|
+
"place",
|
|
33
|
+
"material",
|
|
34
|
+
"trade",
|
|
35
|
+
"print",
|
|
36
|
+
"photo",
|
|
37
|
+
"founder"
|
|
38
|
+
];
|
|
39
|
+
var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
|
|
40
|
+
|
|
41
|
+
// src/direction/init.ts
|
|
42
|
+
var hex = (o) => {
|
|
43
|
+
const { r, g, b } = oklchToRgb(o);
|
|
44
|
+
const clamp = (n) => Math.min(1, Math.max(0, n));
|
|
45
|
+
return formatHex({ r: clamp(r), g: clamp(g), b: clamp(b) });
|
|
46
|
+
};
|
|
47
|
+
function initDirection(input) {
|
|
48
|
+
const fp = input.current;
|
|
49
|
+
const empty = (value) => ({ value, because: "", evidence: [] });
|
|
50
|
+
const choices = fp ? {
|
|
51
|
+
...fp.accent ? { accent: empty(hex(fp.accent)) } : {},
|
|
52
|
+
ground: empty(hex(fp.ground)),
|
|
53
|
+
display: empty(fp.display.family),
|
|
54
|
+
body: empty(fp.body.family),
|
|
55
|
+
...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`) } : {}
|
|
56
|
+
} : {};
|
|
57
|
+
return {
|
|
58
|
+
$schema: "https://unpkg.com/@domandigital/craft/art-direction.schema.json",
|
|
59
|
+
version: DIRECTION_VERSION,
|
|
60
|
+
client: input.client ?? "",
|
|
61
|
+
brief: input.brief ?? "",
|
|
62
|
+
sources: [],
|
|
63
|
+
choices,
|
|
64
|
+
...input.exceptions?.length ? { exceptions: input.exceptions } : {}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/direction/png.ts
|
|
69
|
+
import { inflateSync } from "zlib";
|
|
70
|
+
function decodePng(buf) {
|
|
71
|
+
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
72
|
+
if (!sig.every((b, i) => buf[i] === b)) throw new Error("not a PNG");
|
|
73
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
74
|
+
let pos = 8;
|
|
75
|
+
let width = 0;
|
|
76
|
+
let height = 0;
|
|
77
|
+
let depth = 0;
|
|
78
|
+
let colour = 0;
|
|
79
|
+
let interlace = 0;
|
|
80
|
+
const idat = [];
|
|
81
|
+
while (pos < buf.length) {
|
|
82
|
+
const len = view.getUint32(pos);
|
|
83
|
+
const type = String.fromCharCode(...buf.subarray(pos + 4, pos + 8));
|
|
84
|
+
const data = buf.subarray(pos + 8, pos + 8 + len);
|
|
85
|
+
if (type === "IHDR") {
|
|
86
|
+
width = view.getUint32(pos + 8);
|
|
87
|
+
height = view.getUint32(pos + 12);
|
|
88
|
+
depth = data[8];
|
|
89
|
+
colour = data[9];
|
|
90
|
+
interlace = data[12];
|
|
91
|
+
} else if (type === "IDAT") idat.push(data);
|
|
92
|
+
else if (type === "IEND") break;
|
|
93
|
+
pos += 12 + len;
|
|
94
|
+
}
|
|
95
|
+
const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colour];
|
|
96
|
+
if (depth !== 8 || !channels || interlace !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour type ${colour}${interlace ? ", interlaced" : ""}); export it as 8-bit RGB`);
|
|
97
|
+
const raw = inflateSync(Buffer.concat(idat.map((d) => Buffer.from(d))));
|
|
98
|
+
const stride = width * channels;
|
|
99
|
+
const out = new Uint8Array(width * height * 4);
|
|
100
|
+
const prev = new Uint8Array(stride);
|
|
101
|
+
const line = new Uint8Array(stride);
|
|
102
|
+
for (let y = 0; y < height; y += 1) {
|
|
103
|
+
const filter = raw[y * (stride + 1)];
|
|
104
|
+
const src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
105
|
+
for (let x = 0; x < stride; x += 1) {
|
|
106
|
+
const a = x >= channels ? line[x - channels] : 0;
|
|
107
|
+
const b = prev[x];
|
|
108
|
+
const c = x >= channels ? prev[x - channels] : 0;
|
|
109
|
+
let v = src[x];
|
|
110
|
+
if (filter === 1) v += a;
|
|
111
|
+
else if (filter === 2) v += b;
|
|
112
|
+
else if (filter === 3) v += a + b >> 1;
|
|
113
|
+
else if (filter === 4) {
|
|
114
|
+
const p = a + b - c;
|
|
115
|
+
const pa = Math.abs(p - a);
|
|
116
|
+
const pb = Math.abs(p - b);
|
|
117
|
+
const pc = Math.abs(p - c);
|
|
118
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
119
|
+
}
|
|
120
|
+
line[x] = v & 255;
|
|
121
|
+
}
|
|
122
|
+
for (let x = 0; x < width; x += 1) {
|
|
123
|
+
const i = x * channels;
|
|
124
|
+
const o = (y * width + x) * 4;
|
|
125
|
+
if (channels >= 3) {
|
|
126
|
+
out[o] = line[i];
|
|
127
|
+
out[o + 1] = line[i + 1];
|
|
128
|
+
out[o + 2] = line[i + 2];
|
|
129
|
+
out[o + 3] = channels === 4 ? line[i + 3] : 255;
|
|
130
|
+
} else {
|
|
131
|
+
out[o] = out[o + 1] = out[o + 2] = line[i];
|
|
132
|
+
out[o + 3] = channels === 2 ? line[i + 1] : 255;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
prev.set(line);
|
|
136
|
+
}
|
|
137
|
+
return { width, height, rgba: out };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/direction/propose.ts
|
|
141
|
+
var PROPOSED = "PROPOSED:";
|
|
142
|
+
function srgbToLinear(c) {
|
|
143
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
144
|
+
}
|
|
145
|
+
function toLab(r, g, b) {
|
|
146
|
+
const lr = srgbToLinear(r / 255);
|
|
147
|
+
const lg = srgbToLinear(g / 255);
|
|
148
|
+
const lb = srgbToLinear(b / 255);
|
|
149
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
150
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
151
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
152
|
+
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];
|
|
153
|
+
}
|
|
154
|
+
function paletteFromPixels(rgba, k = 6, maxSamples = 2e4) {
|
|
155
|
+
const total = Math.floor(rgba.length / 4);
|
|
156
|
+
const step = Math.max(1, Math.floor(total / maxSamples));
|
|
157
|
+
const px = [];
|
|
158
|
+
for (let i = 0; i < total; i += step) {
|
|
159
|
+
const o = i * 4;
|
|
160
|
+
if (rgba[o + 3] < 128) continue;
|
|
161
|
+
px.push({ lab: toLab(rgba[o], rgba[o + 1], rgba[o + 2]), rgb: [rgba[o], rgba[o + 1], rgba[o + 2]] });
|
|
162
|
+
}
|
|
163
|
+
if (px.length === 0) return [];
|
|
164
|
+
const sorted = [...px].sort((a, b) => a.lab[0] - b.lab[0]);
|
|
165
|
+
const n = Math.min(k, px.length);
|
|
166
|
+
let centres = Array.from({ length: n }, (_, i) => sorted[Math.floor((i + 0.5) / n * sorted.length)].lab.slice());
|
|
167
|
+
let assign = new Array(px.length).fill(0);
|
|
168
|
+
for (let iter = 0; iter < 12; iter += 1) {
|
|
169
|
+
assign = px.map((p) => {
|
|
170
|
+
let best = 0;
|
|
171
|
+
let bestD = Infinity;
|
|
172
|
+
centres.forEach((c, j) => {
|
|
173
|
+
const d = (p.lab[0] - c[0]) ** 2 + (p.lab[1] - c[1]) ** 2 + (p.lab[2] - c[2]) ** 2;
|
|
174
|
+
if (d < bestD) {
|
|
175
|
+
bestD = d;
|
|
176
|
+
best = j;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
return best;
|
|
180
|
+
});
|
|
181
|
+
centres = centres.map((c, j) => {
|
|
182
|
+
const members = px.filter((_, i) => assign[i] === j);
|
|
183
|
+
if (members.length === 0) return c;
|
|
184
|
+
return [0, 1, 2].map((d) => members.reduce((s, m) => s + m.lab[d], 0) / members.length);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return centres.map((_, j) => {
|
|
188
|
+
const members = px.filter((__, i) => assign[i] === j);
|
|
189
|
+
if (members.length === 0) return null;
|
|
190
|
+
const avg = [0, 1, 2].map((d) => members.reduce((s, m) => s + m.rgb[d], 0) / members.length / 255);
|
|
191
|
+
const hex2 = formatHex({ r: avg[0], g: avg[1], b: avg[2] });
|
|
192
|
+
return { hex: hex2, share: members.length / px.length, oklch: hexToOklch(hex2) };
|
|
193
|
+
}).filter((c) => c !== null).sort((a, b) => b.share - a.share);
|
|
194
|
+
}
|
|
195
|
+
var chromatic = (o) => o.c >= 0.04 && o.l >= 0.2 && o.l <= 0.85;
|
|
196
|
+
function accentCandidates(input) {
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const s of input.sources) {
|
|
199
|
+
for (const c of s.colours ?? []) {
|
|
200
|
+
const parsed = parseColour(c);
|
|
201
|
+
if (!parsed || !chromatic(parsed.oklch)) continue;
|
|
202
|
+
const o = parsed.oklch;
|
|
203
|
+
const siblings = (input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 }));
|
|
204
|
+
const nearest = siblings.sort((a, b) => a.d - b.d)[0];
|
|
205
|
+
const penalty = isAiViolet(o) ? 1 : 0;
|
|
206
|
+
out.push({
|
|
207
|
+
value: c.startsWith("#") ? c.toLowerCase() : c,
|
|
208
|
+
evidence: [s.id],
|
|
209
|
+
note: `from ${s.id} (${s.kind})${nearest ? `; nearest estate accent ${nearest.id} at \u0394E ${nearest.d.toFixed(2)}` : ""}${penalty ? "; in the reflex violet band" : ""}`,
|
|
210
|
+
score: (nearest ? Math.min(nearest.d, 0.3) : 0.3) - penalty + o.c
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out.sort((a, b) => b.score - a.score).map(({ score: _s, ...rest }) => rest);
|
|
215
|
+
}
|
|
216
|
+
function groundCandidates(input) {
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const s of input.sources) {
|
|
219
|
+
for (const c of s.colours ?? []) {
|
|
220
|
+
const parsed = parseColour(c);
|
|
221
|
+
if (!parsed || parsed.oklch.l < 0.9) continue;
|
|
222
|
+
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" : ""}` });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
function typeCandidates(input) {
|
|
228
|
+
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}` }));
|
|
229
|
+
}
|
|
230
|
+
function propose(input) {
|
|
231
|
+
const proposals = [
|
|
232
|
+
{ key: "accent", candidates: accentCandidates(input) },
|
|
233
|
+
{ key: "ground", candidates: groundCandidates(input) },
|
|
234
|
+
{ key: "display", candidates: typeCandidates(input) }
|
|
235
|
+
];
|
|
236
|
+
const choices = {};
|
|
237
|
+
const draft = (p) => {
|
|
238
|
+
const top = p.candidates[0];
|
|
239
|
+
if (!top || !top.value) return void 0;
|
|
240
|
+
const source = input.sources.find((s) => s.id === top.evidence[0]);
|
|
241
|
+
return {
|
|
242
|
+
value: top.value,
|
|
243
|
+
because: `${PROPOSED} taken from the ${source.id}: ${source.note} Confirm it matches, then rewrite this in your own words.`,
|
|
244
|
+
evidence: top.evidence
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
for (const p of proposals) {
|
|
248
|
+
const c = draft(p);
|
|
249
|
+
if (c) choices[p.key] = c;
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
proposals,
|
|
253
|
+
direction: { version: DIRECTION_VERSION, client: input.client, brief: input.brief, sources: input.sources, choices }
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/direction/validate.ts
|
|
258
|
+
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;
|
|
259
|
+
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;
|
|
260
|
+
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"]);
|
|
261
|
+
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"]);
|
|
262
|
+
function words(text) {
|
|
263
|
+
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)));
|
|
264
|
+
}
|
|
265
|
+
function tellFor(key, value) {
|
|
266
|
+
if (key === "accent") {
|
|
267
|
+
const c = parseColour(value);
|
|
268
|
+
return c && isAiViolet(c.oklch) ? "ai-violet" : null;
|
|
269
|
+
}
|
|
270
|
+
if (key === "ground") {
|
|
271
|
+
const c = parseColour(value);
|
|
272
|
+
return c && isCream(c.oklch) ? "cream-palette" : null;
|
|
273
|
+
}
|
|
274
|
+
if (key === "display" || key === "body") {
|
|
275
|
+
const name = normaliseFamily(value).toLowerCase();
|
|
276
|
+
if (REFLEX_FONTS_1.some((f) => f.toLowerCase() === name)) return "reflex-font";
|
|
277
|
+
if (REFLEX_FONTS_2.some((f) => f.toLowerCase() === name)) return "reflex-font-2";
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
function validateDirection(input, ctx = {}) {
|
|
282
|
+
const problems = [];
|
|
283
|
+
const err = (at, message) => problems.push({ severity: "error", at, message });
|
|
284
|
+
const warn = (at, message) => problems.push({ severity: "warn", at, message });
|
|
285
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
286
|
+
err("", "art-direction.json must be a JSON object");
|
|
287
|
+
return { valid: false, problems, decided: 0 };
|
|
288
|
+
}
|
|
289
|
+
const d = input;
|
|
290
|
+
const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
|
|
291
|
+
if (legacy) {
|
|
292
|
+
warn("", "exceptions only: run craft direction init to record the choices and their reasons");
|
|
293
|
+
return { valid: true, problems, decided: 0 };
|
|
294
|
+
}
|
|
295
|
+
if (d.version !== DIRECTION_VERSION) err("version", `version must be ${DIRECTION_VERSION}`);
|
|
296
|
+
if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
|
|
297
|
+
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");
|
|
298
|
+
const sources = Array.isArray(d.sources) ? d.sources : [];
|
|
299
|
+
if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
|
|
300
|
+
if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
|
|
301
|
+
const ids = /* @__PURE__ */ new Set();
|
|
302
|
+
sources.forEach((s, i) => {
|
|
303
|
+
const at = `sources[${i}]`;
|
|
304
|
+
if (!s || typeof s !== "object") return err(at, "a source must be an object");
|
|
305
|
+
if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
|
|
306
|
+
else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
|
|
307
|
+
else ids.add(s.id);
|
|
308
|
+
if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
|
|
309
|
+
if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
|
|
310
|
+
if (s.path && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
|
|
311
|
+
for (const [j, c] of (s.colours ?? []).entries()) if (!parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
|
|
312
|
+
});
|
|
313
|
+
const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
|
|
314
|
+
if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
|
|
315
|
+
const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
|
|
316
|
+
let decided = 0;
|
|
317
|
+
for (const key of Object.keys(choices)) {
|
|
318
|
+
if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Layout and navigation stay conventional.`);
|
|
319
|
+
}
|
|
320
|
+
for (const key of CHOICE_KEYS) {
|
|
321
|
+
const c = choices[key];
|
|
322
|
+
const at = `choices.${key}`;
|
|
323
|
+
if (!c) {
|
|
324
|
+
warn(at, "not decided yet");
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
let ok = true;
|
|
328
|
+
if (typeof c.value !== "string" || !c.value.trim()) {
|
|
329
|
+
err(`${at}.value`, "no value");
|
|
330
|
+
ok = false;
|
|
331
|
+
} else if ((key === "accent" || key === "ground") && !parseColour(c.value)) {
|
|
332
|
+
err(`${at}.value`, `"${c.value}" is not a colour`);
|
|
333
|
+
ok = false;
|
|
334
|
+
}
|
|
335
|
+
const because = typeof c.because === "string" ? c.because.trim() : "";
|
|
336
|
+
const evidence = Array.isArray(c.evidence) ? c.evidence : [];
|
|
337
|
+
if (because.split(/\s+/).filter(Boolean).length < 8) {
|
|
338
|
+
err(`${at}.because`, "a reason is at least a sentence. Without one this is a default, however good it looks.");
|
|
339
|
+
ok = false;
|
|
340
|
+
} else if (because.startsWith("PROPOSED:")) {
|
|
341
|
+
err(`${at}.because`, "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
|
|
342
|
+
ok = false;
|
|
343
|
+
} else {
|
|
344
|
+
if (PREFERENCE.test(because)) {
|
|
345
|
+
err(`${at}.because`, "that is a preference, not a reason. Say what in the client's world this comes from.");
|
|
346
|
+
ok = false;
|
|
347
|
+
}
|
|
348
|
+
const moods = because.match(MOOD) ?? [];
|
|
349
|
+
if (moods.length >= 2) {
|
|
350
|
+
err(`${at}.because`, `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
|
|
351
|
+
ok = false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (evidence.length === 0) {
|
|
355
|
+
err(`${at}.evidence`, "cite at least one source");
|
|
356
|
+
ok = false;
|
|
357
|
+
}
|
|
358
|
+
for (const id of evidence) {
|
|
359
|
+
if (!ids.has(id)) {
|
|
360
|
+
err(`${at}.evidence`, `no source called "${id}"`);
|
|
361
|
+
ok = false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (ok) {
|
|
365
|
+
const cited = sources.filter((s) => evidence.includes(s.id));
|
|
366
|
+
const reasonWords = words(because);
|
|
367
|
+
const tied = cited.some((s) => reasonWords.has(s.id) || [...words(`${s.note} ${s.lettering ?? ""}`)].some((w) => reasonWords.has(w)));
|
|
368
|
+
if (!tied) {
|
|
369
|
+
err(`${at}.because`, `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
|
|
370
|
+
ok = false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (typeof c.value === "string") {
|
|
374
|
+
const tell = tellFor(key, c.value);
|
|
375
|
+
if (tell && !exceptions.some((e) => e.tell === tell)) {
|
|
376
|
+
err(`${at}.value`, `${c.value} is on the tell catalogue (${tell}). Keep it only with an exception for ${tell}, carrying the same reason.`);
|
|
377
|
+
ok = false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const fp = ctx.fingerprint;
|
|
381
|
+
if (fp && typeof c.value === "string") {
|
|
382
|
+
if ((key === "accent" || key === "ground") && parseColour(c.value)) {
|
|
383
|
+
const declared = parseColour(c.value).oklch;
|
|
384
|
+
const measured = key === "accent" ? fp.accent : fp.ground;
|
|
385
|
+
if (measured && deltaEOk(declared, measured) > 0.08) {
|
|
386
|
+
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)})`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (key === "display" || key === "body") {
|
|
390
|
+
const measured = key === "display" ? fp.display.family : fp.body.family;
|
|
391
|
+
if (normaliseFamily(c.value).toLowerCase() !== measured.toLowerCase()) warn(`${at}.value`, `the page sets ${measured}, not ${c.value}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (ok) decided += 1;
|
|
395
|
+
}
|
|
396
|
+
return { valid: problems.every((p) => p.severity !== "error"), problems, decided };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/direction/cli.ts
|
|
400
|
+
var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
|
401
|
+
function fingerprintFrom(path, cwd) {
|
|
402
|
+
return path ? fingerprint(readJson(resolve(cwd, path))) : void 0;
|
|
403
|
+
}
|
|
404
|
+
function estateFrom(dir, cwd) {
|
|
405
|
+
if (!dir) return [];
|
|
406
|
+
const root = resolve(cwd, dir);
|
|
407
|
+
return readdirSync(root).filter((f) => f.endsWith(".json")).flatMap((f) => {
|
|
408
|
+
try {
|
|
409
|
+
const snap = readJson(join(root, f));
|
|
410
|
+
return snap.version === 1 && snap.fonts ? [{ id: f.replace(/(\.snapshot)?\.json$/, ""), fingerprint: fingerprint(snap) }] : [];
|
|
411
|
+
} catch {
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
function runDirection(args, io) {
|
|
417
|
+
try {
|
|
418
|
+
const flags = parseFlags(args);
|
|
419
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
420
|
+
const [sub] = flags.positional;
|
|
421
|
+
const file = resolve(io.cwd, flags.direction ?? "art-direction.json");
|
|
422
|
+
const current = fingerprintFrom(flags.snapshot, io.cwd);
|
|
423
|
+
if (sub === "init") {
|
|
424
|
+
const out = resolve(io.cwd, flags.out ?? "art-direction.json");
|
|
425
|
+
if (existsSync(out) && !flags.out) {
|
|
426
|
+
const existing = readJson(out);
|
|
427
|
+
if (existing.version !== void 0) throw new Error(`${out} already records choices. Pass --out to write elsewhere.`);
|
|
428
|
+
const next = initDirection({ client: flags.client, current, exceptions: existing.exceptions });
|
|
429
|
+
writeFileSync(out, `${JSON.stringify(next, null, 2)}
|
|
430
|
+
`);
|
|
431
|
+
} else {
|
|
432
|
+
writeFileSync(out, `${JSON.stringify(initDirection({ client: flags.client, current }), null, 2)}
|
|
433
|
+
`);
|
|
434
|
+
}
|
|
435
|
+
io.out(`craft direction: wrote ${out}. Add the sources and a reason for every choice, then run craft direction validate.`);
|
|
436
|
+
return 0;
|
|
437
|
+
}
|
|
438
|
+
if (sub === "validate") {
|
|
439
|
+
if (!existsSync(file)) throw new Error(`no ${file}. Run craft direction init.`);
|
|
440
|
+
const report = validateDirection(readJson(file), {
|
|
441
|
+
fingerprint: current,
|
|
442
|
+
pathExists: (p) => existsSync(resolve(dirname(file), p))
|
|
443
|
+
});
|
|
444
|
+
if (flags.json) io.out(JSON.stringify(report, null, 2));
|
|
445
|
+
else {
|
|
446
|
+
for (const p of report.problems) io.out(` ${p.severity === "error" ? "error" : "warn "} ${p.at || "(file)"} ${p.message}`);
|
|
447
|
+
io.out(`craft direction: ${report.valid ? "valid" : "not valid"}, ${report.decided} of 7 choices decided with a reason.`);
|
|
448
|
+
}
|
|
449
|
+
return report.valid ? 0 : 1;
|
|
450
|
+
}
|
|
451
|
+
if (sub === "propose") {
|
|
452
|
+
if (!existsSync(file)) throw new Error(`no ${file}. Run craft direction init and add the sources first.`);
|
|
453
|
+
const base = readJson(file);
|
|
454
|
+
const sources = (base.sources ?? []).map((s) => {
|
|
455
|
+
if (s.colours?.length || !s.path || !/\.png$/i.test(s.path)) return s;
|
|
456
|
+
const path = resolve(dirname(file), s.path);
|
|
457
|
+
if (!existsSync(path)) return s;
|
|
458
|
+
try {
|
|
459
|
+
const { rgba } = decodePng(readFileSync(path));
|
|
460
|
+
return { ...s, colours: paletteFromPixels(rgba).filter((c) => c.share >= 0.03).map((c) => c.hex) };
|
|
461
|
+
} catch (error) {
|
|
462
|
+
io.err(`craft direction: ${s.path}: ${error.message}`);
|
|
463
|
+
return s;
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
const result = propose({ client: base.client, brief: base.brief, sources, current, estate: estateFrom(flags.estate, io.cwd) });
|
|
467
|
+
const merged = { ...base, sources, choices: { ...result.direction.choices, ...stripUndecided(base.choices) }, ...base.exceptions ? { exceptions: base.exceptions } : {} };
|
|
468
|
+
const text = `${JSON.stringify(merged, null, 2)}
|
|
469
|
+
`;
|
|
470
|
+
if (flags.out) writeFileSync(resolve(io.cwd, flags.out), text);
|
|
471
|
+
else io.out(text);
|
|
472
|
+
for (const p of result.proposals) {
|
|
473
|
+
io.err(`${p.key}: ${p.candidates.length ? p.candidates.map((c) => `${c.value || "?"} (${c.note})`).join("; ") : "nothing in the sources to draw on yet"}`);
|
|
474
|
+
}
|
|
475
|
+
return 0;
|
|
476
|
+
}
|
|
477
|
+
throw new Error("usage: craft direction init | validate | propose");
|
|
478
|
+
} catch (error) {
|
|
479
|
+
io.err(`craft: ${error.message}`);
|
|
480
|
+
return 2;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function stripUndecided(choices = {}) {
|
|
484
|
+
return Object.fromEntries(Object.entries(choices).filter(([, c]) => c && c.because && c.because.trim().length > 0));
|
|
485
|
+
}
|
|
486
|
+
export {
|
|
487
|
+
runDirection
|
|
488
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
run
|
|
4
|
+
} from "./chunk-GWMSSRIQ.js";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
process.stdout.on("error", (error) => {
|
|
8
|
+
if (error.code === "EPIPE") process.exit(process.exitCode ?? 0);
|
|
9
|
+
throw error;
|
|
10
|
+
});
|
|
11
|
+
void Promise.resolve(
|
|
12
|
+
run(process.argv.slice(2), {
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
out: (text) => process.stdout.write(`${text}
|
|
15
|
+
`),
|
|
16
|
+
err: (text) => process.stderr.write(`${text}
|
|
17
|
+
`)
|
|
18
|
+
})
|
|
19
|
+
).then((code) => {
|
|
20
|
+
process.exitCode = code;
|
|
21
|
+
});
|