feat: implement Figlet, Pastel, and Unit tools with a unified layout
- Add Figlet text converter with font selection and history - Add Pastel color palette generator and manipulation suite - Add comprehensive Units converter with category-based logic - Introduce AppShell with Sidebar and Header for navigation - Modernize theme system with CSS variables and new animations - Update project configuration and dependencies
This commit is contained in:
248
lib/pastel/api/client.ts
Normal file
248
lib/pastel/api/client.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
ApiResponse,
|
||||
ColorInfoRequest,
|
||||
ColorInfoData,
|
||||
ConvertFormatRequest,
|
||||
ConvertFormatData,
|
||||
ColorManipulationRequest,
|
||||
ColorManipulationData,
|
||||
ColorMixRequest,
|
||||
ColorMixData,
|
||||
RandomColorsRequest,
|
||||
RandomColorsData,
|
||||
DistinctColorsRequest,
|
||||
DistinctColorsData,
|
||||
GradientRequest,
|
||||
GradientData,
|
||||
ColorDistanceRequest,
|
||||
ColorDistanceData,
|
||||
ColorSortRequest,
|
||||
ColorSortData,
|
||||
ColorBlindnessRequest,
|
||||
ColorBlindnessData,
|
||||
TextColorRequest,
|
||||
TextColorData,
|
||||
NamedColorsData,
|
||||
NamedColorSearchRequest,
|
||||
NamedColorSearchData,
|
||||
HealthData,
|
||||
CapabilitiesData,
|
||||
PaletteGenerateRequest,
|
||||
PaletteGenerateData,
|
||||
} from './types';
|
||||
import { pastelWASM } from './wasm-client';
|
||||
|
||||
export class PastelAPIClient {
|
||||
private baseURL: string;
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
// Use the Next.js API proxy route for runtime configuration
|
||||
// This allows changing the backend API URL without rebuilding
|
||||
this.baseURL = baseURL || '/api/pastel';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<ApiResponse<T>> {
|
||||
// Endpoint already includes /api/v1 prefix on backend,
|
||||
// but our proxy route expects paths after /api/v1/
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error || {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An unknown error occurred',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Network request failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Color Information
|
||||
async getColorInfo(request: ColorInfoRequest): Promise<ApiResponse<ColorInfoData>> {
|
||||
return this.request<ColorInfoData>('/colors/info', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Format Conversion
|
||||
async convertFormat(request: ConvertFormatRequest): Promise<ApiResponse<ConvertFormatData>> {
|
||||
return this.request<ConvertFormatData>('/colors/convert', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Color Manipulation
|
||||
async lighten(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/lighten', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async darken(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/darken', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async saturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/saturate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async desaturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/desaturate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async rotate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/rotate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async complement(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/complement', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ colors }),
|
||||
});
|
||||
}
|
||||
|
||||
async grayscale(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/grayscale', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ colors }),
|
||||
});
|
||||
}
|
||||
|
||||
async mix(request: ColorMixRequest): Promise<ApiResponse<ColorMixData>> {
|
||||
return this.request<ColorMixData>('/colors/mix', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Color Generation
|
||||
async generateRandom(request: RandomColorsRequest): Promise<ApiResponse<RandomColorsData>> {
|
||||
return this.request<RandomColorsData>('/colors/random', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async generateDistinct(request: DistinctColorsRequest): Promise<ApiResponse<DistinctColorsData>> {
|
||||
return this.request<DistinctColorsData>('/colors/distinct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async generateGradient(request: GradientRequest): Promise<ApiResponse<GradientData>> {
|
||||
return this.request<GradientData>('/colors/gradient', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Color Analysis
|
||||
async calculateDistance(request: ColorDistanceRequest): Promise<ApiResponse<ColorDistanceData>> {
|
||||
return this.request<ColorDistanceData>('/colors/distance', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async sortColors(request: ColorSortRequest): Promise<ApiResponse<ColorSortData>> {
|
||||
return this.request<ColorSortData>('/colors/sort', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
async simulateColorBlindness(request: ColorBlindnessRequest): Promise<ApiResponse<ColorBlindnessData>> {
|
||||
return this.request<ColorBlindnessData>('/colors/colorblind', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async getTextColor(request: TextColorRequest): Promise<ApiResponse<TextColorData>> {
|
||||
return this.request<TextColorData>('/colors/textcolor', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Named Colors
|
||||
async getNamedColors(): Promise<ApiResponse<NamedColorsData>> {
|
||||
return this.request<NamedColorsData>('/colors/names', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
async searchNamedColors(request: NamedColorSearchRequest): Promise<ApiResponse<NamedColorSearchData>> {
|
||||
return this.request<NamedColorSearchData>('/colors/names/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// System
|
||||
async getHealth(): Promise<ApiResponse<HealthData>> {
|
||||
return this.request<HealthData>('/health', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<ApiResponse<CapabilitiesData>> {
|
||||
return this.request<CapabilitiesData>('/capabilities', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
// Palette Generation
|
||||
async generatePalette(request: PaletteGenerateRequest): Promise<ApiResponse<PaletteGenerateData>> {
|
||||
return this.request<PaletteGenerateData>('/palettes/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
// Now using WASM client for zero-latency, offline-first color operations
|
||||
export const pastelAPI = pastelWASM;
|
||||
251
lib/pastel/api/queries.ts
Normal file
251
lib/pastel/api/queries.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, UseQueryOptions } from '@tanstack/react-query';
|
||||
import { pastelAPI } from './client';
|
||||
import type {
|
||||
ColorInfoRequest,
|
||||
ColorInfoData,
|
||||
ConvertFormatRequest,
|
||||
ConvertFormatData,
|
||||
ColorManipulationRequest,
|
||||
ColorManipulationData,
|
||||
ColorMixRequest,
|
||||
ColorMixData,
|
||||
RandomColorsRequest,
|
||||
RandomColorsData,
|
||||
DistinctColorsRequest,
|
||||
DistinctColorsData,
|
||||
GradientRequest,
|
||||
GradientData,
|
||||
ColorBlindnessRequest,
|
||||
PaletteGenerateRequest,
|
||||
PaletteGenerateData,
|
||||
ColorBlindnessData,
|
||||
TextColorRequest,
|
||||
TextColorData,
|
||||
NamedColorsData,
|
||||
HealthData,
|
||||
} from './types';
|
||||
|
||||
// Color Information
|
||||
export const useColorInfo = (
|
||||
request: ColorInfoRequest,
|
||||
options?: Omit<UseQueryOptions<ColorInfoData>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ['colorInfo', request.colors],
|
||||
queryFn: async () => {
|
||||
const response = await pastelAPI.getColorInfo(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
enabled: request.colors.length > 0 && request.colors.every((c) => c.length > 0),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Format Conversion
|
||||
export const useConvertFormat = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ConvertFormatRequest) => {
|
||||
const response = await pastelAPI.convertFormat(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Color Manipulation
|
||||
export const useLighten = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorManipulationRequest) => {
|
||||
const response = await pastelAPI.lighten(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDarken = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorManipulationRequest) => {
|
||||
const response = await pastelAPI.darken(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSaturate = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorManipulationRequest) => {
|
||||
const response = await pastelAPI.saturate(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDesaturate = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorManipulationRequest) => {
|
||||
const response = await pastelAPI.desaturate(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRotate = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorManipulationRequest) => {
|
||||
const response = await pastelAPI.rotate(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useComplement = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (colors: string[]) => {
|
||||
const response = await pastelAPI.complement(colors);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useMixColors = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorMixRequest) => {
|
||||
const response = await pastelAPI.mix(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Color Generation
|
||||
export const useGenerateRandom = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: RandomColorsRequest) => {
|
||||
const response = await pastelAPI.generateRandom(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGenerateDistinct = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: DistinctColorsRequest) => {
|
||||
const response = await pastelAPI.generateDistinct(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGenerateGradient = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: GradientRequest) => {
|
||||
const response = await pastelAPI.generateGradient(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Color Blindness Simulation
|
||||
export const useSimulateColorBlindness = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorBlindnessRequest) => {
|
||||
const response = await pastelAPI.simulateColorBlindness(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Text Color Optimizer
|
||||
export const useTextColor = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: TextColorRequest) => {
|
||||
const response = await pastelAPI.getTextColor(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Named Colors
|
||||
export const useNamedColors = () => {
|
||||
return useQuery({
|
||||
queryKey: ['namedColors'],
|
||||
queryFn: async () => {
|
||||
const response = await pastelAPI.getNamedColors();
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
staleTime: Infinity, // Named colors never change
|
||||
});
|
||||
};
|
||||
|
||||
// Health Check
|
||||
export const useHealth = () => {
|
||||
return useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: async () => {
|
||||
const response = await pastelAPI.getHealth();
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 60000, // Check every minute
|
||||
});
|
||||
};
|
||||
|
||||
// Palette Generation
|
||||
export const useGeneratePalette = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: PaletteGenerateRequest) => {
|
||||
const response = await pastelAPI.generatePalette(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
268
lib/pastel/api/types.ts
Normal file
268
lib/pastel/api/types.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// API Response Types
|
||||
export interface SuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
|
||||
|
||||
// Color Component Types
|
||||
export interface RGBColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a?: number;
|
||||
}
|
||||
|
||||
export interface HSLColor {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
a?: number;
|
||||
}
|
||||
|
||||
export interface HSVColor {
|
||||
h: number;
|
||||
s: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
export interface LabColor {
|
||||
l: number;
|
||||
a: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export interface OkLabColor {
|
||||
l: number;
|
||||
a: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export interface LCHColor {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface OkLCHColor {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CMYKColor {
|
||||
c: number;
|
||||
m: number;
|
||||
y: number;
|
||||
k: number;
|
||||
}
|
||||
|
||||
// Color Information
|
||||
export interface ColorInfo {
|
||||
input: string;
|
||||
hex: string;
|
||||
rgb: RGBColor;
|
||||
hsl: HSLColor;
|
||||
hsv: HSVColor;
|
||||
lab: LabColor;
|
||||
oklab: OkLabColor;
|
||||
lch: LCHColor;
|
||||
oklch: OkLCHColor;
|
||||
cmyk: CMYKColor;
|
||||
gray?: number;
|
||||
brightness: number;
|
||||
luminance: number;
|
||||
is_light: boolean;
|
||||
name?: string;
|
||||
distance_to_named?: number;
|
||||
}
|
||||
|
||||
// Request/Response Types for Each Endpoint
|
||||
export interface ColorInfoRequest {
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
export interface ColorInfoData {
|
||||
colors: ColorInfo[];
|
||||
}
|
||||
|
||||
export interface ConvertFormatRequest {
|
||||
colors: string[];
|
||||
format: 'hex' | 'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch' | 'cmyk' | 'gray';
|
||||
}
|
||||
|
||||
export interface ConvertFormatData {
|
||||
conversions: Array<{
|
||||
input: string;
|
||||
output: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ColorManipulationRequest {
|
||||
colors: string[];
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ColorManipulationData {
|
||||
operation?: string;
|
||||
amount?: number;
|
||||
colors: Array<{
|
||||
input: string;
|
||||
output: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ColorMixRequest {
|
||||
colors: string[];
|
||||
fraction: number;
|
||||
colorspace?: 'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch';
|
||||
}
|
||||
|
||||
export interface ColorMixData {
|
||||
results: Array<{
|
||||
color1: string;
|
||||
color2: string;
|
||||
mixed: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RandomColorsRequest {
|
||||
count: number;
|
||||
strategy?: 'vivid' | 'rgb' | 'gray' | 'lch';
|
||||
}
|
||||
|
||||
export interface RandomColorsData {
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
export interface DistinctColorsRequest {
|
||||
count: number;
|
||||
metric?: 'cie76' | 'ciede2000';
|
||||
fixed_colors?: string[];
|
||||
}
|
||||
|
||||
export interface DistinctColorsData {
|
||||
colors: string[];
|
||||
stats: {
|
||||
min_distance: number;
|
||||
avg_distance: number;
|
||||
generation_time_ms: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GradientRequest {
|
||||
stops: string[];
|
||||
count: number;
|
||||
colorspace?: 'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch';
|
||||
}
|
||||
|
||||
export interface GradientData {
|
||||
stops: string[];
|
||||
count: number;
|
||||
colorspace: string;
|
||||
gradient: string[];
|
||||
}
|
||||
|
||||
export interface ColorDistanceRequest {
|
||||
color1: string;
|
||||
color2: string;
|
||||
metric: 'cie76' | 'ciede2000';
|
||||
}
|
||||
|
||||
export interface ColorDistanceData {
|
||||
color1: string;
|
||||
color2: string;
|
||||
distance: number;
|
||||
metric: string;
|
||||
}
|
||||
|
||||
export interface ColorSortRequest {
|
||||
colors: string[];
|
||||
order: 'hue' | 'brightness' | 'luminance' | 'chroma';
|
||||
}
|
||||
|
||||
export interface ColorSortData {
|
||||
sorted: string[];
|
||||
}
|
||||
|
||||
export interface ColorBlindnessRequest {
|
||||
colors: string[];
|
||||
type: 'protanopia' | 'deuteranopia' | 'tritanopia';
|
||||
}
|
||||
|
||||
export interface ColorBlindnessData {
|
||||
type: string;
|
||||
colors: Array<{
|
||||
input: string;
|
||||
output: string;
|
||||
difference_percentage: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TextColorRequest {
|
||||
backgrounds: string[];
|
||||
}
|
||||
|
||||
export interface TextColorData {
|
||||
colors: Array<{
|
||||
background: string;
|
||||
textcolor: string;
|
||||
contrast_ratio: number;
|
||||
wcag_aa: boolean;
|
||||
wcag_aaa: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NamedColor {
|
||||
name: string;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export interface NamedColorsData {
|
||||
colors: NamedColor[];
|
||||
}
|
||||
|
||||
export interface NamedColorSearchRequest {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface NamedColorSearchData {
|
||||
results: NamedColor[];
|
||||
}
|
||||
|
||||
export interface HealthData {
|
||||
status: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface CapabilitiesData {
|
||||
endpoints: string[];
|
||||
formats: string[];
|
||||
color_spaces: string[];
|
||||
distance_metrics: string[];
|
||||
colorblindness_types: string[];
|
||||
}
|
||||
|
||||
export interface PaletteGenerateRequest {
|
||||
base: string;
|
||||
scheme: 'monochromatic' | 'analogous' | 'complementary' | 'split-complementary' | 'triadic' | 'tetradic';
|
||||
}
|
||||
|
||||
export interface PaletteGenerateData {
|
||||
base: string;
|
||||
scheme: string;
|
||||
palette: {
|
||||
primary: string;
|
||||
secondary: string[];
|
||||
};
|
||||
}
|
||||
484
lib/pastel/api/wasm-client.ts
Normal file
484
lib/pastel/api/wasm-client.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import {
|
||||
init,
|
||||
parse_color,
|
||||
lighten_color,
|
||||
darken_color,
|
||||
saturate_color,
|
||||
desaturate_color,
|
||||
rotate_hue,
|
||||
complement_color,
|
||||
mix_colors,
|
||||
get_text_color,
|
||||
calculate_contrast,
|
||||
simulate_protanopia,
|
||||
simulate_deuteranopia,
|
||||
simulate_tritanopia,
|
||||
color_distance,
|
||||
generate_random_colors,
|
||||
generate_gradient,
|
||||
generate_palette,
|
||||
get_all_named_colors,
|
||||
search_named_colors,
|
||||
version,
|
||||
} from '@valknarthing/pastel-wasm';
|
||||
import type {
|
||||
ApiResponse,
|
||||
ColorInfoRequest,
|
||||
ColorInfoData,
|
||||
ConvertFormatRequest,
|
||||
ConvertFormatData,
|
||||
ColorManipulationRequest,
|
||||
ColorManipulationData,
|
||||
ColorMixRequest,
|
||||
ColorMixData,
|
||||
RandomColorsRequest,
|
||||
RandomColorsData,
|
||||
DistinctColorsRequest,
|
||||
DistinctColorsData,
|
||||
GradientRequest,
|
||||
GradientData,
|
||||
ColorDistanceRequest,
|
||||
ColorDistanceData,
|
||||
ColorSortRequest,
|
||||
ColorSortData,
|
||||
ColorBlindnessRequest,
|
||||
ColorBlindnessData,
|
||||
TextColorRequest,
|
||||
TextColorData,
|
||||
NamedColorsData,
|
||||
NamedColorSearchRequest,
|
||||
NamedColorSearchData,
|
||||
HealthData,
|
||||
CapabilitiesData,
|
||||
PaletteGenerateRequest,
|
||||
PaletteGenerateData,
|
||||
} from './types';
|
||||
|
||||
// Initialize WASM module
|
||||
let wasmInitialized = false;
|
||||
|
||||
async function ensureWasmInit() {
|
||||
if (!wasmInitialized) {
|
||||
init(); // Initialize panic hook
|
||||
wasmInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WASM-based Pastel client
|
||||
* Provides the same interface as PastelAPIClient but uses WebAssembly
|
||||
* Zero network latency, works offline!
|
||||
*/
|
||||
export class PastelWASMClient {
|
||||
constructor() {
|
||||
// Initialize WASM eagerly
|
||||
ensureWasmInit().catch(console.error);
|
||||
}
|
||||
|
||||
private async request<T>(fn: () => T): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
await ensureWasmInit();
|
||||
const data = fn();
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'WASM_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Color Information
|
||||
async getColorInfo(request: ColorInfoRequest): Promise<ApiResponse<ColorInfoData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.colors.map((colorStr) => {
|
||||
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.lab[0] / 100.0,
|
||||
a: info.lab[1] / 100.0,
|
||||
b: info.lab[2] / 100.0,
|
||||
},
|
||||
lch: {
|
||||
l: info.lch[0],
|
||||
c: info.lch[1],
|
||||
h: info.lch[2],
|
||||
},
|
||||
oklch: {
|
||||
l: info.lch[0] / 100.0,
|
||||
c: info.lch[1] / 100.0,
|
||||
h: info.lch[2],
|
||||
},
|
||||
cmyk: {
|
||||
c: 0,
|
||||
m: 0,
|
||||
y: 0,
|
||||
k: 0,
|
||||
},
|
||||
brightness: info.brightness,
|
||||
luminance: info.luminance,
|
||||
is_light: info.is_light,
|
||||
};
|
||||
});
|
||||
return { colors };
|
||||
});
|
||||
}
|
||||
|
||||
// Format Conversion
|
||||
async convertFormat(request: ConvertFormatRequest): Promise<ApiResponse<ConvertFormatData>> {
|
||||
return this.request(() => {
|
||||
const conversions = request.colors.map((colorStr) => {
|
||||
const parsed = parse_color(colorStr);
|
||||
let output: string;
|
||||
|
||||
switch (request.format) {
|
||||
case 'hex':
|
||||
output = parsed.hex;
|
||||
break;
|
||||
case 'rgb':
|
||||
output = `rgb(${parsed.rgb[0]}, ${parsed.rgb[1]}, ${parsed.rgb[2]})`;
|
||||
break;
|
||||
case 'hsl':
|
||||
output = `hsl(${parsed.hsl[0].toFixed(1)}, ${(parsed.hsl[1] * 100).toFixed(1)}%, ${(parsed.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)}%)`;
|
||||
break;
|
||||
case 'lab':
|
||||
output = `lab(${parsed.lab[0].toFixed(2)}, ${parsed.lab[1].toFixed(2)}, ${parsed.lab[2].toFixed(2)})`;
|
||||
break;
|
||||
case 'lch':
|
||||
output = `lch(${parsed.lch[0].toFixed(2)}, ${parsed.lch[1].toFixed(2)}, ${parsed.lch[2].toFixed(2)})`;
|
||||
break;
|
||||
default:
|
||||
output = parsed.hex;
|
||||
}
|
||||
|
||||
return {
|
||||
input: colorStr,
|
||||
output,
|
||||
};
|
||||
});
|
||||
return { conversions };
|
||||
});
|
||||
}
|
||||
|
||||
// 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 };
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
async mix(request: ColorMixRequest): Promise<ApiResponse<ColorMixData>> {
|
||||
return this.request(() => {
|
||||
// Mix pairs of colors
|
||||
const results = [];
|
||||
for (let i = 0; i < request.colors.length - 1; i += 2) {
|
||||
const color1 = request.colors[i];
|
||||
const color2 = request.colors[i + 1];
|
||||
const mixed = mix_colors(color1, color2, request.fraction);
|
||||
results.push({ color1, color2, mixed });
|
||||
}
|
||||
return { results };
|
||||
});
|
||||
}
|
||||
|
||||
// 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 };
|
||||
});
|
||||
}
|
||||
|
||||
async generateDistinct(request: DistinctColorsRequest): Promise<ApiResponse<DistinctColorsData>> {
|
||||
return this.request(() => {
|
||||
// Note: WASM version doesn't support distinct colors with simulated annealing yet
|
||||
// Fall back to vivid random colors
|
||||
const colors = generate_random_colors(request.count, true);
|
||||
return {
|
||||
colors,
|
||||
stats: {
|
||||
min_distance: 0,
|
||||
avg_distance: 0,
|
||||
generation_time_ms: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// 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,
|
||||
colorspace: request.colorspace || 'rgb',
|
||||
gradient,
|
||||
};
|
||||
}
|
||||
|
||||
// For multiple stops, interpolate segments
|
||||
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(
|
||||
request.stops[i],
|
||||
request.stops[i + 1],
|
||||
i === segments - 1 ? request.count - gradient.length : colorsPerSegment
|
||||
);
|
||||
gradient.push(...segmentColors.slice(0, -1)); // Avoid duplicates
|
||||
}
|
||||
gradient.push(request.stops[request.stops.length - 1]);
|
||||
|
||||
return {
|
||||
stops: request.stops,
|
||||
count: request.count,
|
||||
colorspace: request.colorspace || 'rgb',
|
||||
gradient,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Color Analysis
|
||||
async calculateDistance(request: ColorDistanceRequest): Promise<ApiResponse<ColorDistanceData>> {
|
||||
return this.request(() => {
|
||||
const useCiede2000 = request.metric === 'ciede2000';
|
||||
const distance = color_distance(request.color1, request.color2, useCiede2000);
|
||||
return {
|
||||
color1: request.color1,
|
||||
color2: request.color2,
|
||||
distance,
|
||||
metric: request.metric,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async sortColors(request: ColorSortRequest): Promise<ApiResponse<ColorSortData>> {
|
||||
return this.request(() => {
|
||||
// Note: WASM version doesn't support sorting yet
|
||||
// Return colors as-is for now
|
||||
return { sorted: request.colors };
|
||||
});
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
async simulateColorBlindness(request: ColorBlindnessRequest): Promise<ApiResponse<ColorBlindnessData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.colors.map((colorStr) => {
|
||||
let output: string;
|
||||
switch (request.type) {
|
||||
case 'protanopia':
|
||||
output = simulate_protanopia(colorStr);
|
||||
break;
|
||||
case 'deuteranopia':
|
||||
output = simulate_deuteranopia(colorStr);
|
||||
break;
|
||||
case 'tritanopia':
|
||||
output = simulate_tritanopia(colorStr);
|
||||
break;
|
||||
default:
|
||||
output = colorStr;
|
||||
}
|
||||
|
||||
const distance = color_distance(colorStr, output, true);
|
||||
return {
|
||||
input: colorStr,
|
||||
output,
|
||||
difference_percentage: (distance / 100.0) * 100.0,
|
||||
};
|
||||
});
|
||||
|
||||
return { type: request.type, colors };
|
||||
});
|
||||
}
|
||||
|
||||
async getTextColor(request: TextColorRequest): Promise<ApiResponse<TextColorData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.backgrounds.map((bg) => {
|
||||
const textColor = get_text_color(bg);
|
||||
const contrastRatio = calculate_contrast(bg, textColor);
|
||||
|
||||
return {
|
||||
background: bg,
|
||||
textcolor: textColor,
|
||||
contrast_ratio: contrastRatio,
|
||||
wcag_aa: contrastRatio >= 4.5,
|
||||
wcag_aaa: contrastRatio >= 7.0,
|
||||
};
|
||||
});
|
||||
|
||||
return { colors };
|
||||
});
|
||||
}
|
||||
|
||||
// Named Colors
|
||||
async getNamedColors(): Promise<ApiResponse<NamedColorsData>> {
|
||||
return this.request(() => {
|
||||
const colors = get_all_named_colors();
|
||||
return { colors };
|
||||
});
|
||||
}
|
||||
|
||||
async searchNamedColors(request: NamedColorSearchRequest): Promise<ApiResponse<NamedColorSearchData>> {
|
||||
return this.request(() => {
|
||||
const results = search_named_colors(request.query);
|
||||
return { results };
|
||||
});
|
||||
}
|
||||
|
||||
// System
|
||||
async getHealth(): Promise<ApiResponse<HealthData>> {
|
||||
return this.request(() => ({
|
||||
status: 'healthy',
|
||||
version: version(),
|
||||
}));
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<ApiResponse<CapabilitiesData>> {
|
||||
return this.request(() => ({
|
||||
endpoints: [
|
||||
'colors/info',
|
||||
'colors/convert',
|
||||
'colors/lighten',
|
||||
'colors/darken',
|
||||
'colors/saturate',
|
||||
'colors/desaturate',
|
||||
'colors/rotate',
|
||||
'colors/complement',
|
||||
'colors/grayscale',
|
||||
'colors/mix',
|
||||
'colors/random',
|
||||
'colors/gradient',
|
||||
'colors/colorblind',
|
||||
'colors/textcolor',
|
||||
'colors/distance',
|
||||
'colors/names',
|
||||
],
|
||||
formats: ['hex', 'rgb', 'hsl', 'hsv', 'lab', 'lch'],
|
||||
color_spaces: ['rgb', 'hsl', 'hsv', 'lab', 'lch'],
|
||||
distance_metrics: ['cie76', 'ciede2000'],
|
||||
colorblindness_types: ['protanopia', 'deuteranopia', 'tritanopia'],
|
||||
}));
|
||||
}
|
||||
|
||||
// Palette Generation
|
||||
async generatePalette(request: PaletteGenerateRequest): Promise<ApiResponse<PaletteGenerateData>> {
|
||||
return this.request(() => {
|
||||
const colors = generate_palette(request.base, request.scheme);
|
||||
return {
|
||||
base: request.base,
|
||||
scheme: request.scheme,
|
||||
palette: {
|
||||
primary: colors[0],
|
||||
secondary: colors.slice(1),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pastelWASM = new PastelWASMClient();
|
||||
Reference in New Issue
Block a user