Claude Skill

color-ops

Color for developers: color spaces, accessibility contrast, palette generation, CSS color functions, design tokens, dark mode. Triggers on: color, palette, contrast, WCAG, APCA, OKLCH, color-mix, dark mode colors, design tokens, CVD, color blind, P3, sRGB.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_color-ops-3dfaf0b.zip · 22 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/color-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Color Operations

Practical color knowledge for developers and designers. Covers color spaces, accessibility, palette generation, CSS implementation, and design token architecture.

Inspired by meodai/skill.color-expert - a comprehensive 286K-word color science knowledge base with 113 reference files. This is a lightweight operational skill for everyday frontend and design work. For deep color science (spectral mixing, historical color theory, CAM16, pigment physics), install the full skill.

Color Space Decision Table

Pick the right space for the task. This is the single most impactful color decision you'll make.

Task Use Why
Perceptual color manipulation OKLCH Best uniformity for lightness, chroma, hue
CSS gradients & palettes OKLCH or color-mix(in oklab) No mid-gradient grey/brown deadzone
Gamut-aware color picking OKHSL / OKHSV Cylindrical like HSL but perceptually grounded
Normalized saturation (0-100%) HSLuv CIELUV chroma normalized per hue/lightness
Print workflows CIELAB D50 ICC standard illuminant
Screen workflows OKLAB D65 standard, perceptually uniform
Color difference (precision) CIEDE2000 Gold standard perceptual distance metric
Color difference (fast) Euclidean in OKLAB Good enough for most applications
Quick prototyping HSL Simple, fast, every tool supports it

Why HSL Falls Short

HSL is fine for quick prototyping. It fails for anything perceptual:

  • Lightness is a lie: hsl(60,100%,50%) (yellow) and hsl(240,100%,50%) (blue) have the same L=50% but vastly different perceived brightness
  • Hue is non-uniform: 20 degrees near red is a dramatic shift; 20 degrees near green is barely visible
  • Saturation doesn't correlate: S=100% dark blue still looks muted

Rule of thumb: Use HSL for throwaway work. Use OKLCH for anything that ships.

Key Distinctions

  • Chroma = colorfulness relative to a same-lightness neutral
  • Saturation = perceived colorfulness relative to the color's own brightness
  • Lightness = perceived reflectance relative to a similarly lit white
  • Same chroma != same saturation. These are different dimensions.

Accessibility - Contrast Numbers That Matter

The Odds Are Against You

Of ~281 trillion hex color pairs:

Threshold % passing Odds
WCAG 3:1 (large text) 26.49% ~1 in 4
WCAG 4.5:1 (AA body) 11.98% ~1 in 8
WCAG 7:1 (AAA) 3.64% ~1 in 27
APCA 60 7.33% ~1 in 14
APCA 75 (fluent reading) 1.57% ~1 in 64
APCA 90 (preferred body) 0.08% ~1 in 1,250

WCAG vs APCA

WCAG 2.x APCA (WCAG 3 draft)
Model Simple luminance ratio Perceptual contrast, polarity-aware
Dark-on-light vs light-on-dark Same ratio Different - accounts for spatial frequency
Text size/weight Only large vs normal Continuous scale with font lookup table
Accuracy Known problems with blue, dark mode Much better perceptual accuracy
Status Current standard, legally referenced Draft - not yet a requirement

Practical guidance: Test with WCAG 2.x for compliance. Use APCA for better perceptual results. When they disagree, APCA is usually more accurate.

Quick Contrast Checks

/* Use relative color syntax to auto-generate readable text */
--surface: oklch(0.95 0.02 250);
--on-surface: oklch(from var(--surface) calc(l - 0.6) c h);

/* Or simpler: light surface = dark text, dark surface = light text */
--text: oklch(from var(--surface) calc(1 - l) 0 h);
// Quick WCAG 2.x relative luminance contrast
function contrastRatio(l1, l2) {
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

function relativeLuminance(r, g, b) {
  const [rs, gs, bs] = [r, g, b].map(c => {
    c /= 255;
    return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

Color Vision Deficiency (CVD)

~8% of men and ~0.5% of women have some form of color vision deficiency. Design accordingly.

Type Affects Prevalence What breaks
Protanopia Red perception ~1% men Red/green distinction, red appears dark
Deuteranopia Green perception ~1% men Red/green distinction (most common)
Tritanopia Blue perception ~0.01% Blue/yellow distinction (rare)

Rules:

  • Never use color alone to convey information (add icons, labels, patterns)
  • Test with CVD simulation tools (see references)
  • Red/green is the most dangerous pair - always add a secondary signal

CSS Color Functions - Modern Syntax

Core Functions (Baseline 2024+)

/* OKLCH - the recommended default */
color: oklch(0.7 0.15 150);           /* lightness chroma hue */
color: oklch(0.7 0.15 150 / 0.5);     /* with alpha */

/* OKLAB - for interpolation and mixing */
color: oklab(0.7 -0.1 0.1);           /* lightness a b */

/* color-mix() - blend two colors in any space */
color: color-mix(in oklch, #3b82f6 70%, white);
color: color-mix(in oklab, var(--primary), black 20%);

/* Relative color syntax - transform existing colors */
color: oklch(from var(--brand) calc(l + 0.1) c h);          /* lighten */
color: oklch(from var(--brand) calc(l - 0.1) c h);          /* darken */
color: oklch(from var(--brand) l calc(c * 0.5) h);          /* desaturate */
color: oklch(from var(--brand) l c calc(h + 180));           /* complement */

/* P3 wide gamut */
color: color(display-p3 1 0.5 0);     /* ~25% more colors than sRGB */

/* Fallback pattern for wide gamut */
color: #ff8800;                        /* sRGB fallback */
color: oklch(0.79 0.17 70);           /* oklch version */
color: color(display-p3 1 0.55 0);    /* P3 if supported */

Gradients That Don't Muddy

/* BAD - RGB interpolation goes through grey/brown */
background: linear-gradient(to right, blue, yellow);

/* GOOD - OKLCH interpolation stays vivid */
background: linear-gradient(in oklch, blue, yellow);

/* GOOD - OKLAB also works well */
background: linear-gradient(in oklab, blue, yellow);

/* Longer hue path for rainbow-style gradients */
background: linear-gradient(in oklch longer hue, red, red);

Design Token Architecture

Three-Layer Pattern

/* Layer 1: Reference tokens (the palette) */
:root {
  --ref-blue-50: oklch(0.97 0.01 250);
  --ref-blue-100: oklch(0.93 0.03 250);
  --ref-blue-500: oklch(0.62 0.18 250);
  --ref-blue-900: oklch(0.25 0.09 250);
  --ref-red-500: oklch(0.63 0.22 25);
  --ref-neutral-50: oklch(0.97 0.005 250);
  --ref-neutral-900: oklch(0.15 0.005 250);
}

/* Layer 2: Semantic tokens (meaning) */
:root {
  --color-surface: var(--ref-neutral-50);
  --color-on-surface: var(--ref-neutral-900);
  --color-primary: var(--ref-blue-500);
  --color-error: var(--ref-red-500);
  --color-border: oklch(from var(--color-surface) calc(l - 0.15) 0.01 h);
}

/* Layer 3: Dark mode swaps semantics, not components */
[data-theme="dark"] {
  --color-surface: var(--ref-neutral-900);
  --color-on-surface: var(--ref-neutral-50);
  --color-primary: var(--ref-blue-100);
  --color-border: oklch(from var(--color-surface) calc(l + 0.15) 0.01 h);
}

Generating Scales in OKLCH

// Generate a perceptually uniform color scale
function generateScale(hue, steps = 10) {
  return Array.from({ length: steps }, (_, i) => {
    const t = i / (steps - 1);
    return {
      step: (i + 1) * 100,  // 100..1000
      l: 0.97 - t * 0.82,   // 0.97 (lightest) to 0.15 (darkest)
      c: Math.sin(t * Math.PI) * 0.18,  // peak chroma in midtones
      h: hue,
    };
  });
}

// Usage: generateScale(250) for a blue scale
// Format: oklch(${l} ${c} ${h})

Palette & Harmony

What Actually Works

Geometric hue harmony (complementary, triadic, etc.) is a weak predictor of good palettes on its own. Better approaches:

  • Character-first: Organize by mood (pale/muted/deep/vivid/dark). Chroma + lightness drive emotional response more than hue.
  • 60-30-10 rule: 60% dominant, 30% secondary, 10% accent. One color dominates.
  • Lightness variation = legibility: Same character + varied lightness is readable. Same lightness across hues is illegible.
  • Grayscale sanity check: If your UI doesn't work in grayscale, the color system has a structural problem.

Practical Palette Workflow

  1. Pick a brand hue in OKLCH
  2. Generate a 10-step scale (lightness 0.97 to 0.15, chroma peaks at midtones)
  3. Pick a neutral (same hue, near-zero chroma) for another 10-step scale
  4. Add 1-2 semantic accent hues (success green, error red, warning amber)
  5. Map to semantic tokens: surface, on-surface, primary, secondary, error
  6. Test contrast at every text/surface combination (WCAG 4.5:1 minimum)
  7. Swap semantic mappings for dark mode (don't just invert)

Quick Harmony Shortcuts

/* Complementary (opposite hue) */
--complement: oklch(from var(--primary) l c calc(h + 180));

/* Analogous (adjacent hues) */
--analogous-1: oklch(from var(--primary) l c calc(h - 30));
--analogous-2: oklch(from var(--primary) l c calc(h + 30));

/* Triadic */
--triadic-1: oklch(from var(--primary) l c calc(h + 120));
--triadic-2: oklch(from var(--primary) l c calc(h + 240));

/* Tint (lighter, less chroma) */
--tint: oklch(from var(--primary) calc(l + 0.2) calc(c * 0.5) h);

/* Shade (darker, slightly less chroma) */
--shade: oklch(from var(--primary) calc(l - 0.2) calc(c * 0.8) h);

Gamut & Wide Color

sRGB vs P3 vs Rec2020

Gamut Coverage Support
sRGB Baseline Universal - every screen
Display P3 ~25% more than sRGB Modern Apple, high-end Android, new monitors
Rec2020 ~37% more than P3 HDR content, limited device support
/* Progressive enhancement for wide gamut */
.brand-accent {
  /* sRGB fallback - every browser */
  background: #ff6b00;

  /* P3 if supported - more vivid */
  @supports (color: color(display-p3 1 0 0)) {
    background: color(display-p3 1 0.42 0);
  }
}

/* Or use @media for gamut detection */
@media (color-gamut: p3) {
  :root {
    --accent: oklch(0.75 0.2 50);  /* Can push chroma higher in P3 */
  }
}

Gamut Mapping

When a color is out of gamut (e.g., high-chroma OKLCH on an sRGB screen), browsers clamp it. Control this:

/* Browser auto-maps (default) */
color: oklch(0.7 0.3 150);  /* if out of sRGB, browser reduces chroma */

/* Explicit gamut check in JS */
// 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

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

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

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

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:

  • Palette generation algorithms (RampenSau, Poline, IQ cosine): Route to frontend-design skill or a dedicated subagent with references/tools-and-libraries.md preloaded
  • Accessibility audits (full APCA + CVD simulation): Route to a subagent that runs contrast checks across all component/token combinations
  • Design system color architecture: Route to tailwind-ops for Tailwind-specific implementation, or handle directly for CSS custom properties

Reference Files

File Content
references/tools-and-libraries.md Palette generators, analysis tools, color libraries, online tools, browser extensions
references/css-color-reference.md Complete CSS Color Level 4/5 function reference, browser support, conversion formulas

See Also

  • tailwind-ops - Tailwind color configuration and dark mode patterns
  • react-ops - Theme context and color mode implementation in React
  • meodai/skill.color-expert - Full color science skill (113 references, spectral mixing, historical theory)
  • oklch.com - Interactive OKLCH picker by Evil Martians
  • Huetone - Accessible color system builder
Files (claude-mods)
  • assets
    • .gitkeep 0 B · in bundle
  • references
    • css-color-reference.md 8.5 KB
      # CSS Color Reference
      
      Complete reference for CSS Color Level 4 and Level 5 functions. Baseline 2024+ unless noted.
      
      ## Color Functions
      
      ### oklch()
      
      The recommended default for CSS color work. Perceptually uniform, intuitive cylindrical coordinates.
      
      ```css
      oklch(L C H)
      oklch(L C H / alpha)
      
      /* L: lightness 0-1 (0 = black, 1 = white) */
      /* C: chroma 0-0.4 (0 = grey, higher = more vivid) */
      /* H: hue 0-360 (degrees on color wheel) */
      
      color: oklch(0.7 0.15 250);           /* medium blue */
      color: oklch(0.7 0.15 250 / 0.5);     /* 50% transparent */
      color: oklch(0.9 0.04 90);            /* pale yellow */
      color: oklch(0.3 0.2 30);             /* deep red */
      ```
      
      **Practical chroma ranges by context:**
      
      | Context | Chroma Range | Notes |
      |---------|-------------|-------|
      | Neutral/grey | 0 - 0.02 | Near-zero, slight warmth/coolness via hue |
      | Muted/pastel | 0.02 - 0.08 | Backgrounds, large surfaces |
      | Medium | 0.08 - 0.15 | Body text links, secondary UI |
      | Vivid | 0.15 - 0.25 | Primary actions, brand colors |
      | Maximum | 0.25 - 0.37 | Saturated accents (gamut-dependent) |
      
      **OKLCH hue map (approximate):**
      
      | Hue | Color |
      |-----|-------|
      | 0-30 | Pink / Red |
      | 30-70 | Orange / Amber |
      | 70-110 | Yellow / Lime |
      | 110-160 | Green |
      | 160-200 | Teal / Cyan |
      | 200-260 | Blue |
      | 260-310 | Indigo / Violet |
      | 310-360 | Magenta / Pink |
      
      ### oklab()
      
      Cartesian version of OKLCH. Better for interpolation and mixing.
      
      ```css
      oklab(L a b)
      oklab(L a b / alpha)
      
      /* L: lightness 0-1 */
      /* a: green(-) to red(+), roughly -0.4 to 0.4 */
      /* b: blue(-) to yellow(+), roughly -0.4 to 0.4 */
      
      color: oklab(0.7 -0.1 0.1);           /* greenish */
      color: oklab(0.5 0.15 -0.1);          /* purplish */
      ```
      
      ### color-mix()
      
      Blend two colors in any color space.
      
      ```css
      color-mix(in <colorspace>, <color1> <percentage>?, <color2> <percentage>?)
      
      /* Default: 50/50 mix */
      color: color-mix(in oklch, blue, white);
      
      /* Weighted mix */
      color: color-mix(in oklch, #3b82f6 70%, white);        /* 70% blue, 30% white */
      color: color-mix(in oklab, var(--primary), black 20%);  /* 80% primary, 20% black */
      
      /* Hue interpolation control */
      color: color-mix(in oklch shorter hue, red, blue);      /* shorter arc */
      color: color-mix(in oklch longer hue, red, blue);       /* longer arc (rainbow) */
      ```
      
      **Supported color spaces for interpolation:**
      `srgb`, `srgb-linear`, `display-p3`, `a98-rgb`, `prophoto-rgb`, `rec2020`, `lab`, `oklab`, `xyz`, `xyz-d50`, `xyz-d65`, `hsl`, `hwb`, `lch`, `oklch`
      
      **Best spaces for mixing:**
      
      | Space | Result |
      |-------|--------|
      | `oklch` | Vivid, predictable hue path |
      | `oklab` | Smooth, no hue shift surprises |
      | `srgb` | Legacy default, can muddy |
      | `hsl` | Unpredictable brightness |
      
      ### Relative Color Syntax
      
      Transform an existing color by modifying its components. Game-changer for design systems.
      
      ```css
      /* Syntax: <colorspace>(from <origin> <component-expressions>) */
      
      /* Lighten */
      color: oklch(from var(--brand) calc(l + 0.1) c h);
      
      /* Darken */
      color: oklch(from var(--brand) calc(l - 0.1) c h);
      
      /* Desaturate */
      color: oklch(from var(--brand) l calc(c * 0.5) h);
      
      /* Saturate */
      color: oklch(from var(--brand) l calc(c * 1.5) h);
      
      /* Shift hue (complement) */
      color: oklch(from var(--brand) l c calc(h + 180));
      
      /* Auto readable text (invert lightness) */
      color: oklch(from var(--surface) calc(1 - l) 0 h);
      
      /* Extract and modify alpha */
      color: oklch(from var(--brand) l c h / 0.5);
      
      /* Works with any origin format */
      color: oklch(from #3b82f6 calc(l + 0.2) c h);
      color: oklch(from rgb(59 130 246) l calc(c * 0.5) h);
      ```
      
      **Available channel keywords by space:**
      
      | Space | Keywords |
      |-------|----------|
      | oklch | `l` `c` `h` |
      | oklab | `l` `a` `b` |
      | hsl | `h` `s` `l` |
      | srgb | `r` `g` `b` |
      
      ### color()
      
      Access predefined color spaces directly. Primary use: Display P3 wide gamut.
      
      ```css
      color(<colorspace> <values>)
      color(<colorspace> <values> / alpha)
      
      /* Display P3 (wider gamut than sRGB) */
      color: color(display-p3 1 0.5 0);
      color: color(display-p3 0.3 0.8 0.2 / 0.9);
      
      /* sRGB (explicit) */
      color: color(srgb 0.5 0.5 0.5);
      
      /* Other spaces */
      color: color(a98-rgb 0.44 0.5 0.37);
      color: color(prophoto-rgb 0.36 0.48 0.14);
      color: color(rec2020 0.42 0.47 0.13);
      ```
      
      ### light-dark()
      
      Return different values based on computed color-scheme.
      
      ```css
      /* Requires color-scheme to be set */
      :root { color-scheme: light dark; }
      
      color: light-dark(#333, #eee);
      background: light-dark(white, oklch(0.2 0.01 250));
      border-color: light-dark(
        oklch(0.8 0.02 250),
        oklch(0.3 0.02 250)
      );
      ```
      
      **Browser support:** Baseline 2024
      
      ## Gradients
      
      ### Interpolation Space
      
      ```css
      /* Default (sRGB) - often muddy */
      background: linear-gradient(blue, yellow);
      
      /* OKLCH - vivid, predictable */
      background: linear-gradient(in oklch, blue, yellow);
      
      /* OKLAB - smooth, no hue surprises */
      background: linear-gradient(in oklab, blue, yellow);
      
      /* Hue interpolation for oklch/hsl/lch */
      background: linear-gradient(in oklch shorter hue, red, blue);
      background: linear-gradient(in oklch longer hue, red, red);  /* rainbow */
      background: linear-gradient(in oklch increasing hue, red, blue);
      background: linear-gradient(in oklch decreasing hue, red, blue);
      ```
      
      ### Common Gradient Patterns
      
      ```css
      /* Smooth multi-stop */
      background: linear-gradient(in oklch,
        oklch(0.6 0.2 30),   /* warm red */
        oklch(0.7 0.18 60),  /* orange */
        oklch(0.8 0.15 90)   /* gold */
      );
      
      /* Eased gradient (manual) - smoother than default linear */
      background: linear-gradient(in oklch,
        oklch(0.3 0.15 250) 0%,
        oklch(0.35 0.14 250) 10%,
        oklch(0.45 0.12 250) 30%,
        oklch(0.6 0.08 250) 60%,
        oklch(0.8 0.04 250) 85%,
        oklch(0.95 0.01 250) 100%
      );
      
      /* Radial in oklch */
      background: radial-gradient(in oklch, oklch(0.8 0.2 60), oklch(0.3 0.1 30));
      
      /* Conic in oklch (color wheel) */
      background: conic-gradient(in oklch longer hue, red, red);
      ```
      
      ## Conversion Formulas
      
      ### Hex to sRGB
      
      ```javascript
      function hexToRgb(hex) {
        const n = parseInt(hex.replace('#', ''), 16);
        return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
      }
      ```
      
      ### sRGB to Linear RGB
      
      ```javascript
      function srgbToLinear(c) {
        c /= 255;
        return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
      }
      ```
      
      ### Linear RGB to OKLAB
      
      ```javascript
      function linearRgbToOklab(r, g, b) {
        const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
        const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
        const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
      
        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,
        ];
      }
      ```
      
      ### OKLAB to OKLCH
      
      ```javascript
      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];
      }
      ```
      
      ### Relative Luminance (WCAG 2.x)
      
      ```javascript
      function relativeLuminance(r, g, b) {
        const [rs, gs, bs] = [r, g, b].map(srgbToLinear);
        return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
      }
      
      function contrastRatio(l1, l2) {
        const lighter = Math.max(l1, l2);
        const darker = Math.min(l1, l2);
        return (lighter + 0.05) / (darker + 0.05);
      }
      ```
      
      ## @media and @supports Queries
      
      ```css
      /* Detect wide gamut display */
      @media (color-gamut: p3) {
        :root { --can-p3: true; }
      }
      @media (color-gamut: rec2020) {
        :root { --can-rec2020: true; }
      }
      
      /* Detect color scheme preference */
      @media (prefers-color-scheme: dark) { /* dark mode */ }
      @media (prefers-color-scheme: light) { /* light mode */ }
      
      /* Detect reduced motion (relevant for animated gradients) */
      @media (prefers-reduced-motion: reduce) { /* tone it down */ }
      
      /* Detect contrast preference */
      @media (prefers-contrast: more) { /* increase contrast */ }
      @media (prefers-contrast: less) { /* decrease contrast */ }
      
      /* Feature detection */
      @supports (color: oklch(0 0 0)) { /* oklch supported */ }
      @supports (color: color(display-p3 1 0 0)) { /* P3 supported */ }
      @supports (color: color-mix(in oklch, red, blue)) { /* color-mix supported */ }
      ```
      
      ## Browser Support Summary
      
      | Feature | Chrome | Firefox | Safari | Baseline |
      |---------|--------|---------|--------|----------|
      | oklch() / oklab() | 111+ | 113+ | 15.4+ | 2023 |
      | color-mix() | 111+ | 113+ | 16.2+ | 2023 |
      | Relative color syntax | 119+ | 128+ | 16.4+ | 2024 |
      | color() (P3, etc.) | 111+ | 113+ | 15+ | 2023 |
      | light-dark() | 123+ | 120+ | 17.5+ | 2024 |
      | Gradient interpolation | 111+ | 127+ | 16.2+ | 2024 |
      
      All features listed here are Baseline 2023-2024 - safe for production with a simple sRGB fallback for older browsers.
      
    • tools-and-libraries.md 4.3 KB
      # Color Tools & Libraries
      
      Curated toolkit for practical color work. Organized by task.
      
      ## JavaScript Libraries
      
      ### Manipulation & Conversion
      
      | Library | Size | Strengths | Install |
      |---------|------|-----------|---------|
      | [Culori](https://culorijs.org/) | ~15KB | 50+ color spaces, tree-shakeable, OKLCH native | `npm i culori` |
      | [Color.js](https://colorjs.io/) | ~40KB | CSS Color Level 4/5 reference impl, by Lea Verou & Chris Lilley | `npm i colorjs.io` |
      | [chroma.js](https://gka.github.io/chroma.js/) | ~14KB | Great API, bezier interpolation, battle-tested | `npm i chroma-js` |
      | [tinycolor2](https://github.com/bgrins/TinyColor) | ~10KB | Lightweight, good enough for simple tasks | `npm i tinycolor2` |
      
      ### Palette Generation
      
      | Library | Approach | Best For |
      |---------|----------|----------|
      | [RampenSau](https://github.com/meodai/rampensau) | Hue cycling with easing | Generative palettes, art-directed ramps |
      | [Poline](https://meodai.github.io/poline/) | Positionable anchor points in OKLCH | Perceptually smooth multi-stop gradients |
      | [IQ Cosine Palettes](https://iquilezles.org/articles/palettes/) | 4-coefficient cosine function | Procedural palettes, shader-friendly |
      | [Leonardo](https://leonardocolor.io/) | Contrast-ratio targeting | Accessible design systems (by Adobe) |
      
      ### Accessibility
      
      | Library | Purpose | Install |
      |---------|---------|---------|
      | [apca-w3](https://github.com/nickmarcucci/apca-w3) | APCA contrast calculation | `npm i apca-w3` |
      | [colorParsley](https://github.com/nickmarcucci/colorparsley) | Parse any CSS color string | `npm i colorparsley` |
      
      ### Spectral & Physical Mixing
      
      | Library | What It Does |
      |---------|-------------|
      | [Spectral.js](https://github.com/rvanwijnen/spectral.js) | Kubelka-Munk spectral mixing - colors mix like paint, not light |
      | [mixbox](https://github.com/scrtwpns/mixbox) | Pigment-based mixing by Scratchapixel |
      
      ## Online Tools
      
      ### Color Pickers & Explorers
      
      | Tool | URL | Best For |
      |------|-----|----------|
      | OKLCH Picker | oklch.com | Interactive OKLCH exploration (Evil Martians) |
      | Huetone | huetone.ardov.me | Building accessible color systems with contrast checks |
      | Color Buddy | colorbuddy.app | Quick palette exploration |
      | Coolors | coolors.co | Fast palette generation with locking |
      | Realtime Colors | realtimecolors.com | See palette applied to a real page layout |
      
      ### Contrast Checkers
      
      | Tool | URL | Algorithm |
      |------|-----|-----------|
      | WebAIM Contrast Checker | webaim.org/resources/contrastchecker | WCAG 2.x |
      | APCA Contrast Calculator | apcacontrast.com | APCA (WCAG 3 draft) |
      | Polypane Contrast | polypane.app/color-contrast | Both WCAG + APCA |
      | Colour Contrast Analyzer | colourcontrast.cc | WCAG 2.x with visual preview |
      
      ### CVD Simulation
      
      | Tool | URL | Notes |
      |------|-----|-------|
      | Sim Daltonism | michelf.ca/projects/sim-daltonism | macOS app, real-time screen filter |
      | Chrome DevTools | Built-in (Rendering > Emulate vision deficiencies) | No install needed |
      | Stark (Figma plugin) | getstark.co | WCAG + CVD inside Figma |
      
      ### Design System Tools
      
      | Tool | URL | Best For |
      |------|-----|----------|
      | Leonardo | leonardocolor.io | Contrast-ratio-based scale generation (Adobe) |
      | Radix Colors | radix-ui.com/colors | Pre-built accessible scales for UI |
      | Open Props Colors | open-props.style | CSS custom property color system |
      | Tailwind Color Generator | uicolors.app/create | Generate Tailwind-compatible scales |
      
      ## CSS-Native Features
      
      No library needed - these ship in browsers (Baseline 2024+):
      
      ```css
      /* oklch() */
      color: oklch(0.7 0.15 250);
      
      /* color-mix() */
      color: color-mix(in oklch, #3b82f6 70%, white);
      
      /* Relative color syntax */
      color: oklch(from var(--brand) calc(l + 0.1) c h);
      
      /* color() for P3 */
      color: color(display-p3 1 0.5 0);
      ```
      
      ## Color Naming
      
      | Resource | URL | Notes |
      |----------|-----|-------|
      | meodai/color-names | github.com/meodai/color-names | 30K+ crowd-sourced color names with hex values |
      | Name That Color | chir.ag/projects/name-that-color | Quick hex-to-name lookup |
      
      ## Figma Plugins
      
      | Plugin | Purpose |
      |--------|---------|
      | Stark | Accessibility contrast + CVD simulation |
      | Realtime Colors | Apply palette to realistic layouts |
      | Color Blind | Simulate all CVD types on selected frames |
      | P3 Color Picker | Pick Display P3 colors natively |
      
  • scripts
    • color-convert.js 6.2 KB
      #!/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());
      
    • contrast-check.js 4.6 KB
      #!/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());
      
    • harmony-gen.js 13.7 KB
      #!/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('}');
      
    • palette-gen.js 4.4 KB
      #!/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(`}`);
      
  • SKILL.md 14.2 KB
    ---
    name: color-ops
    description: "Color for developers: color spaces, accessibility contrast, palette generation, CSS color functions, design tokens, dark mode. Triggers on: color, palette, contrast, WCAG, APCA, OKLCH, color-mix, dark mode colors, design tokens, CVD, color blind, P3, sRGB."
    license: MIT
    allowed-tools: "Read Write Bash"
    metadata:
      author: claude-mods
      related-skills: tailwind-ops, react-ops, frontend-design
    ---
    
    # Color Operations
    
    Practical color knowledge for developers and designers. Covers color spaces, accessibility, palette generation, CSS implementation, and design token architecture.
    
    > Inspired by [meodai/skill.color-expert](https://github.com/meodai/skill.color-expert) - a comprehensive 286K-word color science knowledge base with 113 reference files. This is a lightweight operational skill for everyday frontend and design work. For deep color science (spectral mixing, historical color theory, CAM16, pigment physics), install the full skill.
    
    ## Color Space Decision Table
    
    Pick the right space for the task. This is the single most impactful color decision you'll make.
    
    | Task | Use | Why |
    |------|-----|-----|
    | Perceptual color manipulation | **OKLCH** | Best uniformity for lightness, chroma, hue |
    | CSS gradients & palettes | **OKLCH** or `color-mix(in oklab)` | No mid-gradient grey/brown deadzone |
    | Gamut-aware color picking | **OKHSL / OKHSV** | Cylindrical like HSL but perceptually grounded |
    | Normalized saturation (0-100%) | **HSLuv** | CIELUV chroma normalized per hue/lightness |
    | Print workflows | **CIELAB D50** | ICC standard illuminant |
    | Screen workflows | **OKLAB** | D65 standard, perceptually uniform |
    | Color difference (precision) | **CIEDE2000** | Gold standard perceptual distance metric |
    | Color difference (fast) | **Euclidean in OKLAB** | Good enough for most applications |
    | Quick prototyping | **HSL** | Simple, fast, every tool supports it |
    
    ### Why HSL Falls Short
    
    HSL is fine for quick prototyping. It fails for anything perceptual:
    
    - **Lightness is a lie**: `hsl(60,100%,50%)` (yellow) and `hsl(240,100%,50%)` (blue) have the same L=50% but vastly different perceived brightness
    - **Hue is non-uniform**: 20 degrees near red is a dramatic shift; 20 degrees near green is barely visible
    - **Saturation doesn't correlate**: S=100% dark blue still looks muted
    
    **Rule of thumb**: Use HSL for throwaway work. Use OKLCH for anything that ships.
    
    ### Key Distinctions
    
    - **Chroma** = colorfulness relative to a same-lightness neutral
    - **Saturation** = perceived colorfulness relative to the color's own brightness
    - **Lightness** = perceived reflectance relative to a similarly lit white
    - Same chroma != same saturation. These are different dimensions.
    
    ## Accessibility - Contrast Numbers That Matter
    
    ### The Odds Are Against You
    
    Of ~281 trillion hex color pairs:
    
    | Threshold | % passing | Odds |
    |-----------|-----------|------|
    | WCAG 3:1 (large text) | 26.49% | ~1 in 4 |
    | WCAG 4.5:1 (AA body) | 11.98% | ~1 in 8 |
    | WCAG 7:1 (AAA) | 3.64% | ~1 in 27 |
    | APCA 60 | 7.33% | ~1 in 14 |
    | APCA 75 (fluent reading) | 1.57% | ~1 in 64 |
    | APCA 90 (preferred body) | 0.08% | ~1 in 1,250 |
    
    ### WCAG vs APCA
    
    | | WCAG 2.x | APCA (WCAG 3 draft) |
    |---|----------|---------------------|
    | Model | Simple luminance ratio | Perceptual contrast, polarity-aware |
    | Dark-on-light vs light-on-dark | Same ratio | Different - accounts for spatial frequency |
    | Text size/weight | Only large vs normal | Continuous scale with font lookup table |
    | Accuracy | Known problems with blue, dark mode | Much better perceptual accuracy |
    | Status | Current standard, legally referenced | Draft - not yet a requirement |
    
    **Practical guidance**: Test with WCAG 2.x for compliance. Use APCA for better perceptual results. When they disagree, APCA is usually more accurate.
    
    ### Quick Contrast Checks
    
    ```css
    /* Use relative color syntax to auto-generate readable text */
    --surface: oklch(0.95 0.02 250);
    --on-surface: oklch(from var(--surface) calc(l - 0.6) c h);
    
    /* Or simpler: light surface = dark text, dark surface = light text */
    --text: oklch(from var(--surface) calc(1 - l) 0 h);
    ```
    
    ```javascript
    // Quick WCAG 2.x relative luminance contrast
    function contrastRatio(l1, l2) {
      const lighter = Math.max(l1, l2);
      const darker = Math.min(l1, l2);
      return (lighter + 0.05) / (darker + 0.05);
    }
    
    function relativeLuminance(r, g, b) {
      const [rs, gs, bs] = [r, g, b].map(c => {
        c /= 255;
        return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
      });
      return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
    }
    ```
    
    ### Color Vision Deficiency (CVD)
    
    ~8% of men and ~0.5% of women have some form of color vision deficiency. Design accordingly.
    
    | Type | Affects | Prevalence | What breaks |
    |------|---------|------------|-------------|
    | Protanopia | Red perception | ~1% men | Red/green distinction, red appears dark |
    | Deuteranopia | Green perception | ~1% men | Red/green distinction (most common) |
    | Tritanopia | Blue perception | ~0.01% | Blue/yellow distinction (rare) |
    
    **Rules**:
    - Never use color alone to convey information (add icons, labels, patterns)
    - Test with CVD simulation tools (see references)
    - Red/green is the most dangerous pair - always add a secondary signal
    
    ## CSS Color Functions - Modern Syntax
    
    ### Core Functions (Baseline 2024+)
    
    ```css
    /* OKLCH - the recommended default */
    color: oklch(0.7 0.15 150);           /* lightness chroma hue */
    color: oklch(0.7 0.15 150 / 0.5);     /* with alpha */
    
    /* OKLAB - for interpolation and mixing */
    color: oklab(0.7 -0.1 0.1);           /* lightness a b */
    
    /* color-mix() - blend two colors in any space */
    color: color-mix(in oklch, #3b82f6 70%, white);
    color: color-mix(in oklab, var(--primary), black 20%);
    
    /* Relative color syntax - transform existing colors */
    color: oklch(from var(--brand) calc(l + 0.1) c h);          /* lighten */
    color: oklch(from var(--brand) calc(l - 0.1) c h);          /* darken */
    color: oklch(from var(--brand) l calc(c * 0.5) h);          /* desaturate */
    color: oklch(from var(--brand) l c calc(h + 180));           /* complement */
    
    /* P3 wide gamut */
    color: color(display-p3 1 0.5 0);     /* ~25% more colors than sRGB */
    
    /* Fallback pattern for wide gamut */
    color: #ff8800;                        /* sRGB fallback */
    color: oklch(0.79 0.17 70);           /* oklch version */
    color: color(display-p3 1 0.55 0);    /* P3 if supported */
    ```
    
    ### Gradients That Don't Muddy
    
    ```css
    /* BAD - RGB interpolation goes through grey/brown */
    background: linear-gradient(to right, blue, yellow);
    
    /* GOOD - OKLCH interpolation stays vivid */
    background: linear-gradient(in oklch, blue, yellow);
    
    /* GOOD - OKLAB also works well */
    background: linear-gradient(in oklab, blue, yellow);
    
    /* Longer hue path for rainbow-style gradients */
    background: linear-gradient(in oklch longer hue, red, red);
    ```
    
    ## Design Token Architecture
    
    ### Three-Layer Pattern
    
    ```css
    /* Layer 1: Reference tokens (the palette) */
    :root {
      --ref-blue-50: oklch(0.97 0.01 250);
      --ref-blue-100: oklch(0.93 0.03 250);
      --ref-blue-500: oklch(0.62 0.18 250);
      --ref-blue-900: oklch(0.25 0.09 250);
      --ref-red-500: oklch(0.63 0.22 25);
      --ref-neutral-50: oklch(0.97 0.005 250);
      --ref-neutral-900: oklch(0.15 0.005 250);
    }
    
    /* Layer 2: Semantic tokens (meaning) */
    :root {
      --color-surface: var(--ref-neutral-50);
      --color-on-surface: var(--ref-neutral-900);
      --color-primary: var(--ref-blue-500);
      --color-error: var(--ref-red-500);
      --color-border: oklch(from var(--color-surface) calc(l - 0.15) 0.01 h);
    }
    
    /* Layer 3: Dark mode swaps semantics, not components */
    [data-theme="dark"] {
      --color-surface: var(--ref-neutral-900);
      --color-on-surface: var(--ref-neutral-50);
      --color-primary: var(--ref-blue-100);
      --color-border: oklch(from var(--color-surface) calc(l + 0.15) 0.01 h);
    }
    ```
    
    ### Generating Scales in OKLCH
    
    ```javascript
    // Generate a perceptually uniform color scale
    function generateScale(hue, steps = 10) {
      return Array.from({ length: steps }, (_, i) => {
        const t = i / (steps - 1);
        return {
          step: (i + 1) * 100,  // 100..1000
          l: 0.97 - t * 0.82,   // 0.97 (lightest) to 0.15 (darkest)
          c: Math.sin(t * Math.PI) * 0.18,  // peak chroma in midtones
          h: hue,
        };
      });
    }
    
    // Usage: generateScale(250) for a blue scale
    // Format: oklch(${l} ${c} ${h})
    ```
    
    ## Palette & Harmony
    
    ### What Actually Works
    
    Geometric hue harmony (complementary, triadic, etc.) is a weak predictor of good palettes on its own. Better approaches:
    
    - **Character-first**: Organize by mood (pale/muted/deep/vivid/dark). Chroma + lightness drive emotional response more than hue.
    - **60-30-10 rule**: 60% dominant, 30% secondary, 10% accent. One color dominates.
    - **Lightness variation = legibility**: Same character + varied lightness is readable. Same lightness across hues is illegible.
    - **Grayscale sanity check**: If your UI doesn't work in grayscale, the color system has a structural problem.
    
    ### Practical Palette Workflow
    
    1. Pick a brand hue in OKLCH
    2. Generate a 10-step scale (lightness 0.97 to 0.15, chroma peaks at midtones)
    3. Pick a neutral (same hue, near-zero chroma) for another 10-step scale
    4. Add 1-2 semantic accent hues (success green, error red, warning amber)
    5. Map to semantic tokens: surface, on-surface, primary, secondary, error
    6. Test contrast at every text/surface combination (WCAG 4.5:1 minimum)
    7. Swap semantic mappings for dark mode (don't just invert)
    
    ### Quick Harmony Shortcuts
    
    ```css
    /* Complementary (opposite hue) */
    --complement: oklch(from var(--primary) l c calc(h + 180));
    
    /* Analogous (adjacent hues) */
    --analogous-1: oklch(from var(--primary) l c calc(h - 30));
    --analogous-2: oklch(from var(--primary) l c calc(h + 30));
    
    /* Triadic */
    --triadic-1: oklch(from var(--primary) l c calc(h + 120));
    --triadic-2: oklch(from var(--primary) l c calc(h + 240));
    
    /* Tint (lighter, less chroma) */
    --tint: oklch(from var(--primary) calc(l + 0.2) calc(c * 0.5) h);
    
    /* Shade (darker, slightly less chroma) */
    --shade: oklch(from var(--primary) calc(l - 0.2) calc(c * 0.8) h);
    ```
    
    ## Gamut & Wide Color
    
    ### sRGB vs P3 vs Rec2020
    
    | Gamut | Coverage | Support |
    |-------|----------|---------|
    | sRGB | Baseline | Universal - every screen |
    | Display P3 | ~25% more than sRGB | Modern Apple, high-end Android, new monitors |
    | Rec2020 | ~37% more than P3 | HDR content, limited device support |
    
    ```css
    /* Progressive enhancement for wide gamut */
    .brand-accent {
      /* sRGB fallback - every browser */
      background: #ff6b00;
    
      /* P3 if supported - more vivid */
      @supports (color: color(display-p3 1 0 0)) {
        background: color(display-p3 1 0.42 0);
      }
    }
    
    /* Or use @media for gamut detection */
    @media (color-gamut: p3) {
      :root {
        --accent: oklch(0.75 0.2 50);  /* Can push chroma higher in P3 */
      }
    }
    ```
    
    ### Gamut Mapping
    
    When a color is out of gamut (e.g., high-chroma OKLCH on an sRGB screen), browsers clamp it. Control this:
    
    ```css
    /* Browser auto-maps (default) */
    color: oklch(0.7 0.3 150);  /* if out of sRGB, browser reduces chroma */
    
    /* Explicit gamut check in JS */
    // 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:
    
    - **Palette generation algorithms** (RampenSau, Poline, IQ cosine): Route to `frontend-design` skill or a dedicated subagent with `references/tools-and-libraries.md` preloaded
    - **Accessibility audits** (full APCA + CVD simulation): Route to a subagent that runs contrast checks across all component/token combinations
    - **Design system color architecture**: Route to `tailwind-ops` for Tailwind-specific implementation, or handle directly for CSS custom properties
    
    ## Reference Files
    
    | File | Content |
    |------|---------|
    | `references/tools-and-libraries.md` | Palette generators, analysis tools, color libraries, online tools, browser extensions |
    | `references/css-color-reference.md` | Complete CSS Color Level 4/5 function reference, browser support, conversion formulas |
    
    ## See Also
    
    - `tailwind-ops` - Tailwind color configuration and dark mode patterns
    - `react-ops` - Theme context and color mode implementation in React
    - [meodai/skill.color-expert](https://github.com/meodai/skill.color-expert) - Full color science skill (113 references, spectral mixing, historical theory)
    - [oklch.com](https://oklch.com/) - Interactive OKLCH picker by Evil Martians
    - [Huetone](https://huetone.ardov.me/) - Accessible color system builder
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related