feat: implement Figlet, Pastel, and Unit tools with a unified layout

- Add Figlet text converter with font selection and history
- Add Pastel color palette generator and manipulation suite
- Add comprehensive Units converter with category-based logic
- Introduce AppShell with Sidebar and Header for navigation
- Modernize theme system with CSS variables and new animations
- Update project configuration and dependencies
This commit is contained in:
2026-02-22 21:35:53 +01:00
parent ff6bb873eb
commit 2000623c67
540 changed files with 338653 additions and 809 deletions

View File

@@ -0,0 +1,214 @@
'use client';
import { useState } from 'react';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { ColorDisplay } from '@/components/pastel/color/ColorDisplay';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
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 (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Color Blindness Simulator</h1>
<p className="text-muted-foreground">
Simulate how colors appear with different types of color blindness
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Controls */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Colors to Test</h2>
<Button
onClick={addColor}
variant="outline"
size="sm"
disabled={colors.length >= 10}
>
<Plus className="h-4 w-4 mr-2" />
Add Color
</Button>
</div>
<div 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>
))}
</div>
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Blindness Type</h2>
<div className="space-y-4">
<Select
label="Type"
value={blindnessType}
onChange={(e) => setBlindnessType(e.target.value as ColorBlindnessType)}
>
<option value="protanopia">Protanopia (Red-blind)</option>
<option value="deuteranopia">Deuteranopia (Green-blind)</option>
<option value="tritanopia">Tritanopia (Blue-blind)</option>
</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>
</div>
</div>
</div>
{/* Results */}
<div className="space-y-6">
{simulations.length > 0 ? (
<>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Simulation Results</h2>
<p className="text-sm text-muted-foreground mb-6">
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>
</div>
<div className="p-6 border rounded-lg bg-card bg-blue-50 dark:bg-blue-950/20">
<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>
</div>
</>
) : (
<div className="p-12 border rounded-lg bg-card 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>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useState, useEffect } from 'react';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
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 ? 'success' : 'destructive'}>
{passed ? (
<>
<Check className="h-3 w-3 mr-1" />
Pass
</>
) : (
<>
<X className="h-3 w-3 mr-1" />
Fail
</>
)}
</Badge>
</div>
);
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Contrast Checker</h1>
<p className="text-muted-foreground">
Test color combinations for WCAG 2.1 compliance
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Color Pickers */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Foreground Color</h2>
</div>
<ColorPicker color={foreground} onChange={setForeground} />
</div>
<div className="flex justify-center">
<Button
onClick={swapColors}
variant="outline"
size="icon"
className="rounded-full"
>
<ArrowLeftRight className="h-4 w-4" />
</Button>
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Background Color</h2>
<ColorPicker color={background} onChange={setBackground} />
</div>
</div>
{/* Results */}
<div className="space-y-6">
{/* Preview */}
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Preview</h2>
<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>
</div>
{/* Contrast Ratio */}
{ratio !== null && (
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Contrast Ratio</h2>
<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>
</div>
)}
{/* WCAG Compliance */}
{compliance && (
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">WCAG 2.1 Compliance</h2>
<div 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>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import Link from 'next/link';
import { Contrast, Eye, Palette } from 'lucide-react';
export default function AccessibilityPage() {
const tools = [
{
title: 'Contrast Checker',
description: 'Test color combinations for WCAG 2.1 AA and AAA compliance',
href: '/pastel/accessibility/contrast',
icon: Contrast,
features: ['WCAG 2.1 standards', 'AA/AAA ratings', 'Live preview'],
},
{
title: 'Color Blindness Simulator',
description: 'Simulate how colors appear with different types of color blindness',
href: '/pastel/accessibility/colorblind',
icon: Eye,
features: ['Protanopia', 'Deuteranopia', 'Tritanopia'],
},
{
title: 'Text Color Optimizer',
description: 'Find the best text color for any background automatically',
href: '/pastel/accessibility/textcolor',
icon: Palette,
features: ['Automatic optimization', 'WCAG guaranteed', 'Light/dark options'],
},
];
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Accessibility Tools</h1>
<p className="text-muted-foreground">
Ensure your colors are accessible to everyone
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tools.map((tool) => {
const Icon = tool.icon;
return (
<Link
key={tool.href}
href={tool.href}
className="p-6 border rounded-lg bg-card hover:border-primary transition-colors"
>
<div className="flex items-start justify-between mb-4">
<Icon className="h-8 w-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-2">{tool.title}</h2>
<p className="text-sm text-muted-foreground mb-4">{tool.description}</p>
<ul className="space-y-1">
{tool.features.map((feature) => (
<li key={feature} className="text-sm text-muted-foreground flex items-center">
<span className="mr-2"></span>
{feature}
</li>
))}
</ul>
</Link>
);
})}
</div>
{/* Educational Content */}
<div className="p-6 border rounded-lg bg-card mt-8">
<h2 className="text-xl font-semibold mb-4">About WCAG 2.1</h2>
<div className="space-y-4 text-sm text-muted-foreground">
<p>
The Web Content Accessibility Guidelines (WCAG) 2.1 provide standards for making web
content more accessible to people with disabilities
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="font-semibold text-foreground mb-2">Level AA (Minimum)</h3>
<ul className="space-y-1">
<li> Normal text: 4.5:1 contrast ratio</li>
<li> Large text: 3:1 contrast ratio</li>
<li> UI components: 3:1 contrast ratio</li>
</ul>
</div>
<div>
<h3 className="font-semibold text-foreground mb-2">Level AAA (Enhanced)</h3>
<ul className="space-y-1">
<li> Normal text: 7:1 contrast ratio</li>
<li> Large text: 4.5:1 contrast ratio</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,221 @@
'use client';
import { useState } from 'react';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { ColorDisplay } from '@/components/pastel/color/ColorDisplay';
import { Button } from '@/components/ui/Button';
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 (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Text Color Optimizer</h1>
<p className="text-muted-foreground">
Automatically find the best text color (black or white) for any background color
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Input */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Background Colors</h2>
<Button
onClick={addBackground}
disabled={backgrounds.length >= 10}
variant="outline"
size="sm"
>
<Plus className="h-4 w-4 mr-2" />
Add
</Button>
</div>
<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>
</div>
<div className="p-6 border rounded-lg bg-card bg-blue-50 dark:bg-blue-950/20">
<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>
</div>
</div>
{/* Results */}
<div className="space-y-6">
{results.length > 0 ? (
<>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Optimized Results</h2>
<div className="space-y-4">
{results.map((result, index) => (
<div
key={index}
className="p-4 border rounded-lg"
style={{ backgroundColor: result.background }}
>
<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>
</div>
))}
</div>
</div>
</>
) : (
<div className="p-12 border rounded-lg bg-card 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>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { Input } from '@/components/ui/Input';
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
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 (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Batch Operations</h1>
<p className="text-muted-foreground">
Process multiple colors at once with manipulation operations
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Input */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Input Colors</h2>
<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&#10;#ff5533&#10;#3355ff"
className="w-full h-48 p-3 border rounded-lg bg-background font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-2">
{parseColors(inputColors).length} valid colors found
</p>
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Operation</h2>
<div className="space-y-4">
<Select
label="Operation"
value={operation}
onChange={(e) => setOperation(e.target.value as Operation)}
>
<option value="lighten">Lighten</option>
<option value="darken">Darken</option>
<option value="saturate">Saturate</option>
<option value="desaturate">Desaturate</option>
<option value="rotate">Rotate Hue</option>
</Select>
<div>
<label className="text-sm font-medium mb-2 block">
Amount: {operation === 'rotate' ? (amount * 360).toFixed(0) + '°' : (amount * 100).toFixed(0) + '%'}
</label>
<Input
type="range"
min="0"
max="1"
step="0.01"
value={amount}
onChange={(e) => setAmount(parseFloat(e.target.value))}
/>
</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>
</div>
</div>
</div>
{/* Output */}
<div className="space-y-6">
{outputColors.length > 0 ? (
<>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">
Output Colors ({outputColors.length})
</h2>
<PaletteGrid colors={outputColors} />
</div>
<div className="p-6 border rounded-lg bg-card">
<ExportMenu colors={outputColors} />
</div>
</>
) : (
<div className="p-12 border rounded-lg bg-card 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>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "../components/color/*.{js,ts,jsx,tsx}";
@source "../components/layout/*.{js,ts,jsx,tsx}";
@source "../components/providers/*.{js,ts,jsx,tsx}";
@source "../components/tools/*.{js,ts,jsx,tsx}";
@source "../components/ui/*.{js,ts,jsx,tsx}";
@source "./playground/*.{js,ts,jsx,tsx}";
@source "./palettes/*.{js,ts,jsx,tsx}";
@source "./palettes/distinct/*.{js,ts,jsx,tsx}";
@source "./palettes/gradient/*.{js,ts,jsx,tsx}";
@source "./palettes/harmony/*.{js,ts,jsx,tsx}";
@source "./names/*.{js,ts,jsx,tsx}";
@source "./batch/*.{js,ts,jsx,tsx}";
@source "./accessibility/*.{js,ts,jsx,tsx}";
@source "./accessibility/colorblind/*.{js,ts,jsx,tsx}";
@source "./accessibility/contrast/*.{js,ts,jsx,tsx}";
@source "*.{js,ts,jsx,tsx}";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.5rem;
/* Light Mode Colors - Using OKLCH for better color precision */
--background: oklch(100% 0 0);
--foreground: oklch(9.8% 0.038 285.8);
--card: oklch(100% 0 0);
--card-foreground: oklch(9.8% 0.038 285.8);
--popover: oklch(100% 0 0);
--popover-foreground: oklch(9.8% 0.038 285.8);
--primary: oklch(22.4% 0.053 285.8);
--primary-foreground: oklch(98% 0.016 240);
--secondary: oklch(96.1% 0.016 240);
--secondary-foreground: oklch(22.4% 0.053 285.8);
--muted: oklch(96.1% 0.016 240);
--muted-foreground: oklch(46.9% 0.025 244.1);
--accent: oklch(96.1% 0.016 240);
--accent-foreground: oklch(22.4% 0.053 285.8);
--destructive: oklch(60.2% 0.168 29.2);
--destructive-foreground: oklch(98% 0.016 240);
--border: oklch(91.4% 0.026 243.1);
--input: oklch(91.4% 0.026 243.1);
--ring: oklch(9.8% 0.038 285.8);
}
@theme inline {
/* Tailwind v4 theme color definitions */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Custom Animations */
--animate-fade-in: fadeIn 0.3s ease-in-out;
--animate-slide-up: slideUp 0.4s ease-out;
--animate-slide-down: slideDown 0.4s ease-out;
--animate-slide-in-right: slideInRight 0.3s ease-out;
--animate-slide-in-left: slideInLeft 0.3s ease-out;
--animate-scale-in: scaleIn 0.2s ease-out;
--animate-bounce-gentle: bounceGentle 0.5s ease-in-out;
--animate-shimmer: shimmer 2s infinite;
}
.dark {
--background: oklch(9.8% 0.038 285.8);
--foreground: oklch(98% 0.016 240);
--card: oklch(9.8% 0.038 285.8);
--card-foreground: oklch(98% 0.016 240);
--popover: oklch(9.8% 0.038 285.8);
--popover-foreground: oklch(98% 0.016 240);
--primary: oklch(98% 0.016 240);
--primary-foreground: oklch(22.4% 0.053 285.8);
--secondary: oklch(17.5% 0.036 242.3);
--secondary-foreground: oklch(98% 0.016 240);
--muted: oklch(17.5% 0.036 242.3);
--muted-foreground: oklch(65.1% 0.031 244);
--accent: oklch(17.5% 0.036 242.3);
--accent-foreground: oklch(98% 0.016 240);
--destructive: oklch(30.6% 0.125 29.2);
--destructive-foreground: oklch(98% 0.016 240);
--border: oklch(17.5% 0.036 242.3);
--input: oklch(17.5% 0.036 242.3);
--ring: oklch(83.9% 0.031 243.7);
}
@layer base {
* {
@apply border-border outline-ring/50;
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
body {
@apply bg-background text-foreground;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
html {
scroll-behavior: smooth;
}
/* Disable transitions during theme switch to prevent flash */
.theme-transitioning * {
transition: none !important;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
@apply bg-background;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/20 rounded-lg hover:bg-muted-foreground/30;
}
/* Screen reader only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
}
/* Animation Keyframes */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideInRight {
from { transform: translateX(-20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideInLeft {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes bounceGentle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@keyframes shimmer {
from { background-position: -1000px 0; }
to { background-position: 1000px 0; }
}

View File

@@ -0,0 +1,11 @@
export default function PastelLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="p-4 sm:p-8 max-w-7xl mx-auto">
{children}
</div>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import { useState, useMemo } from 'react';
import { ColorSwatch } from '@/components/pastel/color/ColorSwatch';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { useNamedColors } from '@/lib/pastel/api/queries';
import { Loader2 } from 'lucide-react';
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));
}
// For hue sorting, we'd need to convert to HSL which requires the API
// For now, just keep alphabetical as default
return colors;
}, [data, search, sortBy]);
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Named Colors</h1>
<p className="text-muted-foreground">
Explore 148 CSS/X11 named colors
</p>
</div>
{/* 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} onChange={(e) => setSortBy(e.target.value as 'name' | 'hue')}>
<option value="name">Sort by Name</option>
<option value="hue">Sort by Hue</option>
</Select>
</div>
</div>
{/* Colors Grid */}
<div className="p-6 border rounded-lg bg-card">
{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>
)}
</div>
</div>
</div>
);
}

218
app/(app)/pastel/page.tsx Normal file
View File

@@ -0,0 +1,218 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { ColorDisplay } from '@/components/pastel/color/ColorDisplay';
import { ColorInfo } from '@/components/pastel/color/ColorInfo';
import { ManipulationPanel } from '@/components/pastel/tools/ManipulationPanel';
import { useColorInfo } from '@/lib/pastel/api/queries';
import { useKeyboard } from '@/lib/pastel/hooks/useKeyboard';
import { useColorHistory } from '@/lib/pastel/stores/historyStore';
import { Loader2, Share2, History, X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { toast } from 'sonner';
function PlaygroundContent() {
const searchParams = useSearchParams();
const router = useRouter();
const [color, setColor] = useState(() => {
// Initialize from URL if available
const urlColor = searchParams.get('color');
return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099';
});
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 and history when color changes
useEffect(() => {
const hex = color.replace('#', '');
router.push(`/pastel?color=${hex}`, { scroll: false });
addColor(color);
}, [color, router, addColor]);
// Share color via URL
const handleShare = () => {
const url = `${window.location.origin}/pastel?color=${color.replace('#', '')}`;
navigator.clipboard.writeText(url);
toast.success('Link copied to clipboard!');
};
// Copy color to clipboard
const handleCopyColor = () => {
navigator.clipboard.writeText(color);
toast.success('Color copied to clipboard!');
};
// Random color generation
const handleRandomColor = () => {
const randomHex = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
setColor(randomHex);
};
// Keyboard shortcuts
useKeyboard([
{
key: 'c',
meta: true,
handler: handleCopyColor,
description: 'Copy color',
},
{
key: 's',
meta: true,
handler: handleShare,
description: 'Share color',
},
{
key: 'r',
meta: true,
handler: handleRandomColor,
description: 'Random color',
},
]);
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Color Playground</h1>
<p className="text-muted-foreground">
Interactive color manipulation and analysis tool
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<kbd className="px-2 py-1 bg-muted rounded border">C</kbd>
<span>Copy</span>
<kbd className="px-2 py-1 bg-muted rounded border ml-3">S</kbd>
<span>Share</span>
<kbd className="px-2 py-1 bg-muted rounded border ml-3">R</kbd>
<span>Random</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column: Color Picker and Display */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Color Picker</h2>
<ColorPicker color={color} onChange={setColor} />
</div>
<div className="p-6 border rounded-lg bg-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Preview</h2>
<Button onClick={handleShare} variant="outline" size="sm">
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
</div>
<div className="flex justify-center">
<ColorDisplay color={color} size="xl" />
</div>
</div>
{recentColors.length > 0 && (
<div className="p-6 border rounded-lg bg-card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<History className="h-5 w-5" />
<h2 className="text-xl font-semibold">Recent Colors</h2>
</div>
<Button
onClick={clearHistory}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
>
Clear
</Button>
</div>
<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>
</div>
)}
</div>
{/* Right Column: Color Information */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Color Information</h2>
{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="p-4 bg-destructive/10 text-destructive rounded-lg">
<p className="font-medium">Error loading color information</p>
<p className="text-sm mt-1">{error?.message || 'Unknown error'}</p>
</div>
)}
{colorInfo && <ColorInfo info={colorInfo} />}
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Color Manipulation</h2>
<ManipulationPanel color={color} onColorChange={setColor} />
</div>
</div>
</div>
</div>
</div>
);
}
export default function PlaygroundPage() {
return (
<Suspense fallback={
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</div>
}>
<PlaygroundContent />
</Suspense>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
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 [stats, setStats] = useState<{
min_distance: number;
avg_distance: number;
generation_time_ms: number;
} | null>(null);
const generateMutation = useGenerateDistinct();
const handleGenerate = async () => {
try {
const result = await generateMutation.mutateAsync({
count,
metric,
});
setColors(result.colors);
setStats(result.stats);
toast.success(`Generated ${result.colors.length} distinct colors`);
} catch (error) {
toast.error('Failed to generate distinct colors');
}
};
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Distinct Colors Generator</h1>
<p className="text-muted-foreground">
Generate visually distinct colors using simulated annealing
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Controls */}
<div className="lg:col-span-1">
<div className="p-6 border rounded-lg bg-card space-y-6">
<div>
<h2 className="text-xl font-semibold mb-4">Settings</h2>
</div>
<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>
<Select
label="Distance Metric"
value={metric}
onChange={(e) => setMetric(e.target.value as 'cie76' | 'ciede2000')}
>
<option value="cie76">CIE76 (Faster)</option>
<option value="ciede2000">CIEDE2000 (More Accurate)</option>
</Select>
<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>
)}
{stats && (
<div className="pt-4 border-t space-y-2">
<h3 className="font-semibold text-sm">Statistics</h3>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Min Distance:</span>
<span className="font-mono">{stats.min_distance.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Avg Distance:</span>
<span className="font-mono">{stats.avg_distance.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Generation Time:</span>
<span className="font-mono">
{(stats.generation_time_ms / 1000).toFixed(2)}s
</span>
</div>
</div>
</div>
)}
</div>
</div>
{/* Results */}
<div className="lg:col-span-2 space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">
Generated Colors {colors.length > 0 && `(${colors.length})`}
</h2>
<PaletteGrid colors={colors} />
</div>
{colors.length > 0 && (
<div className="p-6 border rounded-lg bg-card">
<ExportMenu colors={colors} />
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useState } from 'react';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
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 [colorspace, setColorspace] = useState<
'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch'
>('lch');
const [gradient, setGradient] = useState<string[]>([]);
const generateMutation = useGenerateGradient();
const handleGenerate = async () => {
try {
const result = await generateMutation.mutateAsync({
stops,
count,
colorspace,
});
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 (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Gradient Creator</h1>
<p className="text-muted-foreground">
Create smooth color gradients with multiple stops
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Controls */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Color Stops</h2>
<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>
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Settings</h2>
<div 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>
<Select
label="Color Space"
value={colorspace}
onChange={(e) =>
setColorspace(
e.target.value as
| 'rgb'
| 'hsl'
| 'hsv'
| 'lab'
| 'oklab'
| 'lch'
| 'oklch'
)
}
>
<option value="rgb">RGB</option>
<option value="hsl">HSL</option>
<option value="hsv">HSV</option>
<option value="lab">Lab</option>
<option value="oklab">OkLab</option>
<option value="lch">LCH</option>
<option value="oklch">OkLCH (Recommended)</option>
</Select>
<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>
</div>
</div>
</div>
{/* Preview */}
<div className="space-y-6">
{gradient && gradient.length > 0 && (
<>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Gradient Preview</h2>
<div
className="h-32 rounded-lg"
style={{
background: `linear-gradient(to right, ${gradient.join(', ')})`,
}}
/>
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">
Colors ({gradient.length})
</h2>
<PaletteGrid colors={gradient} />
</div>
<div className="p-6 border rounded-lg bg-card">
<ExportMenu colors={gradient} />
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import { useState } from 'react';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
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 (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Harmony Palette Generator</h1>
<p className="text-muted-foreground">
Create color harmonies based on color theory principles
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Controls */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Base Color</h2>
<ColorPicker color={baseColor} onChange={setBaseColor} />
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Harmony Type</h2>
<div className="space-y-4">
<Select
label="Harmony"
value={harmonyType}
onChange={(e) => setHarmonyType(e.target.value as HarmonyType)}
>
<option value="monochromatic">Monochromatic</option>
<option value="analogous">Analogous</option>
<option value="complementary">Complementary</option>
<option value="split-complementary">Split-Complementary</option>
<option value="triadic">Triadic</option>
<option value="tetradic">Tetradic (Square)</option>
</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>
</div>
</div>
</div>
{/* Results */}
<div className="space-y-6">
{palette.length > 0 && (
<>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">
Generated Palette ({palette.length} colors)
</h2>
<PaletteGrid colors={palette} />
</div>
<div className="p-6 border rounded-lg bg-card">
<ExportMenu colors={palette} />
</div>
</>
)}
{palette.length === 0 && (
<div className="p-12 border rounded-lg bg-card 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>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import Link from 'next/link';
import { Palette, Sparkles, GraduationCap } from 'lucide-react';
export default function PalettesPage() {
const paletteTypes = [
{
title: 'Gradient Creator',
description: 'Create smooth color gradients with multiple stops and color spaces',
href: '/pastel/palettes/gradient',
icon: GraduationCap,
features: ['Multiple color stops', 'Various color spaces', 'Live preview'],
},
{
title: 'Distinct Colors',
description: 'Generate visually distinct colors using simulated annealing algorithm',
href: '/pastel/palettes/distinct',
icon: Sparkles,
features: ['Perceptual distance', 'Configurable count', 'Quality metrics'],
},
{
title: 'Harmony Palettes',
description: 'Create color palettes based on color theory and harmony rules',
href: '/pastel/palettes/harmony',
icon: Palette,
features: ['Color theory', 'Multiple schemes', 'Instant generation'],
},
];
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Palette Generation</h1>
<p className="text-muted-foreground">
Create beautiful color palettes using various generation methods
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{paletteTypes.map((type) => {
const Icon = type.icon;
return (
<Link
key={type.href}
href={type.href}
className="p-6 border rounded-lg bg-card hover:border-primary transition-colors"
>
<div className="flex items-start justify-between mb-4">
<Icon className="h-8 w-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-2">{type.title}</h2>
<p className="text-sm text-muted-foreground mb-4">{type.description}</p>
<ul className="space-y-1">
{type.features.map((feature) => (
<li key={feature} className="text-sm text-muted-foreground flex items-center">
<span className="mr-2"></span>
{feature}
</li>
))}
</ul>
</Link>
);
})}
</div>
</div>
</div>
);
}