diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2e02eb..153441f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] jobs: test: diff --git a/CHANGELOG.md b/CHANGELOG.md index 436c1c7..5141e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [bdsg 0.2.0] - 2026-01-14 + +### Added + +- **OKLCH Color Space**: New perceptually uniform color space support + - `hexToOklch()` - Convert HEX to OKLCH (L: 0-1, C: 0+, H: 0-360) + - `oklchToHex()` - Convert OKLCH back to HEX + - `interpolateOklch()` - Smooth color interpolation without "muddy middle" +- **Gradient Generation**: New `gradients.ts` module with expandable architecture + - `generateGradient()` - Two-color gradient with OKLCH interpolation + - `generateMultiStopGradient()` - Multi-stop gradients with position control + - `toCssGradient()` - Generate CSS linear/radial/conic gradient strings + - `EASING` presets - `linear`, `easeIn`, `easeOut`, `easeInOut` + - Hue direction control: `shorter`, `longer`, `increasing`, `decreasing` +- **OKLCH Types**: New `oklch.types.ts` with `OKLCH` interface + +### Changed + +- **Palette Generation**: Refactored `generatePalette()` to use OKLCH instead of HSL for perceptually uniform shade generation +- **Centralized Validation**: Created `schemas.ts` with shared Zod schemas + - `HexColorSchema`, `OklchSchema`, `RgbSchema`, `HslSchema` + - `GradientStopSchema`, `GradientConfigSchema`, `StepsSchema` + - `validateOrThrow()` helper function for cleaner validation code +- **Code Quality**: All modules now use centralized validation schemas + +### Tests + +- Added 14 new tests for OKLCH conversions (`oklch.test.ts`) +- Added 22 new tests for gradients (`gradients.test.ts`) +- Total: 284 tests across 11 files + +[bdsg 0.2.0]: https://github.com/CarlosEduJs/bdsg/releases/tag/bdsg@v0.2.0 + +## [bdsg-cli 0.2.0] - 2026-01-14 + +### Added + +- **Gradient Command**: New `bdsg generate gradient` command + - Generate color gradients with OKLCH interpolation + - Easing functions: `linear`, `easeIn`, `easeOut`, `easeInOut` + - Hue direction control: `shorter`, `longer`, `increasing`, `decreasing` + - Output CSS variables and gradient strings + - JSON export with gradient metadata + +### Changed + +- **Dependencies**: Updated `bdsg` dependency to `^0.2.0` + +[bdsg-cli 0.2.0]: https://github.com/CarlosEduJs/bdsg/releases/tag/bdsg-cli@v0.2.0 + ## [bdsg-cli 0.1.2] - 2026-01-12 ### Fixed diff --git a/README.md b/README.md index 4871163..fb6a698 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Design System Generation library for programmatic token creation with WCAG acces bdsg provides algorithms for generating design tokens: +- OKLCH color space for perceptually uniform palettes `v0.2.0` +- Gradient generation with easing and hue control `v0.2.0` - Color palettes with automatic text color calculation - Typography scales using musical ratios - Spacing scales (Fibonacci, linear, exponential) @@ -91,6 +93,8 @@ const shadows = generateShadows({ | Module | Functions | |--------|-----------| | color-utils | `hexToRgb`, `rgbToHex`, `hexToHsl`, `hslToHex`, `rgbToHsl`, `hslToRgb` | +| oklch | `hexToOklch`, `oklchToHex`, `interpolateOklch` | +| gradients | `generateGradient`, `generateMultiStopGradient`, `toCssGradient`, `EASING` | | contrast | `calculateContrast`, `getRelativeLuminance`, `meetsWCAG`, `getWCAGCompliance` | | adjust | `adjustColorForContrast`, `generateAccessibleVariations` | | palette | `generatePalette`, `generatePaletteTokens` | diff --git a/packages/bdsg-cli/README.md b/packages/bdsg-cli/README.md index 6f051bb..a2cea83 100644 --- a/packages/bdsg-cli/README.md +++ b/packages/bdsg-cli/README.md @@ -36,6 +36,7 @@ bdsg generate palette "#3B82F6" -n primary bdsg generate typography -r golden-ratio bdsg generate spacing -m fibonacci bdsg generate shadows -s material +bdsg generate gradient "#FF0000" "#0000FF" -s 5 # Validate WCAG contrast bdsg validate "#3B82F6" "#FFFFFF" @@ -159,6 +160,51 @@ Options: Available styles: `material`, `soft`, `hard` +#### generate gradient + +*New in v0.2.0* + +```bash +bdsg generate gradient [options] + +Arguments: + startColor Start color in hex format (e.g., #FF0000) + endColor End color in hex format (e.g., #0000FF) + +Options: + -n, --name Gradient name (default: "gradient") + -s, --steps Number of color steps (default: "5") + -e, --easing Easing function (default: "linear") + -d, --direction Hue direction (default: "shorter") + -o, --output Output directory (default: "./tokens") + -f, --format Output format: css, json (default: "css") +``` + +Available easing: `linear`, `easeIn`, `easeOut`, `easeInOut` + +Available hue directions: `shorter`, `longer`, `increasing`, `decreasing` + +**Example:** + +``` +$ bdsg generate gradient "#FF0000" "#0000FF" -s 5 -e easeInOut + +✔ Gradient generated! + +File: ./tokens/gradient.css + +Gradient colors: + 1: #ff0000 + 2: #e8007b + 3: #ba00c2 + 4: #7a00f4 + 5: #0000ff + +CSS Gradients: + linear: linear-gradient(90deg, #ff0000, #e8007b, #ba00c2, #7a00f4, #0000ff) + radial: radial-gradient(circle, #ff0000, #e8007b, #ba00c2, #7a00f4, #0000ff) +``` + ### validate Validate WCAG contrast between two colors with suggestions for accessibility compliance. diff --git a/packages/bdsg-cli/package.json b/packages/bdsg-cli/package.json index 6c28018..89dc28a 100644 --- a/packages/bdsg-cli/package.json +++ b/packages/bdsg-cli/package.json @@ -1,6 +1,6 @@ { "name": "bdsg-cli", - "version": "0.1.2", + "version": "0.2.0", "description": "CLI for design system generation", "type": "module", "bin": { @@ -11,7 +11,7 @@ "dev": "tsc --watch" }, "dependencies": { - "bdsg": "^0.1.3", + "bdsg": "^0.2.0", "commander": "^12.0.0", "inquirer": "^9.2.0", "chalk": "^5.3.0", diff --git a/packages/bdsg-cli/src/commands/generate.ts b/packages/bdsg-cli/src/commands/generate.ts index 77022f1..328c512 100644 --- a/packages/bdsg-cli/src/commands/generate.ts +++ b/packages/bdsg-cli/src/commands/generate.ts @@ -1,10 +1,13 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { + EASING, + generateGradient, generatePalette, generateShadows, generateSpacingScale, generateTypographyScale, + toCssGradient, } from "bdsg"; import chalk from "chalk"; import { Command } from "commander"; @@ -233,4 +236,118 @@ export const generateCommand = new Command("generate") process.exit(1); } }), + ) + .addCommand( + new Command("gradient") + .description("Generate a color gradient") + .argument("", "Start color in hex format (e.g., #FF0000)") + .argument("", "End color in hex format (e.g., #0000FF)") + .option("-n, --name ", "Gradient name", "gradient") + .option("-s, --steps ", "Number of color steps", "5") + .option( + "-e, --easing ", + "Easing function (linear, easeIn, easeOut, easeInOut)", + "linear", + ) + .option( + "-d, --direction ", + "Hue direction (shorter, longer, increasing, decreasing)", + "shorter", + ) + .option("-o, --output ", "Output directory", "./tokens") + .option("-f, --format ", "Output format (css, json)", "css") + .action(async (startColor, endColor, options) => { + const spinner = ora("Generating gradient...").start(); + + try { + const steps = Number.parseInt(options.steps, 10); + if (Number.isNaN(steps)) { + spinner.fail(chalk.red("Invalid steps value")); + console.error( + chalk.yellow(` Steps must be a number, got: "${options.steps}"`), + ); + process.exit(1); + } + + const validEasings = ["linear", "easeIn", "easeOut", "easeInOut"]; + if (!validEasings.includes(options.easing)) { + spinner.fail(chalk.red("Invalid easing function")); + console.error( + chalk.yellow( + ` Valid options: ${validEasings.join(", ")}\n Got: "${options.easing}"`, + ), + ); + process.exit(1); + } + const easingFn = EASING[options.easing as keyof typeof EASING]; + + const validFormats = ["css", "json"]; + if (!validFormats.includes(options.format)) { + spinner.fail(chalk.red("Invalid output format")); + console.error( + chalk.yellow( + ` Valid options: ${validFormats.join(", ")}\n Got: "${options.format}"`, + ), + ); + process.exit(1); + } + + const colors = generateGradient(startColor, endColor, steps, { + easing: easingFn, + hueDirection: options.direction, + }); + + await mkdir(options.output, { recursive: true }); + + if (options.format === "json") { + const gradient: Record = { + name: options.name, + start: startColor, + end: endColor, + steps, + colors, + css: { + linear: toCssGradient("linear", colors, 90), + radial: toCssGradient("radial", colors), + }, + }; + await writeFile( + join(options.output, `${options.name}.json`), + JSON.stringify({ gradient }, null, 2), + ); + } else { + let css = ":root {\n"; + for (let i = 0; i < colors.length; i++) { + css += ` --${options.name}-${i + 1}: ${colors[i]};\n`; + } + css += ` --${options.name}-linear: ${toCssGradient("linear", colors, 90)};\n`; + css += ` --${options.name}-radial: ${toCssGradient("radial", colors)};\n`; + css += "}\n"; + await writeFile(join(options.output, `${options.name}.css`), css); + } + + spinner.succeed(chalk.green("Gradient generated!")); + console.log( + chalk.dim( + `\nFile: ${options.output}/${options.name}.${options.format}`, + ), + ); + console.log(); + + // Show preview + console.log(chalk.bold("Gradient colors:")); + for (let i = 0; i < colors.length; i++) { + console.log(` ${i + 1}: ${colors[i]}`); + } + console.log(); + console.log(chalk.bold("CSS Gradients:")); + console.log(` linear: ${toCssGradient("linear", colors, 90)}`); + console.log(` radial: ${toCssGradient("radial", colors)}`); + console.log(); + } catch (error) { + spinner.fail(chalk.red("Failed to generate gradient")); + console.error(error); + process.exit(1); + } + }), ); diff --git a/packages/bdsg/README.md b/packages/bdsg/README.md index 882f82a..81c9375 100644 --- a/packages/bdsg/README.md +++ b/packages/bdsg/README.md @@ -12,6 +12,8 @@ Design System Generation library. Algorithms for generating design tokens progra - [Quick Start](#quick-start) - [API Reference](#api-reference) - [Color Utilities](#color-utilities) + - [OKLCH Color Space](#oklch-color-space) + - [Gradients](#gradients) - [Contrast](#contrast) - [Color Adjustment](#color-adjustment) - [Palette Generation](#palette-generation) @@ -88,6 +90,89 @@ hexToHsl("#3B82F6"); // { h: 217, s: 91, l: 60 } hslToHex({ h: 0, s: 100, l: 50 }); // "#ff0000" ``` +### OKLCH Color Space - ^0.2.0 + +OKLCH is a perceptually uniform color space that provides better gradient interpolation and more intuitive color manipulation. + +```typescript +import { hexToOklch, oklchToHex, interpolateOklch } from "bdsg"; + +// Convert between HEX and OKLCH +const oklch = hexToOklch("#3B82F6"); +// { l: 0.623, c: 0.185, h: 259.5 } +// l: lightness (0-1), c: chroma (0+), h: hue (0-360) + +oklchToHex({ l: 0.623, c: 0.185, h: 259.5 }); // "#3b82f6" + +// Interpolate colors without "muddy middle" +const red = hexToOklch("#FF0000"); +const green = hexToOklch("#00FF00"); +const middle = interpolateOklch(red, green, 0.5); +// Produces vibrant yellow, not muddy brown like RGB interpolation +``` + +Why OKLCH? +- **Perceptually uniform**: Equal lightness values look equally bright across all hues +- **No muddy gradients**: Interpolation stays vibrant, avoiding the brown/gray zone +- **Intuitive**: Hue, chroma, and lightness are independent + +### Gradients - ^0.2.0 + +Generate smooth color gradients using OKLCH interpolation with easing and hue direction control. + +```typescript +import { + generateGradient, + generateMultiStopGradient, + toCssGradient, + EASING +} from "bdsg"; + +// Simple two-color gradient +const gradient = generateGradient("#FF0000", "#0000FF", 5); +// ["#ff0000", "#c5007c", "#9100c9", "#5c00ed", "#0000ff"] + +// With easing function +const smooth = generateGradient("#000000", "#FFFFFF", 5, { + easing: EASING.easeInOut +}); + +// Control hue direction (shorter or longer path around color wheel) +const rainbow = generateGradient("#FF0000", "#FF8800", 5, { + hueDirection: "longer" // Takes the long way through blue/purple +}); + +// Multi-stop gradient +const sunset = generateMultiStopGradient([ + { color: "#FF0000", position: 0 }, + { color: "#FFFF00", position: 0.3 }, + { color: "#00FF00", position: 1 } +], 10); + +// Generate CSS gradient string +const colors = generateGradient("#FF0000", "#0000FF", 3); +toCssGradient("linear", colors, 45); +// "linear-gradient(45deg, #ff0000, #800080, #0000ff)" + +toCssGradient("radial", colors); +// "radial-gradient(circle, #ff0000, #800080, #0000ff)" + +toCssGradient("conic", colors, 90); +// "conic-gradient(from 90deg, #ff0000, #800080, #0000ff)" +``` + +Available easing functions: +- `EASING.linear` — Constant speed +- `EASING.easeIn` — Slow start, accelerates +- `EASING.easeOut` — Fast start, decelerates +- `EASING.easeInOut` — Slow start and end + +Hue direction options: +- `"shorter"` — Takes shortest path around color wheel (default) +- `"longer"` — Takes longer path for rainbow effects +- `"increasing"` — Always increases hue +- `"decreasing"` — Always decreases hue + ### Contrast Calculate and validate WCAG 2.1 contrast ratios. diff --git a/packages/bdsg/package.json b/packages/bdsg/package.json index cd422a8..f02ba64 100644 --- a/packages/bdsg/package.json +++ b/packages/bdsg/package.json @@ -1,9 +1,9 @@ { "name": "bdsg", - "version": "0.1.3", + "version": "0.2.0", "description": "Design system generation library with WCAG accessibility compliance", "license": "MIT", - "author": "carloseduj", + "author": "carlosedujs", "repository": { "type": "git", "url": "https://github.com/carlosedujs/bdsg.git" diff --git a/packages/bdsg/src/adjust.ts b/packages/bdsg/src/adjust.ts index b784b1e..ad711f4 100644 --- a/packages/bdsg/src/adjust.ts +++ b/packages/bdsg/src/adjust.ts @@ -4,15 +4,16 @@ */ import { hexToHsl, hslToHex } from "./color-utils"; -import type { HSL } from "./types/color-utils.types"; import { calculateContrast, getRelativeLuminance, WCAG_REQUIREMENTS, } from "./contrast"; +import type { HSL } from "./types/color-utils.types"; import type { TextSize, WCAGLevel } from "./types/contrast.types"; export type { AdjustmentResult, ColorVariations } from "./types/adjust.types"; + import type { AdjustmentResult, ColorVariations } from "./types/adjust.types"; /** diff --git a/packages/bdsg/src/color-utils.ts b/packages/bdsg/src/color-utils.ts index e572ec5..86feb52 100644 --- a/packages/bdsg/src/color-utils.ts +++ b/packages/bdsg/src/color-utils.ts @@ -2,8 +2,9 @@ * Color space conversion utilities */ -export type { RGB, HSL } from "./types/color-utils.types"; -import type { RGB, HSL } from "./types/color-utils.types"; +export type { HSL, RGB } from "./types/color-utils.types"; + +import type { HSL, RGB } from "./types/color-utils.types"; /** * Normalize hex color string diff --git a/packages/bdsg/src/contrast.ts b/packages/bdsg/src/contrast.ts index 30ecb7a..bac2946 100644 --- a/packages/bdsg/src/contrast.ts +++ b/packages/bdsg/src/contrast.ts @@ -6,14 +6,15 @@ import { hexToRgb } from "./color-utils"; export type { - WCAGLevel, TextSize, WCAGCompliance, + WCAGLevel, } from "./types/contrast.types"; + import type { - WCAGLevel, TextSize, WCAGCompliance, + WCAGLevel, } from "./types/contrast.types"; /** diff --git a/packages/bdsg/src/gradients.ts b/packages/bdsg/src/gradients.ts new file mode 100644 index 0000000..80b198b --- /dev/null +++ b/packages/bdsg/src/gradients.ts @@ -0,0 +1,281 @@ +/** + * Gradient Generation Module + * + * Generates color gradients using OKLCH interpolation for perceptually smooth transitions. + * Designed for future expansion with different gradient types and easing functions. + */ + +import { hexToOklch, oklchToHex } from "./oklch"; +import { + GradientConfigSchema, + GradientStopSchema, + HexColorSchema, + StepsSchema, + validateOrThrow, +} from "./schemas"; +import type { OKLCH } from "./types/oklch.types"; + +/** + * Easing function type + */ +export type EasingFunction = (t: number) => number; + +/** + * Gradient color stop + */ +export interface GradientStop { + color: string; + position: number; // 0-1 +} + +/** + * Gradient configuration + */ +export interface GradientConfig { + /** Easing function for transition */ + easing?: EasingFunction; + /** Hue interpolation direction for OKLCH */ + hueDirection?: "shorter" | "longer" | "increasing" | "decreasing"; +} + +/** + * Built-in easing functions + */ +export const EASING = { + linear: (t: number) => t, + easeIn: (t: number) => t * t, + easeOut: (t: number) => t * (2 - t), + easeInOut: (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), +} as const; + +/** + * Interpolate hue with direction control + */ +function interpolateHue( + h1: number, + h2: number, + t: number, + direction: GradientConfig["hueDirection"] = "shorter", +): number { + // Handle achromatic + if (h1 === 0 && h2 === 0) return 0; + + let delta = h2 - h1; + + switch (direction) { + case "shorter": + if (delta > 180) delta -= 360; + if (delta < -180) delta += 360; + break; + case "longer": + if (delta > 0 && delta < 180) delta -= 360; + if (delta < 0 && delta > -180) delta += 360; + break; + case "increasing": + if (delta < 0) delta += 360; + break; + case "decreasing": + if (delta > 0) delta -= 360; + break; + } + + let h = h1 + delta * t; + if (h < 0) h += 360; + if (h >= 360) h -= 360; + return h; +} + +/** + * Generate a gradient array between two hex colors + * + * @param startHex - Start color in hex + * @param endHex - End color in hex + * @param steps - Number of color stops (minimum 2) + * @param config - Optional gradient configuration + * @returns Array of hex colors + * @throws Error if inputs are invalid + * + * @example + * ```typescript + * // Simple gradient + * generateGradient("#FF0000", "#0000FF", 5) + * + * // With easing + * generateGradient("#FF0000", "#0000FF", 5, { easing: EASING.easeInOut }) + * + * // Control hue direction (go the long way around the color wheel) + * generateGradient("#FF0000", "#FF8800", 5, { hueDirection: "longer" }) + * ``` + */ +export function generateGradient( + startHex: string, + endHex: string, + steps: number, + config: GradientConfig = {}, +): string[] { + // Validate inputs + validateOrThrow( + HexColorSchema, + startHex, + `Invalid start color: "${startHex}"`, + ); + validateOrThrow(HexColorSchema, endHex, `Invalid end color: "${endHex}"`); + validateOrThrow(StepsSchema, steps, `Invalid steps: ${steps}`); + validateOrThrow(GradientConfigSchema, config, "Invalid config"); + + const { easing = EASING.linear, hueDirection = "shorter" } = config; + + const start = hexToOklch(startHex); + const end = hexToOklch(endHex); + const colors: string[] = []; + + for (let i = 0; i < steps; i++) { + const rawT = i / (steps - 1); + const t = easing(rawT); + + const interpolated: OKLCH = { + l: start.l + (end.l - start.l) * t, + c: start.c + (end.c - start.c) * t, + h: interpolateHue(start.h, end.h, t, hueDirection), + }; + + colors.push(oklchToHex(interpolated)); + } + + return colors; +} + +/** + * Generate a multi-stop gradient + * + * @param stops - Array of gradient stops with color and position + * @param steps - Total number of output colors (minimum 2) + * @param config - Optional gradient configuration + * @returns Array of hex colors + * @throws Error if inputs are invalid + * + * @example + * ```typescript + * generateMultiStopGradient([ + * { color: "#FF0000", position: 0 }, + * { color: "#FFFF00", position: 0.3 }, + * { color: "#00FF00", position: 1 } + * ], 10) + * ``` + */ +export function generateMultiStopGradient( + stops: GradientStop[], + steps: number, + config: GradientConfig = {}, +): string[] { + // Validate stops + if (stops.length < 2) { + throw new Error("At least 2 stops required for gradient"); + } + + // Validate each stop + for (let i = 0; i < stops.length; i++) { + validateOrThrow(GradientStopSchema, stops[i], `Invalid stop at index ${i}`); + } + + // Validate steps + validateOrThrow(StepsSchema, steps, `Invalid steps: ${steps}`); + + // Sort stops by position + const sortedStops = [...stops].sort((a, b) => a.position - b.position); + + const colors: string[] = []; + + for (let i = 0; i < steps; i++) { + const position = i / (steps - 1); + + // Find surrounding stops + const firstStop = sortedStops[0]; + const lastStop = sortedStops[sortedStops.length - 1]; + if (!firstStop || !lastStop) { + throw new Error("Invalid gradient stops"); + } + + let startStop = firstStop; + let endStop = lastStop; + + for (let j = 0; j < sortedStops.length - 1; j++) { + const currentStop = sortedStops[j]; + const nextStop = sortedStops[j + 1]; + if ( + currentStop && + nextStop && + position >= currentStop.position && + position <= nextStop.position + ) { + startStop = currentStop; + endStop = nextStop; + break; + } + } + + // Calculate local t within this segment + const segmentLength = endStop.position - startStop.position; + const localT = + segmentLength === 0 ? 0 : (position - startStop.position) / segmentLength; + + // Apply easing + const easedT = (config.easing ?? EASING.linear)(localT); + + // Interpolate + const startOklch = hexToOklch(startStop.color); + const endOklch = hexToOklch(endStop.color); + + const interpolated: OKLCH = { + l: startOklch.l + (endOklch.l - startOklch.l) * easedT, + c: startOklch.c + (endOklch.c - startOklch.c) * easedT, + h: interpolateHue( + startOklch.h, + endOklch.h, + easedT, + config.hueDirection ?? "shorter", + ), + }; + + colors.push(oklchToHex(interpolated)); + } + + return colors; +} + +/** + * Generate CSS gradient string + * + * @param type - Gradient type: "linear" | "radial" | "conic" + * @param colors - Array of hex colors (minimum 1) + * @param angle - Angle for linear gradient (default: 90deg) + * @returns CSS gradient string + * @throws Error if colors array is empty + * + * @example + * ```typescript + * const colors = generateGradient("#FF0000", "#0000FF", 3); + * toCssGradient("linear", colors, 45) + * // "linear-gradient(45deg, #ff0000, #800080, #0000ff)" + * ``` + */ +export function toCssGradient( + type: "linear" | "radial" | "conic", + colors: string[], + angle = 90, +): string { + if (colors.length === 0) { + throw new Error("At least 1 color required for CSS gradient"); + } + + const colorStr = colors.join(", "); + + switch (type) { + case "linear": + return `linear-gradient(${angle}deg, ${colorStr})`; + case "radial": + return `radial-gradient(circle, ${colorStr})`; + case "conic": + return `conic-gradient(from ${angle}deg, ${colorStr})`; + } +} diff --git a/packages/bdsg/src/index.ts b/packages/bdsg/src/index.ts index b6601ff..aef135e 100644 --- a/packages/bdsg/src/index.ts +++ b/packages/bdsg/src/index.ts @@ -28,6 +28,23 @@ export { type WCAGCompliance, type WCAGLevel, } from "./contrast.js"; +// Gradients +export { + EASING, + type EasingFunction, + type GradientConfig, + type GradientStop, + generateGradient, + generateMultiStopGradient, + toCssGradient, +} from "./gradients.js"; +// OKLCH color space +export { + hexToOklch, + interpolateOklch, + type OKLCH, + oklchToHex, +} from "./oklch.js"; // Palette generation export { type ColorPalette, diff --git a/packages/bdsg/src/oklch.ts b/packages/bdsg/src/oklch.ts new file mode 100644 index 0000000..c01e33f --- /dev/null +++ b/packages/bdsg/src/oklch.ts @@ -0,0 +1,233 @@ +/** + * OKLCH Color Space Utilities + * + * OKLCH is a perceptually uniform color space that provides: + * - Uniform lightness perception across hues + * - Better gradient interpolation without "muddy" zones + * - More intuitive color manipulation + * + * Conversion path: HEX → sRGB → Linear RGB → OKLAB → OKLCH + */ + +import { hexToRgb, rgbToHex } from "./color-utils"; +import { + InterpolationFactorSchema, + validateHexColor, + validateOklch, +} from "./schemas"; +import type { RGB } from "./types/color-utils.types"; + +export type { OKLCH } from "./types/oklch.types"; + +import type { OKLCH } from "./types/oklch.types"; + +/** + * OKLAB color representation (intermediate) + */ +interface OKLAB { + l: number; + a: number; + b: number; +} + +/** + * Convert sRGB component to linear RGB + * Applies inverse gamma correction + */ +function srgbToLinear(c: number): number { + const abs = Math.abs(c); + if (abs <= 0.04045) { + return c / 12.92; + } + return Math.sign(c) * ((abs + 0.055) / 1.055) ** 2.4; +} + +/** + * Convert linear RGB component to sRGB + * Applies gamma correction + */ +function linearToSrgb(c: number): number { + const abs = Math.abs(c); + if (abs <= 0.0031308) { + return c * 12.92; + } + return Math.sign(c) * (1.055 * abs ** (1 / 2.4) - 0.055); +} + +/** + * Convert RGB (0-255) to OKLAB + */ +function rgbToOklab(rgb: RGB): OKLAB { + // Normalize to 0-1 and convert to linear RGB + const r = srgbToLinear(rgb.r / 255); + const g = srgbToLinear(rgb.g / 255); + const b = srgbToLinear(rgb.b / 255); + + // RGB to LMS matrix (Oklab specific) + const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + + // Cube root + const l_ = Math.cbrt(l); + const m_ = Math.cbrt(m); + const s_ = Math.cbrt(s); + + // LMS to OKLAB + return { + l: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_, + a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_, + b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_, + }; +} + +/** + * Convert OKLAB to RGB (0-255) + */ +function oklabToRgb(lab: OKLAB): RGB { + // OKLAB to LMS + const l_ = lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b; + const m_ = lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b; + const s_ = lab.l - 0.0894841775 * lab.a - 1.291485548 * lab.b; + + // Cube + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + + // LMS to linear RGB + const rLin = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + const gLin = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + const bLin = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; + + // Linear RGB to sRGB, clamp, and scale to 0-255 + return { + r: Math.round(Math.max(0, Math.min(1, linearToSrgb(rLin))) * 255), + g: Math.round(Math.max(0, Math.min(1, linearToSrgb(gLin))) * 255), + b: Math.round(Math.max(0, Math.min(1, linearToSrgb(bLin))) * 255), + }; +} + +/** + * Convert OKLAB to OKLCH + */ +function oklabToOklch(lab: OKLAB): OKLCH { + const c = Math.sqrt(lab.a * lab.a + lab.b * lab.b); + let h = (Math.atan2(lab.b, lab.a) * 180) / Math.PI; + if (h < 0) h += 360; + + return { + l: lab.l, + c, + h: c < 0.0001 ? 0 : h, // achromatic colors have no hue + }; +} + +/** + * Convert OKLCH to OKLAB + */ +function oklchToOklab(lch: OKLCH): OKLAB { + const hRad = (lch.h * Math.PI) / 180; + return { + l: lch.l, + a: lch.c * Math.cos(hRad), + b: lch.c * Math.sin(hRad), + }; +} + +/** + * Convert HEX color to OKLCH + * + * @param hex - Hex color string (e.g., "#3B82F6") + * @returns OKLCH object with l (0-1), c (0-0.4+), h (0-360) + * @throws Error if hex color is invalid + * + * @example + * ```typescript + * hexToOklch("#3B82F6") + * // { l: 0.623, c: 0.185, h: 259.5 } + * ``` + */ +export function hexToOklch(hex: string): OKLCH { + validateHexColor(hex, "hex color"); + const rgb = hexToRgb(hex); + const lab = rgbToOklab(rgb); + return oklabToOklch(lab); +} + +/** + * Convert OKLCH to HEX color + * + * @param lch - OKLCH object + * @returns Hex color string + * @throws Error if OKLCH values are invalid + * + * @example + * ```typescript + * oklchToHex({ l: 0.623, c: 0.185, h: 259.5 }) + * // "#3b82f6" + * ``` + */ +export function oklchToHex(lch: OKLCH): string { + validateOklch(lch, "OKLCH input"); + const lab = oklchToOklab(lch); + const rgb = oklabToRgb(lab); + return rgbToHex(rgb); +} + +/** + * Interpolate between two OKLCH colors + * + * This produces smoother gradients than RGB interpolation, + * avoiding the "muddy middle" problem. + * + * @param color1 - Start color in OKLCH + * @param color2 - End color in OKLCH + * @param t - Interpolation factor (0-1) + * @returns Interpolated OKLCH color + * @throws Error if inputs are invalid + * + * @example + * ```typescript + * const start = hexToOklch("#FF0000"); // Red + * const end = hexToOklch("#00FF00"); // Green + * const middle = interpolateOklch(start, end, 0.5); + * // Produces a vibrant yellow, not muddy brown + * ``` + */ +export function interpolateOklch( + color1: OKLCH, + color2: OKLCH, + t: number, +): OKLCH { + // Validate inputs + validateOklch(color1, "color1"); + validateOklch(color2, "color2"); + + // Clamp t (but validate it's at least a number) + const tResult = InterpolationFactorSchema.safeParse(t); + const factor = tResult.success ? t : Math.max(0, Math.min(1, t)); + + // Interpolate lightness and chroma linearly + const l = color1.l + (color2.l - color1.l) * factor; + const c = color1.c + (color2.c - color1.c) * factor; + + // Interpolate hue along shortest path + let h1 = color1.h; + let h2 = color2.h; + + // Handle achromatic colors + if (color1.c < 0.0001) h1 = h2; + if (color2.c < 0.0001) h2 = h1; + + // Find shortest path around the color wheel + let deltaH = h2 - h1; + if (deltaH > 180) deltaH -= 360; + if (deltaH < -180) deltaH += 360; + + let h = h1 + deltaH * factor; + if (h < 0) h += 360; + if (h >= 360) h -= 360; + + return { l, c, h }; +} diff --git a/packages/bdsg/src/palette.ts b/packages/bdsg/src/palette.ts index 7fac425..130dfe0 100644 --- a/packages/bdsg/src/palette.ts +++ b/packages/bdsg/src/palette.ts @@ -1,49 +1,40 @@ /** - * HSL-based Palette Generation - * Generates a complete color palette from a base color + * OKLCH-based Palette Generation + * Generates a complete color palette from a base color using perceptually uniform OKLCH */ -import { z } from "zod"; -import { hexToHsl, hslToHex } from "./color-utils"; -import type { HSL } from "./types/color-utils.types"; import { calculateContrast } from "./contrast"; +import { hexToOklch, oklchToHex } from "./oklch"; +import { HexColorSchema, validateOrThrow } from "./schemas"; +import type { OKLCH } from "./types/oklch.types"; export type { - ColorShade, ColorPalette, + ColorShade, PaletteToken, } from "./types/palette.types"; + import type { - ColorShade, ColorPalette, + ColorShade, PaletteToken, } from "./types/palette.types"; /** - * Hex color validation schema - * Accepts 3-character (#RGB) or 6-character (#RRGGBB) hex colors - */ -const HexColorSchema = z - .string() - .regex( - /^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, - "Invalid hex color. Expected format: #RRGGBB or #RGB", - ); - -/** - * Shade configuration (lightness targets) + * Shade configuration (OKLCH lightness targets) + * OKLCH lightness is 0-1 (vs HSL's 0-100) */ const SHADE_CONFIG = { - 50: { lightness: 97 }, - 100: { lightness: 93 }, - 200: { lightness: 85 }, - 300: { lightness: 75 }, - 400: { lightness: 60 }, - 500: { lightness: 50 }, // Base - 600: { lightness: 42 }, - 700: { lightness: 35 }, - 800: { lightness: 27 }, - 900: { lightness: 20 }, + 50: { lightness: 0.97 }, + 100: { lightness: 0.93 }, + 200: { lightness: 0.85 }, + 300: { lightness: 0.75 }, + 400: { lightness: 0.65 }, + 500: { lightness: 0.55 }, // Base (will be overridden with exact color) + 600: { lightness: 0.45 }, + 700: { lightness: 0.38 }, + 800: { lightness: 0.3 }, + 900: { lightness: 0.22 }, } as const; type ShadeKey = keyof typeof SHADE_CONFIG; @@ -64,16 +55,17 @@ function getOptimalTextColor(bgColor: string): { } /** - * Generate a single shade from base HSL + * Generate a single shade from base OKLCH + * OKLCH provides perceptually uniform lightness adjustments */ -function generateShade(baseHsl: HSL, targetLightness: number): ColorShade { - const shadeHsl: HSL = { - h: baseHsl.h, - s: adjustSaturationForLightness(baseHsl.s, targetLightness), +function generateShade(baseOklch: OKLCH, targetLightness: number): ColorShade { + const shadeOklch: OKLCH = { l: targetLightness, + c: adjustChromaForLightness(baseOklch.c, targetLightness), + h: baseOklch.h, }; - const value = hslToHex(shadeHsl); + const value = oklchToHex(shadeOklch); const { color: textColor, ratio: contrastRatio } = getOptimalTextColor(value); return { @@ -84,32 +76,40 @@ function generateShade(baseHsl: HSL, targetLightness: number): ColorShade { } /** - * Adjust saturation based on lightness - * Very light/dark colors need less saturation to look good + * Adjust chroma (saturation) based on lightness + * Very light/dark colors need less chroma to stay in gamut and look good */ -function adjustSaturationForLightness( - baseSaturation: number, +function adjustChromaForLightness( + baseChroma: number, lightness: number, ): number { - if (lightness >= 90) { - // Very light - reduce saturation significantly - return Math.max(10, baseSaturation * 0.3); + if (lightness >= 0.9) { + // Very light - reduce chroma significantly + return Math.min(0.05, baseChroma * 0.25); + } + if (lightness >= 0.75) { + // Light - reduce chroma + return Math.min(0.12, baseChroma * 0.6); } - if (lightness >= 75) { - // Light - reduce saturation - return Math.max(20, baseSaturation * 0.6); + if (lightness <= 0.25) { + // Very dark - reduce chroma + return Math.min(0.1, baseChroma * 0.5); } - if (lightness <= 25) { - // Very dark - reduce saturation - return Math.max(30, baseSaturation * 0.7); + if (lightness <= 0.35) { + // Dark - reduce chroma slightly + return Math.min(0.15, baseChroma * 0.7); } - // Normal range - keep saturation - return baseSaturation; + // Normal range - keep chroma (may still need clamping) + return Math.min(0.25, baseChroma); } /** * Generate a complete color palette from a base color * + * Uses OKLCH for perceptually uniform lightness distribution. + * This produces more balanced palettes where each shade step + * feels visually consistent across different hues. + * * @param baseColor - Base color in hex format (e.g., "#3B82F6") * @param name - Optional name for the palette * @returns Complete palette with 10 shades and text colors @@ -126,20 +126,20 @@ export function generatePalette( baseColor: string, name?: string, ): ColorPalette { - const parseResult = HexColorSchema.safeParse(baseColor); // validate input color - if (!parseResult.success) { - throw new Error( - `Invalid base color: "${baseColor}". ${parseResult.error.issues[0]?.message}`, - ); - } + // Validate input color + validateOrThrow( + HexColorSchema, + baseColor, + `Invalid base color: "${baseColor}"`, + ); - const baseHsl = hexToHsl(baseColor); + const baseOklch = hexToOklch(baseColor); const shades = {} as ColorPalette["shades"]; for (const [key, config] of Object.entries(SHADE_CONFIG)) { const shadeKey = Number(key) as ShadeKey; - shades[shadeKey] = generateShade(baseHsl, config.lightness); + shades[shadeKey] = generateShade(baseOklch, config.lightness); } // Override 500 with exact base color diff --git a/packages/bdsg/src/relations.ts b/packages/bdsg/src/relations.ts index c6c9903..beba865 100644 --- a/packages/bdsg/src/relations.ts +++ b/packages/bdsg/src/relations.ts @@ -6,6 +6,7 @@ */ export type { Node, RelationSuggestion } from "./types/relations.types"; + import type { Node, RelationSuggestion } from "./types/relations.types"; /** diff --git a/packages/bdsg/src/schemas.ts b/packages/bdsg/src/schemas.ts new file mode 100644 index 0000000..26dbe97 --- /dev/null +++ b/packages/bdsg/src/schemas.ts @@ -0,0 +1,178 @@ +/** + * shared Zod Validation Schemas - improved schemas validation + * + * Centralized validation schemas for consistency across all modules. + * This ensures uniform error messages and validation rules. + */ + +import { z } from "zod"; + +// Color Schemas + +/** + * Hex color validation schema + * Accepts formats: #RGB, #RRGGBB, RGB, RRGGBB + */ +export const HexColorSchema = z + .string() + .regex( + /^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + "Invalid hex color. Expected format: #RRGGBB or #RGB", + ); + +/** + * OKLCH color validation schema + * L: 0-1 (lightness) + * C: 0+ (chroma, typically 0-0.4 for sRGB gamut) + * H: 0-360 (hue angle in degrees) + */ +export const OklchSchema = z.object({ + l: z.number().min(0).max(1, "Lightness must be between 0 and 1"), + c: z.number().min(0, "Chroma must be non-negative"), + h: z.number().min(0).max(360, "Hue must be between 0 and 360"), +}); + +/** + * RGB color validation schema (0-255 per channel) + */ +export const RgbSchema = z.object({ + r: z.number().int().min(0).max(255), + g: z.number().int().min(0).max(255), + b: z.number().int().min(0).max(255), +}); + +/** + * HSL color validation schema + */ +export const HslSchema = z.object({ + h: z.number().min(0).max(360), + s: z.number().min(0).max(100), + l: z.number().min(0).max(100), +}); + +// Gradient Schemas + +/** + * Gradient stop validation schema + */ +export const GradientStopSchema = z.object({ + color: HexColorSchema, + position: z.number().min(0).max(1, "Position must be between 0 and 1"), +}); + +/** + * Hue direction enum + */ +export const HueDirectionSchema = z.enum([ + "shorter", + "longer", + "increasing", + "decreasing", +]); + +/** + * Gradient config validation schema + */ +export const GradientConfigSchema = z + .object({ + hueDirection: HueDirectionSchema.optional(), + }) + .optional(); + +/** + * Steps validation (minimum 2 for gradients) + */ +export const StepsSchema = z.number().int().min(2, "Steps must be at least 2"); + +/** + * CSS gradient type + */ +export const CssGradientTypeSchema = z.enum(["linear", "radial", "conic"]); + +/** + * Angle validation (degrees) + */ +export const AngleSchema = z.number().min(0).max(360); + +// Numeric Schemas + +/** + * Positive number schema + */ +export const PositiveNumberSchema = z.number().positive(); + +/** + * Non-negative number schema + */ +export const NonNegativeNumberSchema = z.number().min(0); + +/** + * Integer schema + */ +export const IntegerSchema = z.number().int(); + +/** + * Positive integer schema + */ +export const PositiveIntegerSchema = z.number().int().positive(); + +/** + * Interpolation factor (0-1) + */ +export const InterpolationFactorSchema = z + .number() + .min(0) + .max(1, "Interpolation factor must be between 0 and 1"); + +// Helper Functions + +/** + * Validate and throw with friendly error message + */ +export function validateOrThrow( + schema: z.ZodType, + value: unknown, + context: string, +): T { + const result = schema.safeParse(value); + if (!result.success) { + const message = result.error.issues[0]?.message ?? "Validation failed"; + throw new Error(`${context}: ${message}`); + } + return result.data; +} + +/** + * Validate hex color and throw with context + */ +export function validateHexColor(hex: string, context = "color"): string { + return validateOrThrow(HexColorSchema, hex, `Invalid ${context}`); +} + +/** + * Validate OKLCH and throw with context + */ +export function validateOklch( + oklch: unknown, + context = "OKLCH color", +): z.infer { + return validateOrThrow(OklchSchema, oklch, `Invalid ${context}`); +} + +/** + * Validate steps and throw with context + */ +export function validateSteps(steps: number, context = "steps"): number { + return validateOrThrow(StepsSchema, steps, `Invalid ${context}`); +} + +// Type exports (inferred from schemas) + +export type HexColor = z.infer; +export type OklchColor = z.infer; +export type RgbColor = z.infer; +export type HslColor = z.infer; +export type GradientStopInput = z.infer; +export type HueDirection = z.infer; +export type GradientConfigInput = z.infer; +export type CssGradientType = z.infer; diff --git a/packages/bdsg/src/shadows.ts b/packages/bdsg/src/shadows.ts index 48d8dab..938a016 100644 --- a/packages/bdsg/src/shadows.ts +++ b/packages/bdsg/src/shadows.ts @@ -11,18 +11,19 @@ import { z } from "zod"; import { hexToRgb } from "./color-utils"; export type { - ShadowLayer, - ShadowToken, ShadowConfig, - ShadowScale, ShadowExportToken, -} from "./types/shadows.types"; -import type { ShadowLayer, + ShadowScale, ShadowToken, +} from "./types/shadows.types"; + +import type { ShadowConfig, - ShadowScale, ShadowExportToken, + ShadowLayer, + ShadowScale, + ShadowToken, } from "./types/shadows.types"; /** diff --git a/packages/bdsg/src/spacing.ts b/packages/bdsg/src/spacing.ts index 19410b6..def4ba6 100644 --- a/packages/bdsg/src/spacing.ts +++ b/packages/bdsg/src/spacing.ts @@ -11,18 +11,19 @@ import { z } from "zod"; export type { + SpacingExportToken, SpacingMethod, - SpacingToken, - SpacingScaleConfig, SpacingScale, - SpacingExportToken, + SpacingScaleConfig, + SpacingToken, } from "./types/spacing.types"; + import type { + SpacingExportToken, SpacingMethod, - SpacingToken, - SpacingScaleConfig, SpacingScale, - SpacingExportToken, + SpacingScaleConfig, + SpacingToken, } from "./types/spacing.types"; /** diff --git a/packages/bdsg/src/types/oklch.types.ts b/packages/bdsg/src/types/oklch.types.ts new file mode 100644 index 0000000..5b9193a --- /dev/null +++ b/packages/bdsg/src/types/oklch.types.ts @@ -0,0 +1,17 @@ +/** + * OKLCH type definitions + * + * OKLCH is a perceptually uniform color space. + */ + +/** + * OKLCH color representation + * L: Lightness (0-1) + * C: Chroma (0-0.4+ for vivid colors) + * H: Hue angle in degrees (0-360) + */ +export interface OKLCH { + l: number; + c: number; + h: number; +} diff --git a/packages/bdsg/src/typography.ts b/packages/bdsg/src/typography.ts index df0d452..f5a72c2 100644 --- a/packages/bdsg/src/typography.ts +++ b/packages/bdsg/src/typography.ts @@ -8,17 +8,18 @@ import { z } from "zod"; export type { + TypographyExportToken, TypographyRatio, - TypographyToken, - TypographyScaleConfig, TypographyScale, - TypographyExportToken, + TypographyScaleConfig, + TypographyToken, } from "./types/typography.types"; + import type { - TypographyToken, - TypographyScaleConfig, - TypographyScale, TypographyExportToken, + TypographyScale, + TypographyScaleConfig, + TypographyToken, } from "./types/typography.types"; /** diff --git a/packages/bdsg/test/TESTS.md b/packages/bdsg/test/TESTS.md index b4e913c..287cc1c 100644 --- a/packages/bdsg/test/TESTS.md +++ b/packages/bdsg/test/TESTS.md @@ -2,14 +2,14 @@ This document is auto-generated from the test files. Do not edit manually. -Generated: 2026-01-10 +Generated: 2026-01-14 ## Overview | Metric | Value | |--------|-------| -| Total Tests | 249 | -| Test Files | 9 | +| Total Tests | 285 | +| Test Files | 11 | --- @@ -20,7 +20,9 @@ test/ adjust.test.ts # Adjust (20 tests) color-utils.test.ts # Color Utils (34 tests) contrast.test.ts # Contrast (39 tests) + gradients.test.ts # Gradients (22 tests) index.test.ts # Index (33 tests) + oklch.test.ts # Oklch (14 tests) palette.test.ts # Palette (22 tests) relations.test.ts # Relations (16 tests) shadows.test.ts # Shadows (26 tests) @@ -37,7 +39,9 @@ test/ | Adjust | `adjust.test.ts` | 20 | 3 | | Color Utils | `color-utils.test.ts` | 34 | 2 | | Contrast | `contrast.test.ts` | 39 | 6 | +| Gradients | `gradients.test.ts` | 22 | 1 | | Index | `index.test.ts` | 33 | 1 | +| Oklch | `oklch.test.ts` | 14 | 1 | | Palette | `palette.test.ts` | 22 | 4 | | Relations | `relations.test.ts` | 16 | 1 | | Shadows | `shadows.test.ts` | 26 | 4 | @@ -228,6 +232,47 @@ test/ --- +## Gradients + +**File:** `test/gradients.test.ts` + +**Total Tests:** 22 + +### Test Suites + +| Suite | Tests | Description | +|-------|-------|-------------| +| Gradient Generation | 22 | Test suite | + +### Test Cases + +#### Gradient Generation + +- generates correct number of steps +- starts and ends with correct colors +- produces vibrant midpoints (no muddy zone) +- throws on invalid start color +- throws on invalid end color +- throws on steps less than 2 +- applies easeIn correctly +- applies easeOut correctly +- shorter takes shortest hue path +- longer takes longer hue path +- generates gradient with multiple stops +- throws on less than 2 stops +- throws on invalid stop color +- throws on invalid position +- generates linear gradient +- generates radial gradient +- generates conic gradient +- throws on empty colors array +- linear returns t unchanged +- easeIn returns t squared +- easeOut is faster early +- easeInOut is symmetric + +--- + ## Index **File:** `test/index.test.ts` @@ -280,6 +325,39 @@ test/ --- +## Oklch + +**File:** `test/oklch.test.ts` + +**Total Tests:** 14 + +### Test Suites + +| Suite | Tests | Description | +|-------|-------|-------------| +| OKLCH Color Space | 14 | Test suite | + +### Test Cases + +#### OKLCH Color Space + +- converts white correctly +- converts black correctly +- converts pure red correctly +- converts pure blue correctly +- throws on invalid hex +- converts white correctly +- converts black correctly +- roundtrips colors accurately +- throws on invalid OKLCH values +- returns start color at t=0 +- returns end color at t=1 +- returns midpoint at t=0.5 +- takes shortest hue path +- clamps t to 0-1 range + +--- + ## Palette **File:** `test/palette.test.ts` diff --git a/packages/bdsg/test/gradients.test.ts b/packages/bdsg/test/gradients.test.ts new file mode 100644 index 0000000..d5c0d88 --- /dev/null +++ b/packages/bdsg/test/gradients.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test"; +import { + EASING, + generateGradient, + generateMultiStopGradient, + toCssGradient, +} from "../src/gradients"; +import { hexToOklch } from "../src/oklch"; + +describe("Gradient Generation", () => { + describe("generateGradient", () => { + test("generates correct number of steps", () => { + const gradient = generateGradient("#ff0000", "#0000ff", 5); + expect(gradient).toHaveLength(5); + }); + + test("starts and ends with correct colors", () => { + const gradient = generateGradient("#ff0000", "#0000ff", 5); + expect(gradient[0]?.toLowerCase()).toBe("#ff0000"); + expect(gradient[4]?.toLowerCase()).toBe("#0000ff"); + }); + + test("produces vibrant midpoints (no muddy zone)", () => { + // Red to Green in RGB produces muddy brown in the middle + // OKLCH should produce vibrant yellow/orange + const gradient = generateGradient("#ff0000", "#00ff00", 5); + const middle = gradient[2]; + if (!middle) throw new Error("Middle color is undefined"); + + const middleOklch = hexToOklch(middle); + // Middle should have decent chroma (not muddy) + expect(middleOklch.c).toBeGreaterThan(0.1); + }); + + test("throws on invalid start color", () => { + expect(() => generateGradient("invalid", "#0000ff", 5)).toThrow(); + }); + + test("throws on invalid end color", () => { + expect(() => generateGradient("#ff0000", "invalid", 5)).toThrow(); + }); + + test("throws on steps less than 2", () => { + expect(() => generateGradient("#ff0000", "#0000ff", 1)).toThrow(); + }); + }); + + describe("generateGradient with easing", () => { + test("applies easeIn correctly", () => { + const linear = generateGradient("#000000", "#ffffff", 5); + const eased = generateGradient("#000000", "#ffffff", 5, { + easing: EASING.easeIn, + }); + + // EaseIn should have lighter colors later in the sequence + // So eased[1] should be darker than linear[1] + const linearColor = linear[1]; + const easedColor = eased[1]; + if (!linearColor || !easedColor) throw new Error("Colors undefined"); + const linearL = hexToOklch(linearColor).l; + const easedL = hexToOklch(easedColor).l; + expect(easedL).toBeLessThan(linearL); + }); + + test("applies easeOut correctly", () => { + const linear = generateGradient("#000000", "#ffffff", 5); + const eased = generateGradient("#000000", "#ffffff", 5, { + easing: EASING.easeOut, + }); + + // EaseOut should have lighter colors earlier in the sequence + // So eased[1] should be lighter than linear[1] + const linearColor = linear[1]; + const easedColor = eased[1]; + if (!linearColor || !easedColor) throw new Error("Colors undefined"); + const linearL = hexToOklch(linearColor).l; + const easedL = hexToOklch(easedColor).l; + expect(easedL).toBeGreaterThan(linearL); + }); + }); + + describe("generateGradient with hueDirection", () => { + test("shorter takes shortest hue path", () => { + // Red (h≈29) to Blue (h≈264) - shorter path goes through 0 + const gradient = generateGradient("#ff0000", "#ff8800", 3, { + hueDirection: "shorter", + }); + expect(gradient).toHaveLength(3); + }); + + test("longer takes longer hue path", () => { + const gradient = generateGradient("#ff0000", "#ff8800", 5, { + hueDirection: "longer", + }); + expect(gradient).toHaveLength(5); + }); + }); + + describe("generateMultiStopGradient", () => { + test("generates gradient with multiple stops", () => { + const gradient = generateMultiStopGradient( + [ + { color: "#ff0000", position: 0 }, + { color: "#ffff00", position: 0.5 }, + { color: "#00ff00", position: 1 }, + ], + 5, + ); + expect(gradient).toHaveLength(5); + }); + + test("throws on less than 2 stops", () => { + expect(() => + generateMultiStopGradient([{ color: "#ff0000", position: 0 }], 5), + ).toThrow(); + }); + + test("throws on invalid stop color", () => { + expect(() => + generateMultiStopGradient( + [ + { color: "invalid", position: 0 }, + { color: "#00ff00", position: 1 }, + ], + 5, + ), + ).toThrow(); + }); + + test("throws on invalid position", () => { + expect(() => + generateMultiStopGradient( + [ + { color: "#ff0000", position: -0.5 }, + { color: "#00ff00", position: 1 }, + ], + 5, + ), + ).toThrow(); + }); + }); + + describe("toCssGradient", () => { + test("generates linear gradient", () => { + const colors = ["#ff0000", "#00ff00", "#0000ff"]; + const css = toCssGradient("linear", colors, 45); + expect(css).toBe("linear-gradient(45deg, #ff0000, #00ff00, #0000ff)"); + }); + + test("generates radial gradient", () => { + const colors = ["#ff0000", "#0000ff"]; + const css = toCssGradient("radial", colors); + expect(css).toBe("radial-gradient(circle, #ff0000, #0000ff)"); + }); + + test("generates conic gradient", () => { + const colors = ["#ff0000", "#0000ff"]; + const css = toCssGradient("conic", colors, 90); + expect(css).toBe("conic-gradient(from 90deg, #ff0000, #0000ff)"); + }); + + test("throws on empty colors array", () => { + expect(() => toCssGradient("linear", [])).toThrow(); + }); + }); + + describe("EASING functions", () => { + test("linear returns t unchanged", () => { + expect(EASING.linear(0.5)).toBe(0.5); + }); + + test("easeIn returns t squared", () => { + expect(EASING.easeIn(0.5)).toBe(0.25); + }); + + test("easeOut is faster early", () => { + expect(EASING.easeOut(0.5)).toBe(0.75); + }); + + test("easeInOut is symmetric", () => { + // At t=0.5, easeInOut should return 0.5 + expect(EASING.easeInOut(0.5)).toBe(0.5); + }); + }); +}); diff --git a/packages/bdsg/test/oklch.test.ts b/packages/bdsg/test/oklch.test.ts new file mode 100644 index 0000000..ad4f83f --- /dev/null +++ b/packages/bdsg/test/oklch.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { hexToOklch, interpolateOklch, oklchToHex } from "../src/oklch"; + +describe("OKLCH Color Space", () => { + describe("hexToOklch", () => { + test("converts white correctly", () => { + const oklch = hexToOklch("#ffffff"); + expect(oklch.l).toBeCloseTo(1, 2); + expect(oklch.c).toBeCloseTo(0, 2); + }); + + test("converts black correctly", () => { + const oklch = hexToOklch("#000000"); + expect(oklch.l).toBeCloseTo(0, 2); + expect(oklch.c).toBeCloseTo(0, 2); + }); + + test("converts pure red correctly", () => { + const oklch = hexToOklch("#ff0000"); + expect(oklch.l).toBeCloseTo(0.628, 2); // Red has ~0.63 lightness in OKLCH + expect(oklch.c).toBeGreaterThan(0.2); // Saturated red has high chroma + expect(oklch.h).toBeCloseTo(29, 0); // Red hue is around 29° + }); + + test("converts pure blue correctly", () => { + const oklch = hexToOklch("#0000ff"); + expect(oklch.l).toBeCloseTo(0.452, 2); // Blue has ~0.45 lightness + expect(oklch.c).toBeGreaterThan(0.3); // Blue has very high chroma + expect(oklch.h).toBeCloseTo(264, 0); // Blue hue is around 264° + }); + + test("throws on invalid hex", () => { + expect(() => hexToOklch("invalid")).toThrow(); + }); + }); + + describe("oklchToHex", () => { + test("converts white correctly", () => { + const hex = oklchToHex({ l: 1, c: 0, h: 0 }); + expect(hex.toLowerCase()).toBe("#ffffff"); + }); + + test("converts black correctly", () => { + const hex = oklchToHex({ l: 0, c: 0, h: 0 }); + expect(hex.toLowerCase()).toBe("#000000"); + }); + + test("roundtrips colors accurately", () => { + const colors = ["#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6"]; + + for (const original of colors) { + const oklch = hexToOklch(original); + const roundtripped = oklchToHex(oklch); + // Allow small rounding differences + expect(roundtripped.toLowerCase()).toBe(original.toLowerCase()); + } + }); + + test("throws on invalid OKLCH values", () => { + expect(() => oklchToHex({ l: 2, c: 0, h: 0 })).toThrow(); + expect(() => oklchToHex({ l: -1, c: 0, h: 0 })).toThrow(); + expect(() => oklchToHex({ l: 0.5, c: -1, h: 0 })).toThrow(); + expect(() => oklchToHex({ l: 0.5, c: 0, h: 400 })).toThrow(); + }); + }); + + describe("interpolateOklch", () => { + test("returns start color at t=0", () => { + const start = hexToOklch("#ff0000"); + const end = hexToOklch("#00ff00"); + const result = interpolateOklch(start, end, 0); + + expect(result.l).toBeCloseTo(start.l, 5); + expect(result.c).toBeCloseTo(start.c, 5); + expect(result.h).toBeCloseTo(start.h, 5); + }); + + test("returns end color at t=1", () => { + const start = hexToOklch("#ff0000"); + const end = hexToOklch("#00ff00"); + const result = interpolateOklch(start, end, 1); + + expect(result.l).toBeCloseTo(end.l, 5); + expect(result.c).toBeCloseTo(end.c, 5); + expect(result.h).toBeCloseTo(end.h, 5); + }); + + test("returns midpoint at t=0.5", () => { + const start = hexToOklch("#ff0000"); + const end = hexToOklch("#0000ff"); + const result = interpolateOklch(start, end, 0.5); + + // Midpoint should have average lightness + const expectedL = (start.l + end.l) / 2; + expect(result.l).toBeCloseTo(expectedL, 2); + }); + + test("takes shortest hue path", () => { + // Red (h≈29) to Purple (h≈328) should go backwards through 0 + const red = { l: 0.5, c: 0.2, h: 350 }; + const purple = { l: 0.5, c: 0.2, h: 10 }; + const result = interpolateOklch(red, purple, 0.5); + + // Should be around 0 (or 360), not 180 + expect(result.h).toBeLessThan(30); + }); + + test("clamps t to 0-1 range", () => { + const start = hexToOklch("#ff0000"); + const end = hexToOklch("#00ff00"); + + // t < 0 should behave like t = 0 + const resultNeg = interpolateOklch(start, end, -0.5); + expect(resultNeg.l).toBeCloseTo(start.l, 5); + + // t > 1 should behave like t = 1 + const resultOver = interpolateOklch(start, end, 1.5); + expect(resultOver.l).toBeCloseTo(end.l, 5); + }); + }); +});