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
+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) },
};
});
}