feat: unify pastel application into single playground and remove standalone pages
This commit is contained in:
66
GEMINI.md
Normal file
66
GEMINI.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# GEMINI.md - Kit UI Context
|
||||
|
||||
This file provides foundational context and instructions for Gemini CLI when working in the `kit-ui` workspace.
|
||||
|
||||
## 🚀 Project Overview
|
||||
|
||||
**Kit UI** is a high-performance, aesthetically pleasing toolkit built with **Next.js 16**, **React 19**, and **Tailwind CSS 4**. It provides four core specialized applications:
|
||||
|
||||
1. **Pastel**: Advanced color theory, manipulation, and accessibility suite powered by `@valknarthing/pastel-wasm`.
|
||||
2. **Units**: Smart unit converter supporting 187+ units across 23 categories, including a custom `tempo` measure.
|
||||
3. **Figlet**: ASCII Art generator with 373 fonts and multi-format export.
|
||||
4. **Media**: Browser-based file converter using **FFmpeg** and **ImageMagick** via WebAssembly (Zero server uploads).
|
||||
|
||||
## 🛠️ Tech Stack & Architecture
|
||||
|
||||
- **Framework**: Next.js 16 (App Router, Static Export).
|
||||
- **Library**: React 19.
|
||||
- **Styling**: Tailwind CSS 4 (CSS-first configuration in `app/globals.css`).
|
||||
- **Animations**: Framer Motion 12.
|
||||
- **State Management**: Zustand & React Query.
|
||||
- **Performance**: Heavy logic (Color, Media) is offloaded to **WebAssembly (WASM)**.
|
||||
- **UI Components**: shadcn/ui (customized for a glassmorphic aesthetic).
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```bash
|
||||
.
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── (app)/ # Core Tool Pages (pastel, units, figlet, media)
|
||||
│ └── globals.css # Tailwind 4 configuration & global styles
|
||||
├── components/ # UI Components
|
||||
│ ├── [tool]/ # Tool-specific components (e.g., components/pastel/)
|
||||
│ ├── layout/ # AppShell, Sidebar, Header
|
||||
│ └── ui/ # Base Atomic Components (shadcn)
|
||||
├── lib/ # Business Logic
|
||||
│ ├── [tool]/ # Tool-specific logic & WASM wrappers
|
||||
│ └── utils/ # General utilities (cn, format, etc.)
|
||||
├── public/ # Static assets
|
||||
│ ├── wasm/ # WASM binaries (ffmpeg, imagemagick)
|
||||
│ └── fonts/ # Figlet fonts (.flf)
|
||||
└── types/ # TypeScript definitions
|
||||
```
|
||||
|
||||
## ⚙️ Development Workflows
|
||||
|
||||
### Key Commands
|
||||
|
||||
- **Development**: `pnpm dev` (Uses Next.js Turbopack).
|
||||
- **Build**: `pnpm build` (Generates a static export in `/out`).
|
||||
- **Lint**: `pnpm lint`.
|
||||
- **WASM Setup**: `pnpm postinstall` (Automatically copies WASM binaries to `public/wasm/`).
|
||||
|
||||
### Coding Standards
|
||||
|
||||
1. **Tailwind 4**: Use the new CSS-first approach. Avoid `tailwind.config.js`. Define theme variables and utilities in `app/globals.css`.
|
||||
2. **Glassmorphism**: Use the `@utility glass` for translucent components.
|
||||
3. **WASM Orchestration**: Heavy processing should stay in `lib/[tool]/` and utilize WASM where possible. Refer to `lib/media/wasm/wasmLoader.ts` for pattern-loading FFmpeg/ImageMagick.
|
||||
4. **Client-Side Only**: Since this is a static export toolkit that relies on browser APIs (WASM, File API), ensure components using these are marked with `'use client'`.
|
||||
5. **Icons**: Exclusively use `lucide-react`.
|
||||
|
||||
## 🧠 Strategic Instructions for Gemini
|
||||
|
||||
- **Surgical Updates**: When modifying tools, ensure the logic remains in `lib/` and the UI in `components/`.
|
||||
- **WASM Handling**: Do not attempt to run WASM-dependent logic in the terminal/Node environment unless specifically configured. These tools are designed for the browser.
|
||||
- **Styling**: Adhere to the `glass` and gradient utilities (`gradient-purple-blue`, etc.) defined in `app/globals.css`.
|
||||
- **Component Consistency**: Use shadcn components from `components/ui/` as the building blocks for new features.
|
||||
@@ -1,219 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PaletteGrid } from '@/components/pastel/PaletteGrid';
|
||||
import { ExportMenu } from '@/components/pastel/ExportMenu';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { useLighten, useDarken, useSaturate, useDesaturate, useRotate } from '@/lib/pastel/api/queries';
|
||||
import { Loader2, Upload, Download } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type Operation = 'lighten' | 'darken' | 'saturate' | 'desaturate' | 'rotate';
|
||||
|
||||
export default function BatchPage() {
|
||||
const [inputColors, setInputColors] = useState('');
|
||||
const [operation, setOperation] = useState<Operation>('lighten');
|
||||
const [amount, setAmount] = useState(0.2);
|
||||
const [outputColors, setOutputColors] = useState<string[]>([]);
|
||||
|
||||
const lightenMutation = useLighten();
|
||||
const darkenMutation = useDarken();
|
||||
const saturateMutation = useSaturate();
|
||||
const desaturateMutation = useDesaturate();
|
||||
const rotateMutation = useRotate();
|
||||
|
||||
const parseColors = (text: string): string[] => {
|
||||
// Parse colors from text (one per line, or comma-separated)
|
||||
return text
|
||||
.split(/[\n,]/)
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length > 0 && c.match(/^#?[0-9a-fA-F]{3,8}$/));
|
||||
};
|
||||
|
||||
const handleProcess = async () => {
|
||||
const colors = parseColors(inputColors);
|
||||
|
||||
if (colors.length === 0) {
|
||||
toast.error('No valid colors found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (colors.length > 100) {
|
||||
toast.error('Maximum 100 colors allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (operation) {
|
||||
case 'lighten':
|
||||
result = await lightenMutation.mutateAsync({ colors, amount });
|
||||
break;
|
||||
case 'darken':
|
||||
result = await darkenMutation.mutateAsync({ colors, amount });
|
||||
break;
|
||||
case 'saturate':
|
||||
result = await saturateMutation.mutateAsync({ colors, amount });
|
||||
break;
|
||||
case 'desaturate':
|
||||
result = await desaturateMutation.mutateAsync({ colors, amount });
|
||||
break;
|
||||
case 'rotate':
|
||||
result = await rotateMutation.mutateAsync({ colors, amount: amount * 360 });
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract output colors from the result
|
||||
const processed = result.colors.map((c) => c.output);
|
||||
setOutputColors(processed);
|
||||
toast.success(`Processed ${processed.length} colors`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to process colors');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending =
|
||||
lightenMutation.isPending ||
|
||||
darkenMutation.isPending ||
|
||||
saturateMutation.isPending ||
|
||||
desaturateMutation.isPending ||
|
||||
rotateMutation.isPending;
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
title="Batch Operations"
|
||||
description="Process multiple colors at once with manipulation operations"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Input */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Input Colors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Enter colors (one per line or comma-separated). Supports hex format
|
||||
</p>
|
||||
|
||||
<Textarea
|
||||
value={inputColors}
|
||||
onChange={(e) => setInputColors(e.target.value)}
|
||||
placeholder="#ff0099, #00ff99, #9900ff #ff5533 #3355ff"
|
||||
className="h-48 font-mono"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{parseColors(inputColors).length} valid colors found
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Operation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Select
|
||||
value={operation}
|
||||
onValueChange={(value) => setOperation(value as Operation)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select operation" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="lighten">Lighten</SelectItem>
|
||||
<SelectItem value="darken">Darken</SelectItem>
|
||||
<SelectItem value="saturate">Saturate</SelectItem>
|
||||
<SelectItem value="desaturate">Desaturate</SelectItem>
|
||||
<SelectItem value="rotate">Rotate Hue</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">
|
||||
Amount
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{operation === 'rotate' ? (amount * 360).toFixed(0) + '°' : (amount * 100).toFixed(0) + '%'}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={[amount]}
|
||||
onValueChange={(vals) => setAmount(vals[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleProcess}
|
||||
disabled={isPending || parseColors(inputColors).length === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing..
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Process Colors
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<div className="space-y-6">
|
||||
{outputColors.length > 0 ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Output Colors ({outputColors.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PaletteGrid colors={outputColors} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ExportMenu colors={outputColors} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center text-muted-foreground">
|
||||
<Download className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Enter colors and click Process to see results</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ColorPicker } from '@/components/pastel/ColorPicker';
|
||||
import { ColorDisplay } from '@/components/pastel/ColorDisplay';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { useSimulateColorBlindness } from '@/lib/pastel/api/queries';
|
||||
import { Loader2, Eye, Plus, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type ColorBlindnessType = 'protanopia' | 'deuteranopia' | 'tritanopia';
|
||||
|
||||
export default function ColorBlindPage() {
|
||||
const [colors, setColors] = useState<string[]>(['#ff0099']);
|
||||
const [blindnessType, setBlindnessType] = useState<ColorBlindnessType>('protanopia');
|
||||
const [simulations, setSimulations] = useState<
|
||||
Array<{ input: string; output: string; difference_percentage: number }>
|
||||
>([]);
|
||||
|
||||
const simulateMutation = useSimulateColorBlindness();
|
||||
|
||||
const handleSimulate = async () => {
|
||||
try {
|
||||
const result = await simulateMutation.mutateAsync({
|
||||
colors,
|
||||
type: blindnessType,
|
||||
});
|
||||
setSimulations(result.colors);
|
||||
toast.success(`Simulated ${blindnessType}`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to simulate color blindness');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const addColor = () => {
|
||||
if (colors.length < 10) {
|
||||
setColors([...colors, '#000000']);
|
||||
}
|
||||
};
|
||||
|
||||
const removeColor = (index: number) => {
|
||||
if (colors.length > 1) {
|
||||
setColors(colors.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateColor = (index: number, color: string) => {
|
||||
const newColors = [...colors];
|
||||
newColors[index] = color;
|
||||
setColors(newColors);
|
||||
};
|
||||
|
||||
const typeDescriptions: Record<ColorBlindnessType, string> = {
|
||||
protanopia: 'Red-blind (affects ~1% of males)',
|
||||
deuteranopia: 'Green-blind (affects ~1% of males)',
|
||||
tritanopia: 'Blue-blind (rare, affects ~0.001%)',
|
||||
};
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
title="Color Blindness Simulator"
|
||||
description="Simulate how colors appear with different types of color blindness"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Controls */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Colors to Test</CardTitle>
|
||||
<Button
|
||||
onClick={addColor}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={colors.length >= 10}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Color
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{colors.map((color, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(newColor) => updateColor(index, newColor)}
|
||||
/>
|
||||
</div>
|
||||
{colors.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeColor(index)}
|
||||
className="mt-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Blindness Type</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Select
|
||||
value={blindnessType}
|
||||
onValueChange={(value) => setBlindnessType(value as ColorBlindnessType)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="protanopia">Protanopia (Red-blind)</SelectItem>
|
||||
<SelectItem value="deuteranopia">Deuteranopia (Green-blind)</SelectItem>
|
||||
<SelectItem value="tritanopia">Tritanopia (Blue-blind)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{typeDescriptions[blindnessType]}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleSimulate}
|
||||
disabled={simulateMutation.isPending || colors.length === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{simulateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Simulating..
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Simulate
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="space-y-6">
|
||||
{simulations.length > 0 ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Simulation Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Compare original colors (left) with how they appear to people with{' '}
|
||||
{blindnessType} (right)
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{simulations.map((sim, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Original
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorDisplay color={sim.input} size="md" />
|
||||
<code className="text-sm font-mono">{sim.input}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
As Seen ({sim.difference_percentage.toFixed(1)}% difference)
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorDisplay color={sim.output} size="md" />
|
||||
<code className="text-sm font-mono">{sim.output}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-blue-50 dark:bg-blue-950/20 border-blue-100 dark:border-blue-900/30 shadow-none">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
Accessibility Tip
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ensure important information isn't conveyed by color alone. Use text
|
||||
labels, patterns, or icons to make your design accessible to everyone
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center text-muted-foreground">
|
||||
<Eye className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Add colors and click Simulate to see how they appear</p>
|
||||
<p className="text-sm mt-2">with different types of color blindness</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ColorPicker } from '@/components/pastel/ColorPicker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { getContrastRatio, hexToRgb, checkWCAGCompliance } from '@/lib/pastel/utils/color';
|
||||
import { ArrowLeftRight, Check, X } from 'lucide-react';
|
||||
|
||||
export default function ContrastPage() {
|
||||
const [foreground, setForeground] = useState('#000000');
|
||||
const [background, setBackground] = useState('#ffffff');
|
||||
const [ratio, setRatio] = useState<number | null>(null);
|
||||
const [compliance, setCompliance] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fgRgb = hexToRgb(foreground);
|
||||
const bgRgb = hexToRgb(background);
|
||||
|
||||
if (fgRgb && bgRgb) {
|
||||
const contrastRatio = getContrastRatio(fgRgb, bgRgb);
|
||||
setRatio(contrastRatio);
|
||||
setCompliance(checkWCAGCompliance(contrastRatio));
|
||||
}
|
||||
}, [foreground, background]);
|
||||
|
||||
const swapColors = () => {
|
||||
const temp = foreground;
|
||||
setForeground(background);
|
||||
setBackground(temp);
|
||||
};
|
||||
|
||||
const ComplianceItem = ({
|
||||
label,
|
||||
passed,
|
||||
}: {
|
||||
label: string;
|
||||
passed: boolean;
|
||||
}) => (
|
||||
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<span className="text-sm">{label}</span>
|
||||
<Badge variant={passed ? 'default' : 'destructive'}>
|
||||
{passed ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Pass
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Fail
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
title="Contrast Checker"
|
||||
description="Test color combinations for WCAG 2.1 compliance"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Color Pickers */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Foreground Color</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ColorPicker color={foreground} onChange={setForeground} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={swapColors}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Background Color</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ColorPicker color={background} onChange={setBackground} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="space-y-6">
|
||||
{/* Preview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="rounded-lg p-8 text-center"
|
||||
style={{ backgroundColor: background, color: foreground }}
|
||||
>
|
||||
<p className="text-xl font-bold mb-2">Normal Text (16px)</p>
|
||||
<p className="text-3xl font-bold">Large Text (24px)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contrast Ratio */}
|
||||
{ratio !== null && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Contrast Ratio</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-5xl font-bold">{ratio.toFixed(2)}:1</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{ratio >= 7
|
||||
? 'Excellent contrast'
|
||||
: ratio >= 4.5
|
||||
? 'Good contrast'
|
||||
: ratio >= 3
|
||||
? 'Minimum contrast'
|
||||
: 'Poor contrast'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* WCAG Compliance */}
|
||||
{compliance && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>WCAG 2.1 Compliance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Level AA</h3>
|
||||
<div className="space-y-2">
|
||||
<ComplianceItem
|
||||
label="Normal Text (4.5:1)"
|
||||
passed={compliance.aa.normalText}
|
||||
/>
|
||||
<ComplianceItem
|
||||
label="Large Text (3:1)"
|
||||
passed={compliance.aa.largeText}
|
||||
/>
|
||||
<ComplianceItem
|
||||
label="UI Components (3:1)"
|
||||
passed={compliance.aa.ui}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Level AAA</h3>
|
||||
<div className="space-y-2">
|
||||
<ComplianceItem
|
||||
label="Normal Text (7:1)"
|
||||
passed={compliance.aaa.normalText}
|
||||
/>
|
||||
<ComplianceItem
|
||||
label="Large Text (4.5:1)"
|
||||
passed={compliance.aaa.largeText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PaletteGrid } from '@/components/pastel/PaletteGrid';
|
||||
import { ExportMenu } from '@/components/pastel/ExportMenu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { useGenerateDistinct } from '@/lib/pastel/api/queries';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function DistinctPage() {
|
||||
const [count, setCount] = useState(8);
|
||||
const [metric, setMetric] = useState<'cie76' | 'ciede2000'>('ciede2000');
|
||||
const [colors, setColors] = useState<string[]>([]);
|
||||
|
||||
const generateMutation = useGenerateDistinct();
|
||||
|
||||
const handleGenerate = async () => {
|
||||
try {
|
||||
const result = await generateMutation.mutateAsync({
|
||||
count,
|
||||
metric,
|
||||
});
|
||||
setColors(result.colors);
|
||||
toast.success(`Generated ${result.colors.length} distinct colors`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate distinct colors');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
title="Distinct Colors Generator"
|
||||
description="Generate visually distinct colors using simulated annealing"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Controls */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="count" className="text-sm font-medium mb-2 block">
|
||||
Number of Colors
|
||||
</label>
|
||||
<Input
|
||||
id="count"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
value={count}
|
||||
onChange={(e) => setCount(parseInt(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Higher counts take longer to generate
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium block">
|
||||
Distance Metric
|
||||
</label>
|
||||
<Select
|
||||
value={metric}
|
||||
onValueChange={(value) => setMetric(value as 'cie76' | 'ciede2000')}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select metric" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cie76">CIE76 (Faster)</SelectItem>
|
||||
<SelectItem value="ciede2000">CIEDE2000 (More Accurate)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={generateMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{generateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating..
|
||||
</>
|
||||
) : (
|
||||
'Generate Colors'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{generateMutation.isPending && (
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
This may take a few moments..
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Generated Colors {colors.length > 0 && `(${colors.length})`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PaletteGrid colors={colors} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{colors.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ExportMenu colors={colors} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ColorPicker } from '@/components/pastel/ColorPicker';
|
||||
import { PaletteGrid } from '@/components/pastel/PaletteGrid';
|
||||
import { ExportMenu } from '@/components/pastel/ExportMenu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { useGenerateGradient } from '@/lib/pastel/api/queries';
|
||||
import { Loader2, Plus, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function GradientPage() {
|
||||
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
|
||||
const [count, setCount] = useState(10);
|
||||
const [gradient, setGradient] = useState<string[]>([]);
|
||||
|
||||
const generateMutation = useGenerateGradient();
|
||||
|
||||
const handleGenerate = async () => {
|
||||
try {
|
||||
const result = await generateMutation.mutateAsync({
|
||||
stops,
|
||||
count,
|
||||
});
|
||||
setGradient(result.gradient);
|
||||
toast.success(`Generated ${result.gradient.length} colors`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate gradient');
|
||||
}
|
||||
};
|
||||
|
||||
const addStop = () => {
|
||||
setStops([...stops, '#000000']);
|
||||
};
|
||||
|
||||
const removeStop = (index: number) => {
|
||||
if (stops.length > 2) {
|
||||
setStops(stops.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateStop = (index: number, color: string) => {
|
||||
const newStops = [...stops];
|
||||
newStops[index] = color;
|
||||
setStops(newStops);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
title="Gradient Creator"
|
||||
description="Create smooth color gradients with multiple stops"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Controls */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Color Stops</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{stops.map((stop, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<ColorPicker
|
||||
color={stop}
|
||||
onChange={(color) => updateStop(index, color)}
|
||||
/>
|
||||
</div>
|
||||
{stops.length > 2 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeStop(index)}
|
||||
className="mt-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={addStop} variant="outline" className="w-full">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Stop
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="count" className="text-sm font-medium mb-2 block">
|
||||
Number of Colors
|
||||
</label>
|
||||
<Input
|
||||
id="count"
|
||||
type="number"
|
||||
min={2}
|
||||
max={1000}
|
||||
value={count}
|
||||
onChange={(e) => setCount(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={generateMutation.isPending || stops.length < 2}
|
||||
className="w-full"
|
||||
>
|
||||
{generateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating..
|
||||
</>
|
||||
) : (
|
||||
'Generate Gradient'
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="space-y-6">
|
||||
{gradient && gradient.length > 0 && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gradient Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="h-32 rounded-lg"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${gradient.join(', ')})`,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Colors ({gradient.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PaletteGrid colors={gradient} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ExportMenu colors={gradient} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ColorPicker } from '@/components/pastel/ColorPicker';
|
||||
import { PaletteGrid } from '@/components/pastel/PaletteGrid';
|
||||
import { ExportMenu } from '@/components/pastel/ExportMenu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { useGeneratePalette } from '@/lib/pastel/api/queries';
|
||||
import { Loader2, Palette } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type HarmonyType =
|
||||
| 'monochromatic'
|
||||
| 'analogous'
|
||||
| 'complementary'
|
||||
| 'split-complementary'
|
||||
| 'triadic'
|
||||
| 'tetradic';
|
||||
|
||||
export default function HarmonyPage() {
|
||||
const [baseColor, setBaseColor] = useState('#ff0099');
|
||||
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
|
||||
const [palette, setPalette] = useState<string[]>([]);
|
||||
|
||||
const paletteMutation = useGeneratePalette();
|
||||
|
||||
const generateHarmony = async () => {
|
||||
try {
|
||||
const result = await paletteMutation.mutateAsync({
|
||||
base: baseColor,
|
||||
scheme: harmonyType,
|
||||
});
|
||||
|
||||
// Combine primary and secondary colors into flat array
|
||||
const colors = [result.palette.primary, ...result.palette.secondary];
|
||||
setPalette(colors);
|
||||
toast.success(`Generated ${harmonyType} harmony palette`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate harmony palette');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const harmonyDescriptions: Record<HarmonyType, string> = {
|
||||
monochromatic: 'Single color with variations',
|
||||
analogous: 'Colors adjacent on the color wheel (±30°)',
|
||||
complementary: 'Colors opposite on the color wheel (180°)',
|
||||
'split-complementary': 'Base color + two colors flanking its complement',
|
||||
triadic: 'Three colors evenly spaced on the color wheel (120°)',
|
||||
tetradic: 'Four colors evenly spaced on the color wheel (90°)',
|
||||
};
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
title="Harmony Palette Generator"
|
||||
description="Create color harmonies based on color theory principles"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Controls */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Base Color</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ColorPicker color={baseColor} onChange={setBaseColor} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Harmony Type</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Select
|
||||
value={harmonyType}
|
||||
onValueChange={(value) => setHarmonyType(value as HarmonyType)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select harmony" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monochromatic">Monochromatic</SelectItem>
|
||||
<SelectItem value="analogous">Analogous</SelectItem>
|
||||
<SelectItem value="complementary">Complementary</SelectItem>
|
||||
<SelectItem value="split-complementary">Split-Complementary</SelectItem>
|
||||
<SelectItem value="triadic">Triadic</SelectItem>
|
||||
<SelectItem value="tetradic">Tetradic (Square)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{harmonyDescriptions[harmonyType]}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={generateHarmony}
|
||||
disabled={paletteMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{paletteMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating..
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Generate Harmony
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="space-y-6">
|
||||
{palette.length > 0 && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Generated Palette ({palette.length} colors)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PaletteGrid colors={palette} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ExportMenu colors={palette} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{palette.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center text-muted-foreground">
|
||||
<Palette className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Select a harmony type and click Generate to create your palette</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ColorSwatch } from '@/components/pastel/ColorSwatch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { useNamedColors } from '@/lib/pastel/api/queries';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { parse_color } from '@valknarthing/pastel-wasm';
|
||||
|
||||
export default function NamedColorsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'name' | 'hue'>('name');
|
||||
|
||||
const { data, isLoading, isError } = useNamedColors();
|
||||
|
||||
const filteredColors = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
let colors = data.colors.filter(
|
||||
(color) =>
|
||||
color.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
color.hex.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (sortBy === 'name') {
|
||||
colors.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else if (sortBy === 'hue') {
|
||||
colors.sort((a, b) => {
|
||||
const infoA = parse_color(a.hex);
|
||||
const infoB = parse_color(b.hex);
|
||||
return infoA.hsl[0] - infoB.hsl[0];
|
||||
});
|
||||
}
|
||||
|
||||
return colors;
|
||||
}, [data, search, sortBy]);
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
title="Named Colors"
|
||||
description="Explore 148 CSS/X11 named colors"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by name or hex..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-48">
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as 'name' | 'hue')}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Sort by Name</SelectItem>
|
||||
<SelectItem value="hue">Sort by Hue</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors Grid */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="text-center py-12 text-destructive">
|
||||
Failed to load named colors
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredColors.length > 0 && (
|
||||
<>
|
||||
<div className="mb-4 text-sm text-muted-foreground">
|
||||
Showing {filteredColors.length} colors
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
{filteredColors.map((color) => (
|
||||
<div key={color.name} className="flex flex-col items-center gap-2">
|
||||
<ColorSwatch color={color.hex} showLabel={false} />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">{color.name}</div>
|
||||
<div className="text-xs font-mono text-muted-foreground">
|
||||
{color.hex}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredColors.length === 0 && !isLoading && !isError && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No colors match your search
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -3,17 +3,32 @@
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { ColorPicker } from '@/components/pastel/ColorPicker';
|
||||
import { ColorDisplay } from '@/components/pastel/ColorDisplay';
|
||||
import { ColorInfo } from '@/components/pastel/ColorInfo';
|
||||
import { ManipulationPanel } from '@/components/pastel/ManipulationPanel';
|
||||
import { PaletteGrid } from '@/components/pastel/PaletteGrid';
|
||||
import { ExportMenu } from '@/components/pastel/ExportMenu';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { useColorInfo } from '@/lib/pastel/api/queries';
|
||||
import { useColorHistory } from '@/lib/pastel/stores/historyStore';
|
||||
import { Loader2, Share2, History, X } from 'lucide-react';
|
||||
import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/pastel/api/queries';
|
||||
import { Loader2, Share2, Palette, Plus, X, Layers } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type HarmonyType =
|
||||
| 'monochromatic'
|
||||
| 'analogous'
|
||||
| 'complementary'
|
||||
| 'triadic'
|
||||
| 'tetradic';
|
||||
|
||||
function PlaygroundContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -23,16 +38,23 @@ function PlaygroundContent() {
|
||||
return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099';
|
||||
});
|
||||
|
||||
// Harmony state
|
||||
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
|
||||
const [palette, setPalette] = useState<string[]>([]);
|
||||
const paletteMutation = useGeneratePalette();
|
||||
|
||||
// Gradient state
|
||||
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
|
||||
const [gradientCount, setGradientCount] = useState(10);
|
||||
const [gradientResult, setGradientResult] = useState<string[]>([]);
|
||||
const gradientMutation = useGenerateGradient();
|
||||
|
||||
const { data, isLoading, isError, error } = useColorInfo({
|
||||
colors: [color],
|
||||
});
|
||||
|
||||
const colorInfo = data?.colors[0];
|
||||
|
||||
// Color history
|
||||
const { history, addColor, removeColor, clearHistory, getRecent } = useColorHistory();
|
||||
const recentColors = getRecent(10);
|
||||
|
||||
// Update URL when color changes
|
||||
useEffect(() => {
|
||||
const hex = color.replace('#', '');
|
||||
@@ -41,18 +63,12 @@ function PlaygroundContent() {
|
||||
}
|
||||
}, [color, router]);
|
||||
|
||||
// Debounced history update
|
||||
// Sync first gradient stop with active color
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const hex = color.replace('#', '');
|
||||
// Only add valid hex colors to history
|
||||
if (hex.length === 6 || hex.length === 3) {
|
||||
addColor(color);
|
||||
}
|
||||
}, 1000); // Wait 1 second before adding to history
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [color, addColor]);
|
||||
const newStops = [...stops];
|
||||
newStops[0] = color;
|
||||
setStops(newStops);
|
||||
}, [color]);
|
||||
|
||||
// Share color via URL
|
||||
const handleShare = () => {
|
||||
@@ -61,16 +77,59 @@ function PlaygroundContent() {
|
||||
toast.success('Link copied to clipboard!');
|
||||
};
|
||||
|
||||
// Copy color to clipboard
|
||||
const handleCopyColor = () => {
|
||||
navigator.clipboard.writeText(color);
|
||||
toast.success('Color copied to clipboard!');
|
||||
const generateHarmony = async () => {
|
||||
try {
|
||||
const result = await paletteMutation.mutateAsync({
|
||||
base: color,
|
||||
scheme: harmonyType,
|
||||
});
|
||||
|
||||
const colors = [result.palette.primary, ...result.palette.secondary];
|
||||
setPalette(colors);
|
||||
toast.success(`Generated ${harmonyType} harmony palette`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate harmony palette');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Random color generation
|
||||
const handleRandomColor = () => {
|
||||
const randomHex = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
|
||||
setColor(randomHex);
|
||||
const generateGradient = async () => {
|
||||
try {
|
||||
const result = await gradientMutation.mutateAsync({
|
||||
stops,
|
||||
count: gradientCount,
|
||||
});
|
||||
setGradientResult(result.gradient);
|
||||
toast.success(`Generated ${result.gradient.length} colors`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate gradient');
|
||||
}
|
||||
};
|
||||
|
||||
const addStop = () => {
|
||||
setStops([...stops, '#000000']);
|
||||
};
|
||||
|
||||
const removeStop = (index: number) => {
|
||||
if (index === 0) return; // Prevent deleting the first stop (synchronized with picker)
|
||||
if (stops.length > 2) {
|
||||
setStops(stops.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateStop = (index: number, colorValue: string) => {
|
||||
const newStops = [...stops];
|
||||
newStops[index] = colorValue;
|
||||
setStops(newStops);
|
||||
if (index === 0) setColor(colorValue);
|
||||
};
|
||||
|
||||
const harmonyDescriptions: Record<HarmonyType, string> = {
|
||||
monochromatic: 'Single color with variations',
|
||||
analogous: 'Colors adjacent on the color wheel (±30°)',
|
||||
complementary: 'Colors opposite on the color wheel (180°)',
|
||||
triadic: 'Three colors evenly spaced on the color wheel (120°)',
|
||||
tetradic: 'Four colors evenly spaced on the color wheel (90°)',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -78,94 +137,27 @@ function PlaygroundContent() {
|
||||
title="Pastel"
|
||||
description="Interactive color manipulation and analysis tool"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Left Column: Color Picker and Display */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Color Picker</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-8">
|
||||
{/* Row 1: Workspace */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-stretch">
|
||||
{/* Main Workspace: Color Picker and Information */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<CardTitle>Color Picker</CardTitle>
|
||||
<Button onClick={handleShare} variant="outline" size="sm">
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
Share
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-center">
|
||||
<ColorDisplay color={color} size="xl" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{recentColors.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
<CardTitle>Recent Colors</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
onClick={clearHistory}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{recentColors.map((entry) => (
|
||||
<div
|
||||
key={entry.timestamp}
|
||||
className="group relative aspect-square rounded-lg border-2 border-border hover:border-primary transition-all hover:scale-110 cursor-pointer"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
onClick={() => setColor(entry.color)}
|
||||
title={entry.color}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
setColor(entry.color);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/30 rounded-lg">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeColor(entry.color);
|
||||
toast.success('Color removed from history');
|
||||
}}
|
||||
className="p-1 bg-destructive rounded-full hover:bg-destructive/80"
|
||||
aria-label="Remove color"
|
||||
>
|
||||
<X className="h-3 w-3 text-destructive-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="flex flex-col md:flex-row gap-12">
|
||||
<div className="flex-shrink-0 mx-auto md:mx-0">
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
</div>
|
||||
|
||||
{/* Right Column: Color Information */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Color Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium mb-4 text-muted-foreground uppercase tracking-wider">Color Information</h3>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
@@ -180,10 +172,15 @@ function PlaygroundContent() {
|
||||
)}
|
||||
|
||||
{colorInfo && <ColorInfo info={colorInfo} />}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{/* Sidebar: Color Manipulation */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Color Manipulation</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -193,6 +190,193 @@ function PlaygroundContent() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Harmony Generator */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-stretch">
|
||||
{/* Harmony Controls */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
Harmony Type
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Select
|
||||
value={harmonyType}
|
||||
onValueChange={(value) => setHarmonyType(value as HarmonyType)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select harmony" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monochromatic">Monochromatic</SelectItem>
|
||||
<SelectItem value="analogous">Analogous</SelectItem>
|
||||
<SelectItem value="complementary">Complementary</SelectItem>
|
||||
<SelectItem value="triadic">Triadic</SelectItem>
|
||||
<SelectItem value="tetradic">Tetradic (Square)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{harmonyDescriptions[harmonyType]}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={generateHarmony}
|
||||
disabled={paletteMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{paletteMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating..
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Generate Harmony
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Harmony Results */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Generated Palette {palette.length > 0 && `(${palette.length} colors)`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{palette.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
<PaletteGrid colors={palette} onColorClick={setColor} />
|
||||
<div className="pt-4 border-t">
|
||||
<ExportMenu colors={palette} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-center text-muted-foreground">
|
||||
<Palette className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Select a harmony type and click Generate to create your palette based on the current color</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Gradient Generator */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-stretch">
|
||||
{/* Gradient Controls */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Layers className="h-5 w-5" />
|
||||
Gradient Controls
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Color Stops</h4>
|
||||
{stops.map((stop, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="color"
|
||||
value={stop}
|
||||
onChange={(e) => updateStop(index, e.target.value)}
|
||||
className="h-10 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
{index !== 0 && stops.length > 2 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeStop(index)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={addStop} variant="outline" size="sm" className="w-full">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Stop
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Steps</h4>
|
||||
<Input
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
value={gradientCount}
|
||||
onChange={(e) => setGradientCount(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={generateGradient}
|
||||
disabled={gradientMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{gradientMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating..
|
||||
</>
|
||||
) : (
|
||||
'Generate Gradient'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Gradient Results */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Generated Gradient {gradientResult.length > 0 && `(${gradientResult.length} colors)`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{gradientResult.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className="h-24 w-full rounded-lg border shadow-inner"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${gradientResult.join(', ')})`,
|
||||
}}
|
||||
/>
|
||||
<PaletteGrid colors={gradientResult} onColorClick={setColor} />
|
||||
<div className="pt-4 border-t">
|
||||
<ExportMenu colors={gradientResult} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-center text-muted-foreground">
|
||||
<Layers className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Add color stops and click Generate to create your smooth gradient</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ColorPicker } from '@/components/pastel/ColorPicker';
|
||||
import { ColorDisplay } from '@/components/pastel/ColorDisplay';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { useTextColor } from '@/lib/pastel/api/queries';
|
||||
import { Loader2, Palette, Plus, X, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function TextColorPage() {
|
||||
const [backgrounds, setBackgrounds] = useState<string[]>(['#ff0099']);
|
||||
const [results, setResults] = useState<
|
||||
Array<{
|
||||
background: string;
|
||||
textcolor: string;
|
||||
contrast_ratio: number;
|
||||
wcag_aa: boolean;
|
||||
wcag_aaa: boolean;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const textColorMutation = useTextColor();
|
||||
|
||||
const handleOptimize = async () => {
|
||||
try {
|
||||
const result = await textColorMutation.mutateAsync({
|
||||
backgrounds,
|
||||
});
|
||||
setResults(result.colors);
|
||||
toast.success(`Optimized text colors for ${result.colors.length} background(s)`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to optimize text colors');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const addBackground = () => {
|
||||
if (backgrounds.length < 10) {
|
||||
setBackgrounds([...backgrounds, '#000000']);
|
||||
}
|
||||
};
|
||||
|
||||
const removeBackground = (index: number) => {
|
||||
if (backgrounds.length > 1) {
|
||||
setBackgrounds(backgrounds.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateBackground = (index: number, color: string) => {
|
||||
const newBackgrounds = [...backgrounds];
|
||||
newBackgrounds[index] = color;
|
||||
setBackgrounds(newBackgrounds);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
title="Text Color Optimizer"
|
||||
description="Automatically find the best text color (black or white) for any background color"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Input */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Background Colors</CardTitle>
|
||||
<Button
|
||||
onClick={addBackground}
|
||||
disabled={backgrounds.length >= 10}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{backgrounds.map((color, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(newColor) => updateBackground(index, newColor)}
|
||||
/>
|
||||
</div>
|
||||
{backgrounds.length > 1 && (
|
||||
<Button
|
||||
onClick={() => removeBackground(index)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleOptimize}
|
||||
disabled={textColorMutation.isPending || backgrounds.length === 0}
|
||||
className="w-full mt-4"
|
||||
>
|
||||
{textColorMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Optimizing..
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Optimize Text Colors
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-blue-50 dark:bg-blue-950/20 border-blue-100 dark:border-blue-900/30 shadow-none">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold mb-2">How it works</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This tool analyzes each background color and automatically selects either black
|
||||
or white text to ensure maximum readability. The algorithm guarantees WCAG AA
|
||||
compliance (4.5:1 contrast ratio) for normal text
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="space-y-6">
|
||||
{results.length > 0 ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Optimized Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{results.map((result, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="overflow-hidden shadow-none"
|
||||
style={{ backgroundColor: result.background }}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorDisplay color={result.background} size="sm" />
|
||||
<code className="text-sm font-mono text-inherit">
|
||||
{result.background}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded border-2"
|
||||
style={{
|
||||
backgroundColor: result.background,
|
||||
color: result.textcolor,
|
||||
borderColor: result.textcolor,
|
||||
}}
|
||||
>
|
||||
<p className="font-semibold mb-2" style={{ color: result.textcolor }}>
|
||||
Sample Text Preview
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: result.textcolor }}>
|
||||
The quick brown fox jumps over the lazy dog. This is how your text
|
||||
will look on this background color
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Text Color: </span>
|
||||
<code className="font-mono">{result.textcolor}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Contrast: </span>
|
||||
<span className="font-medium">
|
||||
{result.contrast_ratio.toFixed(2)}:1
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.wcag_aa ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className={result.wcag_aa ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
||||
WCAG AA
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.wcag_aaa ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
<span className={result.wcag_aaa ? 'text-green-600 dark:text-green-400' : 'text-yellow-600 dark:text-yellow-400'}>
|
||||
WCAG AAA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center text-muted-foreground">
|
||||
<Palette className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Add background colors and click Optimize to see results</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -48,17 +48,7 @@ const navigation: NavGroup[] = [
|
||||
{
|
||||
title: 'Pastel',
|
||||
href: '/pastel',
|
||||
icon: <PastelIcon className="h-4 w-4" />,
|
||||
items: [
|
||||
{ title: 'Harmony Palettes', href: '/pastel/harmony' },
|
||||
{ title: 'Distinct Colors', href: '/pastel/distinct' },
|
||||
{ title: 'Gradients', href: '/pastel/gradient' },
|
||||
{ title: 'Contrast Checker', href: '/pastel/contrast' },
|
||||
{ title: 'Color Blindness', href: '/pastel/colorblind' },
|
||||
{ title: 'Text Color', href: '/pastel/textcolor' },
|
||||
{ title: 'Named Colors', href: '/pastel/names' },
|
||||
{ title: 'Batch Operations', href: '/pastel/batch' },
|
||||
]
|
||||
icon: <PastelIcon className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: 'Media Converter',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { hexToRgb } from '@/lib/pastel/utils/color';
|
||||
|
||||
interface ColorPickerProps {
|
||||
color: string;
|
||||
@@ -17,9 +18,20 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
// Determine text color based on background brightness
|
||||
const getContrastColor = (hex: string) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return 'inherit';
|
||||
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
|
||||
return brightness > 128 ? '#000000' : '#ffffff';
|
||||
};
|
||||
|
||||
const textColor = getContrastColor(color);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<HexColorPicker color={color} onChange={onChange} className="w-full" />
|
||||
<div className={cn('flex flex-col items-center justify-center space-y-4', className)}>
|
||||
<div className="w-full max-w-[200px] space-y-4">
|
||||
<HexColorPicker color={color} onChange={onChange} className="!w-full" />
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="color-input" className="text-sm font-medium">
|
||||
Color Value
|
||||
@@ -30,9 +42,15 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
|
||||
value={color}
|
||||
onChange={handleInputChange}
|
||||
placeholder="#ff0099 or rgb(255, 0, 153)"
|
||||
className="font-mono"
|
||||
className="font-mono transition-colors duration-200"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
color: textColor,
|
||||
borderColor: textColor === '#000000' ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.2)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useState } from 'react';
|
||||
import { Download, Copy, Check } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Download, Copy, Check, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
exportAsCSS,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
downloadAsFile,
|
||||
type ExportColor,
|
||||
} from '@/lib/pastel/utils/export';
|
||||
import { pastelAPI } from '@/lib/pastel/api/client';
|
||||
|
||||
interface ExportMenuProps {
|
||||
colors: string[];
|
||||
@@ -27,12 +28,44 @@ interface ExportMenuProps {
|
||||
}
|
||||
|
||||
type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript';
|
||||
type ColorSpace = 'hex' | 'rgb' | 'hsl' | 'lab' | 'oklab' | 'lch' | 'oklch';
|
||||
|
||||
export function ExportMenu({ colors, className }: ExportMenuProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>('css');
|
||||
const [colorSpace, setColorSpace] = useState<ColorSpace>('hex');
|
||||
const [convertedColors, setConvertedColors] = useState<string[]>(colors);
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const exportColors: ExportColor[] = colors.map((hex) => ({ hex }));
|
||||
useEffect(() => {
|
||||
async function convertColors() {
|
||||
if (colorSpace === 'hex') {
|
||||
setConvertedColors(colors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConverting(true);
|
||||
try {
|
||||
const response = await pastelAPI.convertFormat({
|
||||
colors,
|
||||
format: colorSpace,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setConvertedColors(response.data.conversions.map(c => c.output));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to convert colors:', error);
|
||||
toast.error('Failed to convert colors to selected space');
|
||||
} finally {
|
||||
setIsConverting(false);
|
||||
}
|
||||
}
|
||||
|
||||
convertColors();
|
||||
}, [colors, colorSpace]);
|
||||
|
||||
const exportColors: ExportColor[] = convertedColors.map((value) => ({ value }));
|
||||
|
||||
const getExportContent = (): string => {
|
||||
switch (format) {
|
||||
@@ -86,8 +119,9 @@ export function ExportMenu({ colors, className }: ExportMenuProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Export Palette</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Export Format</h3>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(value) => setFormat(value as ExportFormat)}
|
||||
@@ -105,14 +139,41 @@ export function ExportMenu({ colors, className }: ExportMenuProps) {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Color Space</h3>
|
||||
<Select
|
||||
value={colorSpace}
|
||||
onValueChange={(value) => setColorSpace(value as ColorSpace)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select space" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hex">Hex</SelectItem>
|
||||
<SelectItem value="rgb">RGB</SelectItem>
|
||||
<SelectItem value="hsl">HSL</SelectItem>
|
||||
<SelectItem value="lab">Lab</SelectItem>
|
||||
<SelectItem value="oklab">OkLab</SelectItem>
|
||||
<SelectItem value="lch">LCH</SelectItem>
|
||||
<SelectItem value="oklch">OkLCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg relative min-h-[100px]">
|
||||
{isConverting ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/50 backdrop-blur-sm rounded-lg z-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
<pre className="text-xs overflow-x-auto">
|
||||
<code>{getExportContent()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCopy} variant="outline" className="flex-1">
|
||||
<div className="flex gap-2 flex-col md:flex-row">
|
||||
<Button onClick={handleCopy} variant="outline" className="w-full md:flex-1" disabled={isConverting}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
@@ -125,7 +186,7 @@ export function ExportMenu({ colors, className }: ExportMenuProps) {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleDownload} variant="default" className="flex-1">
|
||||
<Button onClick={handleDownload} variant="default" className="w-full md:flex-1" disabled={isConverting}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
|
||||
@@ -6,25 +6,10 @@ import type {
|
||||
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,
|
||||
@@ -148,13 +133,6 @@ export class PastelAPIClient {
|
||||
});
|
||||
}
|
||||
|
||||
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', {
|
||||
@@ -163,13 +141,6 @@ export class PastelAPIClient {
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -177,50 +148,6 @@ export class PastelAPIClient {
|
||||
});
|
||||
}
|
||||
|
||||
// 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', {
|
||||
|
||||
@@ -2,28 +2,19 @@
|
||||
|
||||
import { useQuery, useMutation, UseQueryOptions } from '@tanstack/react-query';
|
||||
import { pastelAPI } from './client';
|
||||
import type {
|
||||
import {
|
||||
ColorInfoRequest,
|
||||
ColorInfoData,
|
||||
ConvertFormatRequest,
|
||||
ConvertFormatData,
|
||||
ColorManipulationRequest,
|
||||
ColorManipulationData,
|
||||
ColorMixRequest,
|
||||
ColorMixData,
|
||||
RandomColorsRequest,
|
||||
RandomColorsData,
|
||||
DistinctColorsRequest,
|
||||
DistinctColorsData,
|
||||
GradientRequest,
|
||||
GradientData,
|
||||
ColorBlindnessRequest,
|
||||
PaletteGenerateRequest,
|
||||
PaletteGenerateData,
|
||||
ColorBlindnessData,
|
||||
TextColorRequest,
|
||||
TextColorData,
|
||||
NamedColorsData,
|
||||
HealthData,
|
||||
} from './types';
|
||||
|
||||
@@ -132,18 +123,6 @@ export const useComplement = () => {
|
||||
});
|
||||
};
|
||||
|
||||
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({
|
||||
@@ -157,18 +136,6 @@ export const useGenerateRandom = () => {
|
||||
});
|
||||
};
|
||||
|
||||
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) => {
|
||||
@@ -181,47 +148,6 @@ export const useGenerateGradient = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 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({
|
||||
|
||||
@@ -122,19 +122,6 @@ export interface ColorManipulationData {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ColorMixRequest {
|
||||
colors: string[];
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
export interface ColorMixData {
|
||||
results: Array<{
|
||||
color1: string;
|
||||
color2: string;
|
||||
mixed: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RandomColorsRequest {
|
||||
count: number;
|
||||
strategy?: 'vivid' | 'rgb' | 'gray' | 'lch';
|
||||
@@ -144,16 +131,6 @@ export interface RandomColorsData {
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
export interface DistinctColorsRequest {
|
||||
count: number;
|
||||
metric?: 'cie76' | 'ciede2000';
|
||||
fixed_colors?: string[];
|
||||
}
|
||||
|
||||
export interface DistinctColorsData {
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
export interface GradientRequest {
|
||||
stops: string[];
|
||||
count: number;
|
||||
@@ -165,73 +142,6 @@ export interface GradientData {
|
||||
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;
|
||||
@@ -246,7 +156,7 @@ export interface CapabilitiesData {
|
||||
|
||||
export interface PaletteGenerateRequest {
|
||||
base: string;
|
||||
scheme: 'monochromatic' | 'analogous' | 'complementary' | 'split-complementary' | 'triadic' | 'tetradic';
|
||||
scheme: 'monochromatic' | 'analogous' | 'complementary' | 'triadic' | 'tetradic';
|
||||
}
|
||||
|
||||
export interface PaletteGenerateData {
|
||||
|
||||
@@ -7,18 +7,9 @@ import {
|
||||
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 {
|
||||
@@ -29,25 +20,10 @@ import type {
|
||||
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,
|
||||
@@ -98,7 +74,7 @@ export class PastelWASMClient {
|
||||
async getColorInfo(request: ColorInfoRequest): Promise<ApiResponse<ColorInfoData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.colors.map((colorStr) => {
|
||||
const info = parse_color(colorStr);
|
||||
const info = parse_color(colorStr) as any;
|
||||
return {
|
||||
input: info.input,
|
||||
hex: info.hex,
|
||||
@@ -123,9 +99,9 @@ export class PastelWASMClient {
|
||||
b: info.lab[2],
|
||||
},
|
||||
oklab: {
|
||||
l: info.lab[0] / 100.0,
|
||||
a: info.lab[1] / 100.0,
|
||||
b: info.lab[2] / 100.0,
|
||||
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],
|
||||
@@ -133,9 +109,9 @@ export class PastelWASMClient {
|
||||
h: info.lch[2],
|
||||
},
|
||||
oklch: {
|
||||
l: info.lch[0] / 100.0,
|
||||
c: info.lch[1] / 100.0,
|
||||
h: info.lch[2],
|
||||
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,
|
||||
@@ -156,7 +132,7 @@ export class PastelWASMClient {
|
||||
async convertFormat(request: ConvertFormatRequest): Promise<ApiResponse<ConvertFormatData>> {
|
||||
return this.request(() => {
|
||||
const conversions = request.colors.map((colorStr) => {
|
||||
const parsed = parse_color(colorStr);
|
||||
const parsed = parse_color(colorStr) as any;
|
||||
let output: string;
|
||||
|
||||
switch (request.format) {
|
||||
@@ -178,6 +154,20 @@ export class PastelWASMClient {
|
||||
case 'lch':
|
||||
output = `lch(${parsed.lch[0].toFixed(2)}, ${parsed.lch[1].toFixed(2)}, ${parsed.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)})`;
|
||||
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)})`;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
output = parsed.hex;
|
||||
}
|
||||
@@ -262,20 +252,6 @@ export class PastelWASMClient {
|
||||
});
|
||||
}
|
||||
|
||||
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(() => {
|
||||
@@ -285,17 +261,6 @@ export class PastelWASMClient {
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async generateGradient(request: GradientRequest): Promise<ApiResponse<GradientData>> {
|
||||
return this.request(() => {
|
||||
if (request.stops.length < 2) {
|
||||
@@ -335,93 +300,6 @@ export class PastelWASMClient {
|
||||
});
|
||||
}
|
||||
|
||||
// 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(() => ({
|
||||
@@ -442,12 +320,8 @@ export class PastelWASMClient {
|
||||
'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'],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './api/queries';
|
||||
export * from './stores/historyStore';
|
||||
export * from './utils/color';
|
||||
export * from './utils/export';
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
export interface ColorHistoryEntry {
|
||||
color: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ColorHistoryState {
|
||||
history: ColorHistoryEntry[];
|
||||
addColor: (color: string) => void;
|
||||
removeColor: (color: string) => void;
|
||||
clearHistory: () => void;
|
||||
getRecent: (limit?: number) => ColorHistoryEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Color history store with localStorage persistence
|
||||
*
|
||||
* Tracks up to 50 most recent colors with timestamps
|
||||
* Automatically removes duplicates (keeps most recent)
|
||||
* Persists across browser sessions
|
||||
*/
|
||||
export const useColorHistory = create<ColorHistoryState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
history: [],
|
||||
|
||||
addColor: (color) => {
|
||||
const normalizedColor = color.toLowerCase();
|
||||
set((state) => {
|
||||
// Remove existing entry if present
|
||||
const filtered = state.history.filter(
|
||||
(entry) => entry.color.toLowerCase() !== normalizedColor
|
||||
);
|
||||
|
||||
// Add new entry at the beginning
|
||||
const newHistory = [
|
||||
{ color: normalizedColor, timestamp: Date.now() },
|
||||
...filtered,
|
||||
].slice(0, 50); // Keep only 50 most recent
|
||||
|
||||
return { history: newHistory };
|
||||
});
|
||||
},
|
||||
|
||||
removeColor: (color) => {
|
||||
const normalizedColor = color.toLowerCase();
|
||||
set((state) => ({
|
||||
history: state.history.filter(
|
||||
(entry) => entry.color.toLowerCase() !== normalizedColor
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearHistory: () => set({ history: [] }),
|
||||
|
||||
getRecent: (limit = 10) => {
|
||||
const { history } = get();
|
||||
return history.slice(0, limit);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'pastel-color-history',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,30 +1,3 @@
|
||||
/**
|
||||
* Calculate relative luminance of a color
|
||||
* Based on WCAG 2.1 specification
|
||||
*/
|
||||
export function getRelativeLuminance(r: number, g: number, b: number): number {
|
||||
const [rs, gs, bs] = [r, g, b].map((c) => {
|
||||
const sRGB = c / 255;
|
||||
return sRGB <= 0.03928 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate contrast ratio between two colors
|
||||
* Returns ratio from 1 to 21
|
||||
*/
|
||||
export function getContrastRatio(
|
||||
fg: { r: number; g: number; b: number },
|
||||
bg: { r: number; g: number; b: number }
|
||||
): number {
|
||||
const l1 = getRelativeLuminance(fg.r, fg.g, fg.b);
|
||||
const l2 = getRelativeLuminance(bg.r, bg.g, bg.b);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hex color to RGB
|
||||
*/
|
||||
@@ -38,20 +11,3 @@ export function hexToRgb(hex: string): { r: number; g: number; b: number } | nul
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a contrast ratio meets WCAG standards
|
||||
*/
|
||||
export function checkWCAGCompliance(ratio: number) {
|
||||
return {
|
||||
aa: {
|
||||
normalText: ratio >= 4.5,
|
||||
largeText: ratio >= 3,
|
||||
ui: ratio >= 3,
|
||||
},
|
||||
aaa: {
|
||||
normalText: ratio >= 7,
|
||||
largeText: ratio >= 4.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export interface ExportColor {
|
||||
name?: string;
|
||||
hex: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -14,7 +14,7 @@ export function exportAsCSS(colors: ExportColor[]): string {
|
||||
const variables = colors
|
||||
.map((color, index) => {
|
||||
const name = color.name || `color-${index + 1}`;
|
||||
return ` --${name}: ${color.hex};`;
|
||||
return ` --${name}: ${color.value};`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
@@ -28,7 +28,7 @@ export function exportAsSCSS(colors: ExportColor[]): string {
|
||||
return colors
|
||||
.map((color, index) => {
|
||||
const name = color.name || `color-${index + 1}`;
|
||||
return `$${name}: ${color.hex};`;
|
||||
return `$${name}: ${color.value};`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function exportAsTailwind(colors: ExportColor[]): string {
|
||||
const colorEntries = colors
|
||||
.map((color, index) => {
|
||||
const name = color.name || `color-${index + 1}`;
|
||||
return ` '${name}': '${color.hex}',`;
|
||||
return ` '${name}': '${color.value}',`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
@@ -53,7 +53,7 @@ export function exportAsTailwind(colors: ExportColor[]): string {
|
||||
export function exportAsJSON(colors: ExportColor[]): string {
|
||||
const colorObjects = colors.map((color, index) => ({
|
||||
name: color.name || `color-${index + 1}`,
|
||||
hex: color.hex,
|
||||
value: color.value,
|
||||
}));
|
||||
|
||||
return JSON.stringify({ colors: colorObjects }, null, 2);
|
||||
@@ -63,7 +63,7 @@ export function exportAsJSON(colors: ExportColor[]): string {
|
||||
* Export colors as JavaScript array
|
||||
*/
|
||||
export function exportAsJavaScript(colors: ExportColor[]): string {
|
||||
const colorArray = colors.map((c) => `'${c.hex}'`).join(', ');
|
||||
const colorArray = colors.map((c) => `'${c.value}'`).join(', ');
|
||||
return `const colors = [${colorArray}];`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user