diff --git a/lib/color/api/color-engine.ts b/lib/color/api/color-engine.ts new file mode 100644 index 0000000..f947d19 --- /dev/null +++ b/lib/color/api/color-engine.ts @@ -0,0 +1,398 @@ +// Pure TypeScript color engine — replaces @valknarthing/pastel-wasm + +function clamp(v: number, min: number, max: number): number { + return Math.max(min, Math.min(max, v)); +} + +function rgbToHex(r: number, g: number, b: number): string { + return ( + '#' + + [r, g, b] + .map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0')) + .join('') + ); +} + +// sRGB gamma expansion +function linearize(channel: number): number { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +// sRGB gamma compression +function delinearize(c: number): number { + c = clamp(c, 0, 1); + return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; +} + +// Module-level constant — not rebuilt on every call +const NAMED_COLORS: Record = { + red: [255, 0, 0], + green: [0, 128, 0], + blue: [0, 0, 255], + white: [255, 255, 255], + black: [0, 0, 0], + yellow: [255, 255, 0], + cyan: [0, 255, 255], + aqua: [0, 255, 255], + magenta: [255, 0, 255], + fuchsia: [255, 0, 255], + orange: [255, 165, 0], + purple: [128, 0, 128], + pink: [255, 192, 203], + gray: [128, 128, 128], + grey: [128, 128, 128], + silver: [192, 192, 192], + brown: [165, 42, 42], + lime: [0, 255, 0], + navy: [0, 0, 128], + teal: [0, 128, 128], + gold: [255, 215, 0], + indigo: [75, 0, 130], + violet: [238, 130, 238], + coral: [255, 127, 80], + salmon: [250, 128, 114], + turquoise: [64, 224, 208], + khaki: [240, 230, 140], + beige: [245, 245, 220], + ivory: [255, 255, 240], + lavender: [230, 230, 250], + maroon: [128, 0, 0], + olive: [128, 128, 0], + crimson: [220, 20, 60], + tomato: [255, 99, 71], + chocolate: [210, 105, 30], + tan: [210, 180, 140], + skyblue: [135, 206, 235], + steelblue: [70, 130, 180], + royalblue: [65, 105, 225], + hotpink: [255, 105, 180], + deeppink: [255, 20, 147], + plum: [221, 160, 221], + orchid: [218, 112, 214], + mintcream: [245, 255, 250], + honeydew: [240, 255, 240], + aliceblue: [240, 248, 255], +}; + +function parseToRGB(colorStr: string): [number, number, number] | null { + const s = colorStr.trim().toLowerCase(); + + // #rrggbb, #rgb, #rrggbbaa, #rgba + const hexMatch = s.match(/^#?([0-9a-f]{3,8})$/); + if (hexMatch) { + let hex = hexMatch[1]; + if (hex.length === 3 || hex.length === 4) { + hex = hex + .split('') + .map((c) => c + c) + .join(''); + } + return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]; + } + + // rgb() / rgba() — supports both comma and space syntax + const rgbMatch = s.match(/^rgba?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)\s*[,\s]\s*([\d.]+)/); + if (rgbMatch) { + return [parseFloat(rgbMatch[1]), parseFloat(rgbMatch[2]), parseFloat(rgbMatch[3])]; + } + + // hsl() / hsla() + const hslMatch = s.match(/^hsla?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?/); + if (hslMatch) { + return hslToRGB(parseFloat(hslMatch[1]), parseFloat(hslMatch[2]) / 100, parseFloat(hslMatch[3]) / 100); + } + + return NAMED_COLORS[s] ?? null; +} + +function hslToRGB(h: number, s: number, l: number): [number, number, number] { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r = 0, + g = 0, + b = 0; + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else { + r = c; + b = x; + } + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; +} + +// Shared hue calculation for HSL and HSV +function calcHue(rn: number, gn: number, bn: number, max: number, d: number): number { + let h = 0; + switch (max) { + case rn: + h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; + break; + case gn: + h = ((bn - rn) / d + 2) / 6; + break; + case bn: + h = ((rn - gn) / d + 4) / 6; + break; + } + return h; +} + +function rgbToHSL(r: number, g: number, b: number): [number, number, number] { + const rn = r / 255, + gn = g / 255, + bn = b / 255; + const max = Math.max(rn, gn, bn), + min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + if (max === min) return [0, 0, l]; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + return [calcHue(rn, gn, bn, max, d) * 360, s, l]; +} + +function rgbToHSV(r: number, g: number, b: number): [number, number, number] { + const rn = r / 255, + gn = g / 255, + bn = b / 255; + const max = Math.max(rn, gn, bn), + min = Math.min(rn, gn, bn); + const d = max - min; + const s = max === 0 ? 0 : d / max; + if (d === 0) return [0, s, max]; + return [calcHue(rn, gn, bn, max, d) * 360, s, max]; +} + +function rgbToXYZ(r: number, g: number, b: number): [number, number, number] { + const rl = linearize(r), + gl = linearize(g), + bl = linearize(b); + return [ + rl * 0.4124564 + gl * 0.3575761 + bl * 0.1804375, + rl * 0.2126729 + gl * 0.7151522 + bl * 0.0721750, + rl * 0.0193339 + gl * 0.1191920 + bl * 0.9503041, + ]; +} + +function xyzToLab(x: number, y: number, z: number): [number, number, number] { + const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); + const fx = f(x / 0.95047), + fy = f(y / 1.0), + fz = f(z / 1.08883); + return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]; +} + +function xyzToOkLab(x: number, y: number, z: number): [number, number, number] { + const lp = Math.cbrt(0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z); + const mp = Math.cbrt(0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z); + const sp = Math.cbrt(0.0482003018 * x + 0.2643662691 * y + 0.633851707 * z); + return [ + 0.2104542553 * lp + 0.793617785 * mp - 0.0040720468 * sp, + 1.9779984951 * lp - 2.428592205 * mp + 0.4505937099 * sp, + 0.0259040371 * lp + 0.7827717662 * mp - 0.808675766 * sp, + ]; +} + +function oklabToRGB(L: number, a: number, b: number): [number, number, number] { + const lp = L + 0.3963377774 * a + 0.2158037573 * b; + const mp = L - 0.1055613458 * a - 0.0638541728 * b; + const sp = L - 0.0894841775 * a - 1.291485548 * b; + const l = lp * lp * lp; + const m = mp * mp * mp; + const s = sp * sp * sp; + return [ + Math.round(delinearize(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s) * 255), + Math.round(delinearize(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s) * 255), + Math.round(delinearize(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s) * 255), + ]; +} + +function labToLCH(l: number, a: number, b: number): [number, number, number] { + const c = Math.sqrt(a * a + b * b); + let h = Math.atan2(b, a) * (180 / Math.PI); + if (h < 0) h += 360; + return [l, c, h]; +} + +function rgbToCMYK(r: number, g: number, b: number): [number, number, number, number] { + const rn = r / 255, + gn = g / 255, + bn = b / 255; + const k = 1 - Math.max(rn, gn, bn); + if (k >= 1) return [0, 0, 0, 1]; + return [(1 - rn - k) / (1 - k), (1 - gn - k) / (1 - k), (1 - bn - k) / (1 - k), k]; +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +export interface ParsedColorResult { + input: string; + hex: string; + rgb: [number, number, number]; + hsl: [number, number, number]; + hsv: [number, number, number]; + lab: [number, number, number]; + oklab: [number, number, number]; + lch: [number, number, number]; + oklch: [number, number, number]; + cmyk: [number, number, number, number]; + brightness: number; + luminance: number; + is_light: boolean; +} + +export function parse_color(colorStr: string): ParsedColorResult { + const rgb = parseToRGB(colorStr); + if (!rgb) throw new Error(`Cannot parse color: "${colorStr}"`); + const [r, g, b] = rgb; + + const xyz = rgbToXYZ(r, g, b); + const lab = xyzToLab(...xyz); + const oklab = xyzToOkLab(...xyz); + const luminance = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); + + return { + input: colorStr, + hex: rgbToHex(r, g, b), + rgb, + hsl: rgbToHSL(r, g, b), + hsv: rgbToHSV(r, g, b), + lab, + oklab, + lch: labToLCH(...lab), + oklch: labToLCH(...oklab), + cmyk: rgbToCMYK(r, g, b), + brightness: (0.299 * r + 0.587 * g + 0.114 * b) / 255, + luminance, + is_light: luminance > 0.5, + }; +} + +function manipulateHSL( + colorStr: string, + fn: (h: number, s: number, l: number) => [number, number, number], +): string { + const rgb = parseToRGB(colorStr); + if (!rgb) throw new Error(`Cannot parse color: "${colorStr}"`); + return rgbToHex(...hslToRGB(...fn(...rgbToHSL(...rgb)))); +} + +export function lighten_color(colorStr: string, amount: number): string { + return manipulateHSL(colorStr, (h, s, l) => [h, s, clamp(l + amount, 0, 1)]); +} + +export function darken_color(colorStr: string, amount: number): string { + return manipulateHSL(colorStr, (h, s, l) => [h, s, clamp(l - amount, 0, 1)]); +} + +export function saturate_color(colorStr: string, amount: number): string { + return manipulateHSL(colorStr, (h, s, l) => [h, clamp(s + amount, 0, 1), l]); +} + +export function desaturate_color(colorStr: string, amount: number): string { + return manipulateHSL(colorStr, (h, s, l) => [h, clamp(s - amount, 0, 1), l]); +} + +export function rotate_hue(colorStr: string, amount: number): string { + return manipulateHSL(colorStr, (h, s, l) => [((h + amount) % 360 + 360) % 360, s, l]); +} + +export function complement_color(colorStr: string): string { + return rotate_hue(colorStr, 180); +} + +export function generate_random_colors(count: number, vivid: boolean): string[] { + const colors: string[] = []; + for (let i = 0; i < count; i++) { + if (vivid) { + const h = Math.random() * 360; + const s = 0.6 + Math.random() * 0.4; + const l = 0.35 + Math.random() * 0.3; + colors.push(rgbToHex(...hslToRGB(h, s, l))); + } else { + colors.push( + rgbToHex( + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256), + ), + ); + } + } + return colors; +} + +export function generate_gradient(stop1: string, stop2: string, count: number): string[] { + const rgb1 = parseToRGB(stop1); + const rgb2 = parseToRGB(stop2); + if (!rgb1 || !rgb2) throw new Error('Cannot parse gradient stop color'); + + const lab1 = xyzToOkLab(...rgbToXYZ(...rgb1)); + const lab2 = xyzToOkLab(...rgbToXYZ(...rgb2)); + + return Array.from({ length: count }, (_, i) => { + const t = count === 1 ? 0 : i / (count - 1); + return rgbToHex( + ...oklabToRGB( + lab1[0] + (lab2[0] - lab1[0]) * t, + lab1[1] + (lab2[1] - lab1[1]) * t, + lab1[2] + (lab2[2] - lab1[2]) * t, + ), + ); + }); +} + +export function generate_palette(base: string, scheme: string): string[] { + const rgb = parseToRGB(base); + if (!rgb) throw new Error(`Cannot parse color: "${base}"`); + const hex = rgbToHex(...rgb); + const [h, s, l] = rgbToHSL(...rgb); + const mk = (hue: number, sat: number, lit: number) => rgbToHex(...hslToRGB(hue, clamp(sat, 0, 1), clamp(lit, 0, 1))); + + switch (scheme) { + case 'monochromatic': + return [hex, mk(h, s, l + 0.15), mk(h, s, l - 0.15), mk(h, s, l + 0.3), mk(h, s, l - 0.3)]; + case 'analogous': + return [ + hex, + mk((h + 30 + 360) % 360, s, l), + mk((h - 30 + 360) % 360, s, l), + mk((h + 60 + 360) % 360, s, l), + mk((h - 60 + 360) % 360, s, l), + ]; + case 'complementary': { + const ch = (h + 180) % 360; + return [hex, mk(ch, s, l), mk(h, s, l + 0.15), mk(ch, s, l + 0.15), mk(h, s, l - 0.15)]; + } + case 'triadic': + return [ + hex, + mk((h + 120) % 360, s, l), + mk((h + 240) % 360, s, l), + mk((h + 120) % 360, s, l + 0.1), + mk((h + 240) % 360, s, l + 0.1), + ]; + case 'tetradic': + return [hex, mk((h + 90) % 360, s, l), mk((h + 180) % 360, s, l), mk((h + 270) % 360, s, l), mk(h, s, l + 0.1)]; + default: + return [hex]; + } +} + +export function version(): string { + return '1.0.0'; +} diff --git a/lib/color/api/wasm-client.ts b/lib/color/api/wasm-client.ts index f824889..19c7f41 100644 --- a/lib/color/api/wasm-client.ts +++ b/lib/color/api/wasm-client.ts @@ -1,5 +1,4 @@ import { - init, parse_color, lighten_color, darken_color, @@ -11,7 +10,7 @@ import { generate_gradient, generate_palette, version, -} from '@valknarthing/pastel-wasm'; +} from './color-engine'; import type { ApiResponse, ColorInfoRequest, @@ -30,43 +29,22 @@ import type { PaletteGenerateData, } from './types'; -// Initialize WASM module -let wasmInitialized = false; - -async function ensureWasmInit() { - if (!wasmInitialized) { - init(); // Initialize panic hook - wasmInitialized = true; - } -} - /** - * WASM-based Color client - * Provides the same interface as ColorAPIClient but uses WebAssembly - * Zero network latency, works offline! + * Color client backed by a pure TypeScript color engine. + * Zero network latency, works offline — no WASM required. */ export class ColorWASMClient { - constructor() { - // Initialize WASM eagerly - ensureWasmInit().catch(console.error); - } - - private async request(fn: () => T): Promise> { + private request(fn: () => T): Promise> { try { - await ensureWasmInit(); - const data = fn(); - return { - success: true, - data, - }; + return Promise.resolve({ success: true, data: fn() }); } catch (error) { - return { + return Promise.resolve({ success: false, error: { - code: 'WASM_ERROR', + code: 'COLOR_ERROR', message: error instanceof Error ? error.message : 'Unknown error', }, - }; + }); } } @@ -74,51 +52,18 @@ export class ColorWASMClient { async getColorInfo(request: ColorInfoRequest): Promise> { return this.request(() => { const colors = request.colors.map((colorStr) => { - const info = parse_color(colorStr) as any; + const info = parse_color(colorStr); return { input: info.input, hex: info.hex, - rgb: { - r: info.rgb[0], - g: info.rgb[1], - b: info.rgb[2], - }, - hsl: { - h: info.hsl[0], - s: info.hsl[1], - l: info.hsl[2], - }, - hsv: { - h: info.hsv[0], - s: info.hsv[1], - v: info.hsv[2], - }, - lab: { - l: info.lab[0], - a: info.lab[1], - b: info.lab[2], - }, - oklab: { - l: info.oklab ? info.oklab[0] : info.lab[0] / 100.0, - a: info.oklab ? info.oklab[1] : info.lab[1] / 100.0, - b: info.oklab ? info.oklab[2] : info.lab[2] / 100.0, - }, - lch: { - l: info.lch[0], - c: info.lch[1], - h: info.lch[2], - }, - oklch: { - l: info.oklch ? info.oklch[0] : info.lch[0] / 100.0, - c: info.oklch ? info.oklch[1] : info.lch[1] / 100.0, - h: info.oklch ? info.oklch[2] : info.lch[2], - }, - cmyk: { - c: 0, - m: 0, - y: 0, - k: 0, - }, + rgb: { r: info.rgb[0], g: info.rgb[1], b: info.rgb[2] }, + hsl: { h: info.hsl[0], s: info.hsl[1], l: info.hsl[2] }, + hsv: { h: info.hsv[0], s: info.hsv[1], v: info.hsv[2] }, + lab: { l: info.lab[0], a: info.lab[1], b: info.lab[2] }, + oklab: { l: info.oklab[0], a: info.oklab[1], b: info.oklab[2] }, + lch: { l: info.lch[0], c: info.lch[1], h: info.lch[2] }, + oklch: { l: info.oklch[0], c: info.oklch[1], h: info.oklch[2] }, + cmyk: { c: info.cmyk[0], m: info.cmyk[1], y: info.cmyk[2], k: info.cmyk[3] }, brightness: info.brightness, luminance: info.luminance, is_light: info.is_light, @@ -132,50 +77,39 @@ export class ColorWASMClient { async convertFormat(request: ConvertFormatRequest): Promise> { return this.request(() => { const conversions = request.colors.map((colorStr) => { - const parsed = parse_color(colorStr) as any; + const p = parse_color(colorStr); let output: string; switch (request.format) { case 'hex': - output = parsed.hex; + output = p.hex; break; case 'rgb': - output = `rgb(${parsed.rgb[0]}, ${parsed.rgb[1]}, ${parsed.rgb[2]})`; + output = `rgb(${p.rgb[0]}, ${p.rgb[1]}, ${p.rgb[2]})`; break; case 'hsl': - output = `hsl(${parsed.hsl[0].toFixed(1)}, ${(parsed.hsl[1] * 100).toFixed(1)}%, ${(parsed.hsl[2] * 100).toFixed(1)}%)`; + output = `hsl(${p.hsl[0].toFixed(1)}, ${(p.hsl[1] * 100).toFixed(1)}%, ${(p.hsl[2] * 100).toFixed(1)}%)`; break; case 'hsv': - output = `hsv(${parsed.hsv[0].toFixed(1)}, ${(parsed.hsv[1] * 100).toFixed(1)}%, ${(parsed.hsv[2] * 100).toFixed(1)}%)`; + output = `hsv(${p.hsv[0].toFixed(1)}, ${(p.hsv[1] * 100).toFixed(1)}%, ${(p.hsv[2] * 100).toFixed(1)}%)`; break; case 'lab': - output = `lab(${parsed.lab[0].toFixed(2)}, ${parsed.lab[1].toFixed(2)}, ${parsed.lab[2].toFixed(2)})`; + output = `lab(${p.lab[0].toFixed(2)}, ${p.lab[1].toFixed(2)}, ${p.lab[2].toFixed(2)})`; break; case 'lch': - output = `lch(${parsed.lch[0].toFixed(2)}, ${parsed.lch[1].toFixed(2)}, ${parsed.lch[2].toFixed(2)})`; + output = `lch(${p.lch[0].toFixed(2)}, ${p.lch[1].toFixed(2)}, ${p.lch[2].toFixed(2)})`; break; - case 'oklab': { - const l = parsed.oklab ? parsed.oklab[0] : parsed.lab[0] / 100.0; - const a = parsed.oklab ? parsed.oklab[1] : parsed.lab[1] / 100.0; - const b = parsed.oklab ? parsed.oklab[2] : parsed.lab[2] / 100.0; - output = `oklab(${(l * 100).toFixed(1)}% ${a.toFixed(3)} ${b.toFixed(3)})`; + case 'oklab': + output = `oklab(${(p.oklab[0] * 100).toFixed(1)}% ${p.oklab[1].toFixed(3)} ${p.oklab[2].toFixed(3)})`; break; - } - case 'oklch': { - const l = parsed.oklch ? parsed.oklch[0] : parsed.lch[0] / 100.0; - const c = parsed.oklch ? parsed.oklch[1] : parsed.lch[1] / 100.0; - const h = parsed.oklch ? parsed.oklch[2] : parsed.lch[2]; - output = `oklch(${(l * 100).toFixed(1)}% ${c.toFixed(3)} ${h.toFixed(2)})`; + case 'oklch': + output = `oklch(${(p.oklch[0] * 100).toFixed(1)}% ${p.oklch[1].toFixed(3)} ${p.oklch[2].toFixed(2)})`; break; - } default: - output = parsed.hex; + output = p.hex; } - return { - input: colorStr, - output, - }; + return { input: colorStr, output }; }); return { conversions }; }); @@ -183,129 +117,101 @@ export class ColorWASMClient { // Color Manipulation async lighten(request: ColorManipulationRequest): Promise> { - return this.request(() => { - const colors = request.colors.map((colorStr) => ({ - input: colorStr, - output: lighten_color(colorStr, request.amount), - })); - return { operation: 'lighten', amount: request.amount, colors }; - }); + return this.request(() => ({ + operation: 'lighten', + amount: request.amount, + colors: request.colors.map((c) => ({ input: c, output: lighten_color(c, request.amount) })), + })); } async darken(request: ColorManipulationRequest): Promise> { - return this.request(() => { - const colors = request.colors.map((colorStr) => ({ - input: colorStr, - output: darken_color(colorStr, request.amount), - })); - return { operation: 'darken', amount: request.amount, colors }; - }); + return this.request(() => ({ + operation: 'darken', + amount: request.amount, + colors: request.colors.map((c) => ({ input: c, output: darken_color(c, request.amount) })), + })); } async saturate(request: ColorManipulationRequest): Promise> { - return this.request(() => { - const colors = request.colors.map((colorStr) => ({ - input: colorStr, - output: saturate_color(colorStr, request.amount), - })); - return { operation: 'saturate', amount: request.amount, colors }; - }); + return this.request(() => ({ + operation: 'saturate', + amount: request.amount, + colors: request.colors.map((c) => ({ input: c, output: saturate_color(c, request.amount) })), + })); } async desaturate(request: ColorManipulationRequest): Promise> { - return this.request(() => { - const colors = request.colors.map((colorStr) => ({ - input: colorStr, - output: desaturate_color(colorStr, request.amount), - })); - return { operation: 'desaturate', amount: request.amount, colors }; - }); + return this.request(() => ({ + operation: 'desaturate', + amount: request.amount, + colors: request.colors.map((c) => ({ input: c, output: desaturate_color(c, request.amount) })), + })); } async rotate(request: ColorManipulationRequest): Promise> { - return this.request(() => { - const colors = request.colors.map((colorStr) => ({ - input: colorStr, - output: rotate_hue(colorStr, request.amount), - })); - return { operation: 'rotate', amount: request.amount, colors }; - }); + return this.request(() => ({ + operation: 'rotate', + amount: request.amount, + colors: request.colors.map((c) => ({ input: c, output: rotate_hue(c, request.amount) })), + })); } async complement(colors: string[]): Promise> { - return this.request(() => { - const results = colors.map((colorStr) => ({ - input: colorStr, - output: complement_color(colorStr), - })); - return { operation: 'complement', colors: results }; - }); + return this.request(() => ({ + operation: 'complement', + colors: colors.map((c) => ({ input: c, output: complement_color(c) })), + })); } async grayscale(colors: string[]): Promise> { - return this.request(() => { - const results = colors.map((colorStr) => ({ - input: colorStr, - output: desaturate_color(colorStr, 1.0), - })); - return { operation: 'grayscale', colors: results }; - }); + return this.request(() => ({ + operation: 'grayscale', + colors: colors.map((c) => ({ input: c, output: desaturate_color(c, 1.0) })), + })); } // Color Generation async generateRandom(request: RandomColorsRequest): Promise> { return this.request(() => { const vivid = request.strategy === 'vivid' || request.strategy === 'lch'; - const colors = generate_random_colors(request.count, vivid); - return { colors }; + return { colors: generate_random_colors(request.count, vivid) }; }); } async generateGradient(request: GradientRequest): Promise> { return this.request(() => { - if (request.stops.length < 2) { - throw new Error('At least 2 color stops are required'); - } + if (request.stops.length < 2) throw new Error('At least 2 color stops are required'); - // For 2 stops, use the WASM gradient function if (request.stops.length === 2) { - const gradient = generate_gradient(request.stops[0], request.stops[1], request.count); return { stops: request.stops, count: request.count, - gradient, + gradient: generate_gradient(request.stops[0], request.stops[1], request.count), }; } - // For multiple stops, interpolate segments + // Multi-stop: interpolate segment by segment const segments = request.stops.length - 1; const colorsPerSegment = Math.floor(request.count / segments); const gradient: string[] = []; for (let i = 0; i < segments; i++) { - const segmentColors = generate_gradient( + const segColors = generate_gradient( request.stops[i], request.stops[i + 1], - i === segments - 1 ? request.count - gradient.length : colorsPerSegment + i === segments - 1 ? request.count - gradient.length : colorsPerSegment, ); - gradient.push(...segmentColors.slice(0, -1)); // Avoid duplicates + gradient.push(...segColors.slice(0, -1)); } gradient.push(request.stops[request.stops.length - 1]); - return { - stops: request.stops, - count: request.count, - gradient, - }; + return { stops: request.stops, count: request.count, gradient }; }); } // System async getHealth(): Promise> { - return this.request(() => ({ - status: 'healthy', - version: version(), - })); + return this.request(() => ({ status: 'healthy', version: version() })); } async getCapabilities(): Promise> { @@ -337,10 +243,7 @@ export class ColorWASMClient { return { base: request.base, scheme: request.scheme, - palette: { - primary: colors[0], - secondary: colors.slice(1), - }, + palette: { primary: colors[0], secondary: colors.slice(1) }, }; }); } diff --git a/package.json b/package.json index 117c723..ccd9155 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "@ffmpeg/util": "^0.12.2", "@imagemagick/magick-wasm": "^0.0.38", "@tanstack/react-query": "^5.90.21", - "@valknarthing/pastel-wasm": "^0.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -50,6 +49,7 @@ "@types/react-dom": "^19.2.3", "eslint": "^9.21.0", "eslint-config-next": "^15.1.7", + "eslint-plugin-react-hooks": "^7.0.1", "postcss": "^8.5.6", "shadcn": "^3.8.5", "tailwind-scrollbar": "^4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 225b738..c0094c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) - '@valknarthing/pastel-wasm': - specifier: ^0.1.0 - version: 0.1.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -111,6 +108,9 @@ importers: eslint-config-next: specifier: ^15.1.7 version: 15.1.7(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.3(jiti@2.6.1)) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -1701,9 +1701,6 @@ packages: cpu: [x64] os: [win32] - '@valknarthing/pastel-wasm@0.1.0': - resolution: {integrity: sha512-oMEo023SQvs62orZ4WaM+LCPfNwFDjLQcDrGKXnOYfAa1wHtZzt5v8m42te6X+hlld0fPHjY/aR5iAD3RKKipQ==} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2258,6 +2255,12 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint-plugin-react@7.37.5: resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} engines: {node: '>=4'} @@ -2564,6 +2567,12 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hono@4.12.2: resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==} engines: {node: '>=16.9.0'} @@ -4037,6 +4046,12 @@ packages: peerDependencies: zod: ^3.25 || ^4 + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -5664,8 +5679,6 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@valknarthing/pastel-wasm@0.1.0': {} - accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -6302,6 +6315,17 @@ snapshots: dependencies: eslint: 9.39.3(jiti@2.6.1) + eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.3(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + eslint-plugin-react@7.37.5(eslint@9.39.3(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -6681,6 +6705,12 @@ snapshots: headers-polyfill@4.0.3: {} + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + hono@4.12.2: {} html-to-image@1.11.13: {} @@ -8341,6 +8371,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-validation-error@4.0.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):