Color system builder
Принцип — oklch + ramp. Все цвета одного семейства имеют одинаковую chroma, варьируется lightness. Это даёт визуально согласованные шкалы, которых не получишь подбором HEX'ов.
Скрипт
templates/build-palette.mjs:
node build-palette.mjs "#D97757"
# → palette.css + palette.json
Делает:
- 9-step scale базового цвета (50, 100, 200, ..., 900)
- light + dark theme tokens
- semantic: success / warning / danger / info — той же интенсивности
- проверка WCAG AA контраста для каждой пары fg/bg
Алгоритм
- Парсим вход → oklch.
- Для шкалы фиксируем chroma и hue, варьируем lightness:
- 50: L=98%
- 100: L=95%
- 200: L=90%
- 300: L=82%
- 400: L=72%
- 500: L=62% (исходный)
- 600: L=52%
- 700: L=42%
- 800: L=32%
- 900: L=20%
- Semantic — те же L/C, разные H:
- danger: H=25
- warning: H=80
- success: H=145
- info: H=245
- Контраст проверяется через WCAG-формулу.
Скрипт целиком
import fs from 'node:fs/promises';
import { converter, formatHex, wcagContrast } from 'culori';
const arg = process.argv[2];
if (!arg) { console.error('Usage: node build-palette.mjs <color>'); process.exit(1); }
const oklch = converter('oklch')(arg);
const C = oklch.c, H = oklch.h;
const steps = [98, 95, 90, 82, 72, 62, 52, 42, 32, 20];
const names = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
function ramp(hue, chroma) {
const out = {};
steps.forEach((L, i) => {
out[names[i]] = formatHex({ mode: 'oklch', l: L/100, c: chroma, h: hue });
});
return out;
}
const palette = {
primary: ramp(H, C),
neutral: ramp(H, 0.01), // тонированный серый, не «компьютерный»
success: ramp(145, C),
warning: ramp(80, C),
danger: ramp(25, C),
info: ramp(245, C),
};
// Светлая и тёмная тема — semantic mappings
const tokens = {
light: {
bg: palette.neutral[50],
fg: palette.neutral[900],
muted: palette.neutral[600],
rule: palette.neutral[200],
primary: palette.primary[500],
'primary-fg': palette.primary[50],
},
dark: {
bg: palette.neutral[900],
fg: palette.neutral[50],
muted: palette.neutral[400],
rule: palette.neutral[800],
primary: palette.primary[400],
'primary-fg': palette.primary[900],
},
};
// Контраст
function check(fg, bg) {
const c = wcagContrast(fg, bg);
return { ratio: c.toFixed(2), passAA: c >= 4.5, passLarge: c >= 3 };
}
const audit = {
'light fg/bg': check(tokens.light.fg, tokens.light.bg),
'light muted/bg': check(tokens.light.muted, tokens.light.bg),
'light primary-fg/primary': check(tokens.light['primary-fg'], tokens.light.primary),
'dark fg/bg': check(tokens.dark.fg, tokens.dark.bg),
'dark muted/bg': check(tokens.dark.muted, tokens.dark.bg),
};
await fs.writeFile('palette.json', JSON.stringify({ palette, tokens, audit }, null, 2));
let css = ':root {\n';
for (const [k, v] of Object.entries(tokens.light)) css += ` --${k}: ${v};\n`;
css += '}\n[data-theme="dark"] {\n';
for (const [k, v] of Object.entries(tokens.dark)) css += ` --${k}: ${v};\n`;
css += '}\n\n';
for (const [name, ramp] of Object.entries(palette)) {
for (const [step, hex] of Object.entries(ramp)) {
css += `:root { --${name}-${step}: ${hex}; }\n`;
}
}
await fs.writeFile('palette.css', css);
console.log('✓ palette.css + palette.json');
console.table(audit);
Зависимость: npm i culori.
Что делать с выводом
palette.css подключай в проект первым.
palette.json отдай разработчику или используй в dev-handoff.
- Если в
audit есть passAA: false для основных пар — палитра плохая, поправь L-значения.
1---2name: color-system-builder3description: Из одного акцента — полная палитра: light+dark, 9-step scale, semantic, WCAG-контраст. Триггеры: «построй цветовую систему», «color tokens».4---56# Color system builder78Принцип — **oklch + ramp**. Все цвета одного семейства имеют одинаковую chroma, варьируется lightness. Это даёт визуально согласованные шкалы, которых не получишь подбором HEX'ов.910## Скрипт1112`templates/build-palette.mjs`:1314```bash15node build-palette.mjs "#D97757"16# → palette.css + palette.json17```1819Делает:20- 9-step scale базового цвета (50, 100, 200, ..., 900)21- light + dark theme tokens22- semantic: success / warning / danger / info — той же интенсивности23- проверка WCAG AA контраста для каждой пары fg/bg2425## Алгоритм26271. Парсим вход → oklch.282. Для шкалы фиксируем chroma и hue, варьируем lightness:29 - 50: L=98%30 - 100: L=95%31 - 200: L=90%32 - 300: L=82%33 - 400: L=72%34 - 500: L=62% (исходный)35 - 600: L=52%36 - 700: L=42%37 - 800: L=32%38 - 900: L=20%393. Semantic — те же L/C, разные H:40 - danger: H=2541 - warning: H=8042 - success: H=14543 - info: H=245444. Контраст проверяется через WCAG-формулу.4546## Скрипт целиком4748```js49import fs from 'node:fs/promises';50import { converter, formatHex, wcagContrast } from 'culori';5152const arg = process.argv[2];53if (!arg) { console.error('Usage: node build-palette.mjs <color>'); process.exit(1); }5455const oklch = converter('oklch')(arg);56const C = oklch.c, H = oklch.h;5758const steps = [98, 95, 90, 82, 72, 62, 52, 42, 32, 20];59const names = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];6061function ramp(hue, chroma) {62 const out = {};63 steps.forEach((L, i) => {64 out[names[i]] = formatHex({ mode: 'oklch', l: L/100, c: chroma, h: hue });65 });66 return out;67}6869const palette = {70 primary: ramp(H, C),71 neutral: ramp(H, 0.01), // тонированный серый, не «компьютерный»72 success: ramp(145, C),73 warning: ramp(80, C),74 danger: ramp(25, C),75 info: ramp(245, C),76};7778// Светлая и тёмная тема — semantic mappings79const tokens = {80 light: {81 bg: palette.neutral[50],82 fg: palette.neutral[900],83 muted: palette.neutral[600],84 rule: palette.neutral[200],85 primary: palette.primary[500],86 'primary-fg': palette.primary[50],87 },88 dark: {89 bg: palette.neutral[900],90 fg: palette.neutral[50],91 muted: palette.neutral[400],92 rule: palette.neutral[800],93 primary: palette.primary[400],94 'primary-fg': palette.primary[900],95 },96};9798// Контраст99function check(fg, bg) {100 const c = wcagContrast(fg, bg);101 return { ratio: c.toFixed(2), passAA: c >= 4.5, passLarge: c >= 3 };102}103const audit = {104 'light fg/bg': check(tokens.light.fg, tokens.light.bg),105 'light muted/bg': check(tokens.light.muted, tokens.light.bg),106 'light primary-fg/primary': check(tokens.light['primary-fg'], tokens.light.primary),107 'dark fg/bg': check(tokens.dark.fg, tokens.dark.bg),108 'dark muted/bg': check(tokens.dark.muted, tokens.dark.bg),109};110111await fs.writeFile('palette.json', JSON.stringify({ palette, tokens, audit }, null, 2));112113let css = ':root {\n';114for (const [k, v] of Object.entries(tokens.light)) css += ` --${k}: ${v};\n`;115css += '}\n[data-theme="dark"] {\n';116for (const [k, v] of Object.entries(tokens.dark)) css += ` --${k}: ${v};\n`;117css += '}\n\n';118for (const [name, ramp] of Object.entries(palette)) {119 for (const [step, hex] of Object.entries(ramp)) {120 css += `:root { --${name}-${step}: ${hex}; }\n`;121 }122}123await fs.writeFile('palette.css', css);124125console.log('✓ palette.css + palette.json');126console.table(audit);127```128129Зависимость: `npm i culori`.130131## Что делать с выводом132133- `palette.css` подключай в проект первым.134- `palette.json` отдай разработчику или используй в `dev-handoff`.135- Если в `audit` есть `passAA: false` для основных пар — палитра плохая, поправь L-значения.