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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryo Sugimoto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Quick Palette
|
|
2
|
+
|
|
3
|
+
Explore reproducible OKLCH-based color palettes from the command line. The default flow shows a usable five-step palette after one selection, then lets you accept it or move to another candidate with one key.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm dlx quick-palette
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use npm:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx quick-palette
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Press Enter to choose **Explore random palettes**. In the exploration view:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
Enter: accept Space: next e: edit q: quit
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Every candidate displays a seed. Accepting prints concise HEX output without asking about step counts or export formats.
|
|
24
|
+
|
|
25
|
+
## Reproduce a palette
|
|
26
|
+
|
|
27
|
+
Use the seed shown below an exploration preview:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm dlx quick-palette generate --seed 8f3a21c4
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The same seed produces the same configuration and palette within the same Quick Palette version. Save the generated JSON output when exact colors must be retained across versions. JSON and CSS output are available without interactive prompts:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pnpm dlx quick-palette generate --seed 8f3a21c4 --format json
|
|
37
|
+
pnpm dlx quick-palette generate --seed 8f3a21c4 --format css --output palette.css
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Non-interactive output goes only to stdout unless `--output` is supplied. Errors go to stderr and set a non-zero exit status.
|
|
41
|
+
|
|
42
|
+
## Generate an exact configuration
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm dlx quick-palette generate \
|
|
46
|
+
--base '#2563EB' \
|
|
47
|
+
--harmony analogous \
|
|
48
|
+
--tuning ui \
|
|
49
|
+
--neutral tinted \
|
|
50
|
+
--color-steps 5 \
|
|
51
|
+
--neutral-steps 5 \
|
|
52
|
+
--format json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Supported values:
|
|
56
|
+
|
|
57
|
+
- Harmony: `monochrome`, `analogous`, `complementary`, `triadic`
|
|
58
|
+
- Tuning: `mechanical`, `ui`, `branding`, `data-visualization`
|
|
59
|
+
- Neutrals: `neutral`, `tinted`
|
|
60
|
+
- Step counts: `3`, `5`, `7`, `9`
|
|
61
|
+
- Formats: `hex`, `json`, `css`
|
|
62
|
+
|
|
63
|
+
Run `pnpm dlx quick-palette --help` for the complete command reference. For repeated use, install it globally with `npm install --global quick-palette` and run `quick-palette` directly.
|
|
64
|
+
|
|
65
|
+
## Detailed configuration
|
|
66
|
+
|
|
67
|
+
Choose **Create a custom palette** at startup, or run:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pnpm dlx quick-palette configure
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The detailed flow selects a base color, harmony, harmony adjustment, and neutral style before showing a preview. Monochrome palettes skip the adjustment question because it would not change their colors. Its final actions are:
|
|
74
|
+
|
|
75
|
+
- **Finish and print HEX values** prints HEX and exits with the current scales.
|
|
76
|
+
- **Export as JSON or CSS** prints or saves a file, then lets you finish, export another format, or return to the palette.
|
|
77
|
+
- **Change palette settings** changes the base color, harmony, neutral style, or step counts while preserving other values.
|
|
78
|
+
|
|
79
|
+
Press `e` during exploration to open the field picker directly with the current candidate values preselected.
|
|
80
|
+
|
|
81
|
+
## Palette choices
|
|
82
|
+
|
|
83
|
+
### Base color
|
|
84
|
+
|
|
85
|
+
Enter `#RGB` or `#RRGGBB`, or select a curated color by family, mood, or use case. Random exploration also draws from the deduplicated curated color set.
|
|
86
|
+
|
|
87
|
+
### Harmony
|
|
88
|
+
|
|
89
|
+
- **Monochrome (1 hue + neutrals)** keeps the palette focused.
|
|
90
|
+
- **Analogous (3 neighboring hues + neutrals)** creates cohesive variety.
|
|
91
|
+
- **Complementary (2 opposite hues + neutrals)** creates clear contrast.
|
|
92
|
+
- **Triadic (3 evenly spaced hues + neutrals)** creates colorful balance.
|
|
93
|
+
|
|
94
|
+
### Harmony adjustment
|
|
95
|
+
|
|
96
|
+
- **Fixed angles** preserves predictable color-theory angles.
|
|
97
|
+
- **UI** favors restrained screen accents.
|
|
98
|
+
- **Branding** favors vivid, separated accents.
|
|
99
|
+
- **Data visualization** prioritizes categorical separation.
|
|
100
|
+
|
|
101
|
+
Adjustments are deterministic and bounded to 12 degrees from each fixed harmony angle. Monochrome palettes do not ask for an adjustment because they contain no secondary hue.
|
|
102
|
+
|
|
103
|
+
### Neutrals and steps
|
|
104
|
+
|
|
105
|
+
Neutral gray has zero chroma. Base-tinted gray carries a small amount of the base hue into backgrounds, borders, and text colors.
|
|
106
|
+
|
|
107
|
+
Colors and neutrals default to five lightness steps. Terminal output labels them from `100` to `900` in light-to-dark order, matching CSS output. Wider harmonies display one separately labeled scale per hue. Use **Change palette settings > Step counts** or non-interactive flags when 3, 7, or 9 steps are needed.
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { normalizeHex } from "../core/color.js";
|
|
2
|
+
import { HARMONY_MODES, HARMONY_TUNINGS, NEUTRAL_MODES, STEP_COUNTS, } from "../core/types.js";
|
|
3
|
+
export class CliArgumentError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "CliArgumentError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function parseCliArgs(args) {
|
|
10
|
+
if (args.length === 0)
|
|
11
|
+
return { name: "interactive" };
|
|
12
|
+
if (args.includes("--help") || args.includes("-h"))
|
|
13
|
+
return { name: "help" };
|
|
14
|
+
const [command, ...options] = args;
|
|
15
|
+
if (command === "configure") {
|
|
16
|
+
if (options.length > 0)
|
|
17
|
+
throw new CliArgumentError("The configure command does not accept options.");
|
|
18
|
+
return { name: "configure" };
|
|
19
|
+
}
|
|
20
|
+
if (command === "explore")
|
|
21
|
+
return parseExploreOptions(options);
|
|
22
|
+
if (command === "generate")
|
|
23
|
+
return parseGenerateOptions(options);
|
|
24
|
+
throw new CliArgumentError(`Unknown command: ${command}`);
|
|
25
|
+
}
|
|
26
|
+
function parseExploreOptions(args) {
|
|
27
|
+
const values = parseOptionValues(args, new Set(["--seed"]));
|
|
28
|
+
const seed = values.get("--seed");
|
|
29
|
+
return seed === undefined ? { name: "explore" } : { name: "explore", seed };
|
|
30
|
+
}
|
|
31
|
+
function parseGenerateOptions(args) {
|
|
32
|
+
const values = parseOptionValues(args, new Set([
|
|
33
|
+
"--seed",
|
|
34
|
+
"--base",
|
|
35
|
+
"--harmony",
|
|
36
|
+
"--tuning",
|
|
37
|
+
"--neutral",
|
|
38
|
+
"--color-steps",
|
|
39
|
+
"--neutral-steps",
|
|
40
|
+
"--format",
|
|
41
|
+
"--output",
|
|
42
|
+
]));
|
|
43
|
+
const format = parseChoice(values.get("--format") ?? "hex", "format", ["hex", "json", "css"]);
|
|
44
|
+
const base = values.get("--base");
|
|
45
|
+
const seed = values.get("--seed");
|
|
46
|
+
const harmony = optionalChoice(values.get("--harmony"), "harmony", HARMONY_MODES);
|
|
47
|
+
const harmonyTuning = optionalChoice(values.get("--tuning"), "tuning", HARMONY_TUNINGS);
|
|
48
|
+
const neutralMode = optionalChoice(values.get("--neutral"), "neutral mode", NEUTRAL_MODES);
|
|
49
|
+
const colorSteps = optionalStepCount(values.get("--color-steps"), "color steps");
|
|
50
|
+
const neutralSteps = optionalStepCount(values.get("--neutral-steps"), "neutral steps");
|
|
51
|
+
const outputPath = values.get("--output");
|
|
52
|
+
return {
|
|
53
|
+
name: "generate",
|
|
54
|
+
...(seed === undefined ? {} : { seed }),
|
|
55
|
+
...(base === undefined ? {} : { baseColor: parseBaseColor(base) }),
|
|
56
|
+
...(harmony === undefined ? {} : { harmony }),
|
|
57
|
+
...(harmonyTuning === undefined ? {} : { harmonyTuning }),
|
|
58
|
+
...(neutralMode === undefined ? {} : { neutralMode }),
|
|
59
|
+
...(colorSteps === undefined ? {} : { colorSteps }),
|
|
60
|
+
...(neutralSteps === undefined ? {} : { neutralSteps }),
|
|
61
|
+
format,
|
|
62
|
+
...(outputPath === undefined ? {} : { outputPath }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function parseOptionValues(args, allowed) {
|
|
66
|
+
const values = new Map();
|
|
67
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
68
|
+
const option = args[index];
|
|
69
|
+
const value = args[index + 1];
|
|
70
|
+
if (option === undefined || !allowed.has(option)) {
|
|
71
|
+
throw new CliArgumentError(`Unknown option: ${option ?? ""}`);
|
|
72
|
+
}
|
|
73
|
+
if (values.has(option))
|
|
74
|
+
throw new CliArgumentError(`Duplicate option: ${option}`);
|
|
75
|
+
if (value === undefined || value.startsWith("--")) {
|
|
76
|
+
throw new CliArgumentError(`Missing value for ${option}.`);
|
|
77
|
+
}
|
|
78
|
+
values.set(option, value);
|
|
79
|
+
}
|
|
80
|
+
return values;
|
|
81
|
+
}
|
|
82
|
+
function parseBaseColor(value) {
|
|
83
|
+
try {
|
|
84
|
+
return normalizeHex(value);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new CliArgumentError(`Invalid base color: ${value}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function optionalChoice(value, label, allowed) {
|
|
91
|
+
return value === undefined ? undefined : parseChoice(value, label, allowed);
|
|
92
|
+
}
|
|
93
|
+
function parseChoice(value, label, allowed) {
|
|
94
|
+
if (allowed.includes(value))
|
|
95
|
+
return value;
|
|
96
|
+
throw new CliArgumentError(`Invalid ${label}: ${value}. Expected ${allowed.join(", ")}.`);
|
|
97
|
+
}
|
|
98
|
+
function optionalStepCount(value, label) {
|
|
99
|
+
if (value === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
const parsed = Number(value);
|
|
102
|
+
if (STEP_COUNTS.includes(parsed))
|
|
103
|
+
return parsed;
|
|
104
|
+
throw new CliArgumentError(`Invalid ${label}: ${value}. Expected ${STEP_COUNTS.join(", ")}.`);
|
|
105
|
+
}
|
|
106
|
+
export const HELP_TEXT = `Quick Palette
|
|
107
|
+
|
|
108
|
+
Usage:
|
|
109
|
+
quick-palette
|
|
110
|
+
quick-palette explore [--seed <seed>]
|
|
111
|
+
quick-palette configure
|
|
112
|
+
quick-palette generate [options]
|
|
113
|
+
quick-palette --help
|
|
114
|
+
|
|
115
|
+
Generate options:
|
|
116
|
+
--seed <seed> Reproduce a randomized palette
|
|
117
|
+
--base <hex> Base color (default: #2563EB)
|
|
118
|
+
--harmony <mode> monochrome, analogous, complementary, or triadic
|
|
119
|
+
--tuning <purpose> mechanical, ui, branding, or data-visualization
|
|
120
|
+
--neutral <mode> neutral or tinted
|
|
121
|
+
--color-steps <count> 3, 5, 7, or 9 (default: 5)
|
|
122
|
+
--neutral-steps <count> 3, 5, 7, or 9 (default: 5)
|
|
123
|
+
--format <format> hex, json, or css (default: hex)
|
|
124
|
+
--output <path> Write output to a file instead of stdout
|
|
125
|
+
|
|
126
|
+
Piping:
|
|
127
|
+
Non-interactive generate output is written only to stdout, or only to the
|
|
128
|
+
selected file with --output. Errors are written to stderr.`;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DEFAULT_COLOR_STEPS, DEFAULT_NEUTRAL_STEPS } from "../core/constants.js";
|
|
2
|
+
import { generatePalette } from "../core/generate.js";
|
|
3
|
+
import { formatHexOutput, writeCssOutput, writeJsonOutput } from "./output.js";
|
|
4
|
+
import { promptBaseColor, promptConfigurationAction, promptConfigurationEditAction, promptExportCompleteAction, promptExportDestination, promptExportFormat, promptHarmony, promptHarmonyTuning, promptNeutralMode, promptStepCount, } from "./prompt.js";
|
|
5
|
+
import { formatPreview } from "./preview.js";
|
|
6
|
+
export async function configurePalette(prompt, useColor, initialConfig, dependencies = {}) {
|
|
7
|
+
const output = dependencies.output ?? console.log;
|
|
8
|
+
const writeJson = dependencies.writeJson ?? writeJsonOutput;
|
|
9
|
+
const writeCss = dependencies.writeCss ?? writeCssOutput;
|
|
10
|
+
let config = initialConfig ?? await promptInitialConfig(prompt);
|
|
11
|
+
if (dependencies.editImmediately)
|
|
12
|
+
config = await editConfig(prompt, config);
|
|
13
|
+
while (true) {
|
|
14
|
+
const result = generatePalette(config);
|
|
15
|
+
output(formatPreview(result, useColor));
|
|
16
|
+
while (true) {
|
|
17
|
+
const action = await promptConfigurationAction(prompt);
|
|
18
|
+
if (action === "accept") {
|
|
19
|
+
output(`\n${formatHexOutput(result, useColor)}`);
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
if (action === "edit") {
|
|
23
|
+
config = await editConfig(prompt, config);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
const exportAction = await exportPalette(prompt, result, writeJson, writeCss);
|
|
27
|
+
if (exportAction === "done")
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function promptInitialConfig(prompt) {
|
|
33
|
+
const baseColor = await promptBaseColor(prompt);
|
|
34
|
+
const harmony = await promptHarmony(prompt);
|
|
35
|
+
const harmonyTuning = harmony === "monochrome"
|
|
36
|
+
? "mechanical"
|
|
37
|
+
: await promptHarmonyTuning(prompt);
|
|
38
|
+
return {
|
|
39
|
+
baseColor,
|
|
40
|
+
harmony,
|
|
41
|
+
harmonyTuning,
|
|
42
|
+
neutralMode: await promptNeutralMode(prompt),
|
|
43
|
+
colorSteps: DEFAULT_COLOR_STEPS,
|
|
44
|
+
neutralSteps: DEFAULT_NEUTRAL_STEPS,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async function editConfig(prompt, config) {
|
|
48
|
+
const action = await promptConfigurationEditAction(prompt);
|
|
49
|
+
if (action === "cancel")
|
|
50
|
+
return config;
|
|
51
|
+
if (action === "base") {
|
|
52
|
+
return { ...config, baseColor: await promptBaseColor(prompt, config.baseColor) };
|
|
53
|
+
}
|
|
54
|
+
if (action === "harmony") {
|
|
55
|
+
const harmony = await promptHarmony(prompt, config.harmony);
|
|
56
|
+
const harmonyTuning = harmony === "monochrome"
|
|
57
|
+
? "mechanical"
|
|
58
|
+
: await promptHarmonyTuning(prompt, config.harmonyTuning ?? "mechanical");
|
|
59
|
+
return { ...config, harmony, harmonyTuning };
|
|
60
|
+
}
|
|
61
|
+
if (action === "neutral") {
|
|
62
|
+
const neutralMode = await promptNeutralMode(prompt, config.neutralMode);
|
|
63
|
+
return { ...config, neutralMode };
|
|
64
|
+
}
|
|
65
|
+
const colorSteps = await promptStepCount(prompt, "Choose the number of color steps", config.colorSteps);
|
|
66
|
+
const neutralSteps = await promptStepCount(prompt, "Choose the number of neutral steps", config.neutralSteps);
|
|
67
|
+
return { ...config, colorSteps, neutralSteps };
|
|
68
|
+
}
|
|
69
|
+
async function exportPalette(prompt, result, writeJson, writeCss) {
|
|
70
|
+
while (true) {
|
|
71
|
+
const format = await promptExportFormat(prompt);
|
|
72
|
+
if (format === "back")
|
|
73
|
+
return "back";
|
|
74
|
+
const destination = await promptExportDestination(prompt, format);
|
|
75
|
+
if (destination.mode === "back")
|
|
76
|
+
continue;
|
|
77
|
+
if (format === "json") {
|
|
78
|
+
if (destination.mode === "save")
|
|
79
|
+
await writeJson(result, destination.path);
|
|
80
|
+
else
|
|
81
|
+
await writeJson(result);
|
|
82
|
+
}
|
|
83
|
+
if (format === "css") {
|
|
84
|
+
if (destination.mode === "save")
|
|
85
|
+
await writeCss(result, destination.path);
|
|
86
|
+
else
|
|
87
|
+
await writeCss(result);
|
|
88
|
+
}
|
|
89
|
+
const nextAction = await promptExportCompleteAction(prompt);
|
|
90
|
+
if (nextAction === "done")
|
|
91
|
+
return "done";
|
|
92
|
+
if (nextAction === "back")
|
|
93
|
+
return "back";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { generatePalette } from "../core/generate.js";
|
|
2
|
+
import { generateRandomPaletteConfig } from "../core/random.js";
|
|
3
|
+
import { formatHexOutput } from "./output.js";
|
|
4
|
+
import { promptExplorationAction, } from "./prompt.js";
|
|
5
|
+
import { formatPreview } from "./preview.js";
|
|
6
|
+
const MAX_NEXT_ATTEMPTS = 1024;
|
|
7
|
+
export async function explorePalettes(prompt, useColor, dependencies = {}) {
|
|
8
|
+
const randomConfig = dependencies.randomConfig ?? generateRandomPaletteConfig;
|
|
9
|
+
const generate = dependencies.generate ?? generatePalette;
|
|
10
|
+
const promptAction = dependencies.promptAction ?? promptExplorationAction;
|
|
11
|
+
const output = dependencies.output ?? console.log;
|
|
12
|
+
const initialRandom = dependencies.initialSeed === undefined
|
|
13
|
+
? randomConfig()
|
|
14
|
+
: randomConfig({ seed: dependencies.initialSeed });
|
|
15
|
+
let candidate = createCandidate(initialRandom, generate);
|
|
16
|
+
while (true) {
|
|
17
|
+
output(`${formatPreview(candidate.result, useColor)}\n\nSeed: ${candidate.seed}`);
|
|
18
|
+
const action = await promptAction(prompt);
|
|
19
|
+
if (action === "accept") {
|
|
20
|
+
output(`\n${formatHexOutput(candidate.result, useColor)}`);
|
|
21
|
+
return { action, candidate };
|
|
22
|
+
}
|
|
23
|
+
if (action === "edit")
|
|
24
|
+
return { action, config: candidate.config };
|
|
25
|
+
if (action === "quit")
|
|
26
|
+
return { action };
|
|
27
|
+
candidate = nextCandidate(candidate, randomConfig, generate);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function createCandidate(random, generate) {
|
|
31
|
+
return { ...random, result: generate(random.config) };
|
|
32
|
+
}
|
|
33
|
+
function nextCandidate(current, randomConfig, generate) {
|
|
34
|
+
const currentSeed = Number.parseInt(current.seed, 16);
|
|
35
|
+
for (let offset = 1; offset <= MAX_NEXT_ATTEMPTS; offset += 1) {
|
|
36
|
+
const seed = (currentSeed + offset) >>> 0;
|
|
37
|
+
const next = createCandidate(randomConfig({ seed }), generate);
|
|
38
|
+
if (!samePalette(current.result, next.result))
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
throw new Error("Unable to generate a visually different palette.");
|
|
42
|
+
}
|
|
43
|
+
function samePalette(left, right) {
|
|
44
|
+
return sameColors(left.colors, right.colors) && sameColors(left.neutrals, right.neutrals);
|
|
45
|
+
}
|
|
46
|
+
function sameColors(left, right) {
|
|
47
|
+
return left.length === right.length && left.every((color, index) => color === right[index]);
|
|
48
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { DEFAULT_COLOR_STEPS, DEFAULT_NEUTRAL_STEPS } from "../core/constants.js";
|
|
3
|
+
import { generatePalette } from "../core/generate.js";
|
|
4
|
+
import { generateRandomPaletteConfig } from "../core/random.js";
|
|
5
|
+
import { formatCssOutput, formatHexOutput, formatJsonOutput } from "./output.js";
|
|
6
|
+
const DEFAULT_GENERATE_CONFIG = {
|
|
7
|
+
baseColor: "#2563EB",
|
|
8
|
+
harmony: "analogous",
|
|
9
|
+
harmonyTuning: "mechanical",
|
|
10
|
+
neutralMode: "neutral",
|
|
11
|
+
colorSteps: DEFAULT_COLOR_STEPS,
|
|
12
|
+
neutralSteps: DEFAULT_NEUTRAL_STEPS,
|
|
13
|
+
};
|
|
14
|
+
export async function runGenerateCommand(command) {
|
|
15
|
+
const config = resolveConfig(command);
|
|
16
|
+
const result = generatePalette(config);
|
|
17
|
+
const content = `${formatResult(result, command.format)}\n`;
|
|
18
|
+
if (command.outputPath === undefined) {
|
|
19
|
+
process.stdout.write(content);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
await writeFile(command.outputPath, content, "utf8");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function resolveConfig(command) {
|
|
26
|
+
const constraints = {
|
|
27
|
+
...(command.baseColor === undefined ? {} : { baseColor: command.baseColor }),
|
|
28
|
+
...(command.harmony === undefined ? {} : { harmony: command.harmony }),
|
|
29
|
+
...(command.harmonyTuning === undefined ? {} : { harmonyTuning: command.harmonyTuning }),
|
|
30
|
+
...(command.neutralMode === undefined ? {} : { neutralMode: command.neutralMode }),
|
|
31
|
+
...(command.colorSteps === undefined ? {} : { colorSteps: command.colorSteps }),
|
|
32
|
+
...(command.neutralSteps === undefined ? {} : { neutralSteps: command.neutralSteps }),
|
|
33
|
+
};
|
|
34
|
+
if (command.seed !== undefined) {
|
|
35
|
+
return generateRandomPaletteConfig({ seed: command.seed, constraints }).config;
|
|
36
|
+
}
|
|
37
|
+
return { ...DEFAULT_GENERATE_CONFIG, ...constraints };
|
|
38
|
+
}
|
|
39
|
+
function formatResult(result, format) {
|
|
40
|
+
if (format === "json")
|
|
41
|
+
return formatJsonOutput(result);
|
|
42
|
+
if (format === "css")
|
|
43
|
+
return formatCssOutput(result).trimEnd();
|
|
44
|
+
return formatHexOutput(result, false);
|
|
45
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { argv, env, stdout } from "node:process";
|
|
3
|
+
import { CliArgumentError, HELP_TEXT, parseCliArgs } from "./args.js";
|
|
4
|
+
import { configurePalette } from "./configure.js";
|
|
5
|
+
import { explorePalettes } from "./explore.js";
|
|
6
|
+
import { runGenerateCommand } from "./generate-command.js";
|
|
7
|
+
import { assertSupportedNodeVersion } from "./node-version.js";
|
|
8
|
+
import { createPromptInterface, PromptCancelledError, promptStartupMode, } from "./prompt.js";
|
|
9
|
+
async function run(args) {
|
|
10
|
+
assertSupportedNodeVersion();
|
|
11
|
+
const command = parseCliArgs(args);
|
|
12
|
+
if (command.name === "help") {
|
|
13
|
+
console.log(HELP_TEXT);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (command.name === "generate") {
|
|
17
|
+
await runGenerateCommand(command);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const rl = createPromptInterface();
|
|
21
|
+
const useColor = Boolean(stdout.isTTY && env.NO_COLOR === undefined);
|
|
22
|
+
console.log("Quick Palette");
|
|
23
|
+
try {
|
|
24
|
+
if (command.name === "explore") {
|
|
25
|
+
const outcome = await explorePalettes(rl, useColor, {
|
|
26
|
+
...(command.seed === undefined ? {} : { initialSeed: command.seed }),
|
|
27
|
+
});
|
|
28
|
+
if (outcome.action === "edit") {
|
|
29
|
+
await configurePalette(rl, useColor, outcome.config, { editImmediately: true });
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (command.name === "configure") {
|
|
34
|
+
await configurePalette(rl, useColor);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const startupMode = await promptStartupMode(rl);
|
|
38
|
+
if (startupMode === "explore") {
|
|
39
|
+
const outcome = await explorePalettes(rl, useColor);
|
|
40
|
+
if (outcome.action !== "edit")
|
|
41
|
+
return;
|
|
42
|
+
await configurePalette(rl, useColor, outcome.config, { editImmediately: true });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await configurePalette(rl, useColor);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
rl.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
run(argv.slice(2)).catch((error) => {
|
|
52
|
+
if (error instanceof PromptCancelledError) {
|
|
53
|
+
console.error("Cancelled.");
|
|
54
|
+
process.exitCode = 130;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (error instanceof CliArgumentError) {
|
|
58
|
+
console.error(`Error: ${error.message}\nRun quick-palette --help for usage.`);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
console.error(`Could not complete the command: ${message}`);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const MINIMUM_NODE_MAJOR = 22;
|
|
2
|
+
export function assertSupportedNodeVersion(version = process.versions.node) {
|
|
3
|
+
const major = Number.parseInt(version.split(".")[0] ?? "", 10);
|
|
4
|
+
if (!Number.isInteger(major) || major < MINIMUM_NODE_MAJOR) {
|
|
5
|
+
throw new Error(`Node.js ${MINIMUM_NODE_MAJOR} or later is required (current: ${version}).`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { formatColorSwatch } from "./terminal-color.js";
|
|
3
|
+
export function formatHexOutput(result, useColor = false) {
|
|
4
|
+
const colorLabels = STEP_LABELS[result.config.colorSteps];
|
|
5
|
+
const neutralLabels = STEP_LABELS[result.config.neutralSteps];
|
|
6
|
+
const colorGroups = [];
|
|
7
|
+
for (let start = 0; start < result.colors.length; start += result.config.colorSteps) {
|
|
8
|
+
const colors = [...result.colors.slice(start, start + result.config.colorSteps)].reverse();
|
|
9
|
+
colorGroups.push([
|
|
10
|
+
`Color ${colorGroups.length + 1}`,
|
|
11
|
+
...formatScale(colors, colorLabels, useColor),
|
|
12
|
+
].join("\n"));
|
|
13
|
+
}
|
|
14
|
+
return [
|
|
15
|
+
"Colors",
|
|
16
|
+
colorGroups.join("\n\n"),
|
|
17
|
+
"",
|
|
18
|
+
"Neutrals",
|
|
19
|
+
...formatScale(result.neutrals, neutralLabels, useColor),
|
|
20
|
+
].join("\n");
|
|
21
|
+
}
|
|
22
|
+
export function formatJsonOutput(result) {
|
|
23
|
+
return JSON.stringify(result, null, 2);
|
|
24
|
+
}
|
|
25
|
+
const STEP_LABELS = {
|
|
26
|
+
3: [100, 500, 900],
|
|
27
|
+
5: [100, 300, 500, 700, 900],
|
|
28
|
+
7: [100, 200, 300, 500, 700, 800, 900],
|
|
29
|
+
9: [100, 200, 300, 400, 500, 600, 700, 800, 900],
|
|
30
|
+
};
|
|
31
|
+
function formatScale(colors, labels, useColor) {
|
|
32
|
+
return colors.map((hex, index) => (` ${String(labels[index]).padStart(3)} ${formatColorSwatch(hex, useColor).trimStart()}`));
|
|
33
|
+
}
|
|
34
|
+
export function formatCssOutput(result) {
|
|
35
|
+
const colorLabels = STEP_LABELS[result.config.colorSteps];
|
|
36
|
+
const neutralLabels = STEP_LABELS[result.config.neutralSteps];
|
|
37
|
+
const colorGroups = [];
|
|
38
|
+
for (let start = 0; start < result.colors.length; start += result.config.colorSteps) {
|
|
39
|
+
const colors = [...result.colors.slice(start, start + result.config.colorSteps)].reverse();
|
|
40
|
+
const groupNumber = colorGroups.length + 1;
|
|
41
|
+
colorGroups.push(colors.map((hex, index) => (` --palette-color-${groupNumber}-${colorLabels[index]}: ${hex};`)));
|
|
42
|
+
}
|
|
43
|
+
const neutrals = result.neutrals.map((hex, index) => (` --palette-neutral-${neutralLabels[index]}: ${hex};`));
|
|
44
|
+
const declarations = [...colorGroups, neutrals]
|
|
45
|
+
.map((group) => group.join("\n"))
|
|
46
|
+
.join("\n\n");
|
|
47
|
+
return `:root {\n${declarations}\n}\n`;
|
|
48
|
+
}
|
|
49
|
+
export async function writeJsonOutput(result, outputPath) {
|
|
50
|
+
const json = `${formatJsonOutput(result)}\n`;
|
|
51
|
+
if (!outputPath) {
|
|
52
|
+
console.log(`\nJSON\n${json}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
await writeFile(outputPath, json, "utf8");
|
|
56
|
+
console.log(`JSON saved to ${outputPath}`);
|
|
57
|
+
}
|
|
58
|
+
export async function writeCssOutput(result, outputPath) {
|
|
59
|
+
const css = formatCssOutput(result);
|
|
60
|
+
if (!outputPath) {
|
|
61
|
+
console.log(`\nCSS\n${css}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await writeFile(outputPath, css, "utf8");
|
|
65
|
+
console.log(`CSS saved to ${outputPath}`);
|
|
66
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { formatHexOutput } from "./output.js";
|
|
2
|
+
const HARMONY_LABELS = {
|
|
3
|
+
monochrome: "Monochrome",
|
|
4
|
+
analogous: "Analogous",
|
|
5
|
+
complementary: "Complementary",
|
|
6
|
+
triadic: "Triadic",
|
|
7
|
+
};
|
|
8
|
+
const HARMONY_TUNING_LABELS = {
|
|
9
|
+
mechanical: "Fixed angles",
|
|
10
|
+
ui: "UI",
|
|
11
|
+
branding: "Branding",
|
|
12
|
+
"data-visualization": "Data visualization",
|
|
13
|
+
};
|
|
14
|
+
const NEUTRAL_LABELS = {
|
|
15
|
+
neutral: "Neutral gray",
|
|
16
|
+
tinted: "Base-tinted gray",
|
|
17
|
+
};
|
|
18
|
+
export function formatPreview(result, useColor) {
|
|
19
|
+
const metadata = [
|
|
20
|
+
`Base color: ${result.config.baseColor}`,
|
|
21
|
+
`Harmony: ${HARMONY_LABELS[result.config.harmony]}`,
|
|
22
|
+
...(result.config.harmony === "monochrome"
|
|
23
|
+
? []
|
|
24
|
+
: [`Harmony style: ${HARMONY_TUNING_LABELS[result.config.harmonyTuning ?? "mechanical"]}`]),
|
|
25
|
+
`Neutrals: ${NEUTRAL_LABELS[result.config.neutralMode]}`,
|
|
26
|
+
];
|
|
27
|
+
return [
|
|
28
|
+
"",
|
|
29
|
+
"Palette preview",
|
|
30
|
+
...metadata,
|
|
31
|
+
"",
|
|
32
|
+
formatHexOutput(result, useColor),
|
|
33
|
+
].join("\n");
|
|
34
|
+
}
|