@domandigital/craft 0.20.2 → 0.20.4
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 +15 -0
- package/CHARACTER.md +336 -0
- package/PRINCIPLES.md +30 -6
- package/README.md +23 -0
- package/STANDARD.md +5 -1
- package/art-direction.schema.json +82 -0
- package/design-review.schema.json +421 -0
- package/dist/{chunk-TCMM3PTK.js → chunk-2276FJVN.js} +2 -2
- package/dist/{chunk-IJXDBYHH.js → chunk-GBDPNNC2.js} +3 -3
- package/dist/{chunk-YRXMPI3T.js → chunk-KQF44RI3.js} +1 -1
- package/dist/{chunk-JLLULDVW.js → chunk-NQAYRK2K.js} +11 -9
- package/dist/{chunk-2OBDCIVW.js → chunk-S3L7TEJF.js} +193 -2
- package/dist/cli-6AZVLNWL.js +654 -0
- package/dist/{cli-FF2YDHA4.js → cli-6INEFJYN.js} +1 -1
- package/dist/{cli-A6SNEMRL.js → cli-7HKVVL3P.js} +5 -5
- package/dist/{cli-KJRHO3YO.js → cli-AFKXDGUH.js} +2 -2
- package/dist/{cli-LVYTLT2A.js → cli-AK4EQHUF.js} +2 -2
- package/dist/{cli-U7PJQKZT.js → cli-OYVZOHHV.js} +3 -3
- package/dist/{cli-DZGQOIBX.js → cli-QGJB6OUB.js} +4 -4
- package/dist/cli.js +1 -1
- package/dist/index.cjs +480 -5
- package/dist/index.d.cts +286 -1
- package/dist/index.d.ts +286 -1
- package/dist/index.js +464 -5
- package/package.json +4 -2
- package/dist/cli-YQEVPRL5.js +0 -337
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodePng
|
|
3
|
+
} from "./chunk-JYNXGCYZ.js";
|
|
4
|
+
import {
|
|
5
|
+
ADDITIONS,
|
|
6
|
+
CHANGE_CLASSES,
|
|
7
|
+
CHANGE_STEPS,
|
|
8
|
+
CONFIDENCE_LENSES,
|
|
9
|
+
CONVENTION,
|
|
10
|
+
DIRECTION_VERSION,
|
|
11
|
+
EVIDENCE_KINDS,
|
|
12
|
+
INSPECTED_KINDS,
|
|
13
|
+
PAGE_TYPES,
|
|
14
|
+
PREFERENCE,
|
|
15
|
+
PROPOSED,
|
|
16
|
+
REVIEW_BASES,
|
|
17
|
+
SECTION_ROLES,
|
|
18
|
+
VIEWPORTS,
|
|
19
|
+
checkReason,
|
|
20
|
+
researchPrompt,
|
|
21
|
+
validateDirection
|
|
22
|
+
} from "./chunk-S3L7TEJF.js";
|
|
23
|
+
import {
|
|
24
|
+
parseFlags
|
|
25
|
+
} from "./chunk-NQAYRK2K.js";
|
|
26
|
+
import {
|
|
27
|
+
toJson
|
|
28
|
+
} from "./chunk-QIEDBL4J.js";
|
|
29
|
+
import {
|
|
30
|
+
readSnapshot
|
|
31
|
+
} from "./chunk-WUACK2HX.js";
|
|
32
|
+
import {
|
|
33
|
+
deltaEOk,
|
|
34
|
+
fingerprint,
|
|
35
|
+
formatHex,
|
|
36
|
+
hexToOklch,
|
|
37
|
+
isAiViolet,
|
|
38
|
+
isCream,
|
|
39
|
+
oklchToRgb,
|
|
40
|
+
parseColour
|
|
41
|
+
} from "./chunk-JGWOA4LI.js";
|
|
42
|
+
import "./chunk-7DQDF3VL.js";
|
|
43
|
+
import "./chunk-QYBAVU3P.js";
|
|
44
|
+
|
|
45
|
+
// src/direction/cli.ts
|
|
46
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
47
|
+
import { dirname, join, resolve } from "path";
|
|
48
|
+
|
|
49
|
+
// src/direction/init.ts
|
|
50
|
+
var hex = (o) => {
|
|
51
|
+
const { r, g, b } = oklchToRgb(o);
|
|
52
|
+
const clamp = (n) => Math.min(1, Math.max(0, n));
|
|
53
|
+
return formatHex({ r: clamp(r), g: clamp(g), b: clamp(b) });
|
|
54
|
+
};
|
|
55
|
+
function initDirection(input) {
|
|
56
|
+
const fp = input.current;
|
|
57
|
+
const empty = (value) => ({ value, because: "", evidence: [] });
|
|
58
|
+
const choices = fp ? {
|
|
59
|
+
...fp.accent ? { accent: empty(hex(fp.accent)) } : {},
|
|
60
|
+
ground: empty(hex(fp.ground)),
|
|
61
|
+
display: empty(fp.display.family),
|
|
62
|
+
body: empty(fp.body.family),
|
|
63
|
+
...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`) } : {}
|
|
64
|
+
} : {};
|
|
65
|
+
const part = () => ({ value: "", evidence: [] });
|
|
66
|
+
return {
|
|
67
|
+
$schema: "https://unpkg.com/@domandigital/craft/art-direction.schema.json",
|
|
68
|
+
version: DIRECTION_VERSION,
|
|
69
|
+
client: input.client ?? "",
|
|
70
|
+
brief: input.brief ?? "",
|
|
71
|
+
// Empty on purpose, like the reasons: the first validate lists the job and the structure as undecided.
|
|
72
|
+
job: {
|
|
73
|
+
statement: { verb: "", object: "", context: "" },
|
|
74
|
+
functional: part(),
|
|
75
|
+
emotional: part(),
|
|
76
|
+
social: part(),
|
|
77
|
+
consideration: { value: "low", because: "", evidence: [] },
|
|
78
|
+
objections: [],
|
|
79
|
+
language: [],
|
|
80
|
+
evidence: []
|
|
81
|
+
},
|
|
82
|
+
hierarchy: {
|
|
83
|
+
home: {
|
|
84
|
+
primaryAction: { value: "", positions: [], because: "" },
|
|
85
|
+
order: fp?.sections?.length ? fp.sections.map((s) => ({ role: s.role, because: "" })) : [],
|
|
86
|
+
lead: { value: "", because: "" },
|
|
87
|
+
journey: []
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
sources: [],
|
|
91
|
+
choices,
|
|
92
|
+
...input.exceptions?.length ? { exceptions: input.exceptions } : {}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/direction/propose.ts
|
|
97
|
+
function srgbToLinear(c) {
|
|
98
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
99
|
+
}
|
|
100
|
+
function toLab(r, g, b) {
|
|
101
|
+
const lr = srgbToLinear(r / 255);
|
|
102
|
+
const lg = srgbToLinear(g / 255);
|
|
103
|
+
const lb = srgbToLinear(b / 255);
|
|
104
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
105
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
106
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
107
|
+
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];
|
|
108
|
+
}
|
|
109
|
+
function paletteFromPixels(rgba, k = 6, maxSamples = 2e4) {
|
|
110
|
+
const total = Math.floor(rgba.length / 4);
|
|
111
|
+
const step = Math.max(1, Math.floor(total / maxSamples));
|
|
112
|
+
const px = [];
|
|
113
|
+
for (let i = 0; i < total; i += step) {
|
|
114
|
+
const o = i * 4;
|
|
115
|
+
if (rgba[o + 3] < 128) continue;
|
|
116
|
+
px.push({ lab: toLab(rgba[o], rgba[o + 1], rgba[o + 2]), rgb: [rgba[o], rgba[o + 1], rgba[o + 2]] });
|
|
117
|
+
}
|
|
118
|
+
if (px.length === 0) return [];
|
|
119
|
+
const sorted = [...px].sort((a, b) => a.lab[0] - b.lab[0]);
|
|
120
|
+
const n = Math.min(k, px.length);
|
|
121
|
+
let centres = Array.from({ length: n }, (_, i) => sorted[Math.floor((i + 0.5) / n * sorted.length)].lab.slice());
|
|
122
|
+
let assign = new Array(px.length).fill(0);
|
|
123
|
+
for (let iter = 0; iter < 12; iter += 1) {
|
|
124
|
+
assign = px.map((p) => {
|
|
125
|
+
let best = 0;
|
|
126
|
+
let bestD = Infinity;
|
|
127
|
+
centres.forEach((c, j) => {
|
|
128
|
+
const d = (p.lab[0] - c[0]) ** 2 + (p.lab[1] - c[1]) ** 2 + (p.lab[2] - c[2]) ** 2;
|
|
129
|
+
if (d < bestD) {
|
|
130
|
+
bestD = d;
|
|
131
|
+
best = j;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return best;
|
|
135
|
+
});
|
|
136
|
+
centres = centres.map((c, j) => {
|
|
137
|
+
const members = px.filter((_, i) => assign[i] === j);
|
|
138
|
+
if (members.length === 0) return c;
|
|
139
|
+
return [0, 1, 2].map((d) => members.reduce((s, m) => s + m.lab[d], 0) / members.length);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return centres.map((_, j) => {
|
|
143
|
+
const members = px.filter((__, i) => assign[i] === j);
|
|
144
|
+
if (members.length === 0) return null;
|
|
145
|
+
const avg = [0, 1, 2].map((d) => members.reduce((s, m) => s + m.rgb[d], 0) / members.length / 255);
|
|
146
|
+
const hex2 = formatHex({ r: avg[0], g: avg[1], b: avg[2] });
|
|
147
|
+
return { hex: hex2, share: members.length / px.length, oklch: hexToOklch(hex2) };
|
|
148
|
+
}).filter((c) => c !== null).sort((a, b) => b.share - a.share);
|
|
149
|
+
}
|
|
150
|
+
var chromatic = (o) => o.c >= 0.04 && o.l >= 0.2 && o.l <= 0.85;
|
|
151
|
+
function accentCandidates(input) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const s of input.sources) {
|
|
154
|
+
for (const c of s.colours ?? []) {
|
|
155
|
+
const parsed = parseColour(c);
|
|
156
|
+
if (!parsed || !chromatic(parsed.oklch)) continue;
|
|
157
|
+
const o = parsed.oklch;
|
|
158
|
+
const siblings = [
|
|
159
|
+
...(input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 })),
|
|
160
|
+
...(input.declared ?? []).flatMap((e) => {
|
|
161
|
+
const p = parseColour(e.accent);
|
|
162
|
+
return p ? [{ id: `${e.id} (declared)`, d: deltaEOk(o, p.oklch) }] : [];
|
|
163
|
+
})
|
|
164
|
+
];
|
|
165
|
+
const nearest = siblings.sort((a, b) => a.d - b.d)[0];
|
|
166
|
+
const penalty = isAiViolet(o) ? 1 : 0;
|
|
167
|
+
out.push({
|
|
168
|
+
value: c.startsWith("#") ? c.toLowerCase() : c,
|
|
169
|
+
evidence: [s.id],
|
|
170
|
+
note: `from ${s.id} (${s.kind})${nearest ? `; nearest estate accent ${nearest.id} at \u0394E ${nearest.d.toFixed(2)}` : ""}${penalty ? "; in the reflex violet band" : ""}`,
|
|
171
|
+
score: (nearest ? Math.min(nearest.d, 0.3) : 0.3) - penalty + o.c
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out.sort((a, b) => b.score - a.score).map(({ score: _s, ...rest }) => rest);
|
|
176
|
+
}
|
|
177
|
+
function groundCandidates(input) {
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const s of input.sources) {
|
|
180
|
+
for (const c of s.colours ?? []) {
|
|
181
|
+
const parsed = parseColour(c);
|
|
182
|
+
if (!parsed || parsed.oklch.l < 0.9) continue;
|
|
183
|
+
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" : ""}` });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
function typeCandidates(input) {
|
|
189
|
+
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}` }));
|
|
190
|
+
}
|
|
191
|
+
function propose(input) {
|
|
192
|
+
const proposals = [
|
|
193
|
+
{ key: "accent", candidates: accentCandidates(input) },
|
|
194
|
+
{ key: "ground", candidates: groundCandidates(input) },
|
|
195
|
+
{ key: "display", candidates: typeCandidates(input) }
|
|
196
|
+
];
|
|
197
|
+
const choices = {};
|
|
198
|
+
const draft = (p) => {
|
|
199
|
+
const top = p.candidates[0];
|
|
200
|
+
if (!top || !top.value) return void 0;
|
|
201
|
+
const source = input.sources.find((s) => s.id === top.evidence[0]);
|
|
202
|
+
return {
|
|
203
|
+
value: top.value,
|
|
204
|
+
because: `${PROPOSED} taken from the ${source.id}: ${source.note} Confirm it matches, then rewrite this in your own words.`,
|
|
205
|
+
evidence: top.evidence
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
for (const p of proposals) {
|
|
209
|
+
const c = draft(p);
|
|
210
|
+
if (c) choices[p.key] = c;
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
proposals,
|
|
214
|
+
direction: { version: DIRECTION_VERSION, client: input.client, brief: input.brief, sources: input.sources, choices }
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/direction/review.ts
|
|
219
|
+
var isObject = (x) => Boolean(x) && typeof x === "object" && !Array.isArray(x);
|
|
220
|
+
var wordCount = (x) => typeof x === "string" ? x.trim().split(/\s+/).filter(Boolean).length : 0;
|
|
221
|
+
var includes = (list, x) => list.includes(x);
|
|
222
|
+
var STEP_RANK = new Map(CHANGE_STEPS.map((s, i) => [s, i]));
|
|
223
|
+
var CLASS_RANK = { "no-change": 0, "optional-refinement": 1, "design-hypothesis": 2, "confirmed-correction": 3 };
|
|
224
|
+
var RENDERED_KINDS = ["screenshot", "rendered", "measurement"];
|
|
225
|
+
var isStructural = (step) => step === "restructure" || step === "new-component";
|
|
226
|
+
var GALLERY = /\b(?:gallery|galleries|carousel|slideshow|slider|swipe(?:able)?|rotating)\b/i;
|
|
227
|
+
function plain(at, text) {
|
|
228
|
+
if (wordCount(text) < 8) return [{ severity: "error", at, message: "a sentence at least: say why, in this visitor's terms" }];
|
|
229
|
+
const t = text;
|
|
230
|
+
if (PREFERENCE.test(t)) return [{ severity: "error", at, message: "that is a preference, not a reason. Say what it does for this visitor." }];
|
|
231
|
+
const c = t.match(CONVENTION);
|
|
232
|
+
if (c) return [{ severity: "error", at, message: `"${c[0]}" is the category's default speaking, not a reason. Say what in this visitor's job it answers.` }];
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
function checkProposalShape(at, p, required) {
|
|
236
|
+
const problems = [];
|
|
237
|
+
const err = (rest, message) => problems.push({ severity: "error", at: `${at}${rest}`, message });
|
|
238
|
+
if (!isObject(p)) {
|
|
239
|
+
if (required) err("", "a proposal is an object: change, step, and optionally adds and touches");
|
|
240
|
+
return problems;
|
|
241
|
+
}
|
|
242
|
+
if (wordCount(p.change) < 3) err(".change", "say what changes, in a few words");
|
|
243
|
+
if (!includes(CHANGE_STEPS, p.step)) err(".step", `one of ${CHANGE_STEPS.join(", ")}: the smallest step that does it`);
|
|
244
|
+
if (p.adds !== void 0) {
|
|
245
|
+
if (!Array.isArray(p.adds) || p.adds.some((a) => !includes(ADDITIONS, a))) err(".adds", `a list drawn from ${ADDITIONS.join(", ")}`);
|
|
246
|
+
}
|
|
247
|
+
if (p.comparisons !== void 0) {
|
|
248
|
+
if (!Array.isArray(p.comparisons)) err(".comparisons", "a list comparing each relevant existing component");
|
|
249
|
+
else p.comparisons.forEach((c, i) => {
|
|
250
|
+
if (!isObject(c) || typeof c.existing !== "string" || !c.existing.trim() || typeof c.samePurpose !== "boolean") {
|
|
251
|
+
err(`.comparisons[${i}]`, "existing, samePurpose and because are required");
|
|
252
|
+
} else problems.push(...plain(`${at}.comparisons[${i}].because`, c.because));
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (p.touches !== void 0 && !includes(SECTION_ROLES, p.touches)) err(".touches", `a section role: ${SECTION_ROLES.join(", ")}`);
|
|
256
|
+
return problems;
|
|
257
|
+
}
|
|
258
|
+
function checkEvidenceShape(at, e) {
|
|
259
|
+
const problems = [];
|
|
260
|
+
const err = (rest, message) => problems.push({ severity: "error", at: `${at}${rest}`, message });
|
|
261
|
+
if (!isObject(e)) return [{ severity: "error", at, message: "evidence is an object: kind, ref, and a viewport for anything rendered" }];
|
|
262
|
+
if (!includes(EVIDENCE_KINDS, e.kind)) err(".kind", `one of ${EVIDENCE_KINDS.join(", ")}`);
|
|
263
|
+
if (typeof e.ref !== "string" || !e.ref.trim()) err(".ref", "where it can be found: a screenshot path, a selector, a quotation, a report");
|
|
264
|
+
if (e.viewport !== void 0 && !includes(VIEWPORTS, e.viewport)) err(".viewport", `one of ${VIEWPORTS.join(", ")}`);
|
|
265
|
+
if (includes(RENDERED_KINDS, e.kind) && e.viewport === void 0) err(".viewport", "say which width this was seen at: what is seen at one width is not evidence about another");
|
|
266
|
+
return problems;
|
|
267
|
+
}
|
|
268
|
+
function checkNoteShape(i, n) {
|
|
269
|
+
const base = `notes[${i}]`;
|
|
270
|
+
const problems = [];
|
|
271
|
+
const err = (rest, message) => problems.push({ severity: "error", at: `${base}${rest}`, message });
|
|
272
|
+
if (typeof n.id !== "string" || !n.id.trim()) err(".id", "a short name for this note");
|
|
273
|
+
if (n.page !== void 0 && !includes(PAGE_TYPES, n.page)) err(".page", `one of ${PAGE_TYPES.join(", ")}`);
|
|
274
|
+
if (!includes(CONFIDENCE_LENSES, n.lens)) err(".lens", `one of ${CONFIDENCE_LENSES.join(", ")}`);
|
|
275
|
+
if (wordCount(n.observation) < 3) err(".observation", "what was said or seen, as it was said");
|
|
276
|
+
if (typeof n.raisedBy !== "string" || !n.raisedBy.trim()) err(".raisedBy", "who said it, or what found it");
|
|
277
|
+
if (!includes(REVIEW_BASES, n.basis)) err(".basis", `one of ${REVIEW_BASES.join(", ")}`);
|
|
278
|
+
if (!Array.isArray(n.evidence)) err(".evidence", "a list, possibly empty: what supports the note");
|
|
279
|
+
else n.evidence.forEach((e, j) => problems.push(...checkEvidenceShape(`${base}.evidence[${j}]`, e)));
|
|
280
|
+
if (n.appliesTo !== void 0 && (!Array.isArray(n.appliesTo) || n.appliesTo.some((v) => !includes(VIEWPORTS, v)))) err(".appliesTo", `a list drawn from ${VIEWPORTS.join(", ")}`);
|
|
281
|
+
if (n.existing !== void 0 && (!Array.isArray(n.existing) || n.existing.some((x) => typeof x !== "string"))) err(".existing", "a list of what the page already has");
|
|
282
|
+
if (n.suggested !== void 0) problems.push(...checkProposalShape(`${base}.suggested`, n.suggested, false));
|
|
283
|
+
const r = n.recommend;
|
|
284
|
+
if (!isObject(r)) {
|
|
285
|
+
err(".recommend", "the recommendation: a class, and for anything but no-change a change, a step and a reason");
|
|
286
|
+
return problems;
|
|
287
|
+
}
|
|
288
|
+
if (!includes(CHANGE_CLASSES, r.class)) {
|
|
289
|
+
err(".recommend.class", `one of ${CHANGE_CLASSES.join(", ")}. no-change is a result, not a failure to answer.`);
|
|
290
|
+
return problems;
|
|
291
|
+
}
|
|
292
|
+
if (r.class === "no-change") {
|
|
293
|
+
if (r.because !== void 0) problems.push(...plain(`${base}.recommend.because`, r.because));
|
|
294
|
+
return problems;
|
|
295
|
+
}
|
|
296
|
+
problems.push(...checkProposalShape(`${base}.recommend`, r, true));
|
|
297
|
+
problems.push(...plain(`${base}.recommend.because`, r.because));
|
|
298
|
+
const bigger = isStructural(r.step) || Array.isArray(r.adds) && r.adds.length > 0;
|
|
299
|
+
if (bigger) {
|
|
300
|
+
const smaller = Array.isArray(r.smaller) ? r.smaller : [];
|
|
301
|
+
if (smaller.length === 0) err(".recommend.smaller", "a structural change, or one that adds a carousel, script, dependency, animation, testimonial, call to action or repeated content, says which smaller steps were considered and why none was enough");
|
|
302
|
+
const limit = STEP_RANK.get(r.step) ?? 0;
|
|
303
|
+
smaller.forEach((s, j) => {
|
|
304
|
+
const ok = isObject(s) && includes(CHANGE_STEPS, s.step) && (limit === 0 || (STEP_RANK.get(s.step) ?? 99) < limit);
|
|
305
|
+
if (!ok) err(`.recommend.smaller[${j}].step`, "a step on the ladder below the one recommended");
|
|
306
|
+
if (isObject(s)) problems.push(...plain(`${base}.recommend.smaller[${j}].whyNot`, s.whyNot));
|
|
307
|
+
});
|
|
308
|
+
} else {
|
|
309
|
+
problems.push(...plain(`${base}.recommend.whyEnough`, r.whyEnough));
|
|
310
|
+
}
|
|
311
|
+
if (r.class === "design-hypothesis" && wordCount(r.validation) < 4) err(".recommend.validation", "a hypothesis says how it would be checked: a phone test, five visitors, a read of the enquiries");
|
|
312
|
+
if (r.requires !== void 0) {
|
|
313
|
+
if (!Array.isArray(r.requires) || r.requires.some((q) => !isObject(q) || wordCount(q.what) < 2)) err(".recommend.requires", "a list of {what, source?}: facts or assets the change depends on");
|
|
314
|
+
}
|
|
315
|
+
return problems;
|
|
316
|
+
}
|
|
317
|
+
function whyNot(p, ctx) {
|
|
318
|
+
const reasons = [];
|
|
319
|
+
const adds = Array.isArray(p.adds) ? p.adds : [];
|
|
320
|
+
if (adds.includes("carousel")) {
|
|
321
|
+
for (const have of new Set(ctx.existing.filter((e) => GALLERY.test(e)))) {
|
|
322
|
+
const compared = p.comparisons?.filter((c) => c.existing === have) ?? [];
|
|
323
|
+
if (compared.length !== 1) reasons.push(`the page already has "${have}". Compare its purpose, content and placement before adding another carousel; duplication is not established by its name.`);
|
|
324
|
+
else if (compared[0].samePurpose) reasons.push(`the comparison identifies the same purpose as "${have}": ${compared[0].because}. Reuse or adjust that component.`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const defensible = (ctx.basis === "observed" || ctx.basis === "inferred") && ctx.impact;
|
|
328
|
+
if ((isStructural(p.step) || adds.length > 0) && !defensible) {
|
|
329
|
+
reasons.push("it is a structural change or adds something that costs, and the problem under it is neither observed nor inferred with a stated visitor impact. A preference does not justify it.");
|
|
330
|
+
}
|
|
331
|
+
if (p.step === "remove-redundancy" && !(ctx.basis === "observed" && ctx.impact)) {
|
|
332
|
+
reasons.push("it removes information, and nothing observed shows it is redundant or what it costs the visitor. Useful detail stays unless it is shown not to be.");
|
|
333
|
+
}
|
|
334
|
+
if (p.touches && ctx.placed.has(p.touches) && ctx.basis !== "observed") {
|
|
335
|
+
reasons.push(`the declared order already places "${p.touches}" with a reason. Only something observed overrides a reasoned decision.`);
|
|
336
|
+
}
|
|
337
|
+
return reasons;
|
|
338
|
+
}
|
|
339
|
+
function widthsOf(evidence) {
|
|
340
|
+
const seen = /* @__PURE__ */ new Set();
|
|
341
|
+
for (const e of evidence) {
|
|
342
|
+
if (e.viewport) seen.add(e.viewport);
|
|
343
|
+
else if (e.kind === "content") VIEWPORTS.forEach((v) => seen.add(v));
|
|
344
|
+
}
|
|
345
|
+
return VIEWPORTS.filter((v) => seen.has(v));
|
|
346
|
+
}
|
|
347
|
+
function assessReview(input, direction) {
|
|
348
|
+
const problems = [];
|
|
349
|
+
const results = [];
|
|
350
|
+
const fail = (at, message) => problems.push({ severity: "error", at, message });
|
|
351
|
+
const report = () => {
|
|
352
|
+
const n = (o) => results.filter((r) => r.outcome === o).length;
|
|
353
|
+
const summary = { change: n("change"), hold: n("hold"), noChange: n("no-change"), gatherEvidence: n("gather-evidence"), verdict: "" };
|
|
354
|
+
summary.verdict = results.length === 0 ? "The review has no notes to assess." : summary.change + summary.hold + summary.gatherEvidence === 0 ? "The existing design is already appropriate for what was reviewed. No change is recommended." : `${summary.change} change${summary.change === 1 ? "" : "s"} recommended, ${summary.hold} held for something nobody has supplied, ${summary.gatherEvidence} to establish first, ${summary.noChange} needing no change.`;
|
|
355
|
+
return { valid: !problems.some((p) => p.severity === "error"), problems, results, summary };
|
|
356
|
+
};
|
|
357
|
+
if (!isObject(input)) {
|
|
358
|
+
fail("", "a review is an object: version, subject and notes");
|
|
359
|
+
return report();
|
|
360
|
+
}
|
|
361
|
+
if (input.context !== void 0 && !["publication", "protected-staging"].includes(input.context)) fail("context", "publication or protected-staging");
|
|
362
|
+
if (input.version !== 1) fail("version", "version is 1");
|
|
363
|
+
if (typeof input.subject !== "string" || !input.subject.trim()) fail("subject", "who or what was reviewed");
|
|
364
|
+
if (!Array.isArray(input.notes)) {
|
|
365
|
+
fail("notes", "a list of notes");
|
|
366
|
+
return report();
|
|
367
|
+
}
|
|
368
|
+
const job = direction.job;
|
|
369
|
+
if (!job) {
|
|
370
|
+
fail("", "a review is read against the visitor's job, and the art direction has no job map yet. Record it first (craft direction research).");
|
|
371
|
+
return report();
|
|
372
|
+
}
|
|
373
|
+
const ids = /* @__PURE__ */ new Set();
|
|
374
|
+
input.notes.forEach((raw, i) => {
|
|
375
|
+
if (!isObject(raw)) return fail(`notes[${i}]`, "a note is an object");
|
|
376
|
+
const shape = checkNoteShape(i, raw);
|
|
377
|
+
if (typeof raw.id === "string") {
|
|
378
|
+
if (ids.has(raw.id)) shape.push({ severity: "error", at: `notes[${i}].id`, message: `"${raw.id}" is used twice` });
|
|
379
|
+
ids.add(raw.id);
|
|
380
|
+
}
|
|
381
|
+
problems.push(...shape);
|
|
382
|
+
if (shape.length > 0) return;
|
|
383
|
+
const note = raw;
|
|
384
|
+
const page = note.page ?? "home";
|
|
385
|
+
const adjustments = [];
|
|
386
|
+
const hierarchy = direction.hierarchy?.[page];
|
|
387
|
+
const sources = Array.isArray(direction.sources) ? direction.sources : [];
|
|
388
|
+
const inspected = note.evidence.filter((e) => includes(INSPECTED_KINDS, e.kind));
|
|
389
|
+
let basis = note.basis;
|
|
390
|
+
if (basis === "observed" && inspected.length === 0) {
|
|
391
|
+
basis = note.evidence.length > 0 ? "inferred" : "unknown";
|
|
392
|
+
adjustments.push(
|
|
393
|
+
note.evidence.length > 0 ? "declared observed, but everything cited is reported (an audit, a stakeholder, a customer) and nothing was inspected. Read as inferred until someone looks." : "declared observed, but nothing is cited. Read as unknown."
|
|
394
|
+
);
|
|
395
|
+
} else if (basis === "inferred" && note.evidence.length === 0) {
|
|
396
|
+
basis = "unknown";
|
|
397
|
+
adjustments.push("declared inferred, but nothing supports it. Read as unknown.");
|
|
398
|
+
}
|
|
399
|
+
const impactProblems = typeof note.impact === "string" ? checkReason({ at: `notes[${i}].impact`, because: note.impact, sources, tie: "job", job }) : [];
|
|
400
|
+
const impact = typeof note.impact === "string" && impactProblems.length === 0;
|
|
401
|
+
const wantsChange = note.recommend.class !== "no-change";
|
|
402
|
+
if (!impact && (wantsChange || note.suggested)) {
|
|
403
|
+
adjustments.push(
|
|
404
|
+
typeof note.impact === "string" ? `no visitor impact tied to this client's job (${impactProblems[0]?.message ?? "none given"}).` : "no visitor impact stated. Something that costs the visitor nothing they can name is a fact or a preference, not an issue, and is not read as one."
|
|
405
|
+
);
|
|
406
|
+
if (basis === "inferred") basis = "preference";
|
|
407
|
+
}
|
|
408
|
+
const ctx = {
|
|
409
|
+
note,
|
|
410
|
+
basis,
|
|
411
|
+
impact,
|
|
412
|
+
existing: [...note.existing ?? [], ...(hierarchy?.confidence?.proof ?? []).map((p) => p.what).filter((w) => typeof w === "string")],
|
|
413
|
+
placed: new Set((hierarchy?.order ?? []).map((o) => o.role))
|
|
414
|
+
};
|
|
415
|
+
const examined = widthsOf(note.evidence);
|
|
416
|
+
const wanted = note.appliesTo ?? (examined.length > 0 ? examined : [...VIEWPORTS]);
|
|
417
|
+
const claimed = wanted.filter((v) => examined.includes(v));
|
|
418
|
+
const unknown = VIEWPORTS.filter((v) => wanted.includes(v) && !examined.includes(v));
|
|
419
|
+
if (unknown.length > 0) adjustments.push(`nothing here was looked at on ${unknown.join(" or ")}, so the note says nothing about it. That stays unknown until it is examined there.`);
|
|
420
|
+
const rec = note.recommend;
|
|
421
|
+
const recReasons = wantsChange ? whyNot(rec, ctx) : [];
|
|
422
|
+
const sugReasons = note.suggested ? whyNot(note.suggested, ctx) : [];
|
|
423
|
+
const ceiling = basis === "observed" ? impact ? "confirmed-correction" : "optional-refinement" : basis === "inferred" ? "design-hypothesis" : basis === "preference" ? "optional-refinement" : "no-change";
|
|
424
|
+
let effective = wantsChange ? rec.class : "no-change";
|
|
425
|
+
if (basis !== "unknown" && CLASS_RANK[effective] > CLASS_RANK[ceiling]) {
|
|
426
|
+
adjustments.push(`declared ${rec.class}, but a problem that is ${basis}${basis === "observed" && !impact ? " with no stated visitor impact" : ""} supports at most ${ceiling}.`);
|
|
427
|
+
effective = ceiling;
|
|
428
|
+
}
|
|
429
|
+
let withdrawn;
|
|
430
|
+
if (wantsChange && recReasons.length > 0) {
|
|
431
|
+
withdrawn = recReasons;
|
|
432
|
+
effective = "no-change";
|
|
433
|
+
adjustments.push("the recommended change is withdrawn: " + recReasons.join(" "));
|
|
434
|
+
}
|
|
435
|
+
const missing = (rec.requires ?? []).filter((q) => !q.source || !sources.some((s) => s.id === q.source));
|
|
436
|
+
const outstanding = missing.map((q) => q.what);
|
|
437
|
+
const blocking = missing.filter((q) => !(input.context === "protected-staging" && q.kind === "asset" && q.phase === "publication" && q.placeholder));
|
|
438
|
+
if (missing.length > blocking.length) adjustments.push("Protected staging may reserve the explicitly marked placeholder. Required assets remain outstanding for publication; no fact or photograph has been supplied by this review.");
|
|
439
|
+
let outcome;
|
|
440
|
+
if (basis === "unknown") {
|
|
441
|
+
outcome = "gather-evidence";
|
|
442
|
+
effective = "no-change";
|
|
443
|
+
if (wantsChange) adjustments.push(`nothing is recommended until the problem is established${rec.validation ? `: ${rec.validation}` : ". Ask the client, or look, first."}`);
|
|
444
|
+
} else if (effective === "no-change") outcome = "no-change";
|
|
445
|
+
else if (blocking.length > 0) outcome = "hold";
|
|
446
|
+
else outcome = "change";
|
|
447
|
+
if (outcome === "hold") adjustments.push(`held: it depends on ${blocking.map((o) => `"${o.what}"`).join(", ")}, and no source supplies it. Do not write it in. Ask the client.`);
|
|
448
|
+
let suggested;
|
|
449
|
+
if (note.suggested) {
|
|
450
|
+
const recRank = STEP_RANK.get(rec.step ?? "") ?? -1;
|
|
451
|
+
const sugRank = STEP_RANK.get(note.suggested.step) ?? 0;
|
|
452
|
+
const sugAdds = (note.suggested.adds ?? []).length;
|
|
453
|
+
let standing;
|
|
454
|
+
const reasons = [...sugReasons];
|
|
455
|
+
if (sugReasons.length > 0) standing = "not-supported";
|
|
456
|
+
else if (effective === "no-change") {
|
|
457
|
+
standing = "not-needed";
|
|
458
|
+
reasons.push("the existing design already answers the problem, or nothing is yet established to answer.");
|
|
459
|
+
} else if (sugRank > recRank || sugAdds > 0 && (rec.adds ?? []).length === 0) {
|
|
460
|
+
standing = "comparison-needed";
|
|
461
|
+
reasons.push("Compare the proposals before selecting one. A smaller implementation is not evidence of a better visitor experience; compare their adequacy against the stated problem and rendered result.");
|
|
462
|
+
} else standing = "supported";
|
|
463
|
+
suggested = { standing, reasons };
|
|
464
|
+
}
|
|
465
|
+
results.push({
|
|
466
|
+
id: note.id,
|
|
467
|
+
page,
|
|
468
|
+
lens: note.lens,
|
|
469
|
+
basis: { declared: note.basis, effective: basis },
|
|
470
|
+
...suggested ? { suggested } : {},
|
|
471
|
+
recommend: { declared: rec.class, effective, ...wantsChange ? { step: rec.step } : {}, ...withdrawn ? { withdrawn } : {} },
|
|
472
|
+
outcome,
|
|
473
|
+
widths: { examined, claimed, unknown },
|
|
474
|
+
adjustments,
|
|
475
|
+
outstanding,
|
|
476
|
+
publicationReady: outstanding.length === 0
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
return report();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/direction/cli.ts
|
|
483
|
+
var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
|
484
|
+
async function snapshotFrom(target, cwd) {
|
|
485
|
+
if (!target) return void 0;
|
|
486
|
+
if (/^https?:\/\//.test(target)) {
|
|
487
|
+
const { snapshotUrl } = await import("./audit-BN2GSFKT.js");
|
|
488
|
+
return snapshotUrl(target);
|
|
489
|
+
}
|
|
490
|
+
return readSnapshot(readJson(resolve(cwd, target)), target);
|
|
491
|
+
}
|
|
492
|
+
function declaredFrom(path, cwd) {
|
|
493
|
+
if (!path || !path.endsWith(".json")) return [];
|
|
494
|
+
const register = readJson(resolve(cwd, path));
|
|
495
|
+
return register.sites ?? [];
|
|
496
|
+
}
|
|
497
|
+
var TEXT_SOURCE = /\.(?:txt|md|markdown|csv|json|html?)$/i;
|
|
498
|
+
function estateFrom(dir, cwd) {
|
|
499
|
+
if (!dir) return [];
|
|
500
|
+
const root = resolve(cwd, dir);
|
|
501
|
+
if (root.endsWith(".json")) {
|
|
502
|
+
const register = readJson(root);
|
|
503
|
+
return (register.sites ?? []).filter((s) => s.fingerprint).map((s) => ({ id: s.id, fingerprint: s.fingerprint }));
|
|
504
|
+
}
|
|
505
|
+
return readdirSync(root).filter((f) => f.endsWith(".json")).flatMap((f) => {
|
|
506
|
+
try {
|
|
507
|
+
const snap = readSnapshot(readJson(join(root, f)), f);
|
|
508
|
+
return snap.fonts ? [{ id: f.replace(/(\.snapshot)?\.json$/, ""), fingerprint: fingerprint(snap) }] : [];
|
|
509
|
+
} catch {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
async function runDirection(args, io) {
|
|
515
|
+
try {
|
|
516
|
+
const flags = parseFlags(args);
|
|
517
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
518
|
+
const [sub] = flags.positional;
|
|
519
|
+
const file = resolve(io.cwd, flags.direction ?? "art-direction.json");
|
|
520
|
+
if (sub === "research") {
|
|
521
|
+
const base = existsSync(file) ? readJson(file) : {};
|
|
522
|
+
const brief = flags.brief ?? base.brief;
|
|
523
|
+
if (!brief || brief.trim().split(/\s+/).length < 5) throw new Error('usage: craft direction research --brief "<who, where, what they do>" [--visual], or run it next to an art-direction.json with a brief');
|
|
524
|
+
io.out(researchPrompt({ brief, client: flags.client ?? base.client, visual: flags.visual }));
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
if (sub === "review") {
|
|
528
|
+
if (!flags.notes) throw new Error("usage: craft direction review --notes <design-review.json> [--direction <file>] [--json]");
|
|
529
|
+
const notesFile = resolve(io.cwd, flags.notes);
|
|
530
|
+
if (!existsSync(notesFile)) throw new Error(`no such file: ${flags.notes}`);
|
|
531
|
+
const review = readJson(notesFile);
|
|
532
|
+
const report = assessReview(review, existsSync(file) ? readJson(file) : {});
|
|
533
|
+
io.out(flags.json ? toJson(report) : renderReview(review, report));
|
|
534
|
+
return report.valid ? 0 : 1;
|
|
535
|
+
}
|
|
536
|
+
const page = flags.page ?? "home";
|
|
537
|
+
if (!PAGE_TYPES.includes(page)) throw new Error(`--page takes one of ${PAGE_TYPES.join(", ")}`);
|
|
538
|
+
const snapshot = await snapshotFrom(flags.snapshot, io.cwd);
|
|
539
|
+
const current = snapshot ? fingerprint(snapshot) : void 0;
|
|
540
|
+
if (sub === "init") {
|
|
541
|
+
const out = resolve(io.cwd, flags.out ?? "art-direction.json");
|
|
542
|
+
if (existsSync(out) && !flags.out) {
|
|
543
|
+
const existing = readJson(out);
|
|
544
|
+
if (existing.version !== void 0) throw new Error(`${out} already records choices. Pass --out to write elsewhere.`);
|
|
545
|
+
const next = initDirection({ client: flags.client, brief: flags.brief, current, exceptions: existing.exceptions });
|
|
546
|
+
writeFileSync(out, `${JSON.stringify(next, null, 2)}
|
|
547
|
+
`);
|
|
548
|
+
} else {
|
|
549
|
+
writeFileSync(out, `${JSON.stringify(initDirection({ client: flags.client, brief: flags.brief, current }), null, 2)}
|
|
550
|
+
`);
|
|
551
|
+
}
|
|
552
|
+
io.out(`craft direction: wrote ${out}. Add the sources and a reason for every choice, then run craft direction validate.`);
|
|
553
|
+
return 0;
|
|
554
|
+
}
|
|
555
|
+
if (sub === "validate") {
|
|
556
|
+
if (!existsSync(file)) throw new Error(`no ${file}. Run craft direction init.`);
|
|
557
|
+
const direction = readJson(file);
|
|
558
|
+
const estate = declaredFrom(flags.estate, io.cwd);
|
|
559
|
+
const report = validateDirection(direction, {
|
|
560
|
+
fingerprint: current,
|
|
561
|
+
snapshot,
|
|
562
|
+
page,
|
|
563
|
+
strict: flags.strict,
|
|
564
|
+
pathExists: (p) => existsSync(resolve(dirname(file), p)),
|
|
565
|
+
readSource: (s) => {
|
|
566
|
+
if (!s.path || /^https?:/.test(s.path) || !TEXT_SOURCE.test(s.path)) return void 0;
|
|
567
|
+
const path = resolve(dirname(file), s.path);
|
|
568
|
+
return existsSync(path) ? readFileSync(path, "utf8") : void 0;
|
|
569
|
+
},
|
|
570
|
+
estate,
|
|
571
|
+
estateId: estate.find((s) => s.client && s.client === direction.client)?.id
|
|
572
|
+
});
|
|
573
|
+
if (flags.json) io.out(toJson(report));
|
|
574
|
+
else {
|
|
575
|
+
for (const p of report.problems) io.out(` ${p.severity === "error" ? "error" : "warn "} ${p.at || "(file)"} ${p.message}`);
|
|
576
|
+
for (const f of report.confidence ?? []) {
|
|
577
|
+
io.out(` ${f.basis.padEnd(8)} ${f.lens} ${f.at} ${f.message}`);
|
|
578
|
+
io.out(` why it matters: ${f.why}`);
|
|
579
|
+
}
|
|
580
|
+
const l = report.layers;
|
|
581
|
+
const pages = l.hierarchy.map((h) => `${h.page} ${h.decided} of ${h.total}`).join(", ");
|
|
582
|
+
io.out(
|
|
583
|
+
`craft direction: ${report.valid ? "valid" : "not valid"}. Job ${l.job ? "decided" : "not decided"}; hierarchy ${pages || "not decided"}; tokens ${l.tokens.decided} of ${l.tokens.total}.${l.complete ? " Every layer decided." : ""}${report.confidence ? ` Decision confidence: ${confidenceCounts(report.confidence)}, none of them a score.` : ""}`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
return report.valid ? 0 : 1;
|
|
587
|
+
}
|
|
588
|
+
if (sub === "propose") {
|
|
589
|
+
if (!existsSync(file)) throw new Error(`no ${file}. Run craft direction init and add the sources first.`);
|
|
590
|
+
const base = readJson(file);
|
|
591
|
+
const sources = (base.sources ?? []).map((s) => {
|
|
592
|
+
if (s.colours?.length || !s.path || !/\.png$/i.test(s.path)) return s;
|
|
593
|
+
const path = resolve(dirname(file), s.path);
|
|
594
|
+
if (!existsSync(path)) return s;
|
|
595
|
+
try {
|
|
596
|
+
const { rgba } = decodePng(readFileSync(path));
|
|
597
|
+
return { ...s, colours: paletteFromPixels(rgba).filter((c) => c.share >= 0.03).map((c) => c.hex) };
|
|
598
|
+
} catch (error) {
|
|
599
|
+
io.err(`craft direction: ${s.path}: ${error.message}`);
|
|
600
|
+
return s;
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
const declared = declaredFrom(flags.estate, io.cwd).flatMap((s) => s.direction?.accent && s.client !== base.client ? [{ id: s.id, accent: s.direction.accent }] : []);
|
|
604
|
+
const result = propose({ client: base.client, brief: base.brief, sources, current, estate: estateFrom(flags.estate, io.cwd), declared });
|
|
605
|
+
const merged = { ...base, sources, choices: { ...result.direction.choices, ...stripUndecided(base.choices) }, ...base.exceptions ? { exceptions: base.exceptions } : {} };
|
|
606
|
+
const text = `${JSON.stringify(merged, null, 2)}
|
|
607
|
+
`;
|
|
608
|
+
if (flags.out) writeFileSync(resolve(io.cwd, flags.out), text);
|
|
609
|
+
else io.out(text);
|
|
610
|
+
for (const p of result.proposals) {
|
|
611
|
+
io.err(`${p.key}: ${p.candidates.length ? p.candidates.map((c) => `${c.value || "?"} (${c.note})`).join("; ") : "nothing in the sources to draw on yet"}`);
|
|
612
|
+
}
|
|
613
|
+
return 0;
|
|
614
|
+
}
|
|
615
|
+
throw new Error("usage: craft direction init | validate | propose | research | review");
|
|
616
|
+
} catch (error) {
|
|
617
|
+
io.err(`craft: ${error.message}`);
|
|
618
|
+
return 2;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
var confidenceCounts = (findings) => ["observed", "inferred", "unknown"].map((b) => `${findings.filter((f) => f.basis === b).length} ${b}`).join(", ");
|
|
622
|
+
function stripUndecided(choices = {}) {
|
|
623
|
+
return Object.fromEntries(Object.entries(choices).filter(([, c]) => c && c.because && c.because.trim().length > 0));
|
|
624
|
+
}
|
|
625
|
+
function renderReview(review, report) {
|
|
626
|
+
const lines = [`craft direction review: ${review.subject ?? "untitled"}`];
|
|
627
|
+
for (const p of report.problems) lines.push(` ${p.severity} ${p.at}: ${p.message}`);
|
|
628
|
+
for (const r of report.results) {
|
|
629
|
+
const note = review.notes.find((n) => n.id === r.id);
|
|
630
|
+
if (!note) continue;
|
|
631
|
+
const basis = r.basis.declared === r.basis.effective ? r.basis.effective : `${r.basis.effective} (declared ${r.basis.declared})`;
|
|
632
|
+
lines.push("", ` ${r.id} [${r.page}, ${r.lens}] ${basis}: ${r.outcome}${r.outcome === "change" ? `, ${r.recommend.effective.replace("-", " ")}` : ""}`);
|
|
633
|
+
lines.push(` observation: ${note.observation} (${note.raisedBy})`);
|
|
634
|
+
if (note.problem) lines.push(` visitor problem: ${note.problem}`);
|
|
635
|
+
if (note.evidence.length > 0) lines.push(` evidence: ${note.evidence.map((e) => `${e.kind}${e.viewport ? ` at ${e.viewport}` : ""}`).join(", ")}`);
|
|
636
|
+
if (note.impact) lines.push(` visitor impact: ${note.impact}`);
|
|
637
|
+
lines.push(` seen at: ${r.widths.examined.join(", ") || "no width recorded"}${r.widths.unknown.length ? `; unknown at ${r.widths.unknown.join(", ")}` : ""}`);
|
|
638
|
+
if (r.suggested && note.suggested) lines.push(` suggested: ${note.suggested.change} -> ${r.suggested.standing.replace(/-/g, " ")}: ${r.suggested.reasons.join(" ")}`);
|
|
639
|
+
if (r.outcome === "change" || r.outcome === "hold") {
|
|
640
|
+
lines.push(` recommended (${r.recommend.step}): ${note.recommend.change}`);
|
|
641
|
+
lines.push(` why: ${note.recommend.because}`);
|
|
642
|
+
if (note.recommend.whyEnough) lines.push(` why not more: ${note.recommend.whyEnough}`);
|
|
643
|
+
for (const s of note.recommend.smaller ?? []) lines.push(` not ${s.step}: ${s.whyNot}`);
|
|
644
|
+
if (note.recommend.validation) lines.push(` check: ${note.recommend.validation}`);
|
|
645
|
+
} else if (note.recommend.because) lines.push(` no change: ${note.recommend.because}`);
|
|
646
|
+
if (!r.publicationReady) lines.push(` outstanding for publication: ${r.outstanding.join("; ")}`);
|
|
647
|
+
for (const a of r.adjustments) lines.push(` note: ${a}`);
|
|
648
|
+
}
|
|
649
|
+
lines.push("", report.summary.verdict);
|
|
650
|
+
return lines.join("\n");
|
|
651
|
+
}
|
|
652
|
+
export {
|
|
653
|
+
runDirection
|
|
654
|
+
};
|