colbrush 1.0.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/dist/KR-BEBXLJC6.svg +27 -0
- package/dist/US-H2QUNREB.svg +23 -0
- package/dist/cli.cjs +398 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +375 -0
- package/dist/client.cjs +393 -0
- package/dist/client.d.cts +23 -0
- package/dist/client.d.ts +23 -0
- package/dist/client.js +361 -0
- package/dist/default-EPJEL445.svg +4 -0
- package/dist/deuteranopia-C23MR6FZ.svg +4 -0
- package/dist/logo-RY5YYL6A.svg +10 -0
- package/dist/protanopia-HOT6PUHN.svg +5 -0
- package/dist/styles.css +2 -0
- package/dist/tritanopia-MTE6DBJL.svg +5 -0
- package/package.json +80 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/node/parseFlags.ts
|
|
4
|
+
function parseFlags(argv = process.argv.slice(2)) {
|
|
5
|
+
const out = { _: [] };
|
|
6
|
+
for (const s of argv) {
|
|
7
|
+
const m = /^--([^=]+)=(.*)$/.exec(s);
|
|
8
|
+
if (m) out[m[1]] = m[2];
|
|
9
|
+
else if (s.startsWith("--")) out[s.slice(2)] = true;
|
|
10
|
+
else out._.push(s);
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/constants/regex.ts
|
|
16
|
+
var variableRegex = /(--color-[\w-]+):\s*(#[0-9a-fA-F]{3,8})/g;
|
|
17
|
+
var variationRegex = /^--(.+?)-(50|100|200|300|400|500|600|700|800|900)$/i;
|
|
18
|
+
|
|
19
|
+
// src/node/applyThemes.ts
|
|
20
|
+
import fs from "fs";
|
|
21
|
+
import postcss, { Rule } from "postcss";
|
|
22
|
+
import safeParser from "postcss-safe-parser";
|
|
23
|
+
|
|
24
|
+
// src/utils/core/colorScale.ts
|
|
25
|
+
import Color from "colorjs.io";
|
|
26
|
+
|
|
27
|
+
// src/constants/variation.ts
|
|
28
|
+
var DEFAULT_KEYS = [
|
|
29
|
+
"50",
|
|
30
|
+
"100",
|
|
31
|
+
"200",
|
|
32
|
+
"300",
|
|
33
|
+
"400",
|
|
34
|
+
"500",
|
|
35
|
+
"600",
|
|
36
|
+
"700",
|
|
37
|
+
"800",
|
|
38
|
+
"900"
|
|
39
|
+
];
|
|
40
|
+
var DEFAULT_SCALE = {
|
|
41
|
+
"50": { dL: 0.36, cMul: 0.95 },
|
|
42
|
+
"100": { dL: 0.28, cMul: 0.96 },
|
|
43
|
+
"200": { dL: 0.18, cMul: 0.98 },
|
|
44
|
+
"300": { dL: 0.08, cMul: 0.99 },
|
|
45
|
+
"400": { dL: 0.02, cMul: 1 },
|
|
46
|
+
"500": { dL: 0, cMul: 1 },
|
|
47
|
+
"600": { dL: -0.05, cMul: 0.98 },
|
|
48
|
+
"700": { dL: -0.15, cMul: 0.94 },
|
|
49
|
+
"800": { dL: -0.22, cMul: 0.9 },
|
|
50
|
+
"900": { dL: -0.3, cMul: 0.88 }
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// src/utils/core/colorScale.ts
|
|
54
|
+
var CLAMP01 = (x) => Math.max(0, Math.min(1, x));
|
|
55
|
+
function hexToOKLCH(hex) {
|
|
56
|
+
const c = new Color(hex);
|
|
57
|
+
const o = c.to("oklch");
|
|
58
|
+
return { l: o.l, c: o.c, h: o.h ?? 0 };
|
|
59
|
+
}
|
|
60
|
+
function oklchToHex(l, c, h) {
|
|
61
|
+
const color = new Color("oklch", [l, c, h]);
|
|
62
|
+
const srgb = color.to("srgb");
|
|
63
|
+
const r = CLAMP01(srgb.r), g = CLAMP01(srgb.g), b = CLAMP01(srgb.b);
|
|
64
|
+
return new Color("srgb", [r, g, b]).toString({ format: "hex" });
|
|
65
|
+
}
|
|
66
|
+
function buildScaleFromBaseHex(baseHex, opts) {
|
|
67
|
+
const keys = opts?.keys ?? DEFAULT_KEYS;
|
|
68
|
+
const cMin = opts?.cMin ?? 0.02;
|
|
69
|
+
const cMax = opts?.cMax ?? 0.4;
|
|
70
|
+
const lockHue = true;
|
|
71
|
+
const base = hexToOKLCH(baseHex);
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const k of keys) {
|
|
74
|
+
const pat = DEFAULT_SCALE[k] ?? DEFAULT_SCALE["500"];
|
|
75
|
+
const L = CLAMP01(base.l + pat.dL);
|
|
76
|
+
const C = Math.max(cMin, Math.min(cMax, base.c * pat.cMul));
|
|
77
|
+
const H = lockHue ? base.h : base.h;
|
|
78
|
+
out[k] = oklchToHex(L, C, H);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/node/applyThemes.ts
|
|
84
|
+
var CSS_PATH = "src/index.css";
|
|
85
|
+
function toThemeColor(hex, _vision) {
|
|
86
|
+
return hex;
|
|
87
|
+
}
|
|
88
|
+
function loadRoot(cssPath = CSS_PATH) {
|
|
89
|
+
const css = fs.readFileSync(cssPath, "utf8");
|
|
90
|
+
return postcss().process(css, { parser: safeParser }).root;
|
|
91
|
+
}
|
|
92
|
+
function getExistingKeysForToken(root, token) {
|
|
93
|
+
const keys = /* @__PURE__ */ new Set();
|
|
94
|
+
root.walkDecls((d) => {
|
|
95
|
+
if (!d.prop.startsWith("--")) return;
|
|
96
|
+
const m = d.prop.match(variationRegex);
|
|
97
|
+
if (!m) return;
|
|
98
|
+
const [, t, k] = m;
|
|
99
|
+
if (t === token) keys.add(k);
|
|
100
|
+
});
|
|
101
|
+
return Array.from(keys);
|
|
102
|
+
}
|
|
103
|
+
function upsertBlock(root, selector, kv) {
|
|
104
|
+
let rule;
|
|
105
|
+
root.walkRules((r) => {
|
|
106
|
+
if (r.selector === selector) rule = r;
|
|
107
|
+
});
|
|
108
|
+
if (!rule) {
|
|
109
|
+
rule = new Rule({ selector });
|
|
110
|
+
root.append(rule);
|
|
111
|
+
}
|
|
112
|
+
const remain = new Map(Object.entries(kv));
|
|
113
|
+
rule.walkDecls((d) => {
|
|
114
|
+
if (remain.has(d.prop)) {
|
|
115
|
+
d.value = remain.get(d.prop);
|
|
116
|
+
remain.delete(d.prop);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
for (const [prop, value] of remain) rule.append({ prop, value });
|
|
120
|
+
}
|
|
121
|
+
function applyThemes(input, cssPath = CSS_PATH) {
|
|
122
|
+
const root = loadRoot(cssPath);
|
|
123
|
+
const selector = `[data-theme='${input.vision}']`;
|
|
124
|
+
const varsForTheme = {};
|
|
125
|
+
for (const [varName, val] of Object.entries(input.variables)) {
|
|
126
|
+
const rich = typeof val === "string" ? inferRich(varName, val) : val;
|
|
127
|
+
const m = varName.match(variationRegex);
|
|
128
|
+
const isPattern = !!m;
|
|
129
|
+
if (isPattern) {
|
|
130
|
+
const [, token] = m;
|
|
131
|
+
const keys = rich.keys && rich.keys.length ? rich.keys : getExistingKeysForToken(root, token).length ? getExistingKeysForToken(root, token) : Array.from(DEFAULT_KEYS);
|
|
132
|
+
if (rich.scale !== false) {
|
|
133
|
+
const scaleMap = buildScaleFromBaseHex(rich.base, {
|
|
134
|
+
keys
|
|
135
|
+
});
|
|
136
|
+
for (const k of keys) {
|
|
137
|
+
const hex = scaleMap[k];
|
|
138
|
+
varsForTheme[`--${token}-${k}`] = toThemeColor(
|
|
139
|
+
hex,
|
|
140
|
+
input.vision
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
varsForTheme[varName] = toThemeColor(rich.base, input.vision);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
varsForTheme[varName] = toThemeColor(rich.base, input.vision);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
upsertBlock(root, selector, varsForTheme);
|
|
151
|
+
fs.writeFileSync(cssPath, root.toString(), "utf8");
|
|
152
|
+
console.log(`\u2705 [${input.vision}] theme updated in ${cssPath}`);
|
|
153
|
+
}
|
|
154
|
+
function inferRich(varName, baseHex) {
|
|
155
|
+
return variationRegex.test(varName) ? { base: baseHex, scale: true } : { base: baseHex, scale: false };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/node/colorTransform.ts
|
|
159
|
+
import chroma from "chroma-js";
|
|
160
|
+
import blinder from "color-blind";
|
|
161
|
+
var THRESHOLD = 10;
|
|
162
|
+
var CANDIDATE_COUNT = 10;
|
|
163
|
+
var HUE_STEP = 180;
|
|
164
|
+
function generateCandidates(hex) {
|
|
165
|
+
const [baseHue] = chroma(hex).hcl();
|
|
166
|
+
const candidates = [];
|
|
167
|
+
candidates[0] = hex;
|
|
168
|
+
for (let i = 1; i <= CANDIDATE_COUNT / 2; i++) {
|
|
169
|
+
candidates[i * 2 - 1] = chroma(hex).set("hcl.h", (baseHue + HUE_STEP / CANDIDATE_COUNT * i) % 360).hex();
|
|
170
|
+
candidates[i * 2] = chroma(hex).set("hcl.h", (baseHue - HUE_STEP / CANDIDATE_COUNT * i + 360) % 360).hex();
|
|
171
|
+
}
|
|
172
|
+
return candidates;
|
|
173
|
+
}
|
|
174
|
+
function findOptimalColorCombination(colorKeys, baseColorsArray, candidateList, vision) {
|
|
175
|
+
const n = baseColorsArray.length;
|
|
176
|
+
let bestColors = null;
|
|
177
|
+
let minDeltaSum = Infinity;
|
|
178
|
+
let blind;
|
|
179
|
+
switch (vision) {
|
|
180
|
+
// 적색맹
|
|
181
|
+
case "protanopia":
|
|
182
|
+
blind = blinder.protanopia;
|
|
183
|
+
break;
|
|
184
|
+
// 녹색맹
|
|
185
|
+
case "deuteranopia":
|
|
186
|
+
blind = blinder.deuteranopia;
|
|
187
|
+
break;
|
|
188
|
+
// 청색맹
|
|
189
|
+
case "tritanopia":
|
|
190
|
+
blind = blinder.tritanopia;
|
|
191
|
+
break;
|
|
192
|
+
default:
|
|
193
|
+
throw new Error("Invalid color blindness option");
|
|
194
|
+
}
|
|
195
|
+
function dfs(index, currentColors, totalDelta) {
|
|
196
|
+
if (index === n) {
|
|
197
|
+
if (isValidColors(currentColors, blind)) {
|
|
198
|
+
if (totalDelta < minDeltaSum) {
|
|
199
|
+
minDeltaSum = totalDelta;
|
|
200
|
+
bestColors = [...currentColors];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
for (const candidate of candidateList[index]) {
|
|
206
|
+
const delta = chroma.deltaE(baseColorsArray[index], candidate);
|
|
207
|
+
if (totalDelta + delta > minDeltaSum) continue;
|
|
208
|
+
currentColors.push(candidate);
|
|
209
|
+
dfs(index + 1, currentColors, totalDelta + delta);
|
|
210
|
+
currentColors.pop();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
dfs(0, [], 0);
|
|
214
|
+
const finalColors = bestColors ?? [...baseColorsArray];
|
|
215
|
+
return colorKeys.reduce((acc, key, idx) => {
|
|
216
|
+
acc[key] = finalColors[idx];
|
|
217
|
+
return acc;
|
|
218
|
+
}, {});
|
|
219
|
+
}
|
|
220
|
+
async function requestColorTransformation(variables) {
|
|
221
|
+
const scaleGroups = {};
|
|
222
|
+
const filteredVariables = {};
|
|
223
|
+
for (const key in variables) {
|
|
224
|
+
if (variables[key].scale) {
|
|
225
|
+
const prefixMatch = key.match(/^(--[\w-]+)-\d+$/);
|
|
226
|
+
if (!prefixMatch) {
|
|
227
|
+
filteredVariables[key] = variables[key];
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const prefix = prefixMatch[1];
|
|
231
|
+
if (!scaleGroups[prefix]) scaleGroups[prefix] = [];
|
|
232
|
+
scaleGroups[prefix].push(key);
|
|
233
|
+
} else {
|
|
234
|
+
filteredVariables[key] = variables[key];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const prefix in scaleGroups) {
|
|
238
|
+
const keys = scaleGroups[prefix];
|
|
239
|
+
const middleKey = getMiddleScaleKey(keys);
|
|
240
|
+
if (middleKey) {
|
|
241
|
+
filteredVariables[middleKey] = variables[middleKey];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const colorKeys = Object.keys(filteredVariables);
|
|
245
|
+
const baseColorsArray = Object.values(filteredVariables).map((v) => v.base);
|
|
246
|
+
const candidateList = baseColorsArray.map((color) => {
|
|
247
|
+
return generateCandidates(color);
|
|
248
|
+
});
|
|
249
|
+
const colors_d = findOptimalColorCombination(colorKeys, baseColorsArray, candidateList, "deuteranopia");
|
|
250
|
+
const colors_p = findOptimalColorCombination(colorKeys, baseColorsArray, candidateList, "protanopia");
|
|
251
|
+
const colors_t = findOptimalColorCombination(colorKeys, baseColorsArray, candidateList, "tritanopia");
|
|
252
|
+
const visions = ["deuteranopia", "protanopia", "tritanopia"];
|
|
253
|
+
const colorsMap = {
|
|
254
|
+
deuteranopia: colors_d,
|
|
255
|
+
protanopia: colors_p,
|
|
256
|
+
tritanopia: colors_t
|
|
257
|
+
};
|
|
258
|
+
const themes = visions.map((vision) => ({
|
|
259
|
+
vision,
|
|
260
|
+
variables: Object.entries(colorsMap[vision]).reduce((acc, [varName, baseHex]) => {
|
|
261
|
+
acc[varName] = inferRich(varName, baseHex);
|
|
262
|
+
return acc;
|
|
263
|
+
}, {})
|
|
264
|
+
}));
|
|
265
|
+
return { themes };
|
|
266
|
+
}
|
|
267
|
+
function getMiddleScaleKey(keys) {
|
|
268
|
+
const scaleNumbers = keys.map((k) => {
|
|
269
|
+
const m = k.match(/\d+$/);
|
|
270
|
+
return m ? parseInt(m[0], 10) : null;
|
|
271
|
+
}).filter((n) => n !== null);
|
|
272
|
+
if (scaleNumbers.length === 0) return null;
|
|
273
|
+
scaleNumbers.sort((a, b) => a - b);
|
|
274
|
+
const midIndex = Math.floor(scaleNumbers.length / 2);
|
|
275
|
+
const midNumber = scaleNumbers[midIndex];
|
|
276
|
+
const middleKey = keys.find((k) => k.endsWith(midNumber.toString())) || null;
|
|
277
|
+
return middleKey;
|
|
278
|
+
}
|
|
279
|
+
function isValidColors(colors, blind) {
|
|
280
|
+
for (let i = 0; i < colors.length; i++) {
|
|
281
|
+
for (let j = i + 1; j < colors.length; j++) {
|
|
282
|
+
if (chroma.deltaE(blind(colors[i]), blind(colors[j])) < THRESHOLD) return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/node/runThemeApply.ts
|
|
289
|
+
import fs2 from "fs";
|
|
290
|
+
|
|
291
|
+
// src/node/removeExistingThemeBlocks.ts
|
|
292
|
+
function removeExistingThemeBlocks(content) {
|
|
293
|
+
const visions = ["protanopia", "deuteranopia", "tritanopia"];
|
|
294
|
+
let cleaned = content;
|
|
295
|
+
for (const vision of visions) {
|
|
296
|
+
const pattern = new RegExp(
|
|
297
|
+
`\\/\\*\\s*${vision} theme start\\s*\\*\\/[^]*?\\/\\*\\s*${vision} theme end\\s*\\*\\/`,
|
|
298
|
+
"gm"
|
|
299
|
+
);
|
|
300
|
+
cleaned = cleaned.replace(pattern, "");
|
|
301
|
+
}
|
|
302
|
+
return cleaned.trim();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/node/runThemeApply.ts
|
|
306
|
+
function isBlackOrWhite(hexColor) {
|
|
307
|
+
const hex = hexColor.toLowerCase().replace("#", "");
|
|
308
|
+
const fullHex = hex.length === 3 ? hex.split("").map((char) => char + char).join("") : hex;
|
|
309
|
+
const r = parseInt(fullHex.substr(0, 2), 16);
|
|
310
|
+
const g = parseInt(fullHex.substr(2, 2), 16);
|
|
311
|
+
const b = parseInt(fullHex.substr(4, 2), 16);
|
|
312
|
+
const isWhite = r >= 250 && g >= 250 && b >= 250;
|
|
313
|
+
const isBlack = r <= 10 && g <= 10 && b <= 10;
|
|
314
|
+
return isWhite || isBlack;
|
|
315
|
+
}
|
|
316
|
+
function calculateScale(varName, hexColor) {
|
|
317
|
+
if (isBlackOrWhite(hexColor)) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
return /\d+$/.test(varName);
|
|
321
|
+
}
|
|
322
|
+
async function runThemeApply(cssPath) {
|
|
323
|
+
if (!fs2.existsSync(cssPath)) {
|
|
324
|
+
throw new Error(`\u274C CSS \uD30C\uC77C\uC774 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${cssPath}`);
|
|
325
|
+
}
|
|
326
|
+
let content = fs2.readFileSync(cssPath, "utf8");
|
|
327
|
+
content = removeExistingThemeBlocks(content);
|
|
328
|
+
const variables = {};
|
|
329
|
+
let match;
|
|
330
|
+
while ((match = variableRegex.exec(content)) !== null) {
|
|
331
|
+
const [, key, value] = match;
|
|
332
|
+
const cleanKey = key.trim();
|
|
333
|
+
const cleanValue = value.trim();
|
|
334
|
+
const scale = calculateScale(cleanKey, cleanValue);
|
|
335
|
+
const rich = {
|
|
336
|
+
base: cleanValue,
|
|
337
|
+
scale
|
|
338
|
+
};
|
|
339
|
+
variables[cleanKey] = rich;
|
|
340
|
+
}
|
|
341
|
+
const visions = ["deuteranopia", "protanopia", "tritanopia"];
|
|
342
|
+
try {
|
|
343
|
+
const algorithmResult = await requestColorTransformation(variables);
|
|
344
|
+
for (const themeData of algorithmResult.themes) {
|
|
345
|
+
await applyThemes(themeData, cssPath);
|
|
346
|
+
}
|
|
347
|
+
} catch (error) {
|
|
348
|
+
console.log("\u{1F680} ~ runThemeApply ~ error:", error);
|
|
349
|
+
for (const vision of visions) {
|
|
350
|
+
await applyThemes({ vision, variables }, cssPath);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
console.log(`\u2705 ${cssPath}\uC5D0 \uC0C9\uB9F9 \uD14C\uB9C8\uAC00 \uC801\uC6A9\uB418\uC5C8\uC2B5\uB2C8\uB2E4`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// src/cli.ts
|
|
357
|
+
async function main() {
|
|
358
|
+
const flags = parseFlags();
|
|
359
|
+
const cmd = flags._[0] ?? "generate";
|
|
360
|
+
const cssPath = typeof flags.css === "string" ? flags.css : "src/index.css";
|
|
361
|
+
if (cmd === "generate") {
|
|
362
|
+
await runThemeApply(cssPath);
|
|
363
|
+
process.exit(0);
|
|
364
|
+
}
|
|
365
|
+
console.log(`\uC0AC\uC6A9 \uBC29\uBC95:
|
|
366
|
+
- cb-theme generate [--css=src/index.css]
|
|
367
|
+
CSS \uD30C\uC77C\uC5D0\uC11C \uC0C9\uC0C1 \uBCC0\uC218\uB4E4\uC744 \uCD94\uCD9C\uD558\uACE0, \uC0C9\uB9F9 \uD14C\uB9C8\uB97C \uC790\uB3D9 \uC0DD\uC131\uD558\uC5EC \uAC19\uC740 CSS \uD30C\uC77C\uC5D0 \uC801\uC6A9\uD569\uB2C8\uB2E4.
|
|
368
|
+
(\uAE30\uBCF8 \uACBD\uB85C\uB294 src/index.css)
|
|
369
|
+
`);
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
main().catch((e) => {
|
|
373
|
+
console.error("\u274C CLI failed:", e);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
});
|