harmony-gen.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. #!/usr/bin/env node
  2. // Harmony generator - build harmonious palettes from a base color or from scratch
  3. // Usage: node harmony-gen.js <color|hue> [scheme] [--css] [--json] [--tokens]
  4. //
  5. // Inspired by Coolors, Color Hunt, and Colour Lovers palette approaches.
  6. // Generates palettes that work in practice - not just geometric hue math,
  7. // but varied lightness and chroma for actual UI use.
  8. const args = process.argv.slice(2);
  9. if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
  10. console.log(`Usage: node harmony-gen.js <color|hue> [scheme] [--css] [--json] [--tokens]
  11. Arguments:
  12. color Base color: #hex, rgb(r,g,b), oklch(l c h), or bare hue (0-360)
  13. scheme Harmony type (default: analogous)
  14. Schemes:
  15. complementary Base + opposite hue (2 colors + tints)
  16. analogous Base + neighboring hues (5 colors)
  17. triadic 3 evenly spaced hues
  18. split Base + 2 flanking its complement
  19. tetradic 4 hues in rectangle pattern
  20. monochromatic Single hue, varied lightness + chroma (6 stops)
  21. warm Warm palette (reds, oranges, golds)
  22. cool Cool palette (blues, teals, greens)
  23. earth Muted natural tones (ochre, sage, clay, slate)
  24. pastel High lightness, low chroma, varied hue
  25. vibrant Mid lightness, high chroma, varied hue
  26. random 5 curated random colors with good contrast spread
  27. Flags:
  28. --css Output as CSS custom properties (default)
  29. --json Output as JSON
  30. --tokens Output as design token CSS (surface, primary, accent, etc.)
  31. --tints Include 3-step tint/shade per color
  32. Examples:
  33. node harmony-gen.js 250 # Analogous from hue 250
  34. node harmony-gen.js "#3b82f6" triadic # Triadic from a hex color
  35. node harmony-gen.js "oklch(0.6 0.18 250)" split --tokens
  36. node harmony-gen.js 30 earth --json
  37. node harmony-gen.js random`);
  38. process.exit(0);
  39. }
  40. // ========== Color math ==========
  41. function oklchToOklab(L, C, H) {
  42. const hRad = H * Math.PI / 180;
  43. return [L, C * Math.cos(hRad), C * Math.sin(hRad)];
  44. }
  45. function oklabToLinearRgb(L, a, b) {
  46. const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
  47. const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
  48. const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
  49. const l = l_ * l_ * l_;
  50. const m = m_ * m_ * m_;
  51. const s = s_ * s_ * s_;
  52. return [
  53. +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
  54. -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
  55. -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
  56. ];
  57. }
  58. function linearToSrgb(c) {
  59. return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
  60. }
  61. function srgbToLinear(c) {
  62. return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  63. }
  64. function linearRgbToOklab(lr, lg, lb) {
  65. const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
  66. const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
  67. const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lb + 0.6299787005 * lb);
  68. return [
  69. 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
  70. 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
  71. 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
  72. ];
  73. }
  74. function oklabToOklch(L, a, b) {
  75. const C = Math.sqrt(a * a + b * b);
  76. const H = (Math.atan2(b, a) * 180 / Math.PI + 360) % 360;
  77. return [L, C, H];
  78. }
  79. function toHex(l, c, h) {
  80. const [labL, labA, labB] = oklchToOklab(l, c, h);
  81. const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
  82. const clamp = v => Math.round(Math.min(255, Math.max(0, linearToSrgb(v) * 255)));
  83. return '#' + [clamp(lr), clamp(lg), clamp(lb)].map(v => v.toString(16).padStart(2, '0')).join('');
  84. }
  85. function isInGamut(l, c, h) {
  86. const [labL, labA, labB] = oklchToOklab(l, c, h);
  87. const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
  88. return [lr, lg, lb].every(v => v >= -0.002 && v <= 1.002);
  89. }
  90. // Reduce chroma until color fits sRGB
  91. function gamutClamp(l, c, h) {
  92. if (isInGamut(l, c, h)) return { l, c, h };
  93. let lo = 0, hi = c;
  94. for (let i = 0; i < 20; i++) {
  95. const mid = (lo + hi) / 2;
  96. if (isInGamut(l, mid, h)) lo = mid;
  97. else hi = mid;
  98. }
  99. return { l, c: +lo.toFixed(4), h };
  100. }
  101. function wrapHue(h) {
  102. return ((h % 360) + 360) % 360;
  103. }
  104. // ========== Parsing ==========
  105. function parseInput(str) {
  106. str = str.trim();
  107. // Bare hue number
  108. if (/^\d+(\.\d+)?$/.test(str)) {
  109. const h = parseFloat(str);
  110. if (h >= 0 && h <= 360) return { l: 0.6, c: 0.15, h };
  111. }
  112. // Hex
  113. if (str.startsWith('#') || /^[0-9a-f]{6}$/i.test(str)) {
  114. let hex = str.replace('#', '');
  115. if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
  116. const n = parseInt(hex, 16);
  117. const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  118. const [lr, lg, lb] = [srgbToLinear(r / 255), srgbToLinear(g / 255), srgbToLinear(b / 255)];
  119. const [labL, labA, labB] = linearRgbToOklab(lr, lg, lb);
  120. const [L, C, H] = oklabToOklch(labL, labA, labB);
  121. return { l: L, c: C, h: H };
  122. }
  123. // rgb()
  124. const rgbM = str.match(/rgb\(\s*(\d+)\s*[,\s]\s*(\d+)\s*[,\s]\s*(\d+)\s*\)/i);
  125. if (rgbM) {
  126. const [r, g, b] = [+rgbM[1], +rgbM[2], +rgbM[3]];
  127. const [lr, lg, lb] = [srgbToLinear(r / 255), srgbToLinear(g / 255), srgbToLinear(b / 255)];
  128. const [labL, labA, labB] = linearRgbToOklab(lr, lg, lb);
  129. const [L, C, H] = oklabToOklch(labL, labA, labB);
  130. return { l: L, c: C, h: H };
  131. }
  132. // oklch()
  133. const oklchM = str.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)/i);
  134. if (oklchM) return { l: +oklchM[1], c: +oklchM[2], h: +oklchM[3] };
  135. return null;
  136. }
  137. // ========== Scheme generators ==========
  138. // Each returns an array of { name, l, c, h } objects.
  139. // The key insight: geometric hue harmony alone makes boring palettes.
  140. // Good palettes vary lightness and chroma deliberately.
  141. function complementary(base) {
  142. return [
  143. { name: 'base', ...base },
  144. { name: 'base-light', l: base.l + 0.2, c: base.c * 0.5, h: base.h },
  145. { name: 'complement', l: base.l, c: base.c * 0.9, h: wrapHue(base.h + 180) },
  146. { name: 'complement-light', l: base.l + 0.2, c: base.c * 0.4, h: wrapHue(base.h + 180) },
  147. { name: 'neutral', l: base.l + 0.1, c: 0.02, h: base.h },
  148. ];
  149. }
  150. function analogous(base) {
  151. return [
  152. { name: 'color-1', l: base.l + 0.1, c: base.c * 0.7, h: wrapHue(base.h - 30) },
  153. { name: 'color-2', l: base.l + 0.05, c: base.c * 0.85, h: wrapHue(base.h - 15) },
  154. { name: 'base', ...base },
  155. { name: 'color-3', l: base.l - 0.05, c: base.c * 0.85, h: wrapHue(base.h + 15) },
  156. { name: 'color-4', l: base.l - 0.1, c: base.c * 0.7, h: wrapHue(base.h + 30) },
  157. ];
  158. }
  159. function triadic(base) {
  160. return [
  161. { name: 'primary', ...base },
  162. { name: 'primary-muted', l: base.l + 0.15, c: base.c * 0.4, h: base.h },
  163. { name: 'secondary', l: base.l + 0.05, c: base.c * 0.8, h: wrapHue(base.h + 120) },
  164. { name: 'tertiary', l: base.l - 0.05, c: base.c * 0.8, h: wrapHue(base.h + 240) },
  165. { name: 'neutral', l: base.l + 0.25, c: 0.015, h: base.h },
  166. ];
  167. }
  168. function split(base) {
  169. return [
  170. { name: 'base', ...base },
  171. { name: 'base-muted', l: base.l + 0.2, c: base.c * 0.35, h: base.h },
  172. { name: 'split-1', l: base.l + 0.05, c: base.c * 0.75, h: wrapHue(base.h + 150) },
  173. { name: 'split-2', l: base.l - 0.05, c: base.c * 0.75, h: wrapHue(base.h + 210) },
  174. { name: 'neutral', l: 0.92, c: 0.01, h: base.h },
  175. ];
  176. }
  177. function tetradic(base) {
  178. return [
  179. { name: 'primary', ...base },
  180. { name: 'secondary', l: base.l + 0.05, c: base.c * 0.85, h: wrapHue(base.h + 90) },
  181. { name: 'tertiary', l: base.l - 0.05, c: base.c * 0.85, h: wrapHue(base.h + 180) },
  182. { name: 'quaternary', l: base.l + 0.1, c: base.c * 0.7, h: wrapHue(base.h + 270) },
  183. { name: 'neutral', l: 0.93, c: 0.012, h: base.h },
  184. ];
  185. }
  186. function monochromatic(base) {
  187. return [
  188. { name: 'lightest', l: 0.95, c: base.c * 0.15, h: base.h },
  189. { name: 'light', l: 0.82, c: base.c * 0.4, h: base.h },
  190. { name: 'mid-light', l: 0.7, c: base.c * 0.75, h: base.h },
  191. { name: 'mid', l: base.l, c: base.c, h: base.h },
  192. { name: 'dark', l: 0.4, c: base.c * 0.7, h: base.h },
  193. { name: 'darkest', l: 0.22, c: base.c * 0.3, h: base.h },
  194. ];
  195. }
  196. function warm(base) {
  197. const h = base.h;
  198. // Pull toward warm range (0-70)
  199. return [
  200. { name: 'cream', l: 0.94, c: 0.04, h: 80 },
  201. { name: 'gold', l: 0.78, c: 0.14, h: 85 },
  202. { name: 'amber', l: 0.68, c: 0.17, h: 55 },
  203. { name: 'terracotta', l: 0.55, c: 0.14, h: 30 },
  204. { name: 'deep-red', l: 0.38, c: 0.15, h: 15 },
  205. ];
  206. }
  207. function cool(base) {
  208. return [
  209. { name: 'ice', l: 0.95, c: 0.025, h: 230 },
  210. { name: 'sky', l: 0.8, c: 0.1, h: 230 },
  211. { name: 'ocean', l: 0.6, c: 0.15, h: 245 },
  212. { name: 'teal', l: 0.55, c: 0.12, h: 190 },
  213. { name: 'deep-navy', l: 0.25, c: 0.08, h: 260 },
  214. ];
  215. }
  216. function earth(base) {
  217. return [
  218. { name: 'sand', l: 0.88, c: 0.04, h: 80 },
  219. { name: 'sage', l: 0.68, c: 0.06, h: 145 },
  220. { name: 'ochre', l: 0.62, c: 0.1, h: 65 },
  221. { name: 'clay', l: 0.5, c: 0.08, h: 35 },
  222. { name: 'slate', l: 0.35, c: 0.03, h: 260 },
  223. ];
  224. }
  225. function pastel(base) {
  226. const offsets = [0, 60, 130, 210, 290];
  227. return offsets.map((offset, i) => ({
  228. name: `pastel-${i + 1}`,
  229. l: 0.88 + Math.random() * 0.06,
  230. c: 0.04 + Math.random() * 0.03,
  231. h: wrapHue(base.h + offset),
  232. }));
  233. }
  234. function vibrant(base) {
  235. const offsets = [0, 72, 144, 216, 288];
  236. return offsets.map((offset, i) => ({
  237. name: `vibrant-${i + 1}`,
  238. l: 0.55 + (i % 2) * 0.1,
  239. c: 0.18 + Math.random() * 0.04,
  240. h: wrapHue(base.h + offset),
  241. }));
  242. }
  243. function random(_base) {
  244. // Curated random: ensure good lightness spread and hue variety
  245. const hueStart = Math.random() * 360;
  246. const goldenAngle = 137.508;
  247. const colors = [];
  248. const lightnesses = [0.9, 0.75, 0.6, 0.45, 0.3];
  249. for (let i = 0; i < 5; i++) {
  250. colors.push({
  251. name: `color-${i + 1}`,
  252. l: lightnesses[i] + (Math.random() - 0.5) * 0.05,
  253. c: 0.06 + Math.random() * 0.14,
  254. h: wrapHue(hueStart + goldenAngle * i),
  255. });
  256. }
  257. return colors;
  258. }
  259. const schemes = {
  260. complementary, analogous, triadic, split, tetradic,
  261. monochromatic, warm, cool, earth, pastel, vibrant, random,
  262. };
  263. // ========== Parse arguments ==========
  264. const flags = args.filter(a => a.startsWith('--'));
  265. const positional = args.filter(a => !a.startsWith('--'));
  266. const jsonOutput = flags.includes('--json');
  267. const tokensOutput = flags.includes('--tokens');
  268. const includeTints = flags.includes('--tints');
  269. // Handle "random" as first arg
  270. let inputStr, schemeName;
  271. if (positional[0] === 'random') {
  272. inputStr = '180'; // dummy
  273. schemeName = 'random';
  274. } else {
  275. // Rejoin for oklch() parsing
  276. const joined = positional.join(' ');
  277. const oklchM = joined.match(/oklch\(\s*[\d.]+\s+[\d.]+\s+[\d.]+\s*\)/i);
  278. if (oklchM) {
  279. inputStr = oklchM[0];
  280. const rest = joined.replace(oklchM[0], '').trim();
  281. schemeName = rest || 'analogous';
  282. } else {
  283. inputStr = positional[0] || '250';
  284. schemeName = positional[1] || 'analogous';
  285. }
  286. }
  287. const base = parseInput(inputStr);
  288. if (!base) {
  289. console.error(`Error: Could not parse "${inputStr}"`);
  290. process.exit(1);
  291. }
  292. const schemeFn = schemes[schemeName];
  293. if (!schemeFn) {
  294. console.error(`Unknown scheme: "${schemeName}"`);
  295. console.error(`Available: ${Object.keys(schemes).join(', ')}`);
  296. process.exit(1);
  297. }
  298. // ========== Generate ==========
  299. let palette = schemeFn(base);
  300. // Gamut-clamp all colors
  301. palette = palette.map(c => {
  302. const clamped = gamutClamp(
  303. Math.min(1, Math.max(0, c.l)),
  304. c.c,
  305. wrapHue(c.h)
  306. );
  307. return { name: c.name, ...clamped };
  308. });
  309. // Generate tints if requested
  310. function tints(color) {
  311. return [
  312. { name: `${color.name}-tint`, l: color.l + 0.15, c: color.c * 0.5, h: color.h },
  313. { name: `${color.name}-shade`, l: color.l - 0.15, c: color.c * 0.8, h: color.h },
  314. { name: `${color.name}-muted`, l: color.l + 0.05, c: color.c * 0.3, h: color.h },
  315. ].map(t => {
  316. const clamped = gamutClamp(Math.min(1, Math.max(0, t.l)), t.c, wrapHue(t.h));
  317. return { name: t.name, ...clamped };
  318. });
  319. }
  320. if (includeTints) {
  321. const expanded = [];
  322. for (const color of palette) {
  323. expanded.push(color, ...tints(color));
  324. }
  325. palette = expanded;
  326. }
  327. // ========== Output ==========
  328. if (jsonOutput) {
  329. const output = palette.map(c => ({
  330. name: c.name,
  331. oklch: `oklch(${c.l.toFixed(3)} ${c.c.toFixed(4)} ${c.h.toFixed(1)})`,
  332. hex: toHex(c.l, c.c, c.h),
  333. }));
  334. console.log(JSON.stringify({ scheme: schemeName, colors: output }, null, 2));
  335. process.exit(0);
  336. }
  337. if (tokensOutput) {
  338. // Map palette to semantic tokens
  339. console.log(`:root {
  340. /* ${schemeName} harmony - generated from oklch(${base.l.toFixed(2)} ${base.c.toFixed(2)} ${base.h.toFixed(0)}) */`);
  341. const roles = ['primary', 'secondary', 'accent', 'surface', 'muted'];
  342. palette.slice(0, roles.length).forEach((c, i) => {
  343. const role = roles[i] || `color-${i + 1}`;
  344. console.log(` --color-${role}: oklch(${c.l.toFixed(3)} ${c.c.toFixed(4)} ${c.h.toFixed(1)}); /* ${toHex(c.l, c.c, c.h)} */`);
  345. });
  346. // Auto-generate on-surface
  347. const darkest = palette.reduce((a, b) => a.l < b.l ? a : b);
  348. const lightest = palette.reduce((a, b) => a.l > b.l ? a : b);
  349. console.log(` --color-on-surface: oklch(${darkest.l.toFixed(3)} ${darkest.c.toFixed(4)} ${darkest.h.toFixed(1)}); /* ${toHex(darkest.l, darkest.c, darkest.h)} */`);
  350. console.log(` --color-background: oklch(${lightest.l.toFixed(3)} ${lightest.c.toFixed(4)} ${lightest.h.toFixed(1)}); /* ${toHex(lightest.l, lightest.c, lightest.h)} */`);
  351. console.log('}');
  352. process.exit(0);
  353. }
  354. // Default: CSS output
  355. console.log(`/* ${schemeName} harmony from oklch(${base.l.toFixed(2)} ${base.c.toFixed(2)} ${base.h.toFixed(0)}) */`);
  356. console.log(':root {');
  357. for (const c of palette) {
  358. console.log(` --${c.name}: oklch(${c.l.toFixed(3)} ${c.c.toFixed(4)} ${c.h.toFixed(1)}); /* ${toHex(c.l, c.c, c.h)} */`);
  359. }
  360. console.log('}');