palette-gen.js 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. #!/usr/bin/env node
  2. // Palette generator - produce a 10-step OKLCH scale as CSS custom properties
  3. // Usage: node palette-gen.js <hue> [name] [--neutral] [--json]
  4. //
  5. // Examples:
  6. // node palette-gen.js 250 # Blue scale (default name: "brand")
  7. // node palette-gen.js 250 blue # Named "blue"
  8. // node palette-gen.js 250 blue --neutral # Also generate a neutral scale
  9. // node palette-gen.js 30 orange --json # JSON output
  10. const args = process.argv.slice(2);
  11. if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
  12. console.log(`Usage: node palette-gen.js <hue> [name] [--neutral] [--json]
  13. Arguments:
  14. hue OKLCH hue angle (0-360)
  15. name Token prefix (default: "brand")
  16. Flags:
  17. --neutral Also generate a matching neutral scale (same hue, low chroma)
  18. --json Output as JSON instead of CSS
  19. Hue reference:
  20. 0-30 Pink/Red 110-160 Green
  21. 30-70 Orange/Amber 160-200 Teal/Cyan
  22. 70-110 Yellow/Lime 200-260 Blue
  23. 260-310 Violet
  24. 310-360 Magenta`);
  25. process.exit(0);
  26. }
  27. const hue = parseFloat(args[0]);
  28. if (isNaN(hue) || hue < 0 || hue > 360) {
  29. console.error('Error: Hue must be a number between 0 and 360.');
  30. process.exit(1);
  31. }
  32. const flags = args.filter(a => a.startsWith('--'));
  33. const positional = args.filter(a => !a.startsWith('--'));
  34. const name = positional[1] || 'brand';
  35. const includeNeutral = flags.includes('--neutral');
  36. const jsonOutput = flags.includes('--json');
  37. function generateScale(hue, chromaMultiplier = 1) {
  38. const steps = 10;
  39. return Array.from({ length: steps }, (_, i) => {
  40. const t = i / (steps - 1);
  41. const step = (i + 1) * 100; // 100..1000
  42. const l = +(0.97 - t * 0.82).toFixed(3);
  43. // Chroma peaks at midtones (sine curve), clamped for neutrals
  44. const c = +(Math.sin(t * Math.PI) * 0.18 * chromaMultiplier).toFixed(4);
  45. return { step, l, c, h: hue };
  46. });
  47. }
  48. function formatOklch(l, c, h) {
  49. return `oklch(${l} ${c} ${h})`;
  50. }
  51. // --- sRGB conversion for preview swatches ---
  52. function oklchToOklab(L, C, H) {
  53. const hRad = H * Math.PI / 180;
  54. return [L, C * Math.cos(hRad), C * Math.sin(hRad)];
  55. }
  56. function oklabToLinearRgb(L, a, b) {
  57. const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
  58. const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
  59. const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
  60. const l = l_ * l_ * l_;
  61. const m = m_ * m_ * m_;
  62. const s = s_ * s_ * s_;
  63. return [
  64. +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
  65. -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
  66. -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
  67. ];
  68. }
  69. function linearToSrgb(c) {
  70. return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
  71. }
  72. function toHex(l, c, h) {
  73. const [labL, labA, labB] = oklchToOklab(l, c, h);
  74. const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
  75. const clamp = v => Math.round(Math.min(255, Math.max(0, linearToSrgb(v) * 255)));
  76. return '#' + [clamp(lr), clamp(lg), clamp(lb)].map(v => v.toString(16).padStart(2, '0')).join('');
  77. }
  78. function isInGamut(l, c, h) {
  79. const [labL, labA, labB] = oklchToOklab(l, c, h);
  80. const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
  81. return [lr, lg, lb].every(v => v >= -0.001 && v <= 1.001);
  82. }
  83. // --- Output ---
  84. const brandScale = generateScale(hue);
  85. const neutralScale = includeNeutral ? generateScale(hue, 0.12) : [];
  86. if (jsonOutput) {
  87. const output = {
  88. [name]: brandScale.map(s => ({
  89. step: s.step,
  90. oklch: formatOklch(s.l, s.c, s.h),
  91. hex: toHex(s.l, s.c, s.h),
  92. inGamut: isInGamut(s.l, s.c, s.h),
  93. })),
  94. };
  95. if (includeNeutral) {
  96. output[`${name}-neutral`] = neutralScale.map(s => ({
  97. step: s.step,
  98. oklch: formatOklch(s.l, s.c, s.h),
  99. hex: toHex(s.l, s.c, s.h),
  100. inGamut: isInGamut(s.l, s.c, s.h),
  101. }));
  102. }
  103. console.log(JSON.stringify(output, null, 2));
  104. process.exit(0);
  105. }
  106. // CSS output
  107. function printScale(scaleName, scale) {
  108. console.log(` /* ${scaleName} - hue ${hue} */`);
  109. for (const s of scale) {
  110. const hex = toHex(s.l, s.c, s.h);
  111. const gamut = isInGamut(s.l, s.c, s.h) ? '' : ' /* out of sRGB gamut */';
  112. console.log(` --${scaleName}-${s.step}: ${formatOklch(s.l, s.c, s.h)}; /* ${hex} */${gamut}`);
  113. }
  114. }
  115. console.log(`:root {`);
  116. printScale(name, brandScale);
  117. if (includeNeutral) {
  118. console.log('');
  119. printScale(`${name}-neutral`, neutralScale);
  120. }
  121. console.log(`}`);