refactor: replace pastel-wasm with pure TypeScript color engine

Removes the @valknarthing/pastel-wasm WASM dependency and replaces it
with a self-contained TypeScript implementation of all color operations
(parsing, conversion, manipulation, gradients, palette generation).
Adds eslint-plugin-react-hooks which was missing from devDependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 08:24:25 +02:00
parent ba118be485
commit 00af8edce6
4 changed files with 514 additions and 179 deletions
+398
View File
@@ -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<string, [number, number, number]> = {
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';
}
+73 -170
View File
@@ -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<T>(fn: () => T): Promise<ApiResponse<T>> {
private request<T>(fn: () => T): Promise<ApiResponse<T>> {
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<ApiResponse<ColorInfoData>> {
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<ApiResponse<ConvertFormatData>> {
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<ApiResponse<ColorManipulationData>> {
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<ApiResponse<ColorManipulationData>> {
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<ApiResponse<ColorManipulationData>> {
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<ApiResponse<ColorManipulationData>> {
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<ApiResponse<ColorManipulationData>> {
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<ApiResponse<ColorManipulationData>> {
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<ApiResponse<ColorManipulationData>> {
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<ApiResponse<RandomColorsData>> {
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<ApiResponse<GradientData>> {
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<ApiResponse<HealthData>> {
return this.request(() => ({
status: 'healthy',
version: version(),
}));
return this.request(() => ({ status: 'healthy', version: version() }));
}
async getCapabilities(): Promise<ApiResponse<CapabilitiesData>> {
@@ -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) },
};
});
}
+1 -1
View File
@@ -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",
+42 -8
View File
@@ -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)):