quick-palette 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/args.js +128 -0
- package/dist/cli/configure.js +95 -0
- package/dist/cli/explore.js +48 -0
- package/dist/cli/generate-command.js +45 -0
- package/dist/cli/index.js +65 -0
- package/dist/cli/node-version.js +7 -0
- package/dist/cli/output.js +66 -0
- package/dist/cli/preview.js +34 -0
- package/dist/cli/prompt.js +279 -0
- package/dist/cli/terminal-color.js +13 -0
- package/dist/core/color.js +58 -0
- package/dist/core/constants.js +64 -0
- package/dist/core/generate.js +40 -0
- package/dist/core/perceptual-harmony.js +125 -0
- package/dist/core/random.js +73 -0
- package/dist/core/types.js +14 -0
- package/package.json +55 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { clearScreenDown, cursorTo, emitKeypressEvents, moveCursor } from "node:readline";
|
|
3
|
+
import { stdin, stdout } from "node:process";
|
|
4
|
+
import { normalizeHex } from "../core/color.js";
|
|
5
|
+
import { COLOR_FAMILY_CANDIDATES, MOOD_CANDIDATES, USE_CASE_CANDIDATES, } from "../core/constants.js";
|
|
6
|
+
export class PromptCancelledError extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("Prompt cancelled.");
|
|
9
|
+
this.name = "PromptCancelledError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function createPromptInterface() {
|
|
13
|
+
const rl = createInterface({ input: stdin, output: stdout, terminal: Boolean(stdin.isTTY) });
|
|
14
|
+
const lines = stdin.isTTY ? undefined : rl[Symbol.asyncIterator]();
|
|
15
|
+
const prompt = {
|
|
16
|
+
async question(message) {
|
|
17
|
+
if (!lines)
|
|
18
|
+
return rl.question(message);
|
|
19
|
+
stdout.write(message);
|
|
20
|
+
const line = await lines.next();
|
|
21
|
+
if (line.done)
|
|
22
|
+
throw new Error("Input was closed before the prompts were completed.");
|
|
23
|
+
return line.value;
|
|
24
|
+
},
|
|
25
|
+
close: () => rl.close(),
|
|
26
|
+
};
|
|
27
|
+
if (stdin.isTTY && stdout.isTTY) {
|
|
28
|
+
prompt.choose = (question, options, defaultValue) => (selectWithCursor(question, options, defaultValue));
|
|
29
|
+
prompt.readExplorationAction = readExplorationAction;
|
|
30
|
+
}
|
|
31
|
+
return prompt;
|
|
32
|
+
}
|
|
33
|
+
export function promptStartupMode(rl) {
|
|
34
|
+
return select(rl, "How would you like to start?", [
|
|
35
|
+
{ label: "Explore random palettes", value: "explore" },
|
|
36
|
+
{ label: "Create a custom palette", value: "configure" },
|
|
37
|
+
], "explore");
|
|
38
|
+
}
|
|
39
|
+
export function promptExplorationAction(rl) {
|
|
40
|
+
if (rl.readExplorationAction) {
|
|
41
|
+
console.log("\nEnter: accept Space: next e: edit q: quit");
|
|
42
|
+
return rl.readExplorationAction();
|
|
43
|
+
}
|
|
44
|
+
return select(rl, "What would you like to do?", [
|
|
45
|
+
{ label: "Accept this palette", value: "accept" },
|
|
46
|
+
{ label: "Show the next palette", value: "next" },
|
|
47
|
+
{ label: "Edit this palette", value: "edit" },
|
|
48
|
+
{ label: "Quit", value: "quit" },
|
|
49
|
+
], "accept");
|
|
50
|
+
}
|
|
51
|
+
export async function promptBaseColor(rl, currentValue) {
|
|
52
|
+
const currentOption = currentValue === undefined
|
|
53
|
+
? []
|
|
54
|
+
: [{ label: `Keep current base color (${currentValue})`, value: "current" }];
|
|
55
|
+
const method = await select(rl, "How would you like to choose the base color?", [
|
|
56
|
+
...currentOption,
|
|
57
|
+
{ label: "Enter a HEX value", value: "hex" },
|
|
58
|
+
{ label: "Choose a color family", value: "family" },
|
|
59
|
+
{ label: "Choose a mood", value: "mood" },
|
|
60
|
+
{ label: "Choose a use case", value: "use-case" },
|
|
61
|
+
], currentValue === undefined ? undefined : "current");
|
|
62
|
+
if (method === "current")
|
|
63
|
+
return currentValue;
|
|
64
|
+
if (method === "hex")
|
|
65
|
+
return promptHex(rl);
|
|
66
|
+
if (method === "family") {
|
|
67
|
+
const family = await selectKeys(rl, "Choose a color family:", COLOR_FAMILY_CANDIDATES);
|
|
68
|
+
return promptCandidate(rl, COLOR_FAMILY_CANDIDATES[family]);
|
|
69
|
+
}
|
|
70
|
+
if (method === "mood") {
|
|
71
|
+
const mood = await selectKeys(rl, "Choose a mood:", MOOD_CANDIDATES);
|
|
72
|
+
return promptCandidate(rl, MOOD_CANDIDATES[mood]);
|
|
73
|
+
}
|
|
74
|
+
const useCase = await selectKeys(rl, "Choose a use case:", USE_CASE_CANDIDATES);
|
|
75
|
+
return promptCandidate(rl, USE_CASE_CANDIDATES[useCase]);
|
|
76
|
+
}
|
|
77
|
+
export function promptHarmony(rl, defaultValue) {
|
|
78
|
+
return select(rl, "Choose a color harmony:", [
|
|
79
|
+
{ label: "Monochrome (1 hue + neutrals)", value: "monochrome" },
|
|
80
|
+
{ label: "Analogous (3 neighboring hues + neutrals)", value: "analogous" },
|
|
81
|
+
{ label: "Complementary (2 opposite hues + neutrals)", value: "complementary" },
|
|
82
|
+
{ label: "Triadic (3 evenly spaced hues + neutrals)", value: "triadic" },
|
|
83
|
+
], defaultValue);
|
|
84
|
+
}
|
|
85
|
+
export function promptHarmonyTuning(rl, defaultValue = "mechanical") {
|
|
86
|
+
return select(rl, "How should the harmony colors be adjusted?", [
|
|
87
|
+
{ label: "Fixed angles (predictable)", value: "mechanical" },
|
|
88
|
+
{ label: "UI (restrained accents)", value: "ui" },
|
|
89
|
+
{ label: "Branding (vivid accents)", value: "branding" },
|
|
90
|
+
{ label: "Data visualization (separated colors)", value: "data-visualization" },
|
|
91
|
+
], defaultValue);
|
|
92
|
+
}
|
|
93
|
+
export function promptNeutralMode(rl, defaultValue) {
|
|
94
|
+
return select(rl, "Choose a neutral palette:", [
|
|
95
|
+
{ label: "Neutral gray", value: "neutral" },
|
|
96
|
+
{ label: "Base-tinted gray", value: "tinted" },
|
|
97
|
+
], defaultValue);
|
|
98
|
+
}
|
|
99
|
+
export function promptConfigurationAction(rl) {
|
|
100
|
+
return select(rl, "Choose an action:", [
|
|
101
|
+
{ label: "Finish and print HEX values", value: "accept" },
|
|
102
|
+
{ label: "Export as JSON or CSS", value: "export" },
|
|
103
|
+
{ label: "Change palette settings", value: "edit" },
|
|
104
|
+
], "accept");
|
|
105
|
+
}
|
|
106
|
+
export function promptConfigurationEditAction(rl) {
|
|
107
|
+
return select(rl, "What would you like to edit?", [
|
|
108
|
+
{ label: "Base color", value: "base" },
|
|
109
|
+
{ label: "Color harmony", value: "harmony" },
|
|
110
|
+
{ label: "Neutral palette", value: "neutral" },
|
|
111
|
+
{ label: "Step counts", value: "steps" },
|
|
112
|
+
{ label: "Cancel editing", value: "cancel" },
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
export function promptExportFormat(rl) {
|
|
116
|
+
return select(rl, "Choose an export format:", [
|
|
117
|
+
{ label: "JSON", value: "json" },
|
|
118
|
+
{ label: "CSS", value: "css" },
|
|
119
|
+
{ label: "Back to palette", value: "back" },
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
export function promptStepCount(rl, label, defaultValue) {
|
|
123
|
+
return select(rl, `${label} (default: ${defaultValue}):`, [
|
|
124
|
+
{ label: "3 steps", value: 3 },
|
|
125
|
+
{ label: "5 steps", value: 5 },
|
|
126
|
+
{ label: "7 steps", value: 7 },
|
|
127
|
+
{ label: "9 steps", value: 9 },
|
|
128
|
+
], defaultValue);
|
|
129
|
+
}
|
|
130
|
+
export async function promptExportDestination(rl, format) {
|
|
131
|
+
const label = format.toUpperCase();
|
|
132
|
+
const mode = await select(rl, `Where should the ${label} output go?`, [
|
|
133
|
+
{ label: "Print to the terminal", value: "print" },
|
|
134
|
+
{ label: "Save to a file", value: "save" },
|
|
135
|
+
{ label: "Back to format selection", value: "back" },
|
|
136
|
+
], "print");
|
|
137
|
+
if (mode !== "save")
|
|
138
|
+
return { mode };
|
|
139
|
+
while (true) {
|
|
140
|
+
const path = (await rl.question(`${label} output path: `)).trim();
|
|
141
|
+
if (path)
|
|
142
|
+
return { mode, path };
|
|
143
|
+
console.log(`Enter a path for the ${label} output file.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function promptExportCompleteAction(rl) {
|
|
147
|
+
return select(rl, "Export complete. What would you like to do?", [
|
|
148
|
+
{ label: "Done", value: "done" },
|
|
149
|
+
{ label: "Export another format", value: "another" },
|
|
150
|
+
{ label: "Back to palette", value: "back" },
|
|
151
|
+
], "done");
|
|
152
|
+
}
|
|
153
|
+
async function promptHex(rl) {
|
|
154
|
+
while (true) {
|
|
155
|
+
const answer = await rl.question("Enter a HEX color (#RGB or #RRGGBB): ");
|
|
156
|
+
try {
|
|
157
|
+
return normalizeHex(answer);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
console.log("Enter a valid HEX color, such as #2563EB or #F80.");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function promptCandidate(rl, candidates) {
|
|
165
|
+
return select(rl, "Choose a base color:", candidates.map((candidate) => ({
|
|
166
|
+
label: `${candidate.name} (${candidate.hex})`,
|
|
167
|
+
value: candidate.hex,
|
|
168
|
+
})));
|
|
169
|
+
}
|
|
170
|
+
async function selectKeys(rl, question, values) {
|
|
171
|
+
return select(rl, question, Object.keys(values).map((key) => ({
|
|
172
|
+
label: titleCase(key),
|
|
173
|
+
value: key,
|
|
174
|
+
})));
|
|
175
|
+
}
|
|
176
|
+
export async function select(rl, question, options, defaultValue) {
|
|
177
|
+
if (rl.choose)
|
|
178
|
+
return rl.choose(question, options, defaultValue);
|
|
179
|
+
while (true) {
|
|
180
|
+
console.log(`\n${question}`);
|
|
181
|
+
options.forEach((option, index) => console.log(` ${index + 1}. ${option.label}`));
|
|
182
|
+
const answer = (await rl.question("> ")).trim();
|
|
183
|
+
if (answer === "" && defaultValue !== undefined)
|
|
184
|
+
return defaultValue;
|
|
185
|
+
const selected = Number(answer) - 1;
|
|
186
|
+
const option = options[selected];
|
|
187
|
+
if (Number.isInteger(selected) && option)
|
|
188
|
+
return option.value;
|
|
189
|
+
console.log(`Enter a number from 1 to ${options.length}.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function selectWithCursor(question, options, defaultValue) {
|
|
193
|
+
if (options.length === 0)
|
|
194
|
+
throw new Error("At least one option is required.");
|
|
195
|
+
const defaultIndex = defaultValue === undefined
|
|
196
|
+
? -1
|
|
197
|
+
: options.findIndex((option) => Object.is(option.value, defaultValue));
|
|
198
|
+
let selectedIndex = defaultIndex >= 0 ? defaultIndex : 0;
|
|
199
|
+
console.log(`\n${question}`);
|
|
200
|
+
renderCursorOptions(options, selectedIndex, false);
|
|
201
|
+
emitKeypressEvents(stdin);
|
|
202
|
+
const wasRaw = stdin.isRaw;
|
|
203
|
+
stdin.setRawMode(true);
|
|
204
|
+
stdin.resume();
|
|
205
|
+
try {
|
|
206
|
+
return await new Promise((resolve, reject) => {
|
|
207
|
+
const onKeypress = (_input, key) => {
|
|
208
|
+
if (key.ctrl && key.name === "c") {
|
|
209
|
+
stdin.off("keypress", onKeypress);
|
|
210
|
+
reject(new PromptCancelledError());
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (key.name === "up" || key.name === "down") {
|
|
214
|
+
const direction = key.name === "up" ? -1 : 1;
|
|
215
|
+
selectedIndex = (selectedIndex + direction + options.length) % options.length;
|
|
216
|
+
renderCursorOptions(options, selectedIndex, true);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (key.name === "return" || key.name === "enter") {
|
|
220
|
+
stdin.off("keypress", onKeypress);
|
|
221
|
+
resolve(options[selectedIndex].value);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
stdin.on("keypress", onKeypress);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
stdin.setRawMode(Boolean(wasRaw));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function readExplorationAction() {
|
|
232
|
+
emitKeypressEvents(stdin);
|
|
233
|
+
const wasRaw = stdin.isRaw;
|
|
234
|
+
stdin.setRawMode(true);
|
|
235
|
+
stdin.resume();
|
|
236
|
+
try {
|
|
237
|
+
return await new Promise((resolve, reject) => {
|
|
238
|
+
const onKeypress = (input, key) => {
|
|
239
|
+
if (key.ctrl && key.name === "c") {
|
|
240
|
+
stdin.off("keypress", onKeypress);
|
|
241
|
+
reject(new PromptCancelledError());
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const action = explorationActionForKey(input, key.name);
|
|
245
|
+
if (action) {
|
|
246
|
+
stdin.off("keypress", onKeypress);
|
|
247
|
+
stdout.write("\n");
|
|
248
|
+
resolve(action);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
stdin.on("keypress", onKeypress);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
stdin.setRawMode(Boolean(wasRaw));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
export function explorationActionForKey(input, keyName) {
|
|
259
|
+
if (keyName === "return" || keyName === "enter")
|
|
260
|
+
return "accept";
|
|
261
|
+
if (keyName === "space" || input === " ")
|
|
262
|
+
return "next";
|
|
263
|
+
if (input.toLowerCase() === "e")
|
|
264
|
+
return "edit";
|
|
265
|
+
if (input.toLowerCase() === "q")
|
|
266
|
+
return "quit";
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
function renderCursorOptions(options, selectedIndex, redraw) {
|
|
270
|
+
if (redraw) {
|
|
271
|
+
moveCursor(stdout, 0, -options.length);
|
|
272
|
+
cursorTo(stdout, 0);
|
|
273
|
+
clearScreenDown(stdout);
|
|
274
|
+
}
|
|
275
|
+
stdout.write(`${options.map((option, index) => (`${index === selectedIndex ? ">" : " "} ${option.label}`)).join("\n")}\n`);
|
|
276
|
+
}
|
|
277
|
+
function titleCase(value) {
|
|
278
|
+
return value.replaceAll("-", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
279
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function hexToRgb(hex) {
|
|
2
|
+
return {
|
|
3
|
+
r: Number.parseInt(hex.slice(1, 3), 16),
|
|
4
|
+
g: Number.parseInt(hex.slice(3, 5), 16),
|
|
5
|
+
b: Number.parseInt(hex.slice(5, 7), 16),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function formatColorSwatch(hex, useColor) {
|
|
9
|
+
if (!useColor)
|
|
10
|
+
return ` ${hex}`;
|
|
11
|
+
const { r, g, b } = hexToRgb(hex);
|
|
12
|
+
return ` \u001B[48;2;${r};${g};${b}m \u001B[0m ${hex}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import Color from "colorjs.io";
|
|
2
|
+
const HEX_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
3
|
+
const CHROMA_DECREMENT = 0.002;
|
|
4
|
+
export class InvalidHexColorError extends Error {
|
|
5
|
+
constructor(value) {
|
|
6
|
+
super(`Invalid HEX color: ${value}`);
|
|
7
|
+
this.name = "InvalidHexColorError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function normalizeHex(value) {
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (!HEX_PATTERN.test(trimmed)) {
|
|
13
|
+
throw new InvalidHexColorError(value);
|
|
14
|
+
}
|
|
15
|
+
const digits = trimmed.slice(1);
|
|
16
|
+
const expanded = digits.length === 3
|
|
17
|
+
? digits.split("").map((digit) => digit.repeat(2)).join("")
|
|
18
|
+
: digits;
|
|
19
|
+
return `#${expanded.toUpperCase()}`;
|
|
20
|
+
}
|
|
21
|
+
export function isValidHex(value) {
|
|
22
|
+
return HEX_PATTERN.test(value.trim());
|
|
23
|
+
}
|
|
24
|
+
export function hexToOklch(value) {
|
|
25
|
+
const [l, c, hue] = new Color(normalizeHex(value)).to("oklch").coords;
|
|
26
|
+
return {
|
|
27
|
+
l: l ?? 0,
|
|
28
|
+
c: c ?? 0,
|
|
29
|
+
h: Number.isFinite(hue) ? hue : 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function isInSrgb(color) {
|
|
33
|
+
return new Color("oklch", [color.l, color.c, normalizeHue(color.h)]).inGamut("srgb");
|
|
34
|
+
}
|
|
35
|
+
export function oklchToHex(color) {
|
|
36
|
+
const mapped = mapToSrgb(color).to("srgb");
|
|
37
|
+
const channels = mapped.coords.map((channel) => {
|
|
38
|
+
const value = Math.min(1, Math.max(0, channel ?? 0));
|
|
39
|
+
return Math.round(value * 255).toString(16).padStart(2, "0");
|
|
40
|
+
});
|
|
41
|
+
return `#${channels.join("").toUpperCase()}`;
|
|
42
|
+
}
|
|
43
|
+
export function mapToSrgb(color) {
|
|
44
|
+
const l = Math.min(1, Math.max(0, color.l));
|
|
45
|
+
const h = normalizeHue(color.h);
|
|
46
|
+
let c = Math.max(0, color.c);
|
|
47
|
+
while (c > 0) {
|
|
48
|
+
const candidate = new Color("oklch", [l, c, h]);
|
|
49
|
+
if (candidate.inGamut("srgb")) {
|
|
50
|
+
return candidate;
|
|
51
|
+
}
|
|
52
|
+
c = Math.max(0, c - CHROMA_DECREMENT);
|
|
53
|
+
}
|
|
54
|
+
return new Color("oklch", [l, 0, h]);
|
|
55
|
+
}
|
|
56
|
+
export function normalizeHue(hue) {
|
|
57
|
+
return ((hue % 360) + 360) % 360;
|
|
58
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { HARMONY_MODES, HARMONY_TUNINGS, NEUTRAL_MODES, } from "./types.js";
|
|
2
|
+
export const COLOR_FAMILY_CANDIDATES = {
|
|
3
|
+
red: [{ name: "Crimson", hex: "#DC2626" }, { name: "Rose", hex: "#E11D48" }],
|
|
4
|
+
orange: [{ name: "Orange", hex: "#EA580C" }, { name: "Amber", hex: "#D97706" }],
|
|
5
|
+
yellow: [{ name: "Gold", hex: "#CA8A04" }, { name: "Lemon", hex: "#EAB308" }],
|
|
6
|
+
green: [{ name: "Emerald", hex: "#059669" }, { name: "Forest", hex: "#15803D" }],
|
|
7
|
+
blue: [{ name: "Blue", hex: "#2563EB" }, { name: "Sky", hex: "#0284C7" }],
|
|
8
|
+
purple: [{ name: "Violet", hex: "#7C3AED" }, { name: "Purple", hex: "#9333EA" }],
|
|
9
|
+
};
|
|
10
|
+
export const MOOD_CANDIDATES = {
|
|
11
|
+
calm: [{ name: "Quiet Blue", hex: "#3B82F6" }, { name: "Soft Teal", hex: "#0D9488" }],
|
|
12
|
+
energetic: [{ name: "Bright Orange", hex: "#F97316" }, { name: "Hot Pink", hex: "#DB2777" }],
|
|
13
|
+
elegant: [{ name: "Deep Navy", hex: "#1E3A8A" }, { name: "Burgundy", hex: "#881337" }],
|
|
14
|
+
playful: [{ name: "Candy Purple", hex: "#A855F7" }, { name: "Fresh Green", hex: "#22C55E" }],
|
|
15
|
+
};
|
|
16
|
+
export const USE_CASE_CANDIDATES = {
|
|
17
|
+
brand: [{ name: "Confident Blue", hex: "#2563EB" }, { name: "Bold Red", hex: "#DC2626" }],
|
|
18
|
+
dashboard: [{ name: "Data Blue", hex: "#0369A1" }, { name: "Data Teal", hex: "#0F766E" }],
|
|
19
|
+
editorial: [{ name: "Ink", hex: "#334155" }, { name: "Accent Red", hex: "#BE123C" }],
|
|
20
|
+
wellness: [{ name: "Sage", hex: "#65A30D" }, { name: "Ocean", hex: "#0891B2" }],
|
|
21
|
+
};
|
|
22
|
+
export const HUE_OFFSETS = {
|
|
23
|
+
monochrome: [0],
|
|
24
|
+
analogous: [-30, 0, 30],
|
|
25
|
+
complementary: [0, 180],
|
|
26
|
+
triadic: [0, 120, 240],
|
|
27
|
+
};
|
|
28
|
+
export const COLOR_LIGHTNESS_RANGE = { min: 0.35, max: 0.9 };
|
|
29
|
+
export const NEUTRAL_LIGHTNESS_RANGE = { min: 0.18, max: 0.98 };
|
|
30
|
+
export const TINTED_NEUTRAL_MAX_CHROMA = 0.02;
|
|
31
|
+
export const TINTED_NEUTRAL_CHROMA_RATIO = 0.12;
|
|
32
|
+
export const DEFAULT_COLOR_STEPS = 5;
|
|
33
|
+
export const DEFAULT_NEUTRAL_STEPS = 5;
|
|
34
|
+
export const RANDOM_BASE_COLORS = [
|
|
35
|
+
...new Set([
|
|
36
|
+
...Object.values(COLOR_FAMILY_CANDIDATES).flat().map(({ hex }) => hex),
|
|
37
|
+
...Object.values(MOOD_CANDIDATES).flat().map(({ hex }) => hex),
|
|
38
|
+
...Object.values(USE_CASE_CANDIDATES).flat().map(({ hex }) => hex),
|
|
39
|
+
]),
|
|
40
|
+
];
|
|
41
|
+
export const RANDOM_HARMONIES = HARMONY_MODES;
|
|
42
|
+
export const RANDOM_HARMONY_TUNINGS = HARMONY_TUNINGS;
|
|
43
|
+
export const RANDOM_NEUTRAL_MODES = NEUTRAL_MODES;
|
|
44
|
+
export const PERCEPTUAL_HUE_SHIFTS = [-12, -8, -4, 0, 4, 8, 12];
|
|
45
|
+
export const PERCEPTUAL_REPRESENTATIVE_LIGHTNESS = 0.625;
|
|
46
|
+
export const PERCEPTUAL_MIN_DISTANCE = 0.08;
|
|
47
|
+
export const PERCEPTUAL_COLLAPSE_DISTANCE = 0.035;
|
|
48
|
+
// UI and branding scores are maximized. Data visualization uses a lexicographic
|
|
49
|
+
// comparison so chroma cannot override its minimum-distance priority.
|
|
50
|
+
export const PERCEPTUAL_SCORING_WEIGHTS = {
|
|
51
|
+
ui: {
|
|
52
|
+
chromaRetention: 20,
|
|
53
|
+
minimumDistance: 3,
|
|
54
|
+
targetDeviation: 0.02,
|
|
55
|
+
collapse: 10,
|
|
56
|
+
},
|
|
57
|
+
branding: {
|
|
58
|
+
chromaRetention: 30,
|
|
59
|
+
minimumDistance: 8,
|
|
60
|
+
baseSeparation: 2,
|
|
61
|
+
targetDeviation: 0.005,
|
|
62
|
+
collapse: 12,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { COLOR_LIGHTNESS_RANGE, HUE_OFFSETS, NEUTRAL_LIGHTNESS_RANGE, TINTED_NEUTRAL_CHROMA_RATIO, TINTED_NEUTRAL_MAX_CHROMA, } from "./constants.js";
|
|
2
|
+
import { hexToOklch, normalizeHex, normalizeHue, oklchToHex } from "./color.js";
|
|
3
|
+
import { tuneHarmonyHues } from "./perceptual-harmony.js";
|
|
4
|
+
const ACHROMATIC_CHROMA_THRESHOLD = 0.001;
|
|
5
|
+
export function generatePalette(input) {
|
|
6
|
+
const config = { ...input, baseColor: normalizeHex(input.baseColor) };
|
|
7
|
+
const base = hexToOklch(config.baseColor);
|
|
8
|
+
const lightness = interpolate(COLOR_LIGHTNESS_RANGE.min, COLOR_LIGHTNESS_RANGE.max, config.colorSteps);
|
|
9
|
+
const mechanicalHues = HUE_OFFSETS[config.harmony].map((offset) => normalizeHue(base.h + offset));
|
|
10
|
+
const hues = config.harmonyTuning && config.harmonyTuning !== "mechanical"
|
|
11
|
+
? tuneHarmonyHues({
|
|
12
|
+
base,
|
|
13
|
+
harmony: config.harmony,
|
|
14
|
+
mechanicalHues,
|
|
15
|
+
purpose: config.harmonyTuning,
|
|
16
|
+
})
|
|
17
|
+
: mechanicalHues;
|
|
18
|
+
const colorChroma = base.c < ACHROMATIC_CHROMA_THRESHOLD
|
|
19
|
+
? 0
|
|
20
|
+
: Math.max(0.08, Math.min(base.c, 0.22));
|
|
21
|
+
const colors = hues.flatMap((h) => lightness.map((l) => oklchToHex({
|
|
22
|
+
l,
|
|
23
|
+
c: colorChroma,
|
|
24
|
+
h,
|
|
25
|
+
})));
|
|
26
|
+
const neutralLightness = interpolate(NEUTRAL_LIGHTNESS_RANGE.max, NEUTRAL_LIGHTNESS_RANGE.min, config.neutralSteps);
|
|
27
|
+
const neutrals = neutralLightness.map((l) => oklchToHex({
|
|
28
|
+
l,
|
|
29
|
+
c: config.neutralMode === "tinted"
|
|
30
|
+
? Math.min(TINTED_NEUTRAL_MAX_CHROMA, base.c * TINTED_NEUTRAL_CHROMA_RATIO)
|
|
31
|
+
: 0,
|
|
32
|
+
h: base.h,
|
|
33
|
+
}));
|
|
34
|
+
return { config, colors, neutrals };
|
|
35
|
+
}
|
|
36
|
+
function interpolate(start, end, count) {
|
|
37
|
+
if (count === 1)
|
|
38
|
+
return [start];
|
|
39
|
+
return Array.from({ length: count }, (_, index) => (start + ((end - start) * index) / (count - 1)));
|
|
40
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { PERCEPTUAL_COLLAPSE_DISTANCE, PERCEPTUAL_HUE_SHIFTS, PERCEPTUAL_MIN_DISTANCE, PERCEPTUAL_REPRESENTATIVE_LIGHTNESS, PERCEPTUAL_SCORING_WEIGHTS, } from "./constants.js";
|
|
2
|
+
import { mapToSrgb, normalizeHue } from "./color.js";
|
|
3
|
+
const SCORE_EPSILON = 1e-12;
|
|
4
|
+
const ACHROMATIC_CHROMA_THRESHOLD = 0.001;
|
|
5
|
+
export function tuneHarmonyHues(input) {
|
|
6
|
+
const mechanicalHues = input.mechanicalHues.map(normalizeHue);
|
|
7
|
+
if (input.harmony === "monochrome" || mechanicalHues.length < 2)
|
|
8
|
+
return mechanicalHues;
|
|
9
|
+
const baseIndex = findBaseIndex(mechanicalHues, input.base.h);
|
|
10
|
+
const adjustableIndexes = mechanicalHues
|
|
11
|
+
.map((_, index) => index)
|
|
12
|
+
.filter((index) => index !== baseIndex);
|
|
13
|
+
const chroma = input.base.c < ACHROMATIC_CHROMA_THRESHOLD
|
|
14
|
+
? 0
|
|
15
|
+
: Math.max(0.08, Math.min(input.base.c, 0.22));
|
|
16
|
+
let best = mechanicalHues;
|
|
17
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
18
|
+
let bestDeviation = Number.POSITIVE_INFINITY;
|
|
19
|
+
let bestMetrics;
|
|
20
|
+
for (const shifts of generateShiftSets(adjustableIndexes.length)) {
|
|
21
|
+
const hues = [...mechanicalHues];
|
|
22
|
+
adjustableIndexes.forEach((index, shiftIndex) => {
|
|
23
|
+
hues[index] = normalizeHue(mechanicalHues[index] + shifts[shiftIndex]);
|
|
24
|
+
});
|
|
25
|
+
const metrics = measureCandidate(hues, mechanicalHues, baseIndex, chroma);
|
|
26
|
+
if (input.purpose === "data-visualization") {
|
|
27
|
+
if (!bestMetrics || isBetterDataVisualizationCandidate(metrics, bestMetrics)) {
|
|
28
|
+
best = hues;
|
|
29
|
+
bestMetrics = metrics;
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const score = scoreCandidate(input.purpose, metrics);
|
|
34
|
+
if (score > bestScore + SCORE_EPSILON
|
|
35
|
+
|| (Math.abs(score - bestScore) <= SCORE_EPSILON && metrics.targetDeviation < bestDeviation)) {
|
|
36
|
+
best = hues;
|
|
37
|
+
bestScore = score;
|
|
38
|
+
bestDeviation = metrics.targetDeviation;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return best;
|
|
42
|
+
}
|
|
43
|
+
function generateShiftSets(count) {
|
|
44
|
+
let sets = [[]];
|
|
45
|
+
for (let index = 0; index < count; index += 1) {
|
|
46
|
+
sets = sets.flatMap((set) => PERCEPTUAL_HUE_SHIFTS.map((shift) => [...set, shift]));
|
|
47
|
+
}
|
|
48
|
+
return sets;
|
|
49
|
+
}
|
|
50
|
+
function measureCandidate(hues, mechanicalHues, baseIndex, chroma) {
|
|
51
|
+
const mapped = hues.map((h) => mapForScoring({
|
|
52
|
+
l: PERCEPTUAL_REPRESENTATIVE_LIGHTNESS,
|
|
53
|
+
c: chroma,
|
|
54
|
+
h,
|
|
55
|
+
}));
|
|
56
|
+
const distances = [];
|
|
57
|
+
let baseSeparation = 0;
|
|
58
|
+
for (let left = 0; left < mapped.length; left += 1) {
|
|
59
|
+
for (let right = left + 1; right < mapped.length; right += 1) {
|
|
60
|
+
const distance = oklchDistance(mapped[left], mapped[right]);
|
|
61
|
+
distances.push(distance);
|
|
62
|
+
if (left === baseIndex || right === baseIndex)
|
|
63
|
+
baseSeparation += distance;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const minimumDistance = Math.min(...distances);
|
|
67
|
+
return {
|
|
68
|
+
retainedChroma: mapped.reduce((sum, color) => sum + color.c, 0),
|
|
69
|
+
minimumDistance,
|
|
70
|
+
baseSeparation,
|
|
71
|
+
distanceShortfall: Math.max(0, PERCEPTUAL_MIN_DISTANCE - minimumDistance),
|
|
72
|
+
collapsedPairs: distances.filter((distance) => distance < PERCEPTUAL_COLLAPSE_DISTANCE).length,
|
|
73
|
+
targetDeviation: hues.reduce((sum, hue, index) => (sum + circularHueDistance(hue, mechanicalHues[index])), 0),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function scoreCandidate(purpose, metrics) {
|
|
77
|
+
if (purpose === "ui") {
|
|
78
|
+
const weights = PERCEPTUAL_SCORING_WEIGHTS.ui;
|
|
79
|
+
return (metrics.retainedChroma * weights.chromaRetention)
|
|
80
|
+
+ (metrics.minimumDistance * weights.minimumDistance)
|
|
81
|
+
- (metrics.targetDeviation * weights.targetDeviation)
|
|
82
|
+
- (metrics.collapsedPairs * weights.collapse);
|
|
83
|
+
}
|
|
84
|
+
const weights = PERCEPTUAL_SCORING_WEIGHTS.branding;
|
|
85
|
+
return (metrics.retainedChroma * weights.chromaRetention)
|
|
86
|
+
+ (metrics.minimumDistance * weights.minimumDistance)
|
|
87
|
+
+ (metrics.baseSeparation * weights.baseSeparation)
|
|
88
|
+
- (metrics.targetDeviation * weights.targetDeviation)
|
|
89
|
+
- (metrics.collapsedPairs * weights.collapse);
|
|
90
|
+
}
|
|
91
|
+
function isBetterDataVisualizationCandidate(candidate, current) {
|
|
92
|
+
const comparisons = [
|
|
93
|
+
candidate.minimumDistance - current.minimumDistance,
|
|
94
|
+
current.distanceShortfall - candidate.distanceShortfall,
|
|
95
|
+
candidate.retainedChroma - current.retainedChroma,
|
|
96
|
+
current.targetDeviation - candidate.targetDeviation,
|
|
97
|
+
];
|
|
98
|
+
const decisiveDifference = comparisons.find((difference) => Math.abs(difference) > SCORE_EPSILON);
|
|
99
|
+
return decisiveDifference !== undefined && decisiveDifference > 0;
|
|
100
|
+
}
|
|
101
|
+
function mapForScoring(color) {
|
|
102
|
+
const [l, c, hue] = mapToSrgb(color).to("srgb").to("oklch").coords;
|
|
103
|
+
return {
|
|
104
|
+
l: l ?? 0,
|
|
105
|
+
c: c ?? 0,
|
|
106
|
+
h: Number.isFinite(hue) ? normalizeHue(hue) : 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function oklchDistance(left, right) {
|
|
110
|
+
const leftRadians = (left.h * Math.PI) / 180;
|
|
111
|
+
const rightRadians = (right.h * Math.PI) / 180;
|
|
112
|
+
const deltaL = left.l - right.l;
|
|
113
|
+
const deltaA = (left.c * Math.cos(leftRadians)) - (right.c * Math.cos(rightRadians));
|
|
114
|
+
const deltaB = (left.c * Math.sin(leftRadians)) - (right.c * Math.sin(rightRadians));
|
|
115
|
+
return Math.sqrt((deltaL ** 2) + (deltaA ** 2) + (deltaB ** 2));
|
|
116
|
+
}
|
|
117
|
+
function findBaseIndex(hues, baseHue) {
|
|
118
|
+
return hues.reduce((bestIndex, hue, index) => (circularHueDistance(hue, baseHue) < circularHueDistance(hues[bestIndex], baseHue)
|
|
119
|
+
? index
|
|
120
|
+
: bestIndex), 0);
|
|
121
|
+
}
|
|
122
|
+
function circularHueDistance(left, right) {
|
|
123
|
+
const difference = Math.abs(normalizeHue(left) - normalizeHue(right));
|
|
124
|
+
return Math.min(difference, 360 - difference);
|
|
125
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { DEFAULT_COLOR_STEPS, DEFAULT_NEUTRAL_STEPS, RANDOM_BASE_COLORS, RANDOM_HARMONIES, RANDOM_HARMONY_TUNINGS, RANDOM_NEUTRAL_MODES, } from "./constants.js";
|
|
2
|
+
import { normalizeHex } from "./color.js";
|
|
3
|
+
const UINT32_SIZE = 0x1_0000_0000;
|
|
4
|
+
const UINT32_MAX = UINT32_SIZE - 1;
|
|
5
|
+
const HEX_SEED_PATTERN = /^[0-9a-f]{1,8}$/i;
|
|
6
|
+
export class InvalidRandomSeedError extends Error {
|
|
7
|
+
constructor(seed) {
|
|
8
|
+
super(`Invalid random seed: ${String(seed)}`);
|
|
9
|
+
this.name = "InvalidRandomSeedError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function generateRandomPaletteConfig(options = {}) {
|
|
13
|
+
const seedValue = options.seed ?? Math.floor(Math.random() * UINT32_SIZE);
|
|
14
|
+
const numericSeed = seedToUint32(seedValue);
|
|
15
|
+
const random = createPrng(numericSeed);
|
|
16
|
+
const constraints = options.constraints ?? {};
|
|
17
|
+
const randomized = {
|
|
18
|
+
baseColor: pick(RANDOM_BASE_COLORS, random),
|
|
19
|
+
harmony: pick(RANDOM_HARMONIES, random),
|
|
20
|
+
harmonyTuning: pick(RANDOM_HARMONY_TUNINGS, random),
|
|
21
|
+
neutralMode: pick(RANDOM_NEUTRAL_MODES, random),
|
|
22
|
+
};
|
|
23
|
+
const config = {
|
|
24
|
+
baseColor: constraints.baseColor === undefined
|
|
25
|
+
? randomized.baseColor
|
|
26
|
+
: normalizeHex(constraints.baseColor),
|
|
27
|
+
harmony: constraints.harmony ?? randomized.harmony,
|
|
28
|
+
harmonyTuning: constraints.harmonyTuning ?? randomized.harmonyTuning,
|
|
29
|
+
neutralMode: constraints.neutralMode ?? randomized.neutralMode,
|
|
30
|
+
colorSteps: constraints.colorSteps ?? DEFAULT_COLOR_STEPS,
|
|
31
|
+
neutralSteps: constraints.neutralSteps ?? DEFAULT_NEUTRAL_STEPS,
|
|
32
|
+
};
|
|
33
|
+
return { seed: formatSeed(numericSeed), config };
|
|
34
|
+
}
|
|
35
|
+
function seedToUint32(seed) {
|
|
36
|
+
if (typeof seed === "number") {
|
|
37
|
+
if (!Number.isSafeInteger(seed) || seed < 0 || seed > UINT32_MAX) {
|
|
38
|
+
throw new InvalidRandomSeedError(seed);
|
|
39
|
+
}
|
|
40
|
+
return seed;
|
|
41
|
+
}
|
|
42
|
+
const normalized = seed.trim();
|
|
43
|
+
if (normalized.length === 0)
|
|
44
|
+
throw new InvalidRandomSeedError(seed);
|
|
45
|
+
if (HEX_SEED_PATTERN.test(normalized))
|
|
46
|
+
return Number.parseInt(normalized, 16);
|
|
47
|
+
// FNV-1a maps memorable, arbitrary seed strings to the displayed uint32 seed.
|
|
48
|
+
let hash = 0x811c9dc5;
|
|
49
|
+
for (const character of normalized) {
|
|
50
|
+
hash ^= character.codePointAt(0);
|
|
51
|
+
hash = Math.imul(hash, 0x01000193);
|
|
52
|
+
}
|
|
53
|
+
return hash >>> 0;
|
|
54
|
+
}
|
|
55
|
+
function formatSeed(seed) {
|
|
56
|
+
return seed.toString(16).padStart(8, "0");
|
|
57
|
+
}
|
|
58
|
+
function createPrng(seed) {
|
|
59
|
+
let state = seed;
|
|
60
|
+
return () => {
|
|
61
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
62
|
+
let value = state;
|
|
63
|
+
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
64
|
+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
65
|
+
return ((value ^ (value >>> 14)) >>> 0) / UINT32_SIZE;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function pick(values, random) {
|
|
69
|
+
const value = values[Math.floor(random() * values.length)];
|
|
70
|
+
if (value === undefined)
|
|
71
|
+
throw new Error("Cannot select from an empty random candidate list");
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const HARMONY_MODES = [
|
|
2
|
+
"monochrome",
|
|
3
|
+
"analogous",
|
|
4
|
+
"complementary",
|
|
5
|
+
"triadic",
|
|
6
|
+
];
|
|
7
|
+
export const NEUTRAL_MODES = ["neutral", "tinted"];
|
|
8
|
+
export const STEP_COUNTS = [3, 5, 7, 9];
|
|
9
|
+
export const HARMONY_TUNINGS = [
|
|
10
|
+
"mechanical",
|
|
11
|
+
"ui",
|
|
12
|
+
"branding",
|
|
13
|
+
"data-visualization",
|
|
14
|
+
];
|