From af695d2e438918d19e14045836f742a8ca13d6da Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Wed, 20 May 2026 07:44:11 +0530 Subject: [PATCH 1/2] feat(color-picker): migrate to culori and add OKLCH mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `color` dependency with `culori` so the picker can accept and emit `oklch()` values alongside hex/rgb/hsl. The picker's HSL state shape is unchanged, so sliders and the context contract are untouched. OKLCH input is parsed via culori; OKLCH output is formatted with 4-decimal L/C, 2-decimal H, hue pinned to 0 for achromatic colors. Hex output remains uppercase with alpha-aware width to match the previous behavior. Note: sliders still operate in HSL — OKLCH is an I/O format, not a perceptual editing mode, so OKLCH round-trips are not bit-identical. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/app/examples/color-picker/page.tsx | 169 ++++++++++++++++++ .../docs/components/color-picker/demo.ts | 22 +++ .../docs/components/color-picker/index.mdx | 11 +- .../docs/components/color-picker/props.ts | 12 +- .../__tests__/color-picker.test.tsx | 36 ++++ .../color-picker/color-picker-area.tsx | 9 +- .../color-picker/color-picker-input.tsx | 8 +- .../color-picker/color-picker-root.tsx | 22 +-- .../raystack/components/color-picker/utils.ts | 101 +++++++++-- packages/raystack/package.json | 3 +- pnpm-lock.yaml | 22 ++- 11 files changed, 368 insertions(+), 47 deletions(-) create mode 100644 apps/www/src/app/examples/color-picker/page.tsx diff --git a/apps/www/src/app/examples/color-picker/page.tsx b/apps/www/src/app/examples/color-picker/page.tsx new file mode 100644 index 000000000..3c31891f0 --- /dev/null +++ b/apps/www/src/app/examples/color-picker/page.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { Button, ColorPicker, Flex, Popover, Text } from '@raystack/apsara'; +import { useState } from 'react'; + +const cardStyle = { + width: 280, + padding: 16, + borderRadius: 8, + background: 'var(--rs-color-background-base-primary)', + border: '1px solid var(--rs-color-border-base-primary)' +} as const; + +export default function ColorPickerExamplesPage() { + const [controlledValue, setControlledValue] = useState( + 'oklch(0.5438 0.191 267.01)' + ); + const [controlledMode, setControlledMode] = useState< + 'hex' | 'rgb' | 'hsl' | 'oklch' + >('oklch'); + const [popoverColor, setPopoverColor] = useState('#DA2929'); + + return ( + + + + ColorPicker + + + The picker supports hex, rgb, hsl, and oklch as input and output + formats. Internal sliders operate in HSL; oklch is a serialization + mode, not a perceptual editing mode. + + + + + + + Default (hex) + + + + + + + + + + + + + + + OKLCH mode + + + defaultValue='oklch(0.5438 0.191 267.01)'{' '} + with defaultMode='oklch'. + + + + + + + + + + + + + + + Controlled — emits live value + + { + setControlledValue(value); + setControlledMode(mode as typeof controlledMode); + }} + onModeChange={mode => + setControlledMode(mode as typeof controlledMode) + } + > + + + + + + + + + + + value: + + + {controlledValue} + + + mode: {controlledMode} + + + + + + + Popover trigger + + + + } + /> + + + + + + + + + + + + + + {popoverColor} + + + + + ); +} diff --git a/apps/www/src/content/docs/components/color-picker/demo.ts b/apps/www/src/content/docs/components/color-picker/demo.ts index 37e72e9bd..894c1e713 100644 --- a/apps/www/src/content/docs/components/color-picker/demo.ts +++ b/apps/www/src/content/docs/components/color-picker/demo.ts @@ -36,6 +36,28 @@ export const basicDemo = { ` }; +export const oklchDemo = { + type: 'code', + code: ` + + + + + + + +` +}; + export const popoverDemo = { type: 'code', previewCode: false, 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 9722535ce..6e4a54551 100644 --- a/apps/www/src/content/docs/components/color-picker/index.mdx +++ b/apps/www/src/content/docs/components/color-picker/index.mdx @@ -7,6 +7,7 @@ source: packages/raystack/components/color-picker import { preview, basicDemo, + oklchDemo, popoverDemo, } from "./demo.ts"; @@ -50,7 +51,7 @@ Provides a slider for selecting the alpha value of the color. ### Mode -Lets users switch between different color models (e.g., HEX, RGB, HSL) via a dropdown menu. +Lets users switch between different color models (HEX, RGB, HSL, OKLCH) via a dropdown menu. @@ -64,6 +65,14 @@ Displays the current color value in the selected color model and allows direct t +### OKLCH Mode + +The picker accepts and emits `oklch()` values. Pass an `oklch(...)` string as `defaultValue` and set `defaultMode='oklch'` to have the input render and emit oklch. + +Note that the picker's sliders still operate in HSL — `oklch` here is an input/output format, not a perceptual editing mode. The emitted oklch value is the result of an HSL → sRGB → OKLCH conversion, so a value-in will not be bit-identical to the value-out. + + + ### Popover Integration The `ColorPicker` can be embedded within a `Popover` component to create a more interactive and space-efficient color selection experience. diff --git a/apps/www/src/content/docs/components/color-picker/props.ts b/apps/www/src/content/docs/components/color-picker/props.ts index 7a399ebde..f67fdcc4b 100644 --- a/apps/www/src/content/docs/components/color-picker/props.ts +++ b/apps/www/src/content/docs/components/color-picker/props.ts @@ -3,7 +3,7 @@ */ export interface ColorPickerProps { /** - * The controlled color value. Accepts hex, rgb, hsl, etc. + * The controlled color value. Accepts hex, rgb, hsl, oklch, etc. */ value?: string; /** @@ -16,14 +16,14 @@ export interface ColorPickerProps { */ onValueChange?: (value: string, mode: string) => void; /** - * The initial color mode (hex, rgb, hsl). + * The initial color mode (hex, rgb, hsl, oklch). * @default 'hex' */ - defaultMode?: 'hex' | 'rgb' | 'hsl'; + defaultMode?: 'hex' | 'rgb' | 'hsl' | 'oklch'; /** * The controlled color mode. */ - mode?: 'hex' | 'rgb' | 'hsl'; + mode?: 'hex' | 'rgb' | 'hsl' | 'oklch'; /** * Callback fired when the color mode changes. */ @@ -36,7 +36,7 @@ export interface ColorPickerProps { export interface ColorPickerModeProps { /** * Supported color modes for the picker. - * @default ['hex', 'rgb', 'hsl'] + * @default ['hex', 'rgb', 'hsl', 'oklch'] */ - options?: Array<'hex' | 'rgb' | 'hsl'>; + options?: Array<'hex' | 'rgb' | 'hsl' | 'oklch'>; } diff --git a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx index 5d685f2a5..a2c8020d3 100644 --- a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx +++ b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx @@ -202,6 +202,42 @@ describe('ColorPicker', () => { input = screen.getByTestId('color-input'); expect(input).toHaveValue('#00FF00'); }); + + it('accepts oklch input', () => { + render( + + + + ); + const input = screen.getByTestId('color-input'); + // Should render *some* hex value without throwing; exact bytes depend on + // HSL round-trip so we only assert shape. + expect((input as HTMLInputElement).value).toMatch(/^#[0-9A-F]{6}$/); + }); + + it('emits oklch when mode is oklch', () => { + render( + + + + ); + const input = screen.getByTestId('color-input'); + expect((input as HTMLInputElement).value).toMatch( + /^oklch\([\d.]+ [\d.]+ [\d.]+\)$/ + ); + }); + + it('emits oklch with alpha tail when alpha < 1', () => { + render( + + + + ); + const input = screen.getByTestId('color-input'); + expect((input as HTMLInputElement).value).toMatch( + /^oklch\([\d.]+ [\d.]+ [\d.]+ \/ [\d.]+\)$/ + ); + }); }); describe('ColorPicker.Mode', () => { diff --git a/packages/raystack/components/color-picker/color-picker-area.tsx b/packages/raystack/components/color-picker/color-picker-area.tsx index 450db0599..e1c35c293 100644 --- a/packages/raystack/components/color-picker/color-picker-area.tsx +++ b/packages/raystack/components/color-picker/color-picker-area.tsx @@ -1,7 +1,6 @@ 'use client'; import { cx } from 'class-variance-authority'; -import Color from 'color'; import { ComponentProps, PointerEvent as ReactPointerEvent, @@ -12,6 +11,7 @@ import { } from 'react'; import styles from './color-picker.module.css'; import { useColorPicker } from './color-picker-root'; +import { getColorString } from './utils'; export type ColorPickerAreaProps = ComponentProps<'div'>; @@ -25,7 +25,10 @@ export const ColorPickerArea = ({ const isThumbVisible = useRef(false); const { hue, saturation, lightness, setColor } = useColorPicker(); - const color = Color.hsl(hue, saturation, lightness); + const thumbColor = getColorString( + { h: hue, s: saturation, l: lightness, alpha: 1 }, + 'hex' + ); const backgroundGradient = useMemo(() => { return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)), @@ -107,7 +110,7 @@ export const ColorPickerArea = ({ className={cx(styles.sliderThumb, styles.selectionThumb)} ref={thumbRef} style={{ - background: color.hex().toString(), + background: thumbColor, opacity: 0 }} /> diff --git a/packages/raystack/components/color-picker/color-picker-input.tsx b/packages/raystack/components/color-picker/color-picker-input.tsx index 79ba97e21..4541e06f5 100644 --- a/packages/raystack/components/color-picker/color-picker-input.tsx +++ b/packages/raystack/components/color-picker/color-picker-input.tsx @@ -1,6 +1,5 @@ 'use client'; -import Color from 'color'; import { ComponentProps } from 'react'; import { Input } from '../input'; import { useColorPicker } from './color-picker-root'; @@ -8,9 +7,12 @@ import { getColorString } from './utils'; export const ColorPickerInput = (props: ComponentProps) => { const { hue, saturation, lightness, alpha, mode } = useColorPicker(); - const color = Color.hsl(hue, saturation, lightness, alpha ?? 1); + const value = getColorString( + { h: hue, s: saturation, l: lightness, alpha: alpha ?? 1 }, + mode + ); - return ; + return ; }; ColorPickerInput.displayName = 'ColorPicker.Input'; diff --git a/packages/raystack/components/color-picker/color-picker-root.tsx b/packages/raystack/components/color-picker/color-picker-root.tsx index 97c59b4d2..6cc9f2536 100644 --- a/packages/raystack/components/color-picker/color-picker-root.tsx +++ b/packages/raystack/components/color-picker/color-picker-root.tsx @@ -1,6 +1,5 @@ 'use client'; -import Color, { type ColorLike } from 'color'; import { ComponentProps, createContext, @@ -9,7 +8,7 @@ import { useState } from 'react'; import { Flex } from '../flex'; -import { ColorObject, getColorString, ModeType } from './utils'; +import { ColorObject, getColorString, ModeType, parseColor } from './utils'; type ColorPickerContextValue = { hue: number; @@ -35,8 +34,8 @@ export const useColorPicker = () => { export interface ColorPickerProps extends Omit, 'defaultValue'> { - value?: ColorLike; - defaultValue?: ColorLike; + value?: string; + defaultValue?: string; onValueChange?: (value: string, mode: string) => void; defaultMode?: ModeType; mode?: ModeType; @@ -51,11 +50,9 @@ export const ColorPickerRoot = ({ onModeChange, ...props }: ColorPickerProps) => { - const providedColor = value && (Color(value).hsl().object() as ColorObject); + const providedColor = value ? parseColor(value) : undefined; - const [internalColor, setInternalColor] = useState( - Color(defaultValue).hsl().object() as ColorObject - ); + const [internalColor, setInternalColor] = useState(parseColor(defaultValue)); const [internalMode, setInternalMode] = useState(defaultMode); const hue = providedColor ? providedColor.h : internalColor.h; @@ -73,14 +70,7 @@ export const ColorPickerRoot = ({ if (!onValueChange) return updatedColor; - const color = Color.hsl( - updatedColor.h, - updatedColor.s, - updatedColor.l, - updatedColor?.alpha ?? 1 - ); - - onValueChange(getColorString(color, mode), mode); + onValueChange(getColorString(updatedColor, mode), mode); return updatedColor; }); diff --git a/packages/raystack/components/color-picker/utils.ts b/packages/raystack/components/color-picker/utils.ts index fe7a6d696..d4e962471 100644 --- a/packages/raystack/components/color-picker/utils.ts +++ b/packages/raystack/components/color-picker/utils.ts @@ -1,17 +1,13 @@ -import { type ColorInstance } from 'color'; +import { + converter, + formatHex, + formatHex8, + formatHsl, + formatRgb, + parse +} from 'culori'; -export const getColorString = (color: ColorInstance, mode: string) => { - let string; - if (mode === 'hex') - string = - color.alpha() === 1 ? color.hex().toString() : color.hexa().toString(); - else if (mode === 'hsl') string = color.hsl().toString(); - else string = color.rgb().toString(); - - return string; -}; - -export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb']; +export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb', 'oklch']; export type ModeType = (typeof SUPPORTED_MODES)[number]; @@ -21,3 +17,82 @@ export type ColorObject = { l: number; alpha?: number; }; + +const toHsl = converter('hsl'); +const toOklch = converter('oklch'); + +// culori stores HSL with s/l in 0-1; the picker stores them in 0-100 to keep +// the existing context contract intact. +const HSL_PERCENT = 100; + +const FALLBACK: ColorObject = { h: 0, s: 0, l: 100, alpha: 1 }; + +const round = (n: number, p: number) => Number.parseFloat(n.toFixed(p)); + +/** + * Serializes a culori-shaped HSL color as oklch(L C H[ / A]). + * Matches the design system's token format: 4-decimal L/C, 2-decimal H, + * H pinned to 0 for achromatic colors (culori would emit `none` per CSS + * Color 4, which is correct but inconsistent with how tokens are written). + */ +const formatOklch = (hsl: { + mode: 'hsl'; + h: number; + s: number; + l: number; + alpha: number; +}): string => { + const oklch = toOklch(hsl); + if (!oklch) return ''; + const L = round(oklch.l ?? 0, 4); + const C = round(oklch.c ?? 0, 4); + const H = C === 0 || !Number.isFinite(oklch.h) ? 0 : round(oklch.h ?? 0, 2); + const body = `${L} ${C} ${H}`; + return hsl.alpha === 1 + ? `oklch(${body})` + : `oklch(${body} / ${round(hsl.alpha, 4)})`; +}; + +/** + * Parses any CSS color string into the picker's `{h, s, l, alpha}` shape. + * Returns a white fallback (matching the picker's previous default) when the + * input fails to parse, so the picker never throws on bad consumer input. + */ +export const parseColor = (value: string): ColorObject => { + const parsed = parse(value); + if (!parsed) return FALLBACK; + const hsl = toHsl(parsed); + if (!hsl) return FALLBACK; + return { + h: hsl.h ?? 0, + s: (hsl.s ?? 0) * HSL_PERCENT, + l: (hsl.l ?? 0) * HSL_PERCENT, + alpha: hsl.alpha ?? 1 + }; +}; + +/** + * Serializes `{h, s, l, alpha}` to a CSS string in the requested mode. + * Hex output is uppercase and uses 8-digit form only when alpha < 1, mirroring + * the previous `color@5` behavior the tests depend on. + */ +export const getColorString = (color: ColorObject, mode: ModeType): string => { + const culoriColor = { + mode: 'hsl' as const, + h: color.h, + s: color.s / HSL_PERCENT, + l: color.l / HSL_PERCENT, + alpha: color.alpha ?? 1 + }; + + if (mode === 'hex') { + const hex = + culoriColor.alpha === 1 + ? formatHex(culoriColor) + : formatHex8(culoriColor); + return hex.toUpperCase(); + } + if (mode === 'hsl') return formatHsl(culoriColor); + if (mode === 'oklch') return formatOklch(culoriColor); + return formatRgb(culoriColor); +}; diff --git a/packages/raystack/package.json b/packages/raystack/package.json index f26c641f4..f40d3f650 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -89,6 +89,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", + "@types/culori": "^4.0.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitest/ui": "^3.2.4", @@ -121,7 +122,7 @@ "@tanstack/react-virtual": "^3.13.13", "@tanstack/table-core": "^8.9.2", "class-variance-authority": "^0.7.1", - "color": "^5.0.0", + "culori": "^4.0.2", "dayjs": "^1.11.11", "prism-react-renderer": "^2.4.1", "react-day-picker": "^9.6.7" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d56606f33..634274492 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,9 +199,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 - color: - specifier: ^5.0.0 - version: 5.0.0 + culori: + specifier: ^4.0.2 + version: 4.0.2 dayjs: specifier: ^1.11.11 version: 1.11.11 @@ -242,6 +242,9 @@ importers: '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.0) + '@types/culori': + specifier: ^4.0.1 + version: 4.0.1 '@types/react': specifier: ^19.0.0 version: 19.1.9 @@ -3708,6 +3711,9 @@ packages: '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/culori@4.0.1': + resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -4598,6 +4604,10 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + culori@4.0.2: + resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -12704,7 +12714,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 chalk: 4.1.2 @@ -12829,6 +12839,8 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/culori@4.0.1': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 @@ -13810,6 +13822,8 @@ snapshots: csstype@3.1.3: {} + culori@4.0.2: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 From 5a23cd6be9cd9efc8675bead1321309e8e5b80cd Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Wed, 20 May 2026 11:19:39 +0530 Subject: [PATCH 2/2] chore(color-picker): narrow ModeType via `as const` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `as const`, `SUPPORTED_MODES` is `string[]` so `ModeType` widens to `string` — invalid modes pass type-checking and silently fall back to RGB in `getColorString`. `options` on `ColorPickerMode` is widened to `readonly ModeType[]` to accept the const tuple as its default. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/raystack/components/color-picker/color-picker-mode.tsx | 2 +- packages/raystack/components/color-picker/utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/raystack/components/color-picker/color-picker-mode.tsx b/packages/raystack/components/color-picker/color-picker-mode.tsx index 4ba509ee5..b178c8faf 100644 --- a/packages/raystack/components/color-picker/color-picker-mode.tsx +++ b/packages/raystack/components/color-picker/color-picker-mode.tsx @@ -9,7 +9,7 @@ import { ModeType, SUPPORTED_MODES } from './utils'; export interface ColorPickerModeProps extends ComponentProps { - options?: ModeType[]; + options?: readonly ModeType[]; } export const ColorPickerMode = ({ diff --git a/packages/raystack/components/color-picker/utils.ts b/packages/raystack/components/color-picker/utils.ts index d4e962471..be88e9e38 100644 --- a/packages/raystack/components/color-picker/utils.ts +++ b/packages/raystack/components/color-picker/utils.ts @@ -7,7 +7,7 @@ import { parse } from 'culori'; -export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb', 'oklch']; +export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb', 'oklch'] as const; export type ModeType = (typeof SUPPORTED_MODES)[number];