Drop the Quotes: Color Literals in Pathogen

A UX bug became a language feature.

When users changed colors via the playground's color picker on Color('#cc0000'), the picker stripped the quotes — producing Color(#cc0000), which failed to compile. Rather than just fixing the quoting, we asked: why require quotes at all?

The result is color literals — bare hex codes and CSS color functions that are first-class expressions. Writing colors now feels like writing CSS, not calling an API. No Color() wrapper, no string quoting — just #cc0000 directly in your code. Everything is backwards-compatible; existing Color() calls continue to work unchanged.

// viewBox="0 0 540 280" // Before/After — Color('#cc0000') vs #cc0000 // Shows that both produce identical output, but the literal is cleaner // ═══════════════════════════════════════ // Style variables // ═══════════════════════════════════════ let kw = ${ fill: #c084fc; }; let str = ${ fill: #a3e635; }; let hex = ${ fill: #fb923c; }; let pct = ${ fill: #38bdf8; }; let base = ${ fill: #94a3b8; }; // ═══════════════════════════════════════ // Background // ═══════════════════════════════════════ let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; bg.apply { rect(0, 0, 540, 280) } // ═══════════════════════════════════════ // Title // ═══════════════════════════════════════ let title = TextLayer('title') ${ font-family: system-ui, sans-serif; font-size: 14; fill: #e2e8f0; text-anchor: start; }; title.apply { text(30, 30)`Before & After` } let subtitle = TextLayer('subtitle') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #64748b; text-anchor: start; }; subtitle.apply { text(30, 46)`Same output — less ceremony` } // ═══════════════════════════════════════ // Divider // ═══════════════════════════════════════ let divider = PathLayer('divider') ${ stroke: #334155; stroke-width: 1; stroke-dasharray: "4 4"; fill: none; }; divider.apply { M 270 66 v 196 } // ═══════════════════════════════════════ // "Before" panel (left) // ═══════════════════════════════════════ let before_label = TextLayer('before-label') ${ font-family: system-ui, sans-serif; font-size: 11; fill: #94a3b8; text-anchor: middle; }; before_label.apply { text(150, 78)`Before` } // Code block background let before_code_bg = PathLayer('before-code-bg') ${ fill: #1e293b; stroke: #334155; stroke-width: 1; }; before_code_bg.apply { roundRect(30, 90, 225, 80, 6) } // Code text with tspan-based syntax coloring let before_code = TextLayer('before-code') ${ font-family: monospace; font-size: 9; fill: #94a3b8; text-anchor: start; }; before_code.apply { text(42, 111) { tspan(0, 0, 0, kw)`let` tspan(0, 0, 0, base)` c = Color(` tspan(0, 0, 0, str)`'#cc0000'` tspan(0, 0, 0, base)`);` } text(42, 127) { tspan(0, 0, 0, kw)`let` tspan(0, 0, 0, base)` light = c.lighten(0.2);` } text(42, 143) { tspan(0, 0, 0, kw)`let` tspan(0, 0, 0, base)` faded = c.alpha(0.5);` } } // Swatches for before let b_swatch1 = PathLayer('b-sw1') ${ fill: Color('#cc0000'); stroke: #475569; stroke-width: 1; }; b_swatch1.apply { roundRect(48, 183, 55, 40, 4) } let b_swatch2 = PathLayer('b-sw2') ${ fill: Color('#cc0000').lighten(0.2); stroke: #475569; stroke-width: 1; }; b_swatch2.apply { roundRect(113, 183, 55, 40, 4) } let b_swatch3 = PathLayer('b-sw3') ${ fill: Color('#cc0000').alpha(0.5); stroke: #475569; stroke-width: 1; }; b_swatch3.apply { roundRect(178, 183, 55, 40, 4) } let b_sw_labels = TextLayer('b-sw-labels') ${ font-family: monospace; font-size: 8; fill: #64748b; text-anchor: middle; }; b_sw_labels.apply { text(75, 238)`c` text(140, 238)`light` text(205, 238)`faded` } // ═══════════════════════════════════════ // "After" panel (right) // ═══════════════════════════════════════ let after_label = TextLayer('after-label') ${ font-family: system-ui, sans-serif; font-size: 11; fill: #38bdf8; text-anchor: middle; }; after_label.apply { text(400, 78)`After` } // Code block background let after_code_bg = PathLayer('after-code-bg') ${ fill: #1e293b; stroke: #38bdf8; stroke-width: 1; }; after_code_bg.apply { roundRect(285, 90, 225, 80, 6) } // Code text with tspan-based syntax coloring let after_code = TextLayer('after-code') ${ font-family: monospace; font-size: 9; fill: #94a3b8; text-anchor: start; }; after_code.apply { text(297, 111) { tspan(0, 0, 0, kw)`let` tspan(0, 0, 0, base)` c = ` tspan(0, 0, 0, hex)`#cc0000` tspan(0, 0, 0, base)`;` } text(297, 127) { tspan(0, 0, 0, kw)`let` tspan(0, 0, 0, base)` light = c.lighten(` tspan(0, 0, 0, pct)`20%` tspan(0, 0, 0, base)`);` } text(297, 143) { tspan(0, 0, 0, kw)`let` tspan(0, 0, 0, base)` faded = c.alpha(` tspan(0, 0, 0, pct)`50%` tspan(0, 0, 0, base)`);` } } // Swatches for after let a_swatch1 = PathLayer('a-sw1') ${ fill: #cc0000; stroke: #475569; stroke-width: 1; }; a_swatch1.apply { roundRect(298, 183, 55, 40, 4) } let a_swatch2 = PathLayer('a-sw2') ${ fill: (#cc0000).lighten(20%); stroke: #475569; stroke-width: 1; }; a_swatch2.apply { roundRect(363, 183, 55, 40, 4) } let a_swatch3 = PathLayer('a-sw3') ${ fill: (#cc0000).alpha(50%); stroke: #475569; stroke-width: 1; }; a_swatch3.apply { roundRect(428, 183, 55, 40, 4) } let a_sw_labels = TextLayer('a-sw-labels') ${ font-family: monospace; font-size: 8; fill: #64748b; text-anchor: middle; }; a_sw_labels.apply { text(325, 238)`c` text(390, 238)`light` text(455, 238)`faded` } // ═══════════════════════════════════════ // Equals sign between swatches // ═══════════════════════════════════════ let equals = TextLayer('equals') ${ font-family: system-ui, sans-serif; font-size: 20; fill: #38bdf8; text-anchor: middle; }; equals.apply { text(270, 210)`=` } Before and After — Color('#cc0000') vs #cc0000 produce identical output

The left panel shows the old way: wrap a string in Color(), call methods with decimal arguments. The right panel shows the new way: bare hex literal, percent suffix. Both produce the same three swatches. The Color() wrapper still works — it now accepts bare hex values as a pass-through — but you no longer need it for hex colors.

Hex Literals

Hex color codes are first-class expressions anywhere a value is expected:

let c = #cc0000;            // 6-digit hex
let c = #f00;               // 3-digit shorthand
let c = #cc000080;          // 8-digit with alpha
let c = #f008;              // 4-digit with alpha

Wrap in parentheses for method chaining:

let lighter = (#cc0000).lighten(20%);
let shifted = (#0066ff).hueShift(60);

From a single hex literal you can build full color palettes — lighten, darken, shift hue, adjust saturation, set alpha. The demo below starts from #0066ff and derives an entire palette using method chaining and the percent suffix:

// viewBox="0 0 520 320" // Hex Palette — Building a color palette from a single hex literal // Demonstrates method chaining on hex color literals // ═══════════════════════════════════════ // Background // ═══════════════════════════════════════ let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; bg.apply { rect(0, 0, 520, 320) } // ═══════════════════════════════════════ // Title // ═══════════════════════════════════════ let title = TextLayer('title') ${ font-family: system-ui, sans-serif; font-size: 14; fill: #e2e8f0; text-anchor: start; }; title.apply { text(30, 30)`Palette from a Single Hex Literal` } let subtitle = TextLayer('subtitle') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #64748b; text-anchor: start; }; subtitle.apply { text(30, 46)`Method chaining: (#0066ff).lighten(20%), (#0066ff).hueShift(60)` } // ═══════════════════════════════════════ // Base color // ═══════════════════════════════════════ let base = #0066ff; // ── Lightness ramp ────────────────── let dark2 = (base).darken(30%); let dark1 = (base).darken(15%); let light1 = (base).lighten(15%); let light2 = (base).lighten(30%); let lightness_row = [dark2, dark1, base, light1, light2]; let lightness_names = ['darken 30%', 'darken 15%', 'base', 'lighten 15%', 'lighten 30%']; // ── Hue shift ramp ───────────────── let hue1 = (base).hueShift(-60); let hue2 = (base).hueShift(-30); let hue3 = (base).hueShift(30); let hue4 = (base).hueShift(60); let hue_row = [hue1, hue2, base, hue3, hue4]; let hue_names = ['-60°', '-30°', 'base', '+30°', '+60°']; // ── Saturation ramp ──────────────── let desat2 = (base).desaturate(25%); let desat1 = (base).desaturate(50%); let sat1 = (base).saturate(1.3); let sat2 = (base).saturate(1.6); let sat_row = [desat1, desat2, base, sat1, sat2]; let sat_names = ['desat 50%', 'desat 25%', 'base', 'sat 1.3×', 'sat 1.6×']; // ═══════════════════════════════════════ // Swatch grid // ═══════════════════════════════════════ let sx = 65; let sp = 98; let sw = 60; let sh = 44; let sr = 5; // ── Row labels ────────────────────── let row_labels = TextLayer('row-labels') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #94a3b8; text-anchor: end; }; // ── Swatch labels ─────────────────── let sw_labels = TextLayer('sw-labels') ${ font-family: monospace; font-size: 7; fill: #64748b; text-anchor: middle; }; // ── Row 1: Lightness ──────────────── let row1_y = 90; row_labels.apply { text(24, calc(row1_y + sh / 2 + 3))`Lightness` } for ([color, i] in lightness_row) { let x = calc(sx + i * sp); let swatch = PathLayer(`l_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row1_y, sw, sh, sr) } } sw_labels.apply { for ([name, i] in lightness_names) { text(calc(sx + i * sp), calc(row1_y + sh + 12))`${name}` } } // ── Row 2: Hue Shift ──────────────── let row2_y = 160; row_labels.apply { text(24, calc(row2_y + sh / 2 + 3))`Hue` } for ([color, i] in hue_row) { let x = calc(sx + i * sp); let swatch = PathLayer(`h_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row2_y, sw, sh, sr) } } sw_labels.apply { for ([name, i] in hue_names) { text(calc(sx + i * sp), calc(row2_y + sh + 12))`${name}` } } // ── Row 3: Saturation ─────────────── let row3_y = 230; row_labels.apply { text(24, calc(row3_y + sh / 2 + 3))`Chroma` } for ([color, i] in sat_row) { let x = calc(sx + i * sp); let swatch = PathLayer(`s_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row3_y, sw, sh, sr) } } sw_labels.apply { for ([name, i] in sat_names) { text(calc(sx + i * sp), calc(row3_y + sh + 12))`${name}` } } // ═══════════════════════════════════════ // Base indicator // ═══════════════════════════════════════ let base_indicator = TextLayer('base-indicator') ${ font-family: monospace; font-size: 9; fill: #38bdf8; text-anchor: middle; }; base_indicator.apply { text(calc(sx + 2 * sp), calc(row1_y - 8))`#0066ff` } // ── Vertical base line ────────────── let base_line = PathLayer('base-line') ${ stroke: #38bdf8; stroke-width: 1; stroke-dasharray: "3 3"; fill: none; opacity: 0.5; }; base_line.apply { M calc(sx + 2 * sp) row1_y v calc(row3_y + sh - row1_y) } Lightness, hue, and saturation ramps derived from a single hex literal

CSS Color Function Literals

All major CSS color functions work as bare expressions. You can paste any CSS color value directly into Pathogen code and it will just work — % and / inside function arguments are treated as literal characters, not operators:

let c = rgb(255, 0, 0);
let c = hsl(0, 100%, 50%);
let c = oklch(0.6 0.15 30);
let c = oklch(0.6 0.15 30 / 0.5);  // slash alpha
let c = hwb(0 0% 0%);
let c = lab(50 40 59.5);
let c = lch(50 64 30);
let c = oklab(0.6 -0.1 0.15);

Method chaining works directly — no wrapper needed:

let lighter = rgb(255, 0, 0).lighten(20%);
let muted = hsl(210, 80%, 50%).desaturate(50%);

The demo below expresses the same red in seven different color spaces. Every format converts to OKLCH internally, so the swatches are near-identical — minor rounding differences between color spaces are invisible at screen resolution:

// viewBox="0 0 560 340" // Color Spaces — The same red expressed in 7 CSS color function syntaxes // All produce the same ColorValue internally // ═══════════════════════════════════════ // Background // ═══════════════════════════════════════ let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; bg.apply { rect(0, 0, 560, 340) } // ═══════════════════════════════════════ // Title // ═══════════════════════════════════════ let title = TextLayer('title') ${ font-family: system-ui, sans-serif; font-size: 14; fill: #e2e8f0; text-anchor: start; }; title.apply { text(30, 30)`CSS Color Function Literals` } let subtitle = TextLayer('subtitle') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #64748b; text-anchor: start; }; subtitle.apply { text(30, 46)`One red, seven ways — all first-class expressions` } // ═══════════════════════════════════════ // Colors — all expressing the same red // ═══════════════════════════════════════ let c_hex = #cc0000; let c_rgb = rgb(204, 0, 0); let c_hsl = hsl(0, 100%, 40%); let c_oklch = oklch(0.5 0.18 27); let c_hwb = hwb(0 0% 20%); let c_lab = lab(42 60 50); let c_oklab = oklab(0.5 0.13 0.08); let colors = [c_hex, c_rgb, c_hsl, c_oklch, c_hwb, c_lab, c_oklab]; let labels = ['#cc0000', 'rgb(204, 0, 0)', 'hsl(0, 100%, 40%)', 'oklch(0.5 0.18 27)', 'hwb(0 0% 20%)', 'lab(42 60 50)', 'oklab(0.5 0.13 0.08)']; let space_names = ['Hex', 'sRGB', 'HSL', 'OKLCH', 'HWB', 'CIE Lab', 'OKLab']; // ═══════════════════════════════════════ // Grid layout — 2 columns // ═══════════════════════════════════════ let cols = 2; let sx = 50; let sy = 80; let col_w = 260; let row_h = 60; let sw = 40; let sh = 40; let sr = 4; let space_label = TextLayer('space-label') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #e2e8f0; text-anchor: start; }; let code_label = TextLayer('code-label') ${ font-family: monospace; font-size: 8; fill: #94a3b8; text-anchor: start; }; for ([color, i] in colors) { let col = calc(i % cols); let row = calc((i - col) / cols); let x = calc(sx + col * col_w); let y = calc(sy + row * row_h); // Swatch let swatch = PathLayer(`cs_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(x, y, sw, sh, sr) } // Space name space_label.apply { text(calc(x + sw + 12), calc(y + 16))`${space_names[i]}` } // Code syntax code_label.apply { text(calc(x + sw + 12), calc(y + 30))`${labels[i]}` } } // ═══════════════════════════════════════ // Convergence annotation // ═══════════════════════════════════════ let arrow_label = TextLayer('arrow-label') ${ font-family: system-ui, sans-serif; font-size: 9; fill: #38bdf8; text-anchor: middle; }; arrow_label.apply { text(280, 312)`All converge → OKLCH internally` } let arrow_line = PathLayer('arrow-line') ${ stroke: #38bdf8; stroke-width: 1; fill: none; opacity: 0.4; }; arrow_line.apply { M 100 300 h 360 } The same red expressed via seven CSS color function syntaxes — all converge to OKLCH internally

Note: CSS color function names (rgb, rgba, hsl, hsla, oklch, hwb, lab, lch, oklab) are effectively reserved — they always produce color literals, even if a user-defined function of the same name exists. The a-suffixed legacy forms (rgba, hsla) are also supported. See the syntax reference for the full list.

The Percent Suffix

The % suffix converts a number to its decimal form: 20% becomes 0.2, 50% becomes 0.5. This reads naturally with color methods — "lighten by 20%" instead of "lighten by 0.2":

let c = #e63946;
let tint  = c.lighten(20%);      // 20% → 0.2
let shade = c.darken(15%);       // 15% → 0.15
let faded = c.alpha(50%);        // 50% → 0.5
let muted = c.desaturate(40%);   // 40% → 0.4

The percent suffix isn't limited to color methods — it works anywhere a number is expected. 50% is 0.5 whether it's a color alpha, a mix ratio, or a variable assignment.

Disambiguation: 20% (no space) is a percent literal. 20 % 5 (with spaces) is the modulus operator. Existing code that uses modulus with spaces continues to work unchanged.

// viewBox="0 0 520 300" // Percent Tints — Using % suffix with color methods for tint/shade scales // 20% becomes 0.2, reads naturally: "lighten by 20%" // ═══════════════════════════════════════ // Background // ═══════════════════════════════════════ let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; bg.apply { rect(0, 0, 520, 300) } // ═══════════════════════════════════════ // Title // ═══════════════════════════════════════ let title = TextLayer('title') ${ font-family: system-ui, sans-serif; font-size: 14; fill: #e2e8f0; text-anchor: start; }; title.apply { text(30, 30)`The Percent Suffix` } let subtitle = TextLayer('subtitle') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #64748b; text-anchor: start; }; subtitle.apply { text(30, 46)`20% → 0.2 • "lighten by 20%" reads naturally` } // ═══════════════════════════════════════ // Tint scale — lighten by 0% to 40% // ═══════════════════════════════════════ let base = #e63946; let tints = [ base, (base).lighten(10%), (base).lighten(20%), (base).lighten(30%), (base).lighten(40%) ]; let tint_labels = ['0%', '10%', '20%', '30%', '40%']; // ── Shade scale — darken by 0% to 40% ── let shades = [ base, (base).darken(10%), (base).darken(20%), (base).darken(30%), (base).darken(40%) ]; let shade_labels = ['0%', '10%', '20%', '30%', '40%']; // ── Alpha scale — alpha from 100% to 20% ── let alphas = [ base, (base).alpha(80%), (base).alpha(60%), (base).alpha(40%), (base).alpha(20%) ]; let alpha_labels = ['100%', '80%', '60%', '40%', '20%']; // ═══════════════════════════════════════ // Layout // ═══════════════════════════════════════ let sx = 90; let sp = 88; let sw = 54; let sh = 40; let sr = 4; let row_label = TextLayer('row-label') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #94a3b8; text-anchor: end; }; let sw_label = TextLayer('sw-label') ${ font-family: monospace; font-size: 8; fill: #64748b; text-anchor: middle; }; // ── Row 1: Tints ──────────────────── let row1_y = 80; row_label.apply { text(58, calc(row1_y + sh / 2 + 3))`lighten` } for ([color, i] in tints) { let x = calc(sx + i * sp); let swatch = PathLayer(`t_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row1_y, sw, sh, sr) } } sw_label.apply { for ([name, i] in tint_labels) { text(calc(sx + i * sp), calc(row1_y + sh + 12))`${name}` } } // ── Row 2: Shades ─────────────────── let row2_y = 150; row_label.apply { text(58, calc(row2_y + sh / 2 + 3))`darken` } for ([color, i] in shades) { let x = calc(sx + i * sp); let swatch = PathLayer(`d_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row2_y, sw, sh, sr) } } sw_label.apply { for ([name, i] in shade_labels) { text(calc(sx + i * sp), calc(row2_y + sh + 12))`${name}` } } // ── Row 3: Alpha ──────────────────── let row3_y = 220; row_label.apply { text(58, calc(row3_y + sh / 2 + 3))`alpha` } // White background behind alpha row so opacity gradient is visible for ([_, i] in alphas) { let x = calc(sx + i * sp - sw / 2); let checker = PathLayer(`ck_${i}`) ${ fill: #ffffff; stroke: none; }; checker.apply { roundRect(x, row3_y, sw, sh, sr) } } for ([color, i] in alphas) { let x = calc(sx + i * sp); let swatch = PathLayer(`a_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row3_y, sw, sh, sr) } } sw_label.apply { for ([name, i] in alpha_labels) { text(calc(sx + i * sp), calc(row3_y + sh + 12))`${name}` } } // ═══════════════════════════════════════ // Base indicator // ═══════════════════════════════════════ let base_ind = TextLayer('base-ind') ${ font-family: monospace; font-size: 9; fill: #38bdf8; text-anchor: middle; }; base_ind.apply { text(sx, calc(row1_y - 8))`#e63946` } Tint, shade, and alpha scales using the percent suffix

Reactive Colors

Color literals compose naturally with Pathogen's CSSVar-backed reactive colors. Use a bare hex as the fallback value in Color(CSSVar(...)) to create colors that update at runtime when the CSS custom property changes:

let base = Color(CSSVar('--base-color', #0066ff));
let light = base.lighten(20%);
let comp = base.complement();
let triad = base.triadic();

Change --base-color and every derived value recalculates — lighten, complement, triadic harmony, everything. The compiler emits @property declarations so the browser knows these are interpolatable <color> values.

The first demo below shows a full reactive palette — lightness ramp, color transformations, and triadic harmony, all driven by a single CSS variable. Use the color picker to change --base-color and watch every swatch update:

// viewBox="0 0 520 340" // Reactive Palette — CSSVar base color + hex literals + % suffix // Change --base-color in the playground to recolor every swatch // ═══════════════════════════════════════ // Background // ═══════════════════════════════════════ let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; bg.apply { rect(0, 0, 520, 340) } // ═══════════════════════════════════════ // Title // ═══════════════════════════════════════ let title = TextLayer('title') ${ font-family: system-ui, sans-serif; font-size: 14; fill: #e2e8f0; text-anchor: start; }; title.apply { text(30, 30)`Reactive Palette` } let subtitle = TextLayer('subtitle') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #64748b; text-anchor: start; }; subtitle.apply { text(30, 46)`Change --base-color to update all swatches` } // ═══════════════════════════════════════ // Reactive base color — try changing it! // ═══════════════════════════════════════ let base = Color(CSSVar('--base-color', #0066ff)); // ── Derived palette ───────────────── let darker = base.darken(20%); let dark = base.darken(10%); let light = base.lighten(10%); let lighter = base.lighten(20%); let pale = base.lighten(30%); let comp = base.complement(); let shifted = base.hueShift(60); let muted = base.desaturate(50%); let semi = base.alpha(50%); // ═══════════════════════════════════════ // Row 1: Lightness ramp // ═══════════════════════════════════════ let sx = 65; let sp = 80; let sw = 54; let sh = 44; let sr = 5; let row_label = TextLayer('row-label') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #94a3b8; text-anchor: end; }; let sw_label = TextLayer('sw-label') ${ font-family: monospace; font-size: 7; fill: #64748b; text-anchor: middle; }; let row1_y = 80; let ramp = [darker, dark, base, light, lighter, pale]; let ramp_names = ['-20%', '-10%', 'base', '+10%', '+20%', '+30%']; row_label.apply { text(30, calc(row1_y + sh / 2 + 3))`light` } for ([color, i] in ramp) { let x = calc(sx + i * sp); let swatch = PathLayer(`r_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row1_y, sw, sh, sr) } } sw_label.apply { for ([name, i] in ramp_names) { text(calc(sx + i * sp), calc(row1_y + sh + 12))`${name}` } } // ═══════════════════════════════════════ // Row 2: Transformations // ═══════════════════════════════════════ let row2_y = 160; let transforms = [base, comp, shifted, muted, semi]; let xform_names = ['base', 'complement', 'hue +60', 'desat 50%', 'alpha 50%']; row_label.apply { text(30, calc(row2_y + sh / 2 + 3))`xform` } for ([color, i] in transforms) { let x = calc(sx + i * sp); let swatch = PathLayer(`x_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row2_y, sw, sh, sr) } } sw_label.apply { for ([name, i] in xform_names) { text(calc(sx + i * sp), calc(row2_y + sh + 12))`${name}` } } // ═══════════════════════════════════════ // Row 3: Triadic harmony // ═══════════════════════════════════════ let row3_y = 240; let triad = base.triadic(); row_label.apply { text(30, calc(row3_y + sh / 2 + 3))`triad` } for ([color, i] in triad) { let x = calc(sx + i * sp); let swatch = PathLayer(`tri_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row3_y, sw, sh, sr) } } let triad_names = ['0°', '+120°', '+240°']; sw_label.apply { for ([name, i] in triad_names) { text(calc(sx + i * sp), calc(row3_y + sh + 12))`${name}` } } // ═══════════════════════════════════════ // Code annotation // ═══════════════════════════════════════ let code_bg = PathLayer('code-bg') ${ fill: #1e293b; stroke: #334155; stroke-width: 1; }; code_bg.apply { roundRect(30, 300, 460, 28, 4) } let code = TextLayer('code') ${ font-family: monospace; font-size: 9; fill: #94a3b8; text-anchor: start; }; code.apply { text(42, 318)`let base = Color(CSSVar('--base-color', #0066ff));` } let code_kw = TextLayer('code-kw') ${ font-family: monospace; font-size: 9; fill: #c084fc; text-anchor: start; }; code_kw.apply { text(42, 318)`let` } Reactive palette — change --base-color to update all swatches

The second demo shows a tint/shade scale — seven lighten steps and seven darken steps from a single reactive base:

// viewBox="0 0 520 280" // Reactive Tints — CSSVar + percent suffix for tint/shade scales // Change --tint-color to update every row // ═══════════════════════════════════════ // Background // ═══════════════════════════════════════ let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; bg.apply { rect(0, 0, 520, 280) } // ═══════════════════════════════════════ // Title // ═══════════════════════════════════════ let title = TextLayer('title') ${ font-family: system-ui, sans-serif; font-size: 14; fill: #e2e8f0; text-anchor: start; }; title.apply { text(30, 30)`Reactive Tint / Shade Scale` } let subtitle = TextLayer('subtitle') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #64748b; text-anchor: start; }; subtitle.apply { text(30, 46)`Change --tint-color — every swatch updates` } // ═══════════════════════════════════════ // Reactive base // ═══════════════════════════════════════ let base = Color(CSSVar('--tint-color', #e63946)); // ═══════════════════════════════════════ // Layout // ═══════════════════════════════════════ let sx = 70; let sp = 58; let sw = 44; let sh = 44; let sr = 4; let steps = 7; let row_label = TextLayer('row-label') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #94a3b8; text-anchor: end; }; let pct_label = TextLayer('pct-label') ${ font-family: monospace; font-size: 7; fill: #64748b; text-anchor: middle; }; // ═══════════════════════════════════════ // Row 1: Lighten 0% → 60% // ═══════════════════════════════════════ let row1_y = 76; row_label.apply { text(42, calc(row1_y + sh / 2 + 3))`lighten` } let tints = [ base, base.lighten(10%), base.lighten(20%), base.lighten(30%), base.lighten(40%), base.lighten(50%), base.lighten(60%) ]; for ([color, i] in tints) { let x = calc(sx + i * sp); let swatch = PathLayer(`lt_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row1_y, sw, sh, sr) } } let tint_pcts = ['0%', '10%', '20%', '30%', '40%', '50%', '60%']; pct_label.apply { for ([pct, i] in tint_pcts) { text(calc(sx + i * sp), calc(row1_y + sh + 11))`${pct}` } } // ═══════════════════════════════════════ // Row 2: Darken 0% → 60% // ═══════════════════════════════════════ let row2_y = 152; row_label.apply { text(42, calc(row2_y + sh / 2 + 3))`darken` } let shades = [ base, base.darken(10%), base.darken(20%), base.darken(30%), base.darken(40%), base.darken(50%), base.darken(60%) ]; for ([color, i] in shades) { let x = calc(sx + i * sp); let swatch = PathLayer(`dk_${i}`) ${ fill: color; stroke: #475569; stroke-width: 1; }; swatch.apply { roundRect(calc(x - sw / 2), row2_y, sw, sh, sr) } } let shade_pcts = ['0%', '10%', '20%', '30%', '40%', '50%', '60%']; pct_label.apply { for ([pct, i] in shade_pcts) { text(calc(sx + i * sp), calc(row2_y + sh + 11))`${pct}` } } // ═══════════════════════════════════════ // Code annotation // ═══════════════════════════════════════ let code_bg = PathLayer('code-bg') ${ fill: #1e293b; stroke: #334155; stroke-width: 1; }; code_bg.apply { roundRect(30, 228, 460, 40, 4) } let code = TextLayer('code') ${ font-family: monospace; font-size: 9; fill: #94a3b8; text-anchor: start; }; code.apply { text(42, 245)`let base = Color(CSSVar('--tint-color', #e63946));` text(42, 259)`let tint = base.lighten(20%);` } let code_kw = TextLayer('code-kw') ${ font-family: monospace; font-size: 9; fill: #c084fc; text-anchor: start; }; code_kw.apply { text(42, 245)`let` text(42, 259)`let` } let code_comment = TextLayer('code-comment') ${ font-family: monospace; font-size: 9; fill: #64748b; text-anchor: start; }; code_comment.apply { text(274, 259)`// 20% → 0.2` } Reactive tint/shade scale — change --tint-color to update every swatch

What Still Needs Color()

The Color() wrapper isn't going away. You still need it for:

  • Named colors: Color('coral'), Color('dodgerblue') — all 148 CSS named colors
  • Direct OKLCH construction: Color(0.6, 0.15, 30) — numeric L, C, H values
  • String-based input: Color('rgb(255, 0, 0)') — when the color format is in a string variable

Everything is backwards-compatible. Existing Color('#cc0000') calls continue to work — Color() now accepts a bare ColorValue as a pass-through.

Try It

Open the Pathogen playground, start from #0066ff, and build your own palette — lighten, shift hue, take the complement. The full API reference is in the Color documentation, and the syntax details are in the Color Literals and Percent Suffix sections.