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:
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+42
-8
@@ -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)):
|
||||
|
||||
Reference in New Issue
Block a user