// lib/color.ts
interface RGB { r: number; g: number;
b: number }
interface HSV { h: number; s: number;
v: number }
function hsvToRgb(hsv: HSV): RGB {
const { h, s, v } = hsv;
const c = v * s;
const x = c * (1 - Math.abs(
(h / 60) % 2 - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60)
{ r = c; g = x; b = 0; }
else if (h < 120)
{ r = x; g = c; b = 0; }
else if (h < 180)
{ r = 0; g = c; b = x; }
else if (h < 240)
{ r = 0; g = x; b = c; }
else if (h < 300)
{ r = x; g = 0; b = c; }
else
{ r = c; g = 0; b = x; }
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
};
}
function rgbToHex(rgb: RGB): string {
const toHex = (n: number) =>
n.toString(16).padStart(2, '0');
return '#' + toHex(rgb.r)
+ toHex(rgb.g) + toHex(rgb.b);
}
function drawSVSquare(
ctx: CanvasRenderingContext2D,
hue: number,
width: number,
height: number,
) {
// Layer 1: white → hue (horizontal)
const hueRgb =
hsvToRgb({ h: hue, s: 1, v: 1 });
const hueColor =
`rgb(${hueRgb.r},${hueRgb.g},`
+ `${hueRgb.b})`;
const gradH =
ctx.createLinearGradient(
0, 0, width, 0);
gradH.addColorStop(0, '#fff');
gradH.addColorStop(1, hueColor);
ctx.fillStyle = gradH;
ctx.fillRect(0, 0, width, height);
// Layer 2: transparent → black
// (vertical)
const gradV =
ctx.createLinearGradient(
0, 0, 0, height);
gradV.addColorStop(0,
'rgba(0,0,0,0)');
gradV.addColorStop(1,
'rgba(0,0,0,1)');
ctx.fillStyle = gradV;
ctx.fillRect(0, 0, width, height);
// Two gradients overlaid =
// the full SV space for one hue.
// Click position maps to (S, V).
}
function drawHueStrip(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
) {
const grad =
ctx.createLinearGradient(
0, 0, 0, height);
// All 360° of hue
const stops = [
[0, '#ff0000'], // 0° red
[0.17, '#ffff00'], // 60° yellow
[0.33, '#00ff00'], // 120° green
[0.5, '#00ffff'], // 180° cyan
[0.67, '#0000ff'], // 240° blue
[0.83, '#ff00ff'], // 300° magenta
[1, '#ff0000'], // 360° red
] as const;
stops.forEach(([pos, color]) =>
grad.addColorStop(pos, color));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, width, height);
// Click Y position → hue (0-360)
}
function useEyedropper(
canvasRef: RefObject<HTMLCanvasElement>
) {
const pickColor = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect =
canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Get single pixel
const pixel =
ctx.getImageData(x, y, 1, 1).data;
// pixel = [R, G, B, A]
const rgb: RGB = {
r: pixel[0],
g: pixel[1],
b: pixel[2],
};
return {
rgb,
hex: rgbToHex(rgb),
hsv: rgbToHsv(rgb),
};
}, [canvasRef]);
return { pickColor };
}
// Also: EyeDropper API (Chrome 95+)
// Built-in OS-level color picker
// const dropper = new EyeDropper();
// const { sRGBHex } =
// await dropper.open();
// WCAG contrast ratio calculation
function luminance(rgb: RGB): number {
const [rs, gs, bs] =
[rgb.r, rgb.g, rgb.b].map(c => {
c = c / 255;
return c <= 0.03928
? c / 12.92
: Math.pow(
(c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs
+ 0.7152 * gs
+ 0.0722 * bs;
}
function contrastRatio(
c1: RGB, c2: RGB
): number {
const l1 = luminance(c1);
const l2 = luminance(c2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05)
/ (darker + 0.05);
}
// Show in picker UI:
// "vs White: 4.7:1 ✅ AA"
// "vs Black: 3.2:1 ⚠️ Fails AA"
git switch -c feature/PIXELCRAFT-096-color-picker
git add src/components/ColorPicker/ src/lib/color.ts
git commit -m "Add full color picker with eyedropper (PIXELCRAFT-096)"
git push origin feature/PIXELCRAFT-096-color-picker
# PR → Review → Merge → Close ticket ✅
Color spaces are different coordinate systems for the same reality.
Just as you can describe a location with (latitude, longitude) or (street, city), you can describe a color with (R, G, B) or (H, S, V) or (H, S, L). Each has strengths.