diff --git a/apps/www/src/content/docs/components/color-picker/index.mdx b/apps/www/src/content/docs/components/color-picker/index.mdx index 793b63dd3..0c887702b 100644 --- a/apps/www/src/content/docs/components/color-picker/index.mdx +++ b/apps/www/src/content/docs/components/color-picker/index.mdx @@ -84,6 +84,40 @@ The `ColorPicker` can be embedded within a `Popover` component to create a more +## Utilities + +### formatColor + +Converts any CSS color string to the requested format. Useful when consuming apsara color tokens (which are authored in OKLCH) from APIs that need a specific format — chart libraries, SVG attributes, canvas fills, design exports, and similar. + +```tsx +import { formatColor } from '@raystack/apsara' + +formatColor('oklch(0.5438 0.191 267.01)', 'hex') // '#3E63DD' +formatColor('oklch(0.7 0.32 30)', 'hex') // '#FF5843' (gamut-mapped) +formatColor('red', 'rgb') // 'rgb(255, 0, 0)' +formatColor('rgba(255, 0, 0, 0.5)', 'hsl') // 'hsla(0, 100%, 50%, 0.5)' +formatColor('#FF0000', 'oklch') // 'oklch(0.6279 0.2577 29.23)' +formatColor('not a color', 'hex') // null +``` + +**Behavior** + +- Accepts any CSS color string: `oklch()`, `rgb()`/`rgba()`, `hsl()`/`hsla()`, hex, named colors, `transparent`. +- For `hex`, `rgb`, and `hsl` outputs, wide-gamut OKLCH inputs are gamut-mapped into sRGB by **reducing chroma while preserving lightness and hue**, so the returned string is the closest sRGB representation rather than a per-channel-clipped one (which would distort hue). +- For `oklch` output, the full color is preserved — OKLCH can express the wide gamut natively. Output matches the design system's token format (4-decimal L/C, 2-decimal H, hue pinned to 0 when achromatic). +- Hex is uppercase; uses 8-digit form (`#rrggbbaa`) when alpha < 1. Translucent rgb/hsl/oklch produce `rgba()` / `hsla()` / `oklch(... / A)`. +- Returns `null` for unparseable input rather than throwing — safe to call on user-supplied strings. + +**Signature** + +```ts +function formatColor( + value: string, + format: 'hex' | 'rgb' | 'hsl' | 'oklch' +): string | null +``` + ## Accessibility - Provides `aria-label` attributes for color areas and sliders diff --git a/packages/raystack/components/color-picker/__tests__/utils.test.ts b/packages/raystack/components/color-picker/__tests__/utils.test.ts index 26c9c52c6..165c1fc48 100644 --- a/packages/raystack/components/color-picker/__tests__/utils.test.ts +++ b/packages/raystack/components/color-picker/__tests__/utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CHROMA_MAX, clampToSrgb, + formatColor, getColorString, hslToOklch, oklchToHsl, @@ -82,4 +83,70 @@ describe('color-picker utils', () => { expect(closeTo(clamped.h, wide.h, 0.01)).toBe(true); }); }); + + describe('formatColor', () => { + describe('hex', () => { + it('returns uppercase 6-digit hex for opaque inputs', () => { + expect(formatColor('#ff0000', 'hex')).toBe('#FF0000'); + expect(formatColor('rgb(0, 255, 0)', 'hex')).toBe('#00FF00'); + expect(formatColor('red', 'hex')).toBe('#FF0000'); + }); + + it('returns uppercase 8-digit hex when alpha < 1', () => { + expect(formatColor('rgba(255, 0, 0, 0.5)', 'hex')).toBe('#FF000080'); + expect(formatColor('oklch(0 0 0 / 0.5)', 'hex')).toBe('#00000080'); + }); + + it('round-trips an in-gamut oklch to the same sRGB hex', () => { + expect(formatColor('oklch(0.6279 0.2576 29.23)', 'hex')).toBe( + '#FF0000' + ); + }); + + it('gamut-maps wide-gamut OKLCH instead of per-channel clipping', () => { + // Per-channel clip would push this to #FF0000 (R channel saturates, + // hue lost). toGamut preserves hue by reducing chroma. + const hex = formatColor('oklch(0.7 0.32 30)', 'hex'); + expect(hex).toMatch(/^#[0-9A-F]{6}$/); + expect(hex).not.toBe('#FF0000'); + }); + }); + + describe('rgb', () => { + it('returns rgb() for opaque and rgba() for translucent', () => { + expect(formatColor('#ff0000', 'rgb')).toBe('rgb(255, 0, 0)'); + expect(formatColor('rgba(255, 0, 0, 0.5)', 'rgb')).toBe( + 'rgba(255, 0, 0, 0.5)' + ); + }); + }); + + describe('hsl', () => { + it('returns hsl() for opaque and hsla() for translucent', () => { + expect(formatColor('#ff0000', 'hsl')).toMatch(/^hsl\(/); + expect(formatColor('rgba(255, 0, 0, 0.5)', 'hsl')).toMatch(/^hsla\(/); + }); + }); + + describe('oklch', () => { + it('serializes to the design-system oklch() format', () => { + // hex → oklch should produce a parseable oklch with finite L/C/H. + const out = formatColor('#ff0000', 'oklch'); + expect(out).toMatch(/^oklch\([\d.]+ [\d.]+ [\d.]+\)$/); + }); + + it('appends alpha tail when alpha < 1', () => { + expect(formatColor('rgba(255, 0, 0, 0.5)', 'oklch')).toMatch( + /^oklch\([\d.]+ [\d.]+ [\d.]+ \/ [\d.]+\)$/ + ); + }); + }); + + it('returns null for unparseable input in every format', () => { + for (const fmt of ['hex', 'rgb', 'hsl', 'oklch'] as const) { + expect(formatColor('not a color', fmt)).toBeNull(); + expect(formatColor('', fmt)).toBeNull(); + } + }); + }); }); diff --git a/packages/raystack/components/color-picker/index.ts b/packages/raystack/components/color-picker/index.ts index 3d8245885..68e247447 100644 --- a/packages/raystack/components/color-picker/index.ts +++ b/packages/raystack/components/color-picker/index.ts @@ -1 +1,2 @@ export { ColorPicker } from './color-picker'; +export { formatColor } from './utils'; diff --git a/packages/raystack/components/color-picker/utils.ts b/packages/raystack/components/color-picker/utils.ts index dfaaa7595..b85395e43 100644 --- a/packages/raystack/components/color-picker/utils.ts +++ b/packages/raystack/components/color-picker/utils.ts @@ -6,7 +6,8 @@ import { formatHex8, formatHsl, formatRgb, - parse + parse, + toGamut } from 'culori'; export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb', 'oklch'] as const; @@ -28,6 +29,11 @@ const toOklch = converter('oklch'); const toRgb = converter('rgb'); const toHsl = converter('hsl'); +// Reusable gamut-mapper: reduces chroma in OKLCH space until the color fits +// sRGB while preserving lightness and hue. Created once because `toGamut` +// returns a function and the inputs ('rgb' / 'oklch') are constant. +const toSrgb = toGamut('rgb', 'oklch'); + const FALLBACK: ColorObject = { l: 1, c: 0, h: 0, alpha: 1 }; const round = (n: number, p: number) => Number.parseFloat(n.toFixed(p)); @@ -63,6 +69,59 @@ const formatOklchString = (color: ColorObject): string => { return alpha === 1 ? `oklch(${body})` : `oklch(${body} / ${round(alpha, 4)})`; }; +/** + * Convert any CSS color string to the requested format. + * + * Wide-gamut OKLCH inputs are gamut-mapped into sRGB for `hex`/`rgb`/`hsl` + * outputs (chroma reduced, lightness and hue preserved), so the result is + * the closest sRGB representation of the requested color rather than a + * per-channel-clipped one that would distort hue. `oklch` output preserves + * the full color, since OKLCH can express the wide gamut natively. + * + * Hex is uppercase; uses 8-digit form when alpha < 1. RGB/HSL produce + * `rgb()`/`rgba()` and `hsl()`/`hsla()`. OKLCH matches the design system's + * token format (4-decimal L/C, 2-decimal H, hue pinned to 0 when achromatic). + * + * Returns `null` for unparseable input. + * + * @example + * formatColor('oklch(0.5438 0.191 267.01)', 'hex') // '#3E63DD' + * formatColor('oklch(0.7 0.32 30)', 'hex') // '#FF5843' + * formatColor('red', 'rgb') // 'rgb(255, 0, 0)' + * formatColor('rgba(255, 0, 0, 0.5)', 'hsl') // 'hsla(0, 100%, 50%, 0.5)' + * formatColor('#FF0000', 'oklch') // 'oklch(0.6279 0.2577 29.23)' + * formatColor('not a color', 'hex') // null + */ +export const formatColor = ( + value: string, + format: 'hex' | 'rgb' | 'hsl' | 'oklch' +): string | null => { + const parsed = parse(value); + if (!parsed) return null; + + if (format === 'oklch') { + const oklch = toOklch(parsed); + if (!oklch) return null; + return formatOklchString({ + l: oklch.l ?? 0, + c: oklch.c ?? 0, + h: Number.isFinite(oklch.h) ? (oklch.h as number) : 0, + alpha: oklch.alpha ?? 1 + }); + } + + // sRGB-bound formats: gamut-map first so wide-gamut OKLCH inputs land on + // the closest sRGB color (hue/lightness preserved) instead of producing + // per-channel-clipped hex/rgb/hsl that would shift the hue. + const safe = toSrgb(parsed); + if (format === 'hex') { + const hex = (safe.alpha ?? 1) === 1 ? formatHex(safe) : formatHex8(safe); + return hex.toUpperCase(); + } + if (format === 'hsl') return formatHsl(safe); + return formatRgb(safe); +}; + /** * Serializes the OKLCH color to a CSS string in the requested mode. Non-oklch * modes clip out-of-gamut channels to sRGB so the output is always a valid