@domandigital/craft 0.20.1 → 0.20.3
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 +309 -0
- package/PRINCIPLES.md +30 -6
- package/README.md +25 -1
- package/STANDARD.md +5 -1
- package/art-direction.schema.json +82 -0
- package/design-review.schema.json +421 -0
- package/dist/{chunk-GOPHZI6O.js → chunk-2276FJVN.js} +2 -2
- package/dist/{chunk-FQYKKKHI.js → chunk-GBDPNNC2.js} +3 -3
- package/dist/{chunk-IZ6GTU54.js → chunk-KQF44RI3.js} +1 -1
- package/dist/{chunk-6NXPDCUO.js → chunk-NQAYRK2K.js} +12 -10
- package/dist/{chunk-2OBDCIVW.js → chunk-S3L7TEJF.js} +193 -2
- package/dist/cli-6AZVLNWL.js +654 -0
- package/dist/{cli-LSJI7QRD.js → cli-6INEFJYN.js} +1 -1
- package/dist/{cli-VFKKFRB4.js → cli-7HKVVL3P.js} +5 -5
- package/dist/{cli-NTNUZUFP.js → cli-AFKXDGUH.js} +2 -2
- package/dist/{cli-DVYLDCEA.js → cli-AK4EQHUF.js} +2 -2
- package/dist/{cli-IREKGVSM.js → cli-OYVZOHHV.js} +3 -3
- package/dist/{cli-HUN5ZTH5.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/dist/phone.cjs +20 -1
- package/dist/phone.d.cts +30 -1
- package/dist/phone.d.ts +30 -1
- package/dist/phone.js +18 -1
- package/dist/{viewports-cli-C4VXMPKK.js → viewports-cli-WWWWEBKI.js} +2 -1
- package/package.json +4 -2
- package/dist/cli-MKZJMZUJ.js +0 -337
package/dist/phone.d.cts
CHANGED
|
@@ -335,6 +335,35 @@ interface ViewportsResult extends MeasuredRoute {
|
|
|
335
335
|
* not judge or throw on a finding: the baseline decides.
|
|
336
336
|
*/
|
|
337
337
|
declare function measureViewports(page: ViewportPage, options: MeasureViewportsOptions): Promise<ViewportsResult>;
|
|
338
|
+
/** The desktop context the wide half is measured in, beside `PHONE` for the phone half. Spread into `browser.newContext`. */
|
|
339
|
+
declare const DESKTOP: {
|
|
340
|
+
readonly viewport: {
|
|
341
|
+
readonly width: 1024;
|
|
342
|
+
readonly height: 768;
|
|
343
|
+
};
|
|
344
|
+
readonly deviceScaleFactor: 1;
|
|
345
|
+
};
|
|
346
|
+
/** A page opened at the route, and how to close it when its half is measured. */
|
|
347
|
+
interface OpenedPage {
|
|
348
|
+
page: ViewportPage;
|
|
349
|
+
close?: () => Promise<void>;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Every viewport for a route only the caller can open, such as a portal screen
|
|
353
|
+
* behind a sign-in (DOM-835). As `measureSite` does for a public page, the
|
|
354
|
+
* phone half is measured in a phone context and the wide half in a desktop
|
|
355
|
+
* one; `open(scope)` returns a page already at the route, signed in with the
|
|
356
|
+
* suite's fixtures, in `PHONE` for "phone" and `DESKTOP` for "wide". The two
|
|
357
|
+
* halves are merged and written once, for `craft phone-first --measured`.
|
|
358
|
+
*
|
|
359
|
+
* await measureRoute(async (scope) => {
|
|
360
|
+
* const context = await browser.newContext({ ...(scope === "phone" ? PHONE : DESKTOP), extraHTTPHeaders: auth });
|
|
361
|
+
* const page = await context.newPage();
|
|
362
|
+
* await page.goto(base + route);
|
|
363
|
+
* return { page, close: () => context.close() };
|
|
364
|
+
* }, { route, mainAction: 'a[href="/portal/requests/new"]' });
|
|
365
|
+
*/
|
|
366
|
+
declare function measureRoute(open: (scope: "phone" | "wide") => Promise<OpenedPage>, options: Omit<MeasureViewportsOptions, "scope">): Promise<ViewportsResult>;
|
|
338
367
|
interface SitePage extends ViewportPage {
|
|
339
368
|
goto(url: string): Promise<{
|
|
340
369
|
ok(): boolean;
|
|
@@ -419,4 +448,4 @@ interface MeasurePhoneOptions {
|
|
|
419
448
|
*/
|
|
420
449
|
declare function measurePhone(page: MinimalPage, options: MeasurePhoneOptions): Promise<MeasuredRoute>;
|
|
421
450
|
|
|
422
|
-
export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, EMPTY_BASELINE, type FileException, type InPageGroup, type Judgement, LADDER, type MainAction, type MeasurePhoneOptions, type MeasureSiteOptions, type MeasureViewportsOptions, type MeasuredRoute, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, SHORT_PHONE, type SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, formatWidths, judgeMeasured, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, reasonOf, routeFile, scanPhoneFirst, sweepWidths, writeBaseline };
|
|
451
|
+
export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, DESKTOP, EMPTY_BASELINE, type FileException, type InPageGroup, type Judgement, LADDER, type MainAction, type MeasurePhoneOptions, type MeasureSiteOptions, type MeasureViewportsOptions, type MeasuredRoute, type OpenedPage, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, SHORT_PHONE, type SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, formatWidths, judgeMeasured, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureRoute, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, reasonOf, routeFile, scanPhoneFirst, sweepWidths, writeBaseline };
|
package/dist/phone.d.ts
CHANGED
|
@@ -335,6 +335,35 @@ interface ViewportsResult extends MeasuredRoute {
|
|
|
335
335
|
* not judge or throw on a finding: the baseline decides.
|
|
336
336
|
*/
|
|
337
337
|
declare function measureViewports(page: ViewportPage, options: MeasureViewportsOptions): Promise<ViewportsResult>;
|
|
338
|
+
/** The desktop context the wide half is measured in, beside `PHONE` for the phone half. Spread into `browser.newContext`. */
|
|
339
|
+
declare const DESKTOP: {
|
|
340
|
+
readonly viewport: {
|
|
341
|
+
readonly width: 1024;
|
|
342
|
+
readonly height: 768;
|
|
343
|
+
};
|
|
344
|
+
readonly deviceScaleFactor: 1;
|
|
345
|
+
};
|
|
346
|
+
/** A page opened at the route, and how to close it when its half is measured. */
|
|
347
|
+
interface OpenedPage {
|
|
348
|
+
page: ViewportPage;
|
|
349
|
+
close?: () => Promise<void>;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Every viewport for a route only the caller can open, such as a portal screen
|
|
353
|
+
* behind a sign-in (DOM-835). As `measureSite` does for a public page, the
|
|
354
|
+
* phone half is measured in a phone context and the wide half in a desktop
|
|
355
|
+
* one; `open(scope)` returns a page already at the route, signed in with the
|
|
356
|
+
* suite's fixtures, in `PHONE` for "phone" and `DESKTOP` for "wide". The two
|
|
357
|
+
* halves are merged and written once, for `craft phone-first --measured`.
|
|
358
|
+
*
|
|
359
|
+
* await measureRoute(async (scope) => {
|
|
360
|
+
* const context = await browser.newContext({ ...(scope === "phone" ? PHONE : DESKTOP), extraHTTPHeaders: auth });
|
|
361
|
+
* const page = await context.newPage();
|
|
362
|
+
* await page.goto(base + route);
|
|
363
|
+
* return { page, close: () => context.close() };
|
|
364
|
+
* }, { route, mainAction: 'a[href="/portal/requests/new"]' });
|
|
365
|
+
*/
|
|
366
|
+
declare function measureRoute(open: (scope: "phone" | "wide") => Promise<OpenedPage>, options: Omit<MeasureViewportsOptions, "scope">): Promise<ViewportsResult>;
|
|
338
367
|
interface SitePage extends ViewportPage {
|
|
339
368
|
goto(url: string): Promise<{
|
|
340
369
|
ok(): boolean;
|
|
@@ -419,4 +448,4 @@ interface MeasurePhoneOptions {
|
|
|
419
448
|
*/
|
|
420
449
|
declare function measurePhone(page: MinimalPage, options: MeasurePhoneOptions): Promise<MeasuredRoute>;
|
|
421
450
|
|
|
422
|
-
export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, EMPTY_BASELINE, type FileException, type InPageGroup, type Judgement, LADDER, type MainAction, type MeasurePhoneOptions, type MeasureSiteOptions, type MeasureViewportsOptions, type MeasuredRoute, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, SHORT_PHONE, type SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, formatWidths, judgeMeasured, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, reasonOf, routeFile, scanPhoneFirst, sweepWidths, writeBaseline };
|
|
451
|
+
export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, DESKTOP, EMPTY_BASELINE, type FileException, type InPageGroup, type Judgement, LADDER, type MainAction, type MeasurePhoneOptions, type MeasureSiteOptions, type MeasureViewportsOptions, type MeasuredRoute, type OpenedPage, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, SHORT_PHONE, type SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, formatWidths, judgeMeasured, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureRoute, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, reasonOf, routeFile, scanPhoneFirst, sweepWidths, writeBaseline };
|
package/dist/phone.js
CHANGED
|
@@ -673,6 +673,21 @@ async function measureViewports(page, options) {
|
|
|
673
673
|
await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
|
|
674
674
|
return finish(options.route, await collect(page, options), options.out);
|
|
675
675
|
}
|
|
676
|
+
var DESKTOP = { viewport: { width: 1024, height: 768 }, deviceScaleFactor: 1 };
|
|
677
|
+
async function measureRoute(open, options) {
|
|
678
|
+
if (!options.route.startsWith("/")) throw new Error(`measureRoute: route "${options.route}" should start with /`);
|
|
679
|
+
const collected = [];
|
|
680
|
+
for (const scope of ["phone", "wide"]) {
|
|
681
|
+
const { page, close } = await open(scope);
|
|
682
|
+
try {
|
|
683
|
+
await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
|
|
684
|
+
collected.push(await collect(page, { ...options, scope }));
|
|
685
|
+
} finally {
|
|
686
|
+
await close?.();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return finish(options.route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out);
|
|
690
|
+
}
|
|
676
691
|
async function measureSite(browser, options) {
|
|
677
692
|
const result = { routes: [], errors: [] };
|
|
678
693
|
for (const route of options.routes) {
|
|
@@ -680,7 +695,7 @@ async function measureSite(browser, options) {
|
|
|
680
695
|
let failed = null;
|
|
681
696
|
for (const [scope, context] of [
|
|
682
697
|
["phone", PHONE],
|
|
683
|
-
["wide",
|
|
698
|
+
["wide", DESKTOP]
|
|
684
699
|
]) {
|
|
685
700
|
const ctx = await browser.newContext({ ...context });
|
|
686
701
|
try {
|
|
@@ -723,6 +738,7 @@ export {
|
|
|
723
738
|
DEFAULT_MEASURED_DIR,
|
|
724
739
|
DEFAULT_SWEEP,
|
|
725
740
|
DEFAULT_THRESHOLDS,
|
|
741
|
+
DESKTOP,
|
|
726
742
|
EMPTY_BASELINE,
|
|
727
743
|
LADDER,
|
|
728
744
|
PHONE,
|
|
@@ -737,6 +753,7 @@ export {
|
|
|
737
753
|
mainActionFor,
|
|
738
754
|
measurePhone,
|
|
739
755
|
measurePhoneInPage,
|
|
756
|
+
measureRoute,
|
|
740
757
|
measureSite,
|
|
741
758
|
measureViewportInPage,
|
|
742
759
|
measureViewports,
|
|
@@ -534,6 +534,7 @@ function writeMeasured(result, dir) {
|
|
|
534
534
|
writeFileSync(join(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, findings }, null, 1)}
|
|
535
535
|
`);
|
|
536
536
|
}
|
|
537
|
+
var DESKTOP = { viewport: { width: 1024, height: 768 }, deviceScaleFactor: 1 };
|
|
537
538
|
async function measureSite(browser, options) {
|
|
538
539
|
const result = { routes: [], errors: [] };
|
|
539
540
|
for (const route of options.routes) {
|
|
@@ -541,7 +542,7 @@ async function measureSite(browser, options) {
|
|
|
541
542
|
let failed = null;
|
|
542
543
|
for (const [scope, context] of [
|
|
543
544
|
["phone", PHONE],
|
|
544
|
-
["wide",
|
|
545
|
+
["wide", DESKTOP]
|
|
545
546
|
]) {
|
|
546
547
|
const ctx = await browser.newContext({ ...context });
|
|
547
548
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domandigital/craft",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The Doman Digital house craft standard as numbers: OKLCh colour ramps that snap to existing anchors, derived semantic tokens with measured WCAG and APCA contrast, one shared motion vocabulary for CSS and JS, and a base stylesheet. Zero dependencies.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -48,11 +48,13 @@
|
|
|
48
48
|
"./package.json": "./package.json",
|
|
49
49
|
"./craft.css": "./css/craft.css",
|
|
50
50
|
"./craft.tailwind.css": "./css/craft.tailwind.css",
|
|
51
|
-
"./art-direction.schema.json": "./art-direction.schema.json"
|
|
51
|
+
"./art-direction.schema.json": "./art-direction.schema.json",
|
|
52
|
+
"./design-review.schema.json": "./design-review.schema.json"
|
|
52
53
|
},
|
|
53
54
|
"files": [
|
|
54
55
|
"CHANGELOG.md",
|
|
55
56
|
"art-direction.schema.json",
|
|
57
|
+
"design-review.schema.json",
|
|
56
58
|
"CHARACTER.md",
|
|
57
59
|
"COPY.md",
|
|
58
60
|
"LICENSE",
|
package/dist/cli-MKZJMZUJ.js
DELETED
|
@@ -1,337 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
decodePng
|
|
3
|
-
} from "./chunk-JYNXGCYZ.js";
|
|
4
|
-
import {
|
|
5
|
-
DIRECTION_VERSION,
|
|
6
|
-
PAGE_TYPES,
|
|
7
|
-
PROPOSED,
|
|
8
|
-
researchPrompt,
|
|
9
|
-
validateDirection
|
|
10
|
-
} from "./chunk-2OBDCIVW.js";
|
|
11
|
-
import {
|
|
12
|
-
parseFlags
|
|
13
|
-
} from "./chunk-6NXPDCUO.js";
|
|
14
|
-
import {
|
|
15
|
-
toJson
|
|
16
|
-
} from "./chunk-QIEDBL4J.js";
|
|
17
|
-
import {
|
|
18
|
-
readSnapshot
|
|
19
|
-
} from "./chunk-WUACK2HX.js";
|
|
20
|
-
import {
|
|
21
|
-
deltaEOk,
|
|
22
|
-
fingerprint,
|
|
23
|
-
formatHex,
|
|
24
|
-
hexToOklch,
|
|
25
|
-
isAiViolet,
|
|
26
|
-
isCream,
|
|
27
|
-
oklchToRgb,
|
|
28
|
-
parseColour
|
|
29
|
-
} from "./chunk-JGWOA4LI.js";
|
|
30
|
-
import "./chunk-7DQDF3VL.js";
|
|
31
|
-
import "./chunk-QYBAVU3P.js";
|
|
32
|
-
|
|
33
|
-
// src/direction/cli.ts
|
|
34
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
35
|
-
import { dirname, join, resolve } from "path";
|
|
36
|
-
|
|
37
|
-
// src/direction/init.ts
|
|
38
|
-
var hex = (o) => {
|
|
39
|
-
const { r, g, b } = oklchToRgb(o);
|
|
40
|
-
const clamp = (n) => Math.min(1, Math.max(0, n));
|
|
41
|
-
return formatHex({ r: clamp(r), g: clamp(g), b: clamp(b) });
|
|
42
|
-
};
|
|
43
|
-
function initDirection(input) {
|
|
44
|
-
const fp = input.current;
|
|
45
|
-
const empty = (value) => ({ value, because: "", evidence: [] });
|
|
46
|
-
const choices = fp ? {
|
|
47
|
-
...fp.accent ? { accent: empty(hex(fp.accent)) } : {},
|
|
48
|
-
ground: empty(hex(fp.ground)),
|
|
49
|
-
display: empty(fp.display.family),
|
|
50
|
-
body: empty(fp.body.family),
|
|
51
|
-
...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`) } : {}
|
|
52
|
-
} : {};
|
|
53
|
-
const part = () => ({ value: "", evidence: [] });
|
|
54
|
-
return {
|
|
55
|
-
$schema: "https://unpkg.com/@domandigital/craft/art-direction.schema.json",
|
|
56
|
-
version: DIRECTION_VERSION,
|
|
57
|
-
client: input.client ?? "",
|
|
58
|
-
brief: input.brief ?? "",
|
|
59
|
-
// Empty on purpose, like the reasons: the first validate lists the job and the structure as undecided.
|
|
60
|
-
job: {
|
|
61
|
-
statement: { verb: "", object: "", context: "" },
|
|
62
|
-
functional: part(),
|
|
63
|
-
emotional: part(),
|
|
64
|
-
social: part(),
|
|
65
|
-
consideration: { value: "low", because: "", evidence: [] },
|
|
66
|
-
objections: [],
|
|
67
|
-
language: [],
|
|
68
|
-
evidence: []
|
|
69
|
-
},
|
|
70
|
-
hierarchy: {
|
|
71
|
-
home: {
|
|
72
|
-
primaryAction: { value: "", positions: [], because: "" },
|
|
73
|
-
order: fp?.sections?.length ? fp.sections.map((s) => ({ role: s.role, because: "" })) : [],
|
|
74
|
-
lead: { value: "", because: "" },
|
|
75
|
-
journey: []
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
sources: [],
|
|
79
|
-
choices,
|
|
80
|
-
...input.exceptions?.length ? { exceptions: input.exceptions } : {}
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// src/direction/propose.ts
|
|
85
|
-
function srgbToLinear(c) {
|
|
86
|
-
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
87
|
-
}
|
|
88
|
-
function toLab(r, g, b) {
|
|
89
|
-
const lr = srgbToLinear(r / 255);
|
|
90
|
-
const lg = srgbToLinear(g / 255);
|
|
91
|
-
const lb = srgbToLinear(b / 255);
|
|
92
|
-
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
93
|
-
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
94
|
-
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
95
|
-
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];
|
|
96
|
-
}
|
|
97
|
-
function paletteFromPixels(rgba, k = 6, maxSamples = 2e4) {
|
|
98
|
-
const total = Math.floor(rgba.length / 4);
|
|
99
|
-
const step = Math.max(1, Math.floor(total / maxSamples));
|
|
100
|
-
const px = [];
|
|
101
|
-
for (let i = 0; i < total; i += step) {
|
|
102
|
-
const o = i * 4;
|
|
103
|
-
if (rgba[o + 3] < 128) continue;
|
|
104
|
-
px.push({ lab: toLab(rgba[o], rgba[o + 1], rgba[o + 2]), rgb: [rgba[o], rgba[o + 1], rgba[o + 2]] });
|
|
105
|
-
}
|
|
106
|
-
if (px.length === 0) return [];
|
|
107
|
-
const sorted = [...px].sort((a, b) => a.lab[0] - b.lab[0]);
|
|
108
|
-
const n = Math.min(k, px.length);
|
|
109
|
-
let centres = Array.from({ length: n }, (_, i) => sorted[Math.floor((i + 0.5) / n * sorted.length)].lab.slice());
|
|
110
|
-
let assign = new Array(px.length).fill(0);
|
|
111
|
-
for (let iter = 0; iter < 12; iter += 1) {
|
|
112
|
-
assign = px.map((p) => {
|
|
113
|
-
let best = 0;
|
|
114
|
-
let bestD = Infinity;
|
|
115
|
-
centres.forEach((c, j) => {
|
|
116
|
-
const d = (p.lab[0] - c[0]) ** 2 + (p.lab[1] - c[1]) ** 2 + (p.lab[2] - c[2]) ** 2;
|
|
117
|
-
if (d < bestD) {
|
|
118
|
-
bestD = d;
|
|
119
|
-
best = j;
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
return best;
|
|
123
|
-
});
|
|
124
|
-
centres = centres.map((c, j) => {
|
|
125
|
-
const members = px.filter((_, i) => assign[i] === j);
|
|
126
|
-
if (members.length === 0) return c;
|
|
127
|
-
return [0, 1, 2].map((d) => members.reduce((s, m) => s + m.lab[d], 0) / members.length);
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
return centres.map((_, j) => {
|
|
131
|
-
const members = px.filter((__, i) => assign[i] === j);
|
|
132
|
-
if (members.length === 0) return null;
|
|
133
|
-
const avg = [0, 1, 2].map((d) => members.reduce((s, m) => s + m.rgb[d], 0) / members.length / 255);
|
|
134
|
-
const hex2 = formatHex({ r: avg[0], g: avg[1], b: avg[2] });
|
|
135
|
-
return { hex: hex2, share: members.length / px.length, oklch: hexToOklch(hex2) };
|
|
136
|
-
}).filter((c) => c !== null).sort((a, b) => b.share - a.share);
|
|
137
|
-
}
|
|
138
|
-
var chromatic = (o) => o.c >= 0.04 && o.l >= 0.2 && o.l <= 0.85;
|
|
139
|
-
function accentCandidates(input) {
|
|
140
|
-
const out = [];
|
|
141
|
-
for (const s of input.sources) {
|
|
142
|
-
for (const c of s.colours ?? []) {
|
|
143
|
-
const parsed = parseColour(c);
|
|
144
|
-
if (!parsed || !chromatic(parsed.oklch)) continue;
|
|
145
|
-
const o = parsed.oklch;
|
|
146
|
-
const siblings = [
|
|
147
|
-
...(input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 })),
|
|
148
|
-
...(input.declared ?? []).flatMap((e) => {
|
|
149
|
-
const p = parseColour(e.accent);
|
|
150
|
-
return p ? [{ id: `${e.id} (declared)`, d: deltaEOk(o, p.oklch) }] : [];
|
|
151
|
-
})
|
|
152
|
-
];
|
|
153
|
-
const nearest = siblings.sort((a, b) => a.d - b.d)[0];
|
|
154
|
-
const penalty = isAiViolet(o) ? 1 : 0;
|
|
155
|
-
out.push({
|
|
156
|
-
value: c.startsWith("#") ? c.toLowerCase() : c,
|
|
157
|
-
evidence: [s.id],
|
|
158
|
-
note: `from ${s.id} (${s.kind})${nearest ? `; nearest estate accent ${nearest.id} at \u0394E ${nearest.d.toFixed(2)}` : ""}${penalty ? "; in the reflex violet band" : ""}`,
|
|
159
|
-
score: (nearest ? Math.min(nearest.d, 0.3) : 0.3) - penalty + o.c
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return out.sort((a, b) => b.score - a.score).map(({ score: _s, ...rest }) => rest);
|
|
164
|
-
}
|
|
165
|
-
function groundCandidates(input) {
|
|
166
|
-
const out = [];
|
|
167
|
-
for (const s of input.sources) {
|
|
168
|
-
for (const c of s.colours ?? []) {
|
|
169
|
-
const parsed = parseColour(c);
|
|
170
|
-
if (!parsed || parsed.oklch.l < 0.9) continue;
|
|
171
|
-
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" : ""}` });
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
return out;
|
|
175
|
-
}
|
|
176
|
-
function typeCandidates(input) {
|
|
177
|
-
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}` }));
|
|
178
|
-
}
|
|
179
|
-
function propose(input) {
|
|
180
|
-
const proposals = [
|
|
181
|
-
{ key: "accent", candidates: accentCandidates(input) },
|
|
182
|
-
{ key: "ground", candidates: groundCandidates(input) },
|
|
183
|
-
{ key: "display", candidates: typeCandidates(input) }
|
|
184
|
-
];
|
|
185
|
-
const choices = {};
|
|
186
|
-
const draft = (p) => {
|
|
187
|
-
const top = p.candidates[0];
|
|
188
|
-
if (!top || !top.value) return void 0;
|
|
189
|
-
const source = input.sources.find((s) => s.id === top.evidence[0]);
|
|
190
|
-
return {
|
|
191
|
-
value: top.value,
|
|
192
|
-
because: `${PROPOSED} taken from the ${source.id}: ${source.note} Confirm it matches, then rewrite this in your own words.`,
|
|
193
|
-
evidence: top.evidence
|
|
194
|
-
};
|
|
195
|
-
};
|
|
196
|
-
for (const p of proposals) {
|
|
197
|
-
const c = draft(p);
|
|
198
|
-
if (c) choices[p.key] = c;
|
|
199
|
-
}
|
|
200
|
-
return {
|
|
201
|
-
proposals,
|
|
202
|
-
direction: { version: DIRECTION_VERSION, client: input.client, brief: input.brief, sources: input.sources, choices }
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// src/direction/cli.ts
|
|
207
|
-
var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
|
208
|
-
async function snapshotFrom(target, cwd) {
|
|
209
|
-
if (!target) return void 0;
|
|
210
|
-
if (/^https?:\/\//.test(target)) {
|
|
211
|
-
const { snapshotUrl } = await import("./audit-BN2GSFKT.js");
|
|
212
|
-
return snapshotUrl(target);
|
|
213
|
-
}
|
|
214
|
-
return readSnapshot(readJson(resolve(cwd, target)), target);
|
|
215
|
-
}
|
|
216
|
-
function declaredFrom(path, cwd) {
|
|
217
|
-
if (!path || !path.endsWith(".json")) return [];
|
|
218
|
-
const register = readJson(resolve(cwd, path));
|
|
219
|
-
return register.sites ?? [];
|
|
220
|
-
}
|
|
221
|
-
var TEXT_SOURCE = /\.(?:txt|md|markdown|csv|json|html?)$/i;
|
|
222
|
-
function estateFrom(dir, cwd) {
|
|
223
|
-
if (!dir) return [];
|
|
224
|
-
const root = resolve(cwd, dir);
|
|
225
|
-
if (root.endsWith(".json")) {
|
|
226
|
-
const register = readJson(root);
|
|
227
|
-
return (register.sites ?? []).filter((s) => s.fingerprint).map((s) => ({ id: s.id, fingerprint: s.fingerprint }));
|
|
228
|
-
}
|
|
229
|
-
return readdirSync(root).filter((f) => f.endsWith(".json")).flatMap((f) => {
|
|
230
|
-
try {
|
|
231
|
-
const snap = readSnapshot(readJson(join(root, f)), f);
|
|
232
|
-
return snap.fonts ? [{ id: f.replace(/(\.snapshot)?\.json$/, ""), fingerprint: fingerprint(snap) }] : [];
|
|
233
|
-
} catch {
|
|
234
|
-
return [];
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
async function runDirection(args, io) {
|
|
239
|
-
try {
|
|
240
|
-
const flags = parseFlags(args);
|
|
241
|
-
if (typeof flags === "string") throw new Error(flags);
|
|
242
|
-
const [sub] = flags.positional;
|
|
243
|
-
const file = resolve(io.cwd, flags.direction ?? "art-direction.json");
|
|
244
|
-
if (sub === "research") {
|
|
245
|
-
const base = existsSync(file) ? readJson(file) : {};
|
|
246
|
-
const brief = flags.brief ?? base.brief;
|
|
247
|
-
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');
|
|
248
|
-
io.out(researchPrompt({ brief, client: flags.client ?? base.client, visual: flags.visual }));
|
|
249
|
-
return 0;
|
|
250
|
-
}
|
|
251
|
-
const page = flags.page ?? "home";
|
|
252
|
-
if (!PAGE_TYPES.includes(page)) throw new Error(`--page takes one of ${PAGE_TYPES.join(", ")}`);
|
|
253
|
-
const snapshot = await snapshotFrom(flags.snapshot, io.cwd);
|
|
254
|
-
const current = snapshot ? fingerprint(snapshot) : void 0;
|
|
255
|
-
if (sub === "init") {
|
|
256
|
-
const out = resolve(io.cwd, flags.out ?? "art-direction.json");
|
|
257
|
-
if (existsSync(out) && !flags.out) {
|
|
258
|
-
const existing = readJson(out);
|
|
259
|
-
if (existing.version !== void 0) throw new Error(`${out} already records choices. Pass --out to write elsewhere.`);
|
|
260
|
-
const next = initDirection({ client: flags.client, brief: flags.brief, current, exceptions: existing.exceptions });
|
|
261
|
-
writeFileSync(out, `${JSON.stringify(next, null, 2)}
|
|
262
|
-
`);
|
|
263
|
-
} else {
|
|
264
|
-
writeFileSync(out, `${JSON.stringify(initDirection({ client: flags.client, brief: flags.brief, current }), null, 2)}
|
|
265
|
-
`);
|
|
266
|
-
}
|
|
267
|
-
io.out(`craft direction: wrote ${out}. Add the sources and a reason for every choice, then run craft direction validate.`);
|
|
268
|
-
return 0;
|
|
269
|
-
}
|
|
270
|
-
if (sub === "validate") {
|
|
271
|
-
if (!existsSync(file)) throw new Error(`no ${file}. Run craft direction init.`);
|
|
272
|
-
const direction = readJson(file);
|
|
273
|
-
const estate = declaredFrom(flags.estate, io.cwd);
|
|
274
|
-
const report = validateDirection(direction, {
|
|
275
|
-
fingerprint: current,
|
|
276
|
-
snapshot,
|
|
277
|
-
page,
|
|
278
|
-
strict: flags.strict,
|
|
279
|
-
pathExists: (p) => existsSync(resolve(dirname(file), p)),
|
|
280
|
-
readSource: (s) => {
|
|
281
|
-
if (!s.path || /^https?:/.test(s.path) || !TEXT_SOURCE.test(s.path)) return void 0;
|
|
282
|
-
const path = resolve(dirname(file), s.path);
|
|
283
|
-
return existsSync(path) ? readFileSync(path, "utf8") : void 0;
|
|
284
|
-
},
|
|
285
|
-
estate,
|
|
286
|
-
estateId: estate.find((s) => s.client && s.client === direction.client)?.id
|
|
287
|
-
});
|
|
288
|
-
if (flags.json) io.out(toJson(report));
|
|
289
|
-
else {
|
|
290
|
-
for (const p of report.problems) io.out(` ${p.severity === "error" ? "error" : "warn "} ${p.at || "(file)"} ${p.message}`);
|
|
291
|
-
const l = report.layers;
|
|
292
|
-
const pages = l.hierarchy.map((h) => `${h.page} ${h.decided} of ${h.total}`).join(", ");
|
|
293
|
-
io.out(
|
|
294
|
-
`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." : ""}`
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
return report.valid ? 0 : 1;
|
|
298
|
-
}
|
|
299
|
-
if (sub === "propose") {
|
|
300
|
-
if (!existsSync(file)) throw new Error(`no ${file}. Run craft direction init and add the sources first.`);
|
|
301
|
-
const base = readJson(file);
|
|
302
|
-
const sources = (base.sources ?? []).map((s) => {
|
|
303
|
-
if (s.colours?.length || !s.path || !/\.png$/i.test(s.path)) return s;
|
|
304
|
-
const path = resolve(dirname(file), s.path);
|
|
305
|
-
if (!existsSync(path)) return s;
|
|
306
|
-
try {
|
|
307
|
-
const { rgba } = decodePng(readFileSync(path));
|
|
308
|
-
return { ...s, colours: paletteFromPixels(rgba).filter((c) => c.share >= 0.03).map((c) => c.hex) };
|
|
309
|
-
} catch (error) {
|
|
310
|
-
io.err(`craft direction: ${s.path}: ${error.message}`);
|
|
311
|
-
return s;
|
|
312
|
-
}
|
|
313
|
-
});
|
|
314
|
-
const declared = declaredFrom(flags.estate, io.cwd).flatMap((s) => s.direction?.accent && s.client !== base.client ? [{ id: s.id, accent: s.direction.accent }] : []);
|
|
315
|
-
const result = propose({ client: base.client, brief: base.brief, sources, current, estate: estateFrom(flags.estate, io.cwd), declared });
|
|
316
|
-
const merged = { ...base, sources, choices: { ...result.direction.choices, ...stripUndecided(base.choices) }, ...base.exceptions ? { exceptions: base.exceptions } : {} };
|
|
317
|
-
const text = `${JSON.stringify(merged, null, 2)}
|
|
318
|
-
`;
|
|
319
|
-
if (flags.out) writeFileSync(resolve(io.cwd, flags.out), text);
|
|
320
|
-
else io.out(text);
|
|
321
|
-
for (const p of result.proposals) {
|
|
322
|
-
io.err(`${p.key}: ${p.candidates.length ? p.candidates.map((c) => `${c.value || "?"} (${c.note})`).join("; ") : "nothing in the sources to draw on yet"}`);
|
|
323
|
-
}
|
|
324
|
-
return 0;
|
|
325
|
-
}
|
|
326
|
-
throw new Error("usage: craft direction init | validate | propose | research");
|
|
327
|
-
} catch (error) {
|
|
328
|
-
io.err(`craft: ${error.message}`);
|
|
329
|
-
return 2;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
function stripUndecided(choices = {}) {
|
|
333
|
-
return Object.fromEntries(Object.entries(choices).filter(([, c]) => c && c.because && c.because.trim().length > 0));
|
|
334
|
-
}
|
|
335
|
-
export {
|
|
336
|
-
runDirection
|
|
337
|
-
};
|