Browse Source

feat(skills): Add color-ops scripts - contrast checker, palette/harmony generators, converter

Four zero-dependency Node.js tools:
- contrast-check.js: WCAG 2.x ratio with AA/AAA pass/fail
- palette-gen.js: 10-step OKLCH scale as CSS custom properties
- harmony-gen.js: 12 schemes (triadic, earth, warm, etc.) with gamut clamping
- color-convert.js: hex/rgb/hsl/oklch/oklab bidirectional conversion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0xDarkMatter 2 months ago
parent
commit
5ca07f25c4

+ 1 - 1
README.md

@@ -205,7 +205,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [laravel-ops](skills/laravel-ops/) | Laravel Eloquent, architecture, authentication, testing with Pest |
 | [cli-ops](skills/cli-ops/) | Production CLI tool patterns - agentic workflows, stream separation, exit codes |
 | [tailwind-ops](skills/tailwind-ops/) | Tailwind CSS patterns, v4 migration, components, configuration |
-| [color-ops](skills/color-ops/) | Color spaces (OKLCH/OKLAB), accessibility contrast (WCAG/APCA), palette generation, CSS color functions, design tokens, dark mode |
+| [color-ops](skills/color-ops/) | Color spaces, WCAG/APCA contrast checker, palette + harmony generators, CSS color functions, design tokens, color converter |
 
 #### Data & API Skills
 | Skill | Description |

+ 48 - 0
skills/color-ops/SKILL.md

@@ -299,6 +299,54 @@ color: oklch(0.7 0.3 150);  /* if out of sRGB, browser reduces chroma */
 // CSS.supports('color', 'color(display-p3 1 0 0)')
 ```
 
+## Scripts
+
+Zero-dependency Node.js tools. Run directly or let Claude invoke them during color tasks.
+
+### Contrast Checker
+
+```bash
+node scripts/contrast-check.js <color1> <color2>
+node scripts/contrast-check.js "#1a1a2e" "#e0e0e0"
+node scripts/contrast-check.js "oklch(0.15 0.02 250)" "oklch(0.9 0.01 250)"
+```
+
+Returns WCAG 2.x contrast ratio with AA/AAA pass/fail for normal and large text.
+
+### Palette Generator
+
+```bash
+node scripts/palette-gen.js <hue> [name] [--neutral] [--json]
+node scripts/palette-gen.js 250 blue              # 10-step blue scale
+node scripts/palette-gen.js 250 blue --neutral     # + matching neutral scale
+node scripts/palette-gen.js 30 orange --json       # JSON output
+```
+
+Generates a perceptually uniform 10-step OKLCH scale (100-1000) as CSS custom properties. Chroma peaks at midtones via sine curve. Flags out-of-gamut sRGB values.
+
+### Color Converter
+
+```bash
+node scripts/color-convert.js <color>
+node scripts/color-convert.js "#3b82f6"
+node scripts/color-convert.js "oklch(0.62 0.18 250)"
+node scripts/color-convert.js "hsl(217, 91%, 60%)"
+```
+
+Converts any color to all formats: hex, rgb, hsl, oklch, oklab. Shows relative luminance and sRGB gamut status.
+
+### Harmony Generator
+
+```bash
+node scripts/harmony-gen.js <color|hue> [scheme] [--css] [--json] [--tokens] [--tints]
+node scripts/harmony-gen.js "#3b82f6" triadic      # Triadic palette from hex
+node scripts/harmony-gen.js 250 complementary --tokens  # Design tokens
+node scripts/harmony-gen.js 30 earth               # Earth tone palette
+node scripts/harmony-gen.js random                  # Curated random palette
+```
+
+12 harmony schemes: `complementary`, `analogous`, `triadic`, `split`, `tetradic`, `monochromatic`, `warm`, `cool`, `earth`, `pastel`, `vibrant`, `random`. All output gamut-clamped to sRGB. Use `--tokens` for semantic design tokens (primary, secondary, accent, surface), `--tints` for tint/shade/muted variants per color.
+
 ## Agent Dispatch
 
 For complex color work beyond this skill's scope, dispatch to specialized agents:

+ 0 - 0
skills/color-ops/scripts/.gitkeep


+ 192 - 0
skills/color-ops/scripts/color-convert.js

@@ -0,0 +1,192 @@
+#!/usr/bin/env node
+// Color converter - convert between hex, rgb, hsl, oklch, oklab
+// Usage: node color-convert.js <color>
+// Accepts: #hex, rgb(r,g,b), hsl(h,s%,l%), oklch(l c h), oklab(l a b)
+
+const args = process.argv.slice(2);
+if (args.length < 1) {
+  console.log(`Usage: node color-convert.js <color>
+
+Examples:
+  node color-convert.js "#3b82f6"
+  node color-convert.js "rgb(59, 130, 246)"
+  node color-convert.js "hsl(217, 91%, 60%)"
+  node color-convert.js "oklch(0.62 0.18 250)"
+  node color-convert.js "oklab(0.62 -0.05 -0.16)"`);
+  process.exit(1);
+}
+
+const raw = args.join(' ').trim();
+
+// ========== Conversion math ==========
+
+function srgbToLinear(c) {
+  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+}
+
+function linearToSrgb(c) {
+  return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
+}
+
+function rgbToLinear(r, g, b) {
+  return [srgbToLinear(r / 255), srgbToLinear(g / 255), srgbToLinear(b / 255)];
+}
+
+function linearToRgb(lr, lg, lb) {
+  const clamp = v => Math.round(Math.min(255, Math.max(0, linearToSrgb(v) * 255)));
+  return [clamp(lr), clamp(lg), clamp(lb)];
+}
+
+function linearRgbToOklab(lr, lg, lb) {
+  const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
+  const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
+  const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
+  return [
+    0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
+    1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
+    0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
+  ];
+}
+
+function oklabToLinearRgb(L, a, b) {
+  const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
+  const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
+  const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
+  const l = l_ * l_ * l_;
+  const m = m_ * m_ * m_;
+  const s = s_ * s_ * s_;
+  return [
+    +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+    -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
+  ];
+}
+
+function oklabToOklch(L, a, b) {
+  const C = Math.sqrt(a * a + b * b);
+  const H = (Math.atan2(b, a) * 180 / Math.PI + 360) % 360;
+  return [L, C, H];
+}
+
+function oklchToOklab(L, C, H) {
+  const hRad = H * Math.PI / 180;
+  return [L, C * Math.cos(hRad), C * Math.sin(hRad)];
+}
+
+function rgbToHsl(r, g, b) {
+  r /= 255; g /= 255; b /= 255;
+  const max = Math.max(r, g, b);
+  const min = Math.min(r, g, b);
+  const d = max - min;
+  const l = (max + min) / 2;
+  if (d === 0) return [0, 0, l * 100];
+  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+  let h;
+  if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
+  else if (max === g) h = ((b - r) / d + 2) / 6;
+  else h = ((r - g) / d + 4) / 6;
+  return [h * 360, s * 100, l * 100];
+}
+
+function hslToRgb(h, s, l) {
+  h /= 360; s /= 100; l /= 100;
+  if (s === 0) { const v = Math.round(l * 255); return [v, v, v]; }
+  const hue2rgb = (p, q, t) => {
+    if (t < 0) t += 1;
+    if (t > 1) t -= 1;
+    if (t < 1/6) return p + (q - p) * 6 * t;
+    if (t < 1/2) return q;
+    if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
+    return p;
+  };
+  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+  const p = 2 * l - q;
+  return [
+    Math.round(hue2rgb(p, q, h + 1/3) * 255),
+    Math.round(hue2rgb(p, q, h) * 255),
+    Math.round(hue2rgb(p, q, h - 1/3) * 255),
+  ];
+}
+
+function relativeLuminance(r, g, b) {
+  const [lr, lg, lb] = rgbToLinear(r, g, b);
+  return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
+}
+
+// ========== Parsing ==========
+
+function parseColor(str) {
+  str = str.trim();
+
+  // Hex
+  if (str.startsWith('#') || /^[0-9a-f]{3,8}$/i.test(str)) {
+    let hex = str.replace('#', '');
+    if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
+    const n = parseInt(hex, 16);
+    return { type: 'hex', rgb: [(n >> 16) & 255, (n >> 8) & 255, n & 255] };
+  }
+
+  // rgb()
+  const rgbMatch = str.match(/rgb\(\s*(\d+)\s*[,\s]\s*(\d+)\s*[,\s]\s*(\d+)\s*\)/i);
+  if (rgbMatch) return { type: 'rgb', rgb: [+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]] };
+
+  // hsl()
+  const hslMatch = str.match(/hsl\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?\s*\)/i);
+  if (hslMatch) {
+    const rgb = hslToRgb(+hslMatch[1], +hslMatch[2], +hslMatch[3]);
+    return { type: 'hsl', rgb, hsl: [+hslMatch[1], +hslMatch[2], +hslMatch[3]] };
+  }
+
+  // oklch()
+  const oklchMatch = str.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)/i);
+  if (oklchMatch) {
+    const [L, C, H] = [+oklchMatch[1], +oklchMatch[2], +oklchMatch[3]];
+    const [labL, labA, labB] = oklchToOklab(L, C, H);
+    const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
+    const rgb = linearToRgb(lr, lg, lb);
+    return { type: 'oklch', rgb, oklch: [L, C, H], oklab: [labL, labA, labB] };
+  }
+
+  // oklab()
+  const oklabMatch = str.match(/oklab\(\s*([\d.e+-]+)\s+([\d.e+-]+)\s+([\d.e+-]+)\s*\)/i);
+  if (oklabMatch) {
+    const [L, a, b] = [+oklabMatch[1], +oklabMatch[2], +oklabMatch[3]];
+    const [lr, lg, lb] = oklabToLinearRgb(L, a, b);
+    const rgb = linearToRgb(lr, lg, lb);
+    return { type: 'oklab', rgb, oklab: [L, a, b] };
+  }
+
+  return null;
+}
+
+// ========== Main ==========
+
+const parsed = parseColor(raw);
+if (!parsed) {
+  console.error(`Error: Could not parse color "${raw}"`);
+  console.error('Supported formats: #hex, rgb(r,g,b), hsl(h,s%,l%), oklch(l c h), oklab(l a b)');
+  process.exit(1);
+}
+
+const [r, g, b] = parsed.rgb;
+const hex = '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
+const [lr, lg, lb] = rgbToLinear(r, g, b);
+const [labL, labA, labB] = parsed.oklab || linearRgbToOklab(lr, lg, lb);
+const [oklchL, oklchC, oklchH] = parsed.oklch || oklabToOklch(labL, labA, labB);
+const [hslH, hslS, hslL] = parsed.hsl || rgbToHsl(r, g, b);
+const lum = relativeLuminance(r, g, b);
+
+const inGamut = [lr, lg, lb].every(v => v >= -0.001 && v <= 1.001);
+
+console.log(`
+Input:    ${raw}
+
+hex       ${hex}
+rgb       rgb(${r}, ${g}, ${b})
+hsl       hsl(${hslH.toFixed(1)}, ${hslS.toFixed(1)}%, ${hslL.toFixed(1)}%)
+oklch     oklch(${oklchL.toFixed(4)} ${oklchC.toFixed(4)} ${oklchH.toFixed(1)})
+oklab     oklab(${labL.toFixed(4)} ${labA.toFixed(4)} ${labB.toFixed(4)})
+
+Luminance ${lum.toFixed(4)}
+sRGB      ${inGamut ? 'in gamut' : 'OUT OF GAMUT - will be clamped on sRGB displays'}
+`.trim());

+ 161 - 0
skills/color-ops/scripts/contrast-check.js

@@ -0,0 +1,161 @@
+#!/usr/bin/env node
+// Contrast checker - WCAG 2.x ratio + pass/fail for AA/AAA
+// Usage: node contrast-check.js <color1> <color2>
+// Accepts: hex (#fff, #ffffff), rgb(r,g,b), oklch(l c h)
+
+const args = process.argv.slice(2);
+if (args.length < 2) {
+  console.log(`Usage: node contrast-check.js <color1> <color2>
+
+Examples:
+  node contrast-check.js "#1a1a2e" "#e0e0e0"
+  node contrast-check.js "rgb(26,26,46)" "rgb(224,224,224)"
+  node contrast-check.js "oklch(0.15 0.02 250)" "oklch(0.9 0.01 250)"
+  node contrast-check.js "#1a1a2e" "oklch(0.9 0.01 250)"`);
+  process.exit(1);
+}
+
+// --- Color parsing ---
+
+function parseHex(hex) {
+  hex = hex.replace('#', '');
+  if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
+  const n = parseInt(hex, 16);
+  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+}
+
+function parseRgb(str) {
+  const m = str.match(/rgb\(\s*(\d+)\s*[,\s]\s*(\d+)\s*[,\s]\s*(\d+)\s*\)/i);
+  if (!m) return null;
+  return [+m[1], +m[2], +m[3]];
+}
+
+function parseOklch(str) {
+  const m = str.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)/i);
+  if (!m) return null;
+  return oklchToSrgb(+m[1], +m[2], +m[3]);
+}
+
+function parseColor(str) {
+  str = str.trim();
+  if (str.startsWith('#')) return parseHex(str);
+  if (str.startsWith('rgb')) return parseRgb(str);
+  if (str.startsWith('oklch')) return parseOklch(str);
+  // Try as bare hex
+  if (/^[0-9a-f]{3,8}$/i.test(str)) return parseHex(str);
+  return null;
+}
+
+// --- OKLCH -> sRGB conversion ---
+
+function oklchToOklab(L, C, H) {
+  const hRad = H * Math.PI / 180;
+  return [L, C * Math.cos(hRad), C * Math.sin(hRad)];
+}
+
+function oklabToLinearRgb(L, a, b) {
+  const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
+  const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
+  const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
+
+  const l = l_ * l_ * l_;
+  const m = m_ * m_ * m_;
+  const s = s_ * s_ * s_;
+
+  return [
+    +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+    -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
+  ];
+}
+
+function linearToSrgb(c) {
+  return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
+}
+
+function oklchToSrgb(L, C, H) {
+  const [labL, labA, labB] = oklchToOklab(L, C, H);
+  const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
+  return [
+    Math.round(Math.min(255, Math.max(0, linearToSrgb(lr) * 255))),
+    Math.round(Math.min(255, Math.max(0, linearToSrgb(lg) * 255))),
+    Math.round(Math.min(255, Math.max(0, linearToSrgb(lb) * 255))),
+  ];
+}
+
+// --- Contrast calculation ---
+
+function srgbToLinear(c) {
+  c /= 255;
+  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+}
+
+function relativeLuminance(r, g, b) {
+  return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
+}
+
+function contrastRatio(l1, l2) {
+  const lighter = Math.max(l1, l2);
+  const darker = Math.min(l1, l2);
+  return (lighter + 0.05) / (darker + 0.05);
+}
+
+function rgbToHex(r, g, b) {
+  return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
+}
+
+// --- Main ---
+
+// Rejoin args that might have been split by spaces (e.g. "oklch(0.5" "0.1" "250)")
+const raw = args.join(' ');
+const colors = [];
+const patterns = [
+  /oklch\(\s*[\d.]+\s+[\d.]+\s+[\d.]+\s*\)/gi,
+  /rgb\(\s*\d+\s*[,\s]\s*\d+\s*[,\s]\s*\d+\s*\)/gi,
+  /#[0-9a-f]{3,8}/gi,
+];
+
+let remaining = raw;
+for (const pat of patterns) {
+  const matches = remaining.match(pat);
+  if (matches) {
+    for (const m of matches) {
+      colors.push(m);
+      remaining = remaining.replace(m, '');
+    }
+  }
+}
+// Pick up any bare tokens left
+const bare = remaining.trim().split(/\s+/).filter(s => s.length > 0);
+colors.push(...bare);
+
+if (colors.length < 2) {
+  console.error('Error: Could not parse two colors from input.');
+  process.exit(1);
+}
+
+const rgb1 = parseColor(colors[0]);
+const rgb2 = parseColor(colors[1]);
+
+if (!rgb1 || !rgb2) {
+  console.error(`Error: Could not parse color${!rgb1 ? ' 1: ' + colors[0] : ''}${!rgb2 ? ' 2: ' + colors[1] : ''}`);
+  process.exit(1);
+}
+
+const l1 = relativeLuminance(...rgb1);
+const l2 = relativeLuminance(...rgb2);
+const ratio = contrastRatio(l1, l2);
+
+const pass = (threshold) => ratio >= threshold ? 'PASS' : 'FAIL';
+
+console.log(`
+Color 1:  ${colors[0].trim()}  ->  rgb(${rgb1.join(', ')})  ${rgbToHex(...rgb1)}
+Color 2:  ${colors[1].trim()}  ->  rgb(${rgb2.join(', ')})  ${rgbToHex(...rgb2)}
+
+Contrast ratio: ${ratio.toFixed(2)}:1
+
+  WCAG AA  normal text (4.5:1)   ${pass(4.5)}
+  WCAG AA  large text   (3:1)    ${pass(3)}
+  WCAG AAA normal text (7:1)     ${pass(7)}
+  WCAG AAA large text  (4.5:1)   ${pass(4.5)}
+`.trim());

+ 412 - 0
skills/color-ops/scripts/harmony-gen.js

@@ -0,0 +1,412 @@
+#!/usr/bin/env node
+// Harmony generator - build harmonious palettes from a base color or from scratch
+// Usage: node harmony-gen.js <color|hue> [scheme] [--css] [--json] [--tokens]
+//
+// Inspired by Coolors, Color Hunt, and Colour Lovers palette approaches.
+// Generates palettes that work in practice - not just geometric hue math,
+// but varied lightness and chroma for actual UI use.
+
+const args = process.argv.slice(2);
+if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
+  console.log(`Usage: node harmony-gen.js <color|hue> [scheme] [--css] [--json] [--tokens]
+
+Arguments:
+  color      Base color: #hex, rgb(r,g,b), oklch(l c h), or bare hue (0-360)
+  scheme     Harmony type (default: analogous)
+
+Schemes:
+  complementary   Base + opposite hue (2 colors + tints)
+  analogous       Base + neighboring hues (5 colors)
+  triadic         3 evenly spaced hues
+  split           Base + 2 flanking its complement
+  tetradic        4 hues in rectangle pattern
+  monochromatic   Single hue, varied lightness + chroma (6 stops)
+  warm            Warm palette (reds, oranges, golds)
+  cool            Cool palette (blues, teals, greens)
+  earth           Muted natural tones (ochre, sage, clay, slate)
+  pastel          High lightness, low chroma, varied hue
+  vibrant         Mid lightness, high chroma, varied hue
+  random          5 curated random colors with good contrast spread
+
+Flags:
+  --css      Output as CSS custom properties (default)
+  --json     Output as JSON
+  --tokens   Output as design token CSS (surface, primary, accent, etc.)
+  --tints    Include 3-step tint/shade per color
+
+Examples:
+  node harmony-gen.js 250                          # Analogous from hue 250
+  node harmony-gen.js "#3b82f6" triadic            # Triadic from a hex color
+  node harmony-gen.js "oklch(0.6 0.18 250)" split --tokens
+  node harmony-gen.js 30 earth --json
+  node harmony-gen.js random`);
+  process.exit(0);
+}
+
+// ========== Color math ==========
+
+function oklchToOklab(L, C, H) {
+  const hRad = H * Math.PI / 180;
+  return [L, C * Math.cos(hRad), C * Math.sin(hRad)];
+}
+
+function oklabToLinearRgb(L, a, b) {
+  const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
+  const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
+  const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
+  const l = l_ * l_ * l_;
+  const m = m_ * m_ * m_;
+  const s = s_ * s_ * s_;
+  return [
+    +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+    -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
+  ];
+}
+
+function linearToSrgb(c) {
+  return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
+}
+
+function srgbToLinear(c) {
+  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+}
+
+function linearRgbToOklab(lr, lg, lb) {
+  const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
+  const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
+  const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lb + 0.6299787005 * lb);
+  return [
+    0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
+    1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
+    0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
+  ];
+}
+
+function oklabToOklch(L, a, b) {
+  const C = Math.sqrt(a * a + b * b);
+  const H = (Math.atan2(b, a) * 180 / Math.PI + 360) % 360;
+  return [L, C, H];
+}
+
+function toHex(l, c, h) {
+  const [labL, labA, labB] = oklchToOklab(l, c, h);
+  const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
+  const clamp = v => Math.round(Math.min(255, Math.max(0, linearToSrgb(v) * 255)));
+  return '#' + [clamp(lr), clamp(lg), clamp(lb)].map(v => v.toString(16).padStart(2, '0')).join('');
+}
+
+function isInGamut(l, c, h) {
+  const [labL, labA, labB] = oklchToOklab(l, c, h);
+  const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
+  return [lr, lg, lb].every(v => v >= -0.002 && v <= 1.002);
+}
+
+// Reduce chroma until color fits sRGB
+function gamutClamp(l, c, h) {
+  if (isInGamut(l, c, h)) return { l, c, h };
+  let lo = 0, hi = c;
+  for (let i = 0; i < 20; i++) {
+    const mid = (lo + hi) / 2;
+    if (isInGamut(l, mid, h)) lo = mid;
+    else hi = mid;
+  }
+  return { l, c: +lo.toFixed(4), h };
+}
+
+function wrapHue(h) {
+  return ((h % 360) + 360) % 360;
+}
+
+// ========== Parsing ==========
+
+function parseInput(str) {
+  str = str.trim();
+
+  // Bare hue number
+  if (/^\d+(\.\d+)?$/.test(str)) {
+    const h = parseFloat(str);
+    if (h >= 0 && h <= 360) return { l: 0.6, c: 0.15, h };
+  }
+
+  // Hex
+  if (str.startsWith('#') || /^[0-9a-f]{6}$/i.test(str)) {
+    let hex = str.replace('#', '');
+    if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
+    const n = parseInt(hex, 16);
+    const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+    const [lr, lg, lb] = [srgbToLinear(r / 255), srgbToLinear(g / 255), srgbToLinear(b / 255)];
+    const [labL, labA, labB] = linearRgbToOklab(lr, lg, lb);
+    const [L, C, H] = oklabToOklch(labL, labA, labB);
+    return { l: L, c: C, h: H };
+  }
+
+  // rgb()
+  const rgbM = str.match(/rgb\(\s*(\d+)\s*[,\s]\s*(\d+)\s*[,\s]\s*(\d+)\s*\)/i);
+  if (rgbM) {
+    const [r, g, b] = [+rgbM[1], +rgbM[2], +rgbM[3]];
+    const [lr, lg, lb] = [srgbToLinear(r / 255), srgbToLinear(g / 255), srgbToLinear(b / 255)];
+    const [labL, labA, labB] = linearRgbToOklab(lr, lg, lb);
+    const [L, C, H] = oklabToOklch(labL, labA, labB);
+    return { l: L, c: C, h: H };
+  }
+
+  // oklch()
+  const oklchM = str.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)/i);
+  if (oklchM) return { l: +oklchM[1], c: +oklchM[2], h: +oklchM[3] };
+
+  return null;
+}
+
+// ========== Scheme generators ==========
+// Each returns an array of { name, l, c, h } objects.
+// The key insight: geometric hue harmony alone makes boring palettes.
+// Good palettes vary lightness and chroma deliberately.
+
+function complementary(base) {
+  return [
+    { name: 'base', ...base },
+    { name: 'base-light', l: base.l + 0.2, c: base.c * 0.5, h: base.h },
+    { name: 'complement', l: base.l, c: base.c * 0.9, h: wrapHue(base.h + 180) },
+    { name: 'complement-light', l: base.l + 0.2, c: base.c * 0.4, h: wrapHue(base.h + 180) },
+    { name: 'neutral', l: base.l + 0.1, c: 0.02, h: base.h },
+  ];
+}
+
+function analogous(base) {
+  return [
+    { name: 'color-1', l: base.l + 0.1, c: base.c * 0.7, h: wrapHue(base.h - 30) },
+    { name: 'color-2', l: base.l + 0.05, c: base.c * 0.85, h: wrapHue(base.h - 15) },
+    { name: 'base', ...base },
+    { name: 'color-3', l: base.l - 0.05, c: base.c * 0.85, h: wrapHue(base.h + 15) },
+    { name: 'color-4', l: base.l - 0.1, c: base.c * 0.7, h: wrapHue(base.h + 30) },
+  ];
+}
+
+function triadic(base) {
+  return [
+    { name: 'primary', ...base },
+    { name: 'primary-muted', l: base.l + 0.15, c: base.c * 0.4, h: base.h },
+    { name: 'secondary', l: base.l + 0.05, c: base.c * 0.8, h: wrapHue(base.h + 120) },
+    { name: 'tertiary', l: base.l - 0.05, c: base.c * 0.8, h: wrapHue(base.h + 240) },
+    { name: 'neutral', l: base.l + 0.25, c: 0.015, h: base.h },
+  ];
+}
+
+function split(base) {
+  return [
+    { name: 'base', ...base },
+    { name: 'base-muted', l: base.l + 0.2, c: base.c * 0.35, h: base.h },
+    { name: 'split-1', l: base.l + 0.05, c: base.c * 0.75, h: wrapHue(base.h + 150) },
+    { name: 'split-2', l: base.l - 0.05, c: base.c * 0.75, h: wrapHue(base.h + 210) },
+    { name: 'neutral', l: 0.92, c: 0.01, h: base.h },
+  ];
+}
+
+function tetradic(base) {
+  return [
+    { name: 'primary', ...base },
+    { name: 'secondary', l: base.l + 0.05, c: base.c * 0.85, h: wrapHue(base.h + 90) },
+    { name: 'tertiary', l: base.l - 0.05, c: base.c * 0.85, h: wrapHue(base.h + 180) },
+    { name: 'quaternary', l: base.l + 0.1, c: base.c * 0.7, h: wrapHue(base.h + 270) },
+    { name: 'neutral', l: 0.93, c: 0.012, h: base.h },
+  ];
+}
+
+function monochromatic(base) {
+  return [
+    { name: 'lightest', l: 0.95, c: base.c * 0.15, h: base.h },
+    { name: 'light', l: 0.82, c: base.c * 0.4, h: base.h },
+    { name: 'mid-light', l: 0.7, c: base.c * 0.75, h: base.h },
+    { name: 'mid', l: base.l, c: base.c, h: base.h },
+    { name: 'dark', l: 0.4, c: base.c * 0.7, h: base.h },
+    { name: 'darkest', l: 0.22, c: base.c * 0.3, h: base.h },
+  ];
+}
+
+function warm(base) {
+  const h = base.h;
+  // Pull toward warm range (0-70)
+  return [
+    { name: 'cream', l: 0.94, c: 0.04, h: 80 },
+    { name: 'gold', l: 0.78, c: 0.14, h: 85 },
+    { name: 'amber', l: 0.68, c: 0.17, h: 55 },
+    { name: 'terracotta', l: 0.55, c: 0.14, h: 30 },
+    { name: 'deep-red', l: 0.38, c: 0.15, h: 15 },
+  ];
+}
+
+function cool(base) {
+  return [
+    { name: 'ice', l: 0.95, c: 0.025, h: 230 },
+    { name: 'sky', l: 0.8, c: 0.1, h: 230 },
+    { name: 'ocean', l: 0.6, c: 0.15, h: 245 },
+    { name: 'teal', l: 0.55, c: 0.12, h: 190 },
+    { name: 'deep-navy', l: 0.25, c: 0.08, h: 260 },
+  ];
+}
+
+function earth(base) {
+  return [
+    { name: 'sand', l: 0.88, c: 0.04, h: 80 },
+    { name: 'sage', l: 0.68, c: 0.06, h: 145 },
+    { name: 'ochre', l: 0.62, c: 0.1, h: 65 },
+    { name: 'clay', l: 0.5, c: 0.08, h: 35 },
+    { name: 'slate', l: 0.35, c: 0.03, h: 260 },
+  ];
+}
+
+function pastel(base) {
+  const offsets = [0, 60, 130, 210, 290];
+  return offsets.map((offset, i) => ({
+    name: `pastel-${i + 1}`,
+    l: 0.88 + Math.random() * 0.06,
+    c: 0.04 + Math.random() * 0.03,
+    h: wrapHue(base.h + offset),
+  }));
+}
+
+function vibrant(base) {
+  const offsets = [0, 72, 144, 216, 288];
+  return offsets.map((offset, i) => ({
+    name: `vibrant-${i + 1}`,
+    l: 0.55 + (i % 2) * 0.1,
+    c: 0.18 + Math.random() * 0.04,
+    h: wrapHue(base.h + offset),
+  }));
+}
+
+function random(_base) {
+  // Curated random: ensure good lightness spread and hue variety
+  const hueStart = Math.random() * 360;
+  const goldenAngle = 137.508;
+  const colors = [];
+  const lightnesses = [0.9, 0.75, 0.6, 0.45, 0.3];
+  for (let i = 0; i < 5; i++) {
+    colors.push({
+      name: `color-${i + 1}`,
+      l: lightnesses[i] + (Math.random() - 0.5) * 0.05,
+      c: 0.06 + Math.random() * 0.14,
+      h: wrapHue(hueStart + goldenAngle * i),
+    });
+  }
+  return colors;
+}
+
+const schemes = {
+  complementary, analogous, triadic, split, tetradic,
+  monochromatic, warm, cool, earth, pastel, vibrant, random,
+};
+
+// ========== Parse arguments ==========
+
+const flags = args.filter(a => a.startsWith('--'));
+const positional = args.filter(a => !a.startsWith('--'));
+
+const jsonOutput = flags.includes('--json');
+const tokensOutput = flags.includes('--tokens');
+const includeTints = flags.includes('--tints');
+
+// Handle "random" as first arg
+let inputStr, schemeName;
+if (positional[0] === 'random') {
+  inputStr = '180'; // dummy
+  schemeName = 'random';
+} else {
+  // Rejoin for oklch() parsing
+  const joined = positional.join(' ');
+  const oklchM = joined.match(/oklch\(\s*[\d.]+\s+[\d.]+\s+[\d.]+\s*\)/i);
+  if (oklchM) {
+    inputStr = oklchM[0];
+    const rest = joined.replace(oklchM[0], '').trim();
+    schemeName = rest || 'analogous';
+  } else {
+    inputStr = positional[0] || '250';
+    schemeName = positional[1] || 'analogous';
+  }
+}
+
+const base = parseInput(inputStr);
+if (!base) {
+  console.error(`Error: Could not parse "${inputStr}"`);
+  process.exit(1);
+}
+
+const schemeFn = schemes[schemeName];
+if (!schemeFn) {
+  console.error(`Unknown scheme: "${schemeName}"`);
+  console.error(`Available: ${Object.keys(schemes).join(', ')}`);
+  process.exit(1);
+}
+
+// ========== Generate ==========
+
+let palette = schemeFn(base);
+
+// Gamut-clamp all colors
+palette = palette.map(c => {
+  const clamped = gamutClamp(
+    Math.min(1, Math.max(0, c.l)),
+    c.c,
+    wrapHue(c.h)
+  );
+  return { name: c.name, ...clamped };
+});
+
+// Generate tints if requested
+function tints(color) {
+  return [
+    { name: `${color.name}-tint`, l: color.l + 0.15, c: color.c * 0.5, h: color.h },
+    { name: `${color.name}-shade`, l: color.l - 0.15, c: color.c * 0.8, h: color.h },
+    { name: `${color.name}-muted`, l: color.l + 0.05, c: color.c * 0.3, h: color.h },
+  ].map(t => {
+    const clamped = gamutClamp(Math.min(1, Math.max(0, t.l)), t.c, wrapHue(t.h));
+    return { name: t.name, ...clamped };
+  });
+}
+
+if (includeTints) {
+  const expanded = [];
+  for (const color of palette) {
+    expanded.push(color, ...tints(color));
+  }
+  palette = expanded;
+}
+
+// ========== Output ==========
+
+if (jsonOutput) {
+  const output = palette.map(c => ({
+    name: c.name,
+    oklch: `oklch(${c.l.toFixed(3)} ${c.c.toFixed(4)} ${c.h.toFixed(1)})`,
+    hex: toHex(c.l, c.c, c.h),
+  }));
+  console.log(JSON.stringify({ scheme: schemeName, colors: output }, null, 2));
+  process.exit(0);
+}
+
+if (tokensOutput) {
+  // Map palette to semantic tokens
+  console.log(`:root {
+  /* ${schemeName} harmony - generated from oklch(${base.l.toFixed(2)} ${base.c.toFixed(2)} ${base.h.toFixed(0)}) */`);
+  const roles = ['primary', 'secondary', 'accent', 'surface', 'muted'];
+  palette.slice(0, roles.length).forEach((c, i) => {
+    const role = roles[i] || `color-${i + 1}`;
+    console.log(`  --color-${role}: oklch(${c.l.toFixed(3)} ${c.c.toFixed(4)} ${c.h.toFixed(1)});  /* ${toHex(c.l, c.c, c.h)} */`);
+  });
+  // Auto-generate on-surface
+  const darkest = palette.reduce((a, b) => a.l < b.l ? a : b);
+  const lightest = palette.reduce((a, b) => a.l > b.l ? a : b);
+  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)} */`);
+  console.log(`  --color-background: oklch(${lightest.l.toFixed(3)} ${lightest.c.toFixed(4)} ${lightest.h.toFixed(1)});  /* ${toHex(lightest.l, lightest.c, lightest.h)} */`);
+  console.log('}');
+  process.exit(0);
+}
+
+// Default: CSS output
+console.log(`/* ${schemeName} harmony from oklch(${base.l.toFixed(2)} ${base.c.toFixed(2)} ${base.h.toFixed(0)}) */`);
+console.log(':root {');
+for (const c of palette) {
+  console.log(`  --${c.name}: oklch(${c.l.toFixed(3)} ${c.c.toFixed(4)} ${c.h.toFixed(1)});  /* ${toHex(c.l, c.c, c.h)} */`);
+}
+console.log('}');

+ 140 - 0
skills/color-ops/scripts/palette-gen.js

@@ -0,0 +1,140 @@
+#!/usr/bin/env node
+// Palette generator - produce a 10-step OKLCH scale as CSS custom properties
+// Usage: node palette-gen.js <hue> [name] [--neutral] [--json]
+//
+// Examples:
+//   node palette-gen.js 250              # Blue scale (default name: "brand")
+//   node palette-gen.js 250 blue         # Named "blue"
+//   node palette-gen.js 250 blue --neutral  # Also generate a neutral scale
+//   node palette-gen.js 30 orange --json    # JSON output
+
+const args = process.argv.slice(2);
+if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
+  console.log(`Usage: node palette-gen.js <hue> [name] [--neutral] [--json]
+
+Arguments:
+  hue        OKLCH hue angle (0-360)
+  name       Token prefix (default: "brand")
+
+Flags:
+  --neutral  Also generate a matching neutral scale (same hue, low chroma)
+  --json     Output as JSON instead of CSS
+
+Hue reference:
+  0-30   Pink/Red       110-160  Green
+  30-70  Orange/Amber   160-200  Teal/Cyan
+  70-110 Yellow/Lime    200-260  Blue
+                        260-310  Violet
+                        310-360  Magenta`);
+  process.exit(0);
+}
+
+const hue = parseFloat(args[0]);
+if (isNaN(hue) || hue < 0 || hue > 360) {
+  console.error('Error: Hue must be a number between 0 and 360.');
+  process.exit(1);
+}
+
+const flags = args.filter(a => a.startsWith('--'));
+const positional = args.filter(a => !a.startsWith('--'));
+const name = positional[1] || 'brand';
+const includeNeutral = flags.includes('--neutral');
+const jsonOutput = flags.includes('--json');
+
+function generateScale(hue, chromaMultiplier = 1) {
+  const steps = 10;
+  return Array.from({ length: steps }, (_, i) => {
+    const t = i / (steps - 1);
+    const step = (i + 1) * 100; // 100..1000
+    const l = +(0.97 - t * 0.82).toFixed(3);
+    // Chroma peaks at midtones (sine curve), clamped for neutrals
+    const c = +(Math.sin(t * Math.PI) * 0.18 * chromaMultiplier).toFixed(4);
+    return { step, l, c, h: hue };
+  });
+}
+
+function formatOklch(l, c, h) {
+  return `oklch(${l} ${c} ${h})`;
+}
+
+// --- sRGB conversion for preview swatches ---
+
+function oklchToOklab(L, C, H) {
+  const hRad = H * Math.PI / 180;
+  return [L, C * Math.cos(hRad), C * Math.sin(hRad)];
+}
+
+function oklabToLinearRgb(L, a, b) {
+  const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
+  const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
+  const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
+  const l = l_ * l_ * l_;
+  const m = m_ * m_ * m_;
+  const s = s_ * s_ * s_;
+  return [
+    +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+    -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
+  ];
+}
+
+function linearToSrgb(c) {
+  return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
+}
+
+function toHex(l, c, h) {
+  const [labL, labA, labB] = oklchToOklab(l, c, h);
+  const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
+  const clamp = v => Math.round(Math.min(255, Math.max(0, linearToSrgb(v) * 255)));
+  return '#' + [clamp(lr), clamp(lg), clamp(lb)].map(v => v.toString(16).padStart(2, '0')).join('');
+}
+
+function isInGamut(l, c, h) {
+  const [labL, labA, labB] = oklchToOklab(l, c, h);
+  const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
+  return [lr, lg, lb].every(v => v >= -0.001 && v <= 1.001);
+}
+
+// --- Output ---
+
+const brandScale = generateScale(hue);
+const neutralScale = includeNeutral ? generateScale(hue, 0.12) : [];
+
+if (jsonOutput) {
+  const output = {
+    [name]: brandScale.map(s => ({
+      step: s.step,
+      oklch: formatOklch(s.l, s.c, s.h),
+      hex: toHex(s.l, s.c, s.h),
+      inGamut: isInGamut(s.l, s.c, s.h),
+    })),
+  };
+  if (includeNeutral) {
+    output[`${name}-neutral`] = neutralScale.map(s => ({
+      step: s.step,
+      oklch: formatOklch(s.l, s.c, s.h),
+      hex: toHex(s.l, s.c, s.h),
+      inGamut: isInGamut(s.l, s.c, s.h),
+    }));
+  }
+  console.log(JSON.stringify(output, null, 2));
+  process.exit(0);
+}
+
+// CSS output
+function printScale(scaleName, scale) {
+  console.log(`  /* ${scaleName} - hue ${hue} */`);
+  for (const s of scale) {
+    const hex = toHex(s.l, s.c, s.h);
+    const gamut = isInGamut(s.l, s.c, s.h) ? '' : ' /* out of sRGB gamut */';
+    console.log(`  --${scaleName}-${s.step}: ${formatOklch(s.l, s.c, s.h)};  /* ${hex} */${gamut}`);
+  }
+}
+
+console.log(`:root {`);
+printScale(name, brandScale);
+if (includeNeutral) {
+  console.log('');
+  printScale(`${name}-neutral`, neutralScale);
+}
+console.log(`}`);