pageship 0.3.0 → 0.4.1

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.
Files changed (32) hide show
  1. package/dist/cli.mjs +56 -9
  2. package/dist/skills/pageship/SKILL.md +2 -2
  3. package/dist/skills/pageship-chart/SKILL.md +18 -2
  4. package/dist/skills/pageship-code-block/SKILL.md +1 -1
  5. package/dist/skills/pageship-decision-record/SKILL.md +5 -1
  6. package/dist/skills/pageship-diagram/SKILL.md +16 -14
  7. package/dist/skills/pageship-diagram/references/check-diagram.js +3 -2
  8. package/dist/skills/pageship-docs/SKILL.md +32 -8
  9. package/dist/skills/pageship-docs/references/axe.js +34 -0
  10. package/dist/skills/pageship-docs/references/check.md +35 -8
  11. package/dist/skills/pageship-docs/references/design.md +38 -18
  12. package/dist/skills/pageship-docs/references/html-validate-rules.mjs +326 -0
  13. package/dist/skills/pageship-docs/references/html-validate.json +13 -0
  14. package/dist/skills/pageship-docs/references/palette.mjs +791 -0
  15. package/dist/skills/pageship-docs/references/plain-default.md +1 -1
  16. package/dist/skills/pageship-explainer/SKILL.md +13 -5
  17. package/dist/skills/pageship-interaction/SKILL.md +7 -5
  18. package/dist/skills/pageship-interaction/references/pointer.md +3 -0
  19. package/dist/skills/pageship-interaction/references/step-through.md +10 -25
  20. package/dist/skills/pageship-key-figures/SKILL.md +1 -1
  21. package/dist/skills/pageship-media/SKILL.md +2 -2
  22. package/dist/skills/pageship-motion/SKILL.md +31 -28
  23. package/dist/skills/pageship-motion/references/figure-motion.js +252 -0
  24. package/dist/skills/pageship-motion/references/figure-motion.md +294 -0
  25. package/dist/skills/pageship-plan/SKILL.md +3 -3
  26. package/dist/skills/pageship-report/SKILL.md +5 -1
  27. package/dist/skills/pageship-review-report/SKILL.md +5 -1
  28. package/dist/skills/pageship-slide-deck/SKILL.md +5 -3
  29. package/dist/skills/pageship-slide-deck/references/engine.md +1 -1
  30. package/dist/skills/pageship-spec/SKILL.md +5 -1
  31. package/dist/skills/pageship-table/SKILL.md +5 -3
  32. package/package.json +1 -1
@@ -0,0 +1,791 @@
1
+ // Generated from scripts/palette/ by `pnpm run build:palette`. Edit those files, not this one.
2
+ // Writes a document's colour variables for light and dark, or checks a file's. See design.md.
3
+ import { readFileSync } from "node:fs";
4
+ import { parseArgs } from "node:util";
5
+ //#region evals/look.ts
6
+ /**
7
+ * The colour variables a source file defines for light and dark, as the router's design rules
8
+ * (`pageship-docs/references/design.md`) list them. Every pattern skill reads only these.
9
+ */
10
+ const lookVariables = [
11
+ "--bg",
12
+ "--fg",
13
+ "--muted",
14
+ "--line",
15
+ "--surface",
16
+ "--accent",
17
+ "--accent-2",
18
+ "--series-1",
19
+ "--series-2",
20
+ "--series-3",
21
+ "--series-4",
22
+ "--good",
23
+ "--warn",
24
+ "--bad"
25
+ ];
26
+ const themes = ["light", "dark"];
27
+ /**
28
+ * Every style rule in a stylesheet with its declarations, nested rules flattened and the
29
+ * `@media` rules around each one kept. `@font-face`, `@keyframes`, `@page` and `@property`
30
+ * blocks hold no style rules, so they are skipped.
31
+ */
32
+ function cssRules(css) {
33
+ const text = css.replace(/\/\*[\s\S]*?\*\//gu, "");
34
+ const rules = [];
35
+ let at = 0;
36
+ /** Reads up to the next `;`, `{` or `}` at this nesting level, skipping strings and parentheses. */
37
+ const readPrelude = () => {
38
+ const start = at;
39
+ let depth = 0;
40
+ while (at < text.length) {
41
+ const char = text[at];
42
+ if (char === "\"" || char === "'") {
43
+ const end = text.indexOf(char, at + 1);
44
+ at = end === -1 ? text.length : end + 1;
45
+ continue;
46
+ }
47
+ if (char === "(") depth += 1;
48
+ else if (char === ")") depth = Math.max(0, depth - 1);
49
+ else if (depth === 0 && (char === ";" || char === "{" || char === "}")) break;
50
+ at += 1;
51
+ }
52
+ return text.slice(start, at).trim();
53
+ };
54
+ const skipBlock = () => {
55
+ let depth = 1;
56
+ while (at < text.length && depth > 0) {
57
+ const char = text[at];
58
+ if (char === "{") depth += 1;
59
+ else if (char === "}") depth -= 1;
60
+ at += 1;
61
+ }
62
+ };
63
+ const parseBlock = (media, parent) => {
64
+ const rule = parent ? {
65
+ selector: parent,
66
+ declarations: [],
67
+ media
68
+ } : void 0;
69
+ while (at < text.length) {
70
+ const prelude = readPrelude();
71
+ const char = text[at];
72
+ at += 1;
73
+ if (char === ";" || char === "}" || char === void 0) {
74
+ const colon = prelude.indexOf(":");
75
+ if (rule && colon > 0) rule.declarations.push({
76
+ property: prelude.slice(0, colon).trim().toLowerCase(),
77
+ value: prelude.slice(colon + 1).trim()
78
+ });
79
+ if (char === ";") continue;
80
+ break;
81
+ }
82
+ if (prelude.startsWith("@")) {
83
+ const name = /^@([\w-]+)/u.exec(prelude)?.[1]?.toLowerCase();
84
+ if ([
85
+ "media",
86
+ "supports",
87
+ "layer",
88
+ "container",
89
+ "scope"
90
+ ].includes(name ?? "")) parseBlock(name === "media" ? [...media, prelude.slice(6).trim()] : media, parent);
91
+ else skipBlock();
92
+ continue;
93
+ }
94
+ const selector = parent ? prelude.includes("&") ? prelude.replaceAll("&", parent) : `${parent} ${prelude}` : prelude;
95
+ parseBlock(media, selector);
96
+ }
97
+ if (rule) rules.push(rule);
98
+ };
99
+ parseBlock([], void 0);
100
+ return rules;
101
+ }
102
+ /** The `<style>` elements of a page, joined, with the `style` attributes as one-off rules. */
103
+ function stylesheets(markup) {
104
+ const blocks = [...markup.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/giu)].map((match) => match[1]);
105
+ const inline = [...markup.matchAll(/\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/giu)].map((match, index) => `[data-inline-style-${index}] { ${match[1] ?? match[2]} }`);
106
+ return [...blocks, ...inline].join("\n");
107
+ }
108
+ const colourScheme = /^(?:(?:screen|all)\s+and\s+)?\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)$/u;
109
+ /**
110
+ * Which theme a rule's `@media` wrappers select: both when none of them picks one. A condition
111
+ * on the colour scheme that is more than the plain query, such as one with `not`, is reported
112
+ * rather than guessed.
113
+ */
114
+ function themeOf(rule) {
115
+ let theme = "both";
116
+ for (const prelude of rule.media) {
117
+ const text = prelude.trim().toLowerCase();
118
+ if (/\bprint\b/u.test(text) && !/\bnot\b/u.test(text)) return { theme: "print" };
119
+ if (!text.includes("prefers-color-scheme")) continue;
120
+ const match = colourScheme.exec(text);
121
+ if (!match) return {
122
+ theme,
123
+ unsupported: prelude.trim()
124
+ };
125
+ theme = match[1];
126
+ }
127
+ return { theme };
128
+ }
129
+ const isRootSelector = (selector) => selector.split(",").some((part) => /^(?::root|html|body|:where\(:root\))$/u.test(part.trim()));
130
+ /** Splits `text` at the commas outside any parentheses or quotes. */
131
+ function splitArguments(text) {
132
+ const parts = [];
133
+ let depth = 0;
134
+ let start = 0;
135
+ let quote;
136
+ for (let at = 0; at < text.length; at += 1) {
137
+ const char = text[at];
138
+ if (quote) {
139
+ if (char === quote) quote = void 0;
140
+ } else if (char === "\"" || char === "'") quote = char;
141
+ else if (char === "(") depth += 1;
142
+ else if (char === ")") depth -= 1;
143
+ else if (char === "," && depth === 0) {
144
+ parts.push(text.slice(start, at).trim());
145
+ start = at + 1;
146
+ }
147
+ }
148
+ parts.push(text.slice(start).trim());
149
+ return parts;
150
+ }
151
+ /** The arguments of `name(...)` when the whole value is that one call, else undefined. */
152
+ function callArguments(value, name) {
153
+ const text = value.trim();
154
+ if (!text.toLowerCase().startsWith(`${name}(`) || !text.endsWith(")")) return void 0;
155
+ let depth = 0;
156
+ for (let at = name.length; at < text.length; at += 1) if (text[at] === "(") depth += 1;
157
+ else if (text[at] === ")") {
158
+ depth -= 1;
159
+ if (depth === 0 && at !== text.length - 1) return void 0;
160
+ }
161
+ return splitArguments(text.slice(name.length + 1, -1));
162
+ }
163
+ const stripImportant = (value) => value.replace(/\s*!\s*important\s*$/iu, "").trim();
164
+ /**
165
+ * Every custom property the file's root rules set, by theme. A plain root rule sets both themes,
166
+ * so a dark value may point at a variable it sets, and a plain rule after the dark block
167
+ * overrides both. Only a dark rule or a `light-dark()` value counts as setting dark.
168
+ */
169
+ function lookDefinitions(markup) {
170
+ const values = {
171
+ light: /* @__PURE__ */ new Map(),
172
+ dark: /* @__PURE__ */ new Map()
173
+ };
174
+ const darkDeclared = /* @__PURE__ */ new Set();
175
+ const unsupported = /* @__PURE__ */ new Set();
176
+ for (const rule of cssRules(stylesheets(markup))) {
177
+ if (!isRootSelector(rule.selector)) continue;
178
+ const media = themeOf(rule);
179
+ if (media.unsupported) {
180
+ unsupported.add(media.unsupported);
181
+ continue;
182
+ }
183
+ if (media.theme === "print") continue;
184
+ for (const declaration of rule.declarations) {
185
+ const { property } = declaration;
186
+ const value = stripImportant(declaration.value);
187
+ if (!property.startsWith("--") || value === "") continue;
188
+ const both = callArguments(value, "light-dark");
189
+ const [light, dark] = both?.length === 2 ? both : [value, value];
190
+ if (media.theme !== "dark") values.light.set(property, light);
191
+ if (media.theme !== "light") values.dark.set(property, dark);
192
+ if (media.theme === "dark" || both?.length === 2) darkDeclared.add(property);
193
+ }
194
+ }
195
+ return {
196
+ values,
197
+ darkDeclared,
198
+ unsupported
199
+ };
200
+ }
201
+ const list = (names) => names.length <= 1 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
202
+ const namedColours = new Map("aliceblue f0f8ff antiquewhite faebd7 aqua 00ffff aquamarine 7fffd4 azure f0ffff beige f5f5dc bisque ffe4c4 black 000000 blanchedalmond ffebcd blue 0000ff blueviolet 8a2be2 brown a52a2a burlywood deb887 cadetblue 5f9ea0 chartreuse 7fff00 chocolate d2691e coral ff7f50 cornflowerblue 6495ed cornsilk fff8dc crimson dc143c cyan 00ffff darkblue 00008b darkcyan 008b8b darkgoldenrod b8860b darkgray a9a9a9 darkgreen 006400 darkgrey a9a9a9 darkkhaki bdb76b darkmagenta 8b008b darkolivegreen 556b2f darkorange ff8c00 darkorchid 9932cc darkred 8b0000 darksalmon e9967a darkseagreen 8fbc8f darkslateblue 483d8b darkslategray 2f4f4f darkslategrey 2f4f4f darkturquoise 00ced1 darkviolet 9400d3 deeppink ff1493 deepskyblue 00bfff dimgray 696969 dimgrey 696969 dodgerblue 1e90ff firebrick b22222 floralwhite fffaf0 forestgreen 228b22 fuchsia ff00ff gainsboro dcdcdc ghostwhite f8f8ff gold ffd700 goldenrod daa520 gray 808080 green 008000 greenyellow adff2f grey 808080 honeydew f0fff0 hotpink ff69b4 indianred cd5c5c indigo 4b0082 ivory fffff0 khaki f0e68c lavender e6e6fa lavenderblush fff0f5 lawngreen 7cfc00 lemonchiffon fffacd lightblue add8e6 lightcoral f08080 lightcyan e0ffff lightgoldenrodyellow fafad2 lightgray d3d3d3 lightgreen 90ee90 lightgrey d3d3d3 lightpink ffb6c1 lightsalmon ffa07a lightseagreen 20b2aa lightskyblue 87cefa lightslategray 778899 lightslategrey 778899 lightsteelblue b0c4de lightyellow ffffe0 lime 00ff00 limegreen 32cd32 linen faf0e6 magenta ff00ff maroon 800000 mediumaquamarine 66cdaa mediumblue 0000cd mediumorchid ba55d3 mediumpurple 9370db mediumseagreen 3cb371 mediumslateblue 7b68ee mediumspringgreen 00fa9a mediumturquoise 48d1cc mediumvioletred c71585 midnightblue 191970 mintcream f5fffa mistyrose ffe4e1 moccasin ffe4b5 navajowhite ffdead navy 000080 oldlace fdf5e6 olive 808000 olivedrab 6b8e23 orange ffa500 orangered ff4500 orchid da70d6 palegoldenrod eee8aa palegreen 98fb98 paleturquoise afeeee palevioletred db7093 papayawhip ffefd5 peachpuff ffdab9 peru cd853f pink ffc0cb plum dda0dd powderblue b0e0e6 purple 800080 rebeccapurple 663399 red ff0000 rosybrown bc8f8f royalblue 4169e1 saddlebrown 8b4513 salmon fa8072 sandybrown f4a460 seagreen 2e8b57 seashell fff5ee sienna a0522d silver c0c0c0 skyblue 87ceeb slateblue 6a5acd slategray 708090 slategrey 708090 snow fffafa springgreen 00ff7f steelblue 4682b4 tan d2b48c teal 008080 thistle d8bfd8 tomato ff6347 turquoise 40e0d0 violet ee82ee wheat f5deb3 white ffffff whitesmoke f5f5f5 yellow ffff00 yellowgreen 9acd32".split(" ").flatMap((word, index, words) => index % 2 === 0 ? [[word, words[index + 1]]] : []));
203
+ /** A channel as a number from 0 to 1 at `scale`; undefined when it isn't a finite number. */
204
+ const channel = (text, scale) => {
205
+ const trimmed = text.trim();
206
+ if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?%?$/iu.test(trimmed)) return void 0;
207
+ const value = Number.parseFloat(trimmed);
208
+ return trimmed.endsWith("%") ? value / 100 : value / scale;
209
+ };
210
+ /** An OKLab a or b, or an OKLCH chroma: a bare number as it is, and 100% as 0.4. */
211
+ const okChannel = (text) => {
212
+ const value = channel(text, 1);
213
+ return value !== void 0 && text.trim().endsWith("%") ? value * .4 : value;
214
+ };
215
+ /** A hue in degrees from a bare number or a `deg` value; other units are not read. */
216
+ const hue = (text) => {
217
+ const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(deg)?$/iu.exec(text.trim());
218
+ if (!match) return void 0;
219
+ return (Number.parseFloat(match[1]) % 360 + 360) % 360;
220
+ };
221
+ /** Whether an alpha argument makes the colour less than fully opaque. */
222
+ const translucent = (alpha) => alpha !== void 0 && channel(alpha, 1) !== 1;
223
+ const clamp$1 = (value) => Math.min(1, Math.max(0, value));
224
+ function hslToRgb(degrees, saturation, lightness) {
225
+ const part = (n) => {
226
+ const k = (n + degrees / 30) % 12;
227
+ return lightness - saturation * Math.min(lightness, 1 - lightness) * Math.max(-1, Math.min(k - 3, 9 - k, 1));
228
+ };
229
+ return [
230
+ part(0),
231
+ part(8),
232
+ part(4)
233
+ ];
234
+ }
235
+ function oklabToRgb(lightness, a, b) {
236
+ const l = (lightness + .3963377774 * a + .2158037573 * b) ** 3;
237
+ const m = (lightness - .1055613458 * a - .0638541728 * b) ** 3;
238
+ const s = (lightness - .0894841775 * a - 1.291485548 * b) ** 3;
239
+ return [
240
+ 4.0767416621 * l - 3.3077115913 * m + .2309699292 * s,
241
+ -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s,
242
+ -.0041960863 * l - .7034186147 * m + 1.707614701 * s
243
+ ].map((value) => {
244
+ const c = clamp$1(value);
245
+ return c <= .0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - .055;
246
+ });
247
+ }
248
+ /**
249
+ * An opaque CSS colour as sRGB channels from 0 to 1: hex, named, `rgb()`, `hsl()`, `oklab()`
250
+ * and `oklch()`, with hues as bare numbers or degrees. Anything else, such as `color-mix()`,
251
+ * a translucent colour or a `turn` hue, is unreadable here and returns undefined.
252
+ */
253
+ function parseColour(text) {
254
+ const value = stripImportant(text).toLowerCase();
255
+ const hex = /^#([0-9a-f]{3,8})$/u.exec(value)?.[1] ?? namedColours.get(value);
256
+ if (hex) {
257
+ const digits = hex.length <= 4 ? [...hex].map((digit) => digit + digit).join("") : hex;
258
+ if (![6, 8].includes(digits.length)) return void 0;
259
+ if (digits.length === 8 && digits.slice(6) !== "ff") return void 0;
260
+ return [
261
+ 0,
262
+ 2,
263
+ 4
264
+ ].map((at) => Number.parseInt(digits.slice(at, at + 2), 16) / 255);
265
+ }
266
+ const name = /^(rgba?|hsla?|oklab|oklch)\(/u.exec(value)?.[1];
267
+ const parts = name ? callArguments(value, name) : void 0;
268
+ if (!name || !parts) return void 0;
269
+ const [channels, slashAlpha] = parts.length === 1 ? parts[0].split("/") : [parts.join(" ")];
270
+ const words = channels.trim().split(/\s+/u);
271
+ const alpha = slashAlpha ?? (parts.length === 4 ? words.pop() : void 0);
272
+ if (words.length !== 3 || translucent(alpha)) return void 0;
273
+ const [first, second, third] = words;
274
+ const read = (values) => values.every((part) => part !== void 0 && Number.isFinite(part)) ? values.map(clamp$1) : void 0;
275
+ switch (name) {
276
+ case "rgb":
277
+ case "rgba": return read([
278
+ channel(first, 255),
279
+ channel(second, 255),
280
+ channel(third, 255)
281
+ ]);
282
+ case "hsl":
283
+ case "hsla": {
284
+ const [h, s, l] = [
285
+ hue(first),
286
+ channel(second, 1),
287
+ channel(third, 1)
288
+ ];
289
+ return h === void 0 || s === void 0 || l === void 0 ? void 0 : read(hslToRgb(h, s, l));
290
+ }
291
+ case "oklab": {
292
+ const [l, a, b] = [
293
+ channel(first, 1),
294
+ okChannel(second),
295
+ okChannel(third)
296
+ ];
297
+ return l === void 0 || a === void 0 || b === void 0 ? void 0 : read(oklabToRgb(l, a, b));
298
+ }
299
+ case "oklch": {
300
+ const [l, c, h] = [
301
+ channel(first, 1),
302
+ okChannel(second),
303
+ hue(third)
304
+ ];
305
+ if (l === void 0 || c === void 0 || h === void 0) return void 0;
306
+ const radians = h * Math.PI / 180;
307
+ return read(oklabToRgb(l, c * Math.cos(radians), c * Math.sin(radians)));
308
+ }
309
+ default: return;
310
+ }
311
+ }
312
+ /** sRGB channels from 0 to 1 without the gamma curve. */
313
+ const toLinear$1 = (rgb) => rgb.map((part) => part <= .04045 ? part / 12.92 : ((part + .055) / 1.055) ** 2.4);
314
+ /** WCAG relative luminance of sRGB channels from 0 to 1. */
315
+ const luminance = (rgb) => {
316
+ const [r, g, b] = toLinear$1(rgb);
317
+ return .2126 * r + .7152 * g + .0722 * b;
318
+ };
319
+ /** The WCAG contrast ratio between two colours, from 1 to 21. */
320
+ function contrastRatio(a, b) {
321
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
322
+ return (light + .05) / (dark + .05);
323
+ }
324
+ /**
325
+ * The contrast each variable needs against `--bg`: text, headings and links included, reads at
326
+ * 4.5:1, marks at 3:1.
327
+ */
328
+ const floorOf = (name) => name === "--fg" || name === "--muted" || name === "--accent" || name === "--accent-2" ? 4.5 : [
329
+ "--bg",
330
+ "--line",
331
+ "--surface"
332
+ ].includes(name) ? 0 : 3;
333
+ /** A variable's value with `var()` references to the theme's variables followed. */
334
+ function resolve(values, value, depth = 0) {
335
+ const reference = callArguments(value, "var");
336
+ if (!reference || depth > 8) return stripImportant(value);
337
+ const [name, ...fallback] = reference;
338
+ const next = values.get(name.trim()) ?? (fallback.length > 0 ? fallback.join(",") : void 0);
339
+ return next === void 0 ? stripImportant(value) : resolve(values, next, depth + 1);
340
+ }
341
+ /**
342
+ * Rules for the look's colour variables: set for both themes and in contrast with the background.
343
+ * See `evals/README.md`.
344
+ */
345
+ function* lookFindings(markup) {
346
+ const defined = lookDefinitions(markup);
347
+ for (const condition of defined.unsupported) yield {
348
+ check: "palette",
349
+ message: `The media condition "${condition}" is not one this check reads; use (prefers-color-scheme: dark)`
350
+ };
351
+ for (const theme of themes) {
352
+ const declared = (name) => theme === "light" ? defined.values.light.has(name) : defined.darkDeclared.has(name);
353
+ const missing = lookVariables.filter((name) => !declared(name));
354
+ if (missing.length === lookVariables.length) yield {
355
+ check: "palette",
356
+ message: `No look variable is set for ${theme}`
357
+ };
358
+ else if (missing.length > 0) yield {
359
+ check: "palette",
360
+ message: `${list(missing)} ${missing.length === 1 ? "is" : "are"} not set for ${theme}`
361
+ };
362
+ yield* contrastFindings(theme, defined.values[theme], declared);
363
+ }
364
+ }
365
+ function* contrastFindings(theme, values, declared) {
366
+ const unreadable = (name, value) => ({
367
+ check: "contrast",
368
+ message: `In ${theme}, ${name} is ${value}, which this check can't read as an opaque colour`
369
+ });
370
+ const backgroundValue = values.get("--bg");
371
+ if (backgroundValue === void 0 || !declared("--bg")) return;
372
+ const background = parseColour(resolve(values, backgroundValue));
373
+ if (!background) {
374
+ yield unreadable("--bg", backgroundValue);
375
+ return;
376
+ }
377
+ for (const name of lookVariables) {
378
+ const floor = floorOf(name);
379
+ const value = values.get(name);
380
+ if (floor === 0 || value === void 0 || !declared(name)) continue;
381
+ const colour = parseColour(resolve(values, value));
382
+ if (!colour) {
383
+ yield unreadable(name, value);
384
+ continue;
385
+ }
386
+ const ratio = contrastRatio(colour, background);
387
+ if (ratio < floor) yield {
388
+ check: "contrast",
389
+ message: `In ${theme}, ${name} (${value}) is ${ratio.toFixed(2)}:1 against the background; the floor is ${floor}:1`
390
+ };
391
+ }
392
+ }
393
+ //#endregion
394
+ //#region scripts/palette/palette.ts
395
+ /**
396
+ * The palette calculator behind `skills/pageship-docs/references/palette.mjs`: from a seed colour
397
+ * and a colour system, it writes the design rules' fourteen colour variables for light and dark.
398
+ * Every colour keeps its OKLCH hue, takes the lightness that meets its floor against `--bg`, and
399
+ * gives up chroma until it fits sRGB. See docs/adr/0029-generate-document-colours-with-a-palette-calculator.md.
400
+ */
401
+ /** Hue offsets from the seed, for colour systems one gap between hues can't make. */
402
+ const presets = {
403
+ "split-complementary": [
404
+ 0,
405
+ 150,
406
+ 210
407
+ ],
408
+ rectangle: [
409
+ 0,
410
+ 60,
411
+ 180,
412
+ 240
413
+ ]
414
+ };
415
+ /** How much of the seed's hue the neutrals carry, as OKLCH chroma. */
416
+ const tints = {
417
+ none: 0,
418
+ subtle: .01,
419
+ strong: .022
420
+ };
421
+ const toLinear = (part) => part <= .04045 ? part / 12.92 : ((part + .055) / 1.055) ** 2.4;
422
+ const toGamma = (part) => part <= .0031308 ? 12.92 * part : 1.055 * part ** (1 / 2.4) - .055;
423
+ const clamp = (part) => Math.min(1, Math.max(0, part));
424
+ const radians = (degrees) => degrees * Math.PI / 180;
425
+ const wrap = (degrees) => (degrees % 360 + 360) % 360;
426
+ function linearToOklab([r, g, b]) {
427
+ const l = Math.cbrt(.4122214708 * r + .5363325363 * g + .0514459929 * b);
428
+ const m = Math.cbrt(.2119034982 * r + .6806995451 * g + .1073969566 * b);
429
+ const s = Math.cbrt(.0883024619 * r + .2817188376 * g + .6299787005 * b);
430
+ return [
431
+ .2104542553 * l + .793617785 * m - .0040720468 * s,
432
+ 1.9779984951 * l - 2.428592205 * m + .4505937099 * s,
433
+ .0259040371 * l + .7827717662 * m - .808675766 * s
434
+ ];
435
+ }
436
+ function oklchToLinear({ l: lightness, c, h }) {
437
+ const [a, b] = [c * Math.cos(radians(h)), c * Math.sin(radians(h))];
438
+ const l = (lightness + .3963377774 * a + .2158037573 * b) ** 3;
439
+ const m = (lightness - .1055613458 * a - .0638541728 * b) ** 3;
440
+ const s = (lightness - .0894841775 * a - 1.291485548 * b) ** 3;
441
+ return [
442
+ 4.0767416621 * l - 3.3077115913 * m + .2309699292 * s,
443
+ -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s,
444
+ -.0041960863 * l - .7034186147 * m + 1.707614701 * s
445
+ ];
446
+ }
447
+ const inGamut = (colour) => oklchToLinear(colour).every((part) => part >= -1e-4 && part <= 1.0001);
448
+ /** The colour with its lightness and hue kept and its chroma lowered until it fits sRGB. */
449
+ function fitGamut({ l, c, h }) {
450
+ const colour = {
451
+ l: clamp(l),
452
+ c,
453
+ h
454
+ };
455
+ if (inGamut(colour)) return colour;
456
+ let [low, high] = [0, colour.c];
457
+ for (let step = 0; step < 24; step += 1) {
458
+ const middle = (low + high) / 2;
459
+ if (inGamut({
460
+ ...colour,
461
+ c: middle
462
+ })) low = middle;
463
+ else high = middle;
464
+ }
465
+ return {
466
+ ...colour,
467
+ c: low
468
+ };
469
+ }
470
+ const gamma = (colour) => oklchToLinear(colour).map((part) => toGamma(clamp(part)));
471
+ const hex = (colour) => `#${gamma(colour).map((part) => Math.round(clamp(part) * 255).toString(16).padStart(2, "0")).join("")}`;
472
+ /** The WCAG contrast of two colours as they render, after rounding to hex. */
473
+ const contrast = (a, b) => contrastRatio(parseColour(hex(a)), parseColour(hex(b)));
474
+ /**
475
+ * The contrast each colour is solved to against `--bg`: the design rules' floor, with headroom
476
+ * for rounding.
477
+ */
478
+ const target = (name) => [
479
+ "--fg",
480
+ "--muted",
481
+ "--accent",
482
+ "--accent-2"
483
+ ].includes(name) ? 4.6 : 3.1;
484
+ /**
485
+ * The colour at its preferred lightness when that meets `floor` against `bg`; otherwise the
486
+ * lightness nearest to it that does, found by moving away from the page.
487
+ */
488
+ function solveLightness(colour, bg, floor, theme) {
489
+ const at = (l) => fitGamut({
490
+ ...colour,
491
+ l
492
+ });
493
+ if (contrast(at(colour.l), bg) >= floor) return at(colour.l);
494
+ let [low, high] = [colour.l, theme === "dark" ? 1 : 0];
495
+ for (let step = 0; step < 30; step += 1) {
496
+ const middle = (low + high) / 2;
497
+ if (contrast(at(middle), bg) >= floor) high = middle;
498
+ else low = middle;
499
+ }
500
+ return at(high);
501
+ }
502
+ const machado = {
503
+ protanopia: [
504
+ [
505
+ .152286,
506
+ 1.052583,
507
+ -.204868
508
+ ],
509
+ [
510
+ .114503,
511
+ .786281,
512
+ .099216
513
+ ],
514
+ [
515
+ -.003882,
516
+ -.048116,
517
+ 1.051998
518
+ ]
519
+ ],
520
+ deuteranopia: [
521
+ [
522
+ .367322,
523
+ .860646,
524
+ -.227968
525
+ ],
526
+ [
527
+ .280085,
528
+ .672501,
529
+ .047413
530
+ ],
531
+ [
532
+ -.01182,
533
+ .04294,
534
+ .968881
535
+ ]
536
+ ]
537
+ };
538
+ /** The colour as rendered, after rounding to hex, seen with the deficiency, in OKLab. */
539
+ const simulate = (colour, deficiency) => {
540
+ const rgb = parseColour(hex(colour)).map(toLinear);
541
+ return linearToOklab(machado[deficiency].map((row) => row.reduce((sum, weight, at) => sum + weight * rgb[at], 0)).map(clamp));
542
+ };
543
+ /** The smallest OKLab ΔE, scaled so 1 is 0.01, between two colours under either deficiency. */
544
+ function colourBlindDistance(a, b) {
545
+ return Math.min(...Object.keys(machado).map((deficiency) => {
546
+ const [x, y] = [simulate(a, deficiency), simulate(b, deficiency)];
547
+ return 100 * Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]);
548
+ }));
549
+ }
550
+ /** The hue gap in degrees, from 0 to 180. */
551
+ const hueDistance = (a, b) => {
552
+ const gap = Math.abs(a - b) % 360;
553
+ return gap > 180 ? 360 - gap : gap;
554
+ };
555
+ /** Hues closer than this read as one. */
556
+ const sameHue = 15;
557
+ const statusHues = {
558
+ "--good": 150,
559
+ "--warn": 70,
560
+ "--bad": 27
561
+ };
562
+ /** Yellow can't reach 4.5:1 on a light page without turning brown, so --accent-2 skips it. */
563
+ const yellow = (h) => h > 65 && h < 115;
564
+ const orange = 55;
565
+ /** Yellow-green turns olive at 3:1 on a light page, so series hues skip it. */
566
+ const olive = (h) => h > 85 && h < 120;
567
+ /** The hues of the colour system, from the seed's: the accents' first, then the series'. */
568
+ function systemHues(seedHue, input) {
569
+ const hues = ("preset" in input ? presets[input.preset] : Array.from({ length: input.colours }, (_, k) => k * (input.gap ?? 360 / input.colours))).map((offset) => wrap(seedHue + offset));
570
+ if (hues.length > 1 && yellow(hues[1])) hues[1] = orange;
571
+ return hues;
572
+ }
573
+ const seriesNames = [
574
+ "--series-1",
575
+ "--series-2",
576
+ "--series-3",
577
+ "--series-4"
578
+ ];
579
+ /** How far a series colour may move from its preferred lightness, away from the page, to stand apart. */
580
+ const seriesSteps = [
581
+ 0,
582
+ .1,
583
+ .2
584
+ ];
585
+ /**
586
+ * The seed in OKLCH. An `oklch()` seed is read as written, since it may lie outside sRGB and
587
+ * clipping it there would turn its hue; its alpha, if any, is ignored. Other formats go through
588
+ * `parseColour`.
589
+ */
590
+ function readSeed(text) {
591
+ const number = String.raw`[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?`;
592
+ const written = new RegExp(String.raw`^oklch\(\s*(${number})(%?)\s+(${number})(%?)\s+(${number})(?:deg)?\s*(?:\/[^)]*)?\)$`, "iu").exec(text.trim());
593
+ if (written) {
594
+ const [, l, lPercent, c, cPercent, h] = written;
595
+ return {
596
+ l: Number(l) / (lPercent ? 100 : 1),
597
+ c: cPercent ? Number(c) / 100 * .4 : Number(c),
598
+ h: wrap(Number(h))
599
+ };
600
+ }
601
+ const rgb = parseColour(text);
602
+ if (!rgb) throw new Error(`Could not read the seed colour ${text}`);
603
+ const [l, a, b] = linearToOklab(rgb.map(toLinear));
604
+ return {
605
+ l,
606
+ c: Math.hypot(a, b),
607
+ h: wrap(Math.atan2(b, a) * 180 / Math.PI)
608
+ };
609
+ }
610
+ function palette(input) {
611
+ const seed = readSeed(input.seed);
612
+ const hues = systemHues(seed.h, input);
613
+ const tint = tints[input.tint ?? "subtle"];
614
+ const accentChroma = Math.max(.1, seed.c);
615
+ const neutral = (l, c = tint) => fitGamut({
616
+ l,
617
+ c,
618
+ h: seed.h
619
+ });
620
+ const base = Object.fromEntries(themes.map((theme) => {
621
+ const dark = theme === "dark";
622
+ const bg = neutral(dark ? .17 : .985);
623
+ const solve = (name, colour) => solveLightness(fitGamut(colour), bg, target(name), theme);
624
+ const accent = solve("--accent", {
625
+ l: dark ? Math.min(Math.max(seed.l, .7), .85) : Math.min(Math.max(seed.l, .4), .6),
626
+ c: accentChroma,
627
+ h: hues[0]
628
+ });
629
+ const accent2 = hues.length > 1 && hueDistance(hues[0], hues[1]) >= sameHue ? solve("--accent-2", {
630
+ l: accent.l,
631
+ c: accentChroma,
632
+ h: hues[1]
633
+ }) : solve("--accent-2", {
634
+ l: accent.l + (dark ? .14 : -.14),
635
+ c: accentChroma * .8,
636
+ h: hues[0]
637
+ });
638
+ return [theme, {
639
+ "--bg": bg,
640
+ "--fg": neutral(dark ? .94 : .22, tint * 1.5),
641
+ "--muted": solve("--muted", neutral(dark ? .62 : .56)),
642
+ "--line": neutral(dark ? .3 : .905),
643
+ "--surface": neutral(dark ? .215 : .955),
644
+ "--accent": accent,
645
+ "--accent-2": accent2,
646
+ "--good": solve("--good", {
647
+ l: dark ? .72 : .6,
648
+ c: .16,
649
+ h: statusHues["--good"]
650
+ }),
651
+ "--warn": solve("--warn", {
652
+ l: dark ? .8 : .7,
653
+ c: .16,
654
+ h: statusHues["--warn"]
655
+ }),
656
+ "--bad": solve("--bad", {
657
+ l: dark ? .68 : .58,
658
+ c: .19,
659
+ h: statusHues["--bad"]
660
+ })
661
+ }];
662
+ }));
663
+ const cache = /* @__PURE__ */ new Map();
664
+ const seriesColour = (h, step) => {
665
+ const key = `${h} ${step}`;
666
+ let colour = cache.get(key);
667
+ if (!colour) {
668
+ colour = Object.fromEntries(themes.map((theme) => {
669
+ const preferred = theme === "dark" ? .68 + seriesSteps[step] : .6 - seriesSteps[step];
670
+ const bg = base[theme]["--bg"];
671
+ return [theme, solveLightness(fitGamut({
672
+ l: preferred,
673
+ c: .15,
674
+ h
675
+ }), bg, target("--series-1"), theme)];
676
+ }));
677
+ cache.set(key, colour);
678
+ }
679
+ return colour;
680
+ };
681
+ const series = [];
682
+ const apart = (colour) => series.every((other) => themes.every((theme) => colourBlindDistance(colour[theme], other[theme]) >= 8));
683
+ const spaced = () => {
684
+ const taken = [...hues.slice(0, 2), ...series.map((colour) => colour.light.h)];
685
+ const spacing = (h) => Math.min(...taken.map((other) => hueDistance(other, h)), ...Object.values(statusHues).map((other) => 2 * hueDistance(other, h)));
686
+ for (const step of seriesSteps.keys()) {
687
+ let best;
688
+ for (let h = 0; h < 360; h += 5) {
689
+ if (olive(h)) continue;
690
+ const colour = seriesColour(h, step);
691
+ if (apart(colour) && (!best || spacing(h) > best.spacing)) best = {
692
+ colour,
693
+ spacing: spacing(h)
694
+ };
695
+ }
696
+ if (best) return best.colour;
697
+ }
698
+ throw new Error("No series hue stands apart from the others for colour-blind readers");
699
+ };
700
+ for (const at of seriesNames.keys()) {
701
+ let h = hues[at + 2];
702
+ if (h !== void 0 && olive(h)) h = h - 85 < 120 - h ? 85 : 120;
703
+ const onAccent = h !== void 0 && hues.slice(0, 2).some((a) => hueDistance(a, h) < sameHue);
704
+ const colour = h === void 0 || onAccent ? void 0 : seriesColour(h, 0);
705
+ series.push(colour && apart(colour) ? colour : spaced());
706
+ }
707
+ return Object.fromEntries(themes.map((theme) => [theme, {
708
+ ...base[theme],
709
+ ...Object.fromEntries(seriesNames.map((name, at) => [name, series[at][theme]]))
710
+ }]));
711
+ }
712
+ /** The palette as the `:root` block for light and the one for dark, ready to paste. */
713
+ function css(colours) {
714
+ const block = (theme, indent) => lookVariables.map((name) => `${indent}${name}: ${hex(colours[theme][name])};`).join("\n");
715
+ return [
716
+ ":root {",
717
+ block("light", " "),
718
+ "}",
719
+ "@media (prefers-color-scheme: dark) {",
720
+ " :root {",
721
+ block("dark", " "),
722
+ " }",
723
+ "}",
724
+ ""
725
+ ].join("\n");
726
+ }
727
+ //#endregion
728
+ //#region scripts/palette/cli.ts
729
+ const usage = `Usage:
730
+ node palette.mjs --seed <colour> --colours <1-5> [--gap <degrees>] [--tint none|subtle|strong]
731
+ node palette.mjs --seed <colour> --preset split-complementary|rectangle [--tint none|subtle|strong]
732
+ node palette.mjs --check <file.html>`;
733
+ function fail(message) {
734
+ process.stderr.write(`${message}\n\n${usage}\n`);
735
+ process.exit(2);
736
+ }
737
+ const { values } = (() => {
738
+ try {
739
+ return parseArgs({ options: {
740
+ seed: { type: "string" },
741
+ colours: { type: "string" },
742
+ gap: { type: "string" },
743
+ preset: { type: "string" },
744
+ tint: { type: "string" },
745
+ check: { type: "string" }
746
+ } });
747
+ } catch (error) {
748
+ return fail(error.message);
749
+ }
750
+ })();
751
+ if (values.check !== void 0) {
752
+ const findings = [...lookFindings(readFileSync(values.check, "utf8"))];
753
+ for (const finding of findings) process.stdout.write(`${finding.message}\n`);
754
+ if (findings.length > 0) process.exit(1);
755
+ process.stdout.write("Every colour variable is set for light and dark and meets its floor.\n");
756
+ process.exit(0);
757
+ }
758
+ const isTint = (name) => Object.hasOwn(tints, name);
759
+ const isPreset = (name) => Object.hasOwn(presets, name);
760
+ const tint = values.tint ?? "subtle";
761
+ if (!isTint(tint)) fail(`--tint is ${tint}; use none, subtle or strong`);
762
+ if (values.seed === void 0) fail("Give --seed, or --check with a file");
763
+ const seed = values.seed;
764
+ let input;
765
+ if (values.preset !== void 0) {
766
+ if (!isPreset(values.preset)) fail(`--preset is ${values.preset}; use split-complementary or rectangle`);
767
+ if (values.colours !== void 0 || values.gap !== void 0) fail("A preset sets the number of colours and the hues; leave out --colours and --gap");
768
+ input = {
769
+ seed,
770
+ tint,
771
+ preset: values.preset
772
+ };
773
+ } else {
774
+ const colours = Number(values.colours);
775
+ if (!Number.isInteger(colours) || colours < 1 || colours > 5) fail(`--colours is ${values.colours ?? "missing"}; give a whole number from 1 to 5, or a --preset`);
776
+ const gap = values.gap === void 0 ? void 0 : Number(values.gap);
777
+ if (gap !== void 0 && !Number.isFinite(gap)) fail(`--gap is ${values.gap}; give degrees`);
778
+ input = {
779
+ seed,
780
+ tint,
781
+ colours,
782
+ gap
783
+ };
784
+ }
785
+ try {
786
+ process.stdout.write(css(palette(input)));
787
+ } catch (error) {
788
+ fail(error.message);
789
+ }
790
+ //#endregion
791
+ export {};