diff --git a/modules/labs-react/common/lib/theming/index.ts b/modules/labs-react/common/lib/theming/index.ts index c9b7c926be..f64617192a 100644 --- a/modules/labs-react/common/lib/theming/index.ts +++ b/modules/labs-react/common/lib/theming/index.ts @@ -1 +1,2 @@ export * from './useThemeRTL'; +export * from './palette'; diff --git a/modules/labs-react/common/lib/theming/palette/alpha.ts b/modules/labs-react/common/lib/theming/palette/alpha.ts new file mode 100644 index 0000000000..7a75c9b632 --- /dev/null +++ b/modules/labs-react/common/lib/theming/palette/alpha.ts @@ -0,0 +1,215 @@ +/** + * Alpha compositing utilities for transparent colors over a background. + * Porter-Duff blending. @see https://www.w3.org/TR/compositing-1/ + */ +import {clamp, hexToRgb} from './conversion'; +import type {RGB} from './types'; + +/** + * Result of alpha color calculation + */ +export interface AlphaColorResult { + /** RGB channels (values 0-1) */ + color: RGB; + /** Alpha channel (0-1) */ + alpha: number; + /** CSS rgba(r, g, b, a) string */ + rgba: string; + /** Hex with alpha (#RRGGBBAA) */ + hex: string; +} + +/** + * Checks if color is valid RGB + */ +const isRGB = (color: RGB, param: string): void => { + if (!Array.isArray(color) || color.length !== 3) { + throw new TypeError(`${param} must be an RGB tuple [r, g, b]`); + } + if ( + typeof color[0] !== 'number' || + typeof color[1] !== 'number' || + typeof color[2] !== 'number' + ) { + throw new TypeError(`${param} must contain numeric values`); + } + if (!isFinite(color[0]) || !isFinite(color[1]) || !isFinite(color[2])) { + throw new RangeError(`${param} values must be finite numbers`); + } +}; + +const parseColor = (input: RGB | string): RGB => + typeof input === 'string' ? hexToRgb(input) : input; + +/** + * Min alpha needed to represent the solid color using the background. + * @param color - Target RGB (0-1) + * @param bgColor - Background RGB (0-1) + * @returns Alpha 0.01-0.99 + */ +export const minAlpha = (color: RGB, bgColor: RGB): number => { + isRGB(color, 'color'); + isRGB(bgColor, 'bgColor'); + + let result = 0.01; + + for (let i = 0; i < 3; i++) { + const out = color[i]; + const bg = bgColor[i]; + // < 0.0001 is the same + if (Math.abs(out - bg) < 0.0001) { + continue; + } + + const required = out > bg ? (out - bg) / (1 - bg) : (bg - out) / bg; + if (required > result && required <= 1) { + result = required; + } + } + + return clamp(result, 0, 1) * 0.99 + 0.01; +}; + +const alphaChannel = (target: number, bg: number, a: number): number => { + if (a <= 0.001) { + return target; + } + return clamp((target - bg * (1 - a)) / a, 0, 1); +}; + +/** + * Calculates an alpha color that composites to the target over the background. + * Inverse of composite: composite(alpha(target, bg, a), bg, a) equals target. + * + * @param color - Target color as RGB tuple (values 0-1) + * @param bgColor - Background color as RGB tuple (values 0-1) + * @param a - Alpha (0-1) + * @returns Transparent RGB tuple that composites to color over bgColor + */ +export const alpha = (color: RGB, bgColor: RGB, a: number): RGB => [ + alphaChannel(color[0], bgColor[0], a), + alphaChannel(color[1], bgColor[1], a), + alphaChannel(color[2], bgColor[2], a), +]; + +/** + * Composites a color with alpha over a background. + * Output = alpha × Foreground + (1 - alpha) × Background + * + * @param color - Foreground color as RGB tuple (values 0-1) + * @param bgColor - Background color as RGB tuple (values 0-1) + * @param a - Alpha (0-1), clamped + * @returns Composited RGB tuple + * + * @example + * composite([0,0,0], [1,1,1], 0.5) // [0.5, 0.5, 0.5] + */ +export const composite = (color: RGB, bgColor: RGB, a: number): RGB => { + const clamped = clamp(a, 0, 1); + return [ + clamped * color[0] + (1 - clamped) * bgColor[0], + clamped * color[1] + (1 - clamped) * bgColor[1], + clamped * color[2] + (1 - clamped) * bgColor[2], + ]; +}; + +/** + * Formats RGB to CSS rgb() or rgba() string + * + * @param color - RGB (0-1) + * @param alpha - If omitted, rgb(); if provided, rgba() + * @returns CSS color string + */ +export const formatRGBA = (color: RGB, alpha?: number): string => { + const r = Math.round(clamp(color[0], 0, 1) * 255); + const g = Math.round(clamp(color[1], 0, 1) * 255); + const b = Math.round(clamp(color[2], 0, 1) * 255); + if (alpha === undefined) { + return `rgb(${r}, ${g}, ${b})`; + } + return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1).toFixed(2)})`; +}; + +/** + * Converts RGB to hex string + * + * @param color - RGB tuple (values 0-1) + * @param alpha - If omitted, #RRGGBB; if provided, #RRGGBBAA + * @returns Hex string (#RRGGBB or #RRGGBBAA) + */ +export const rgbToHex = (color: RGB, alpha?: number): string => { + const toHex = (v: number) => + Math.round(clamp(v, 0, 1) * 255) + .toString(16) + .padStart(2, '0'); + const hex = `#${toHex(color[0])}${toHex(color[1])}${toHex(color[2])}`; + if (alpha === undefined) { + return hex; + } + return `${hex}${toHex(alpha)}`; +}; + +/** + * Calculates an alpha color that appears identical when composited over the background. + * Accepts either RGB tuples or hex strings (#RGB, #RRGGBB). + * + * @param color - Target color as RGB tuple (values 0-1) + * @param bg - Background as RGB tuple (default: white [1,1,1]) + * @param targetAlpha - Alpha (0-1). Omit to use minimum required. If too low to achieve target, returns original at 100%. + * @returns {AlphaColorResult} + * @throws {TypeError} Invalid color/bg or targetAlpha + * @throws {RangeError} NaN or Infinity in color values + * @throws {Error} Invalid hex format + * + * @example + * alphaColor([0.5,0.5,0.5], [1,1,1]) + * alphaColor('#0875E1', '#FFFFFF', 0.85) + */ +export function alphaColor(color: RGB, bg?: RGB, targetAlpha?: number): AlphaColorResult; +export function alphaColor( + hexColor: string, + bgHex?: string, + targetAlpha?: number +): AlphaColorResult; +export function alphaColor( + colorOrHex: RGB | string, + bg?: RGB | string, + targetAlpha?: number +): AlphaColorResult { + if (targetAlpha !== undefined && (typeof targetAlpha !== 'number' || !isFinite(targetAlpha))) { + throw new TypeError('targetAlpha must be a finite number between 0 and 1'); + } + + const color = parseColor(colorOrHex); + const bgColor = bg !== undefined ? parseColor(bg) : ([1, 1, 1] as RGB); + + isRGB(color, 'color'); + isRGB(bgColor, 'bgColor'); + + const a = targetAlpha !== undefined ? clamp(targetAlpha, 0, 1) : minAlpha(color, bgColor); + const transparent = alpha(color, bgColor, a); + const composited = composite(transparent, bgColor, a); + + // Target not achievable at this alpha (values were clamped) — use solid color + const eps = 1 / 255; + const matches = + Math.abs(composited[0] - color[0]) < eps && + Math.abs(composited[1] - color[1]) < eps && + Math.abs(composited[2] - color[2]) < eps; + + if (!matches) { + return { + color, + alpha: 1, + rgba: formatRGBA(color, 1), + hex: rgbToHex(color, 1), + }; + } + + return { + color: transparent, + alpha: a, + rgba: formatRGBA(transparent, a), + hex: rgbToHex(transparent, a), + }; +} diff --git a/modules/labs-react/common/lib/theming/palette/colorjs.d.ts b/modules/labs-react/common/lib/theming/palette/colorjs.d.ts new file mode 100644 index 0000000000..ae5d7c80c8 --- /dev/null +++ b/modules/labs-react/common/lib/theming/palette/colorjs.d.ts @@ -0,0 +1,76 @@ +/** + * Type declarations for colorjs.io + * + * colorjs.io ships with TypeScript types but uses the `exports` field + * which requires `moduleResolution: "node16"` or higher. + * This declaration file provides a workaround for older module resolution. + */ + +declare module 'colorjs.io' { + export interface Coords extends Array { + 0: number | null; + 1: number | null; + 2: number | null; + } + + export interface ColorObject { + spaceId: string; + coords: Coords; + alpha: number; + } + + export interface SerializeOptions { + format?: string | {name: string}; + precision?: number; + inGamut?: boolean; + } + + export default class Color { + constructor(color: string | Color | ColorObject); + constructor(space: string, coords: [number, number, number], alpha?: number); + + spaceId: string; + coords: Coords; + alpha: number; + + /** + * Convert to another color space + */ + to(space: string): Color; + + /** + * Convert to string representation + */ + toString(options?: SerializeOptions): string; + + /** + * Check if color is within gamut + */ + inGamut(space?: string): boolean; + + /** + * Map color to gamut + */ + toGamut(space?: string): Color; + + /** + * Calculate contrast ratio against another color + */ + contrast(color: Color, algorithm?: 'WCAG21' | 'APCA' | 'Lstar' | 'DeltaPhi'): number; + + /** + * Clone the color + */ + clone(): Color; + + /** + * Get color in a specific format + */ + get(prop: string): number; + + /** + * Set color property + */ + set(prop: string, value: number): Color; + } +} diff --git a/modules/labs-react/common/lib/theming/palette/conversion.ts b/modules/labs-react/common/lib/theming/palette/conversion.ts new file mode 100644 index 0000000000..ec527db1a9 --- /dev/null +++ b/modules/labs-react/common/lib/theming/palette/conversion.ts @@ -0,0 +1,117 @@ +/** + * Color conversion utilities for transforming between color spaces + * and checking WCAG contrast requirements. + * + * Uses colorjs.io for accurate color space conversions. + */ +import Color from 'colorjs.io'; + +import type {GamutType, OklchColor, RGB} from './types'; + +/** + * Checks if a contrast ratio meets WCAG requirements + */ +export const meetsWCAGContrast = (ratio: number, level: 'AA' | 'AAA' = 'AA'): boolean => + ratio >= (level === 'AAA' ? 7 : 4.5); + +/** + * Clamps a value between min and max + */ +export const clamp = (x: number, min: number, max: number): number => + x < min ? min : x > max ? max : x; + +/** + * Creates an OKLCH Color object + */ +export const createOklchColor = (l: number, c: number, h: number): Color => + new Color('oklch', [l, c, h]); + +/** + * Converts OKLCH color to sRGB hex string + */ +export const oklchToHex = (l: number, c: number, h: number): string => { + const color = createOklchColor(l, c, h); + return color.to('srgb').toString({format: 'hex'}); +}; + +/** + * Converts hex color to OKLCH + */ +export const hexToOklch = (hex: string): OklchColor => { + const color = new Color(hex); + const oklch = color.to('oklch'); + const [l, c, h] = oklch.coords; + return {l: l || 0, c: c || 0, h: h || 0}; +}; + +/** + * Parses a hex color string to RGB values (0-1 range) + */ +export const hexToRgb = (hex: string): RGB => { + const color = new Color(hex); + const srgb = color.to('srgb'); + return srgb.coords as RGB; +}; + +/** + * Parses any supported color format to OKLCH + * Uses colorjs.io which supports a wide variety of color formats: + * hex (#fff, #ffffff), rgb(), hsl(), oklch(), oklab(), lab(), lch(), etc. + */ +export const parseColorToOklch = (colorString: string): OklchColor => { + try { + const color = new Color(colorString); + const oklch = color.to('oklch'); + const [l, c, h] = oklch.coords; + return {l: l || 0, c: c || 0, h: h || 0}; + } catch { + // Return a default blue if parsing fails + return {l: 0.5, c: 0.15, h: 255}; + } +}; + +/** + * Calculates contrast ratio between an OKLCH color and a background + */ +export const checkContrastRatio = ( + oklch: OklchColor, + backgroundLuminance: number, + gamut: GamutType = 'sRGB' +): number => { + const colorSpace = gamut === 'P3' ? 'p3' : 'srgb'; + const color = createOklchColor(oklch.l, oklch.c, oklch.h); + + // Create background color based on luminance + const bgValue = backgroundLuminance > 0.5 ? 1 : 0; + const bgColor = new Color(colorSpace, [bgValue, bgValue, bgValue]); + + // Use colorjs.io's built-in contrast calculation + return color.contrast(bgColor, 'WCAG21'); +}; + +/** + * Creates background colors for light and dark modes + */ +export const createBackgroundColors = (gamut: GamutType) => { + const colorSpace = gamut === 'P3' ? 'p3' : 'srgb'; + return { + light: new Color(colorSpace, [1, 1, 1]), + dark: new Color(colorSpace, [0, 0, 0]), + }; +}; + +/** + * Checks if a color is within the specified gamut + */ +export const isInGamut = (color: Color, gamut: GamutType = 'sRGB'): boolean => { + const colorSpace = gamut === 'P3' ? 'p3' : 'srgb'; + return color.inGamut(colorSpace); +}; + +/** + * Converts a Color object to the specified gamut, mapping out-of-gamut colors + */ +export const toGamut = (color: Color, gamut: GamutType = 'sRGB'): Color => { + const colorSpace = gamut === 'P3' ? 'p3' : 'srgb'; + return color.toGamut(colorSpace); +}; diff --git a/modules/labs-react/common/lib/theming/palette/gamut.ts b/modules/labs-react/common/lib/theming/palette/gamut.ts new file mode 100644 index 0000000000..15ba03954b --- /dev/null +++ b/modules/labs-react/common/lib/theming/palette/gamut.ts @@ -0,0 +1,95 @@ +/** + * Gamut calculation utilities for determining maximum chroma values + * within sRGB and Display P3 color spaces. + * + * Uses colorjs.io for accurate gamut boundary calculations. + */ +import Color from 'colorjs.io'; + +import type {GamutType} from './types'; + +/** + * Computes the maximum chroma for a given hue and lightness within a gamut. + * Uses binary search to find the gamut boundary. + * + * @param hue - Hue angle in degrees (0-360) + * @param lightness - Lightness value (0-1) + * @param gamut - Target color gamut ('sRGB' or 'P3') + * @returns Maximum chroma value that stays within the gamut + */ +export const computeMaxChroma = ( + hue: number, + lightness: number, + gamut: GamutType = 'sRGB' +): number => { + const colorSpace = gamut === 'P3' ? 'p3' : 'srgb'; + + // Binary search for maximum chroma + let low = 0; + let high = 0.4; // Maximum reasonable chroma in OKLCH + let maxChroma = 0; + + const maxIterations = 50; + const precision = 0.0001; + + for (let i = 0; i < maxIterations && high - low > precision; i++) { + const testChroma = (low + high) / 2; + const color = new Color('oklch', [lightness, testChroma, hue]); + + if (color.inGamut(colorSpace)) { + maxChroma = testChroma; + low = testChroma; + } else { + high = testChroma; + } + } + + return maxChroma; +}; + +/** + * Computes maximum chroma for both sRGB and P3 gamuts + * + * @param hue - Hue angle in degrees (0-360) + * @param lightness - Lightness value (0-1) + * @returns Object with max chroma for each gamut + */ +export const computeMaxChromaForGamuts = ( + hue: number, + lightness: number +): {sRGB: number; p3: number} => { + return { + sRGB: computeMaxChroma(hue, lightness, 'sRGB'), + p3: computeMaxChroma(hue, lightness, 'P3'), + }; +}; + +/** + * Checks if an OKLCH color is within the specified gamut + * + * @param l - Lightness (0-1) + * @param c - Chroma (0-0.4+) + * @param h - Hue (0-360) + * @param gamut - Target gamut + * @returns True if the color is within gamut + */ +export const isInGamut = (l: number, c: number, h: number, gamut: GamutType = 'sRGB'): boolean => { + const colorSpace = gamut === 'P3' ? 'p3' : 'srgb'; + const color = new Color('oklch', [l, c, h]); + return color.inGamut(colorSpace); +}; + +/** + * Maps an OKLCH color to the specified gamut if it's out of gamut + * + * @param l - Lightness (0-1) + * @param c - Chroma (0-0.4+) + * @param h - Hue (0-360) + * @param gamut - Target gamut + * @returns Mapped Color object within the gamut + */ +export const mapToGamut = (l: number, c: number, h: number, gamut: GamutType = 'sRGB'): Color => { + const colorSpace = gamut === 'P3' ? 'p3' : 'srgb'; + const color = new Color('oklch', [l, c, h]); + return color.toGamut(colorSpace); +}; diff --git a/modules/labs-react/common/lib/theming/palette/generateAccessiblePalette.ts b/modules/labs-react/common/lib/theming/palette/generateAccessiblePalette.ts new file mode 100644 index 0000000000..048aadc4b4 --- /dev/null +++ b/modules/labs-react/common/lib/theming/palette/generateAccessiblePalette.ts @@ -0,0 +1,389 @@ +/** + * Generates an accessible color palette from a base color. + * + * This module provides functionality to generate WCAG-compliant color palettes + * from any input color (hex, rgb, or oklch format). The generated palette + * includes 13 steps from lightest to darkest, with guaranteed contrast ratios + * at key steps (500, 600) for accessibility compliance. + * + * @example + * ```tsx + * import { generateAccessiblePalette } from '@workday/canvas-kit-labs-react/common'; + * + * // Generate palette from a hex color + * const palette = generateAccessiblePalette('#0875E1'); + * + * // Access specific steps + * const primary = palette.getHex(500); // Main brand color + * const light = palette.getHex(100); // Light variant + * const dark = palette.getHex(800); // Dark variant + * + * // Check accessibility + * const step500 = palette.getStep(500); + * console.log(step500?.wcagAA); // true - meets 4.5:1 contrast + * ``` + */ +import {formatRGBA, rgbToHex} from './alpha'; +import { + checkContrastRatio, + clamp, + hexToRgb, + meetsWCAGContrast, + oklchToHex, + parseColorToOklch, +} from './conversion'; +import {computeMaxChroma} from './gamut'; +import type { + AccessiblePalette, + AlphaLevel, + GamutType, + GeneratePaletteOptions, + OklchColor, + PaletteStep, + PaletteStepAlpha, + PaletteType, + RGB, +} from './types'; + +/** + * Palette scale steps from lightest to darkest + */ +const SCALE = [25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950, 975] as const; + +/** + * Maximum step value (used for normalization) + */ +const SCALE_MAX = SCALE[SCALE.length - 1]; + +/** + * Lightness bounds (0-1) + */ +const MAX_LIGHTNESS = 0.9984; +const MIN_LIGHTNESS = 0.2016; +const LIGHTNESS_RANGE = MAX_LIGHTNESS - MIN_LIGHTNESS; + +/** + * Threshold for determining dark mode based on background luminance + */ +const LUM_THRESHOLD = 0.18; + +/** + * Chroma distribution parameters + */ +const CHROMA_PEAK_POSITION = 0.6; +const CHROMA_FALLOFF_FACTOR = 0.6; + +/** + * Alpha channel values for brand tokens (true transparency; matches design tokens) + * a100 primary: oklch(0.6225 0.2064 255.9 / 0.17); a50: / 0.11; a25: / 0.08; a200: / 0.31 + */ +const ALPHA_LEVELS: Record = { + a25: 0.08, + a50: 0.11, + a100: 0.17, + a200: 0.31, +}; + +/** + * Default palette generation options + */ +const DEFAULT_OPTIONS: Required = { + gamut: 'sRGB', + paletteType: 'accessible', + backgroundLuminance: 1.0, + minChroma: 0.02, + hueShiftAmount: 5, +}; + +/** + * Parses #RRGGBBAA to RGB (0-1) and alpha (0-1) + */ +const parseHexWithAlpha = (hex: string): {color: RGB; alpha: number} => { + const s = hex.replace(/^#/, ''); + if (s.length !== 8) { + return {color: hexToRgb(hex) as RGB, alpha: 1}; + } + const r = parseInt(s.slice(0, 2), 16) / 255; + const g = parseInt(s.slice(2, 4), 16) / 255; + const b = parseInt(s.slice(4, 6), 16) / 255; + const a = parseInt(s.slice(6, 8), 16) / 255; + return {color: [r, g, b], alpha: a}; +}; + +/** + * Normalizes a step value to 0-1 range + */ +const normalizeStep = (step: number): number => step / SCALE_MAX; + +/** + * Computes the lightness value for a given step in the color scale + * + * @param step - The step value (25, 50, 100, 200, etc.) + * @param backgroundY - Background lightness value (0-1) + * @param applyGuarantees - Whether to apply WCAG contrast guarantees + * @returns Computed lightness value (0-1) + */ +const computeLightness = (step: number, backgroundY: number, applyGuarantees = false): number => { + const normalizedStep = normalizeStep(step); + const shouldInvert = backgroundY < LUM_THRESHOLD; + let lightness = shouldInvert + ? MIN_LIGHTNESS + normalizedStep * LIGHTNESS_RANGE + : MAX_LIGHTNESS - normalizedStep * LIGHTNESS_RANGE; + + if (applyGuarantees) { + // Apply small lightness adjustments to guarantee WCAG contrast requirements: + // - Step 500: Move slightly away from background for better contrast + // - Step 600: Move slightly toward background to create distinction from 500 + const adjustment = shouldInvert ? -0.01 : 0.01; + if (step === 500) { + lightness += adjustment; + } else if (step === 600) { + lightness -= adjustment; + } + } + + return clamp(lightness, 0, 1); +}; + +/** + * Computes chroma value for a given normalized position + * + * @param scaleValue - Normalized position in scale (0-1) + * @param minChroma - Minimum chroma value + * @param maxChroma - Maximum chroma value + * @param paletteType - Type of palette ('accessible' or 'neutral') + * @returns Computed chroma value + */ +const computeChroma = ( + scaleValue: number, + minChroma: number, + maxChroma: number, + paletteType: PaletteType = 'accessible' +): number => { + if (paletteType === 'neutral') { + const neutralChroma = -0.8 * scaleValue * scaleValue + 0.8 * scaleValue; + const neutralMaxChroma = Math.min(maxChroma * 0.3, 0.08); + return minChroma + neutralChroma * neutralMaxChroma; + } + + const adjustedValue = scaleValue - CHROMA_PEAK_POSITION; + const chromaDifference = maxChroma - minChroma; + const chromaAtPeak = minChroma + chromaDifference; + const chromaValue = + chromaAtPeak - + chromaDifference * CHROMA_FALLOFF_FACTOR * Math.pow(adjustedValue / CHROMA_PEAK_POSITION, 2); + + return clamp(chromaValue, minChroma, maxChroma); +}; + +/** + * Computes hue with optional shifting for better saturation at certain ranges + * + * @param scaleValue - Normalized position in scale (0-1) + * @param baseHue - Base hue value (0-360) + * @param lightness - Lightness value for context + * @param hueShiftAmount - Amount of hue shift to apply + * @returns Computed hue value + */ +const computeScaleHue = ( + scaleValue: number, + baseHue: number, + lightness?: number, + hueShiftAmount = 5 +): number => { + const normalizedHue = ((baseHue % 360) + 360) % 360; + const isYellowAdjacent = normalizedHue >= 60 && normalizedHue <= 100; + + if (isYellowAdjacent && lightness !== undefined) { + // Enhanced hue shifting for yellow-adjacent colors to increase chroma + const lightnessPosition = lightness; + const shiftIntensity = 1 - lightnessPosition; + let targetShiftedHue; + + if (normalizedHue >= 90) { + targetShiftedHue = 45 + (normalizedHue - 90) * 0.5; + } else if (normalizedHue >= 80) { + targetShiftedHue = 40 + (normalizedHue - 80) * 0.5; + } else { + targetShiftedHue = 35 + (normalizedHue - 60) * 0.25; + } + + const hueShiftDistance = (normalizedHue - targetShiftedHue) * shiftIntensity; + const enhancedShiftScale = hueShiftAmount / 5; + const adjustedHueShift = hueShiftDistance * enhancedShiftScale; + const targetHue = normalizedHue - adjustedHueShift; + + return ((targetHue % 360) + 360) % 360; + } + + const standardShift = hueShiftAmount * (1 - scaleValue); + return baseHue + standardShift; +}; + +/** + * Computes a single color in the scale + */ +const computeScaleColor = ( + scaleNumber: number, + baseHue: number, + minChroma: number, + maxChroma: number, + backgroundY: number, + gamut: GamutType, + paletteType: PaletteType, + hueShiftAmount: number +): OklchColor => { + const normalizedStep = normalizeStep(scaleNumber); + const lightness = computeLightness(scaleNumber, backgroundY, true); + const hue = computeScaleHue(normalizedStep, baseHue, lightness, hueShiftAmount); + const desiredChroma = computeChroma(normalizedStep, minChroma, maxChroma, paletteType); + const maxPossibleChroma = computeMaxChroma(hue, lightness, gamut); + const chroma = Math.min(desiredChroma, maxPossibleChroma); + + return {l: lightness, c: chroma, h: hue}; +}; + +/** + * Generates an accessible color palette from any supported color format. + * + * The function accepts colors in the following formats: + * - Hex: `#fff`, `#ffffff`, `fff`, `ffffff` + * - RGB: `rgb(255, 128, 0)`, `rgba(255, 128, 0, 1)` + * - OKLCH: `oklch(0.7 0.15 250)`, `oklch(70% 0.15 250deg)` + * + * @param color - Input color in hex, rgb, or oklch format + * @param options - Optional configuration for palette generation + * @returns An AccessiblePalette object with all color steps and helper methods + * + * @example + * ```tsx + * // Basic usage with hex color + * const palette = generateAccessiblePalette('#0875E1'); + * + * // With options + * const darkModePalette = generateAccessiblePalette('#0875E1', { + * backgroundLuminance: 0.1, // Dark background + * paletteType: 'accessible', + * }); + * + * // Using the palette + * const styles = { + * backgroundColor: palette.getHex(100), + * color: palette.getHex(900), + * borderColor: palette.getHex(300), + * }; + * ``` + */ +export function generateAccessiblePalette( + color: string, + options: GeneratePaletteOptions = {} +): AccessiblePalette { + const opts = {...DEFAULT_OPTIONS, ...options}; + const {gamut, paletteType, backgroundLuminance, minChroma, hueShiftAmount} = opts; + + // Parse input color to OKLCH + const inputOklch = parseColorToOklch(color); + const baseHue = inputOklch.h || 0; + + // Calculate max chroma at the middle of the scale for reference + const lightness500 = computeLightness(500, backgroundLuminance); + const maxChroma = computeMaxChroma(baseHue, lightness500, gamut); + + // Generate all steps + const steps: PaletteStep[] = SCALE.map(step => { + const oklch = computeScaleColor( + step, + baseHue, + minChroma, + maxChroma, + backgroundLuminance, + gamut, + paletteType, + hueShiftAmount + ); + + const hex = oklchToHex(oklch.l, oklch.c, oklch.h); + const contrastRatioValue = checkContrastRatio(oklch, backgroundLuminance, gamut); + + return { + step, + hex, + oklch, + contrastRatio: contrastRatioValue, + wcagAA: meetsWCAGContrast(contrastRatioValue, 'AA'), + wcagAAA: meetsWCAGContrast(contrastRatioValue, 'AAA'), + }; + }); + + // Compute alpha variants: same step color with alpha channel (true transparency) + const alphaLevelKeys = Object.keys(ALPHA_LEVELS) as AlphaLevel[]; + for (const step of steps) { + const stepRgb = hexToRgb(step.hex) as RGB; + const alphaRecord: PaletteStepAlpha = {} as PaletteStepAlpha; + for (const level of alphaLevelKeys) { + alphaRecord[level] = rgbToHex(stepRgb, ALPHA_LEVELS[level]); + } + step.alpha = alphaRecord; + } + + const isDarkMode = backgroundLuminance < LUM_THRESHOLD; + + return { + steps, + inputColor: color, + isDarkMode, + getStep: (step: number) => steps.find(s => s.step === step), + getHex: (step: number) => steps.find(s => s.step === step)?.hex, + getAlphaHex: (step: number, alphaLevel: AlphaLevel) => + steps.find(s => s.step === step)?.alpha?.[alphaLevel], + getAlphaRgba: (step: number, alphaLevel: AlphaLevel) => { + const hexWithAlpha = steps.find(s => s.step === step)?.alpha?.[alphaLevel]; + if (!hexWithAlpha) { + return undefined; + } + const {color: rgb, alpha: a} = parseHexWithAlpha(hexWithAlpha); + return formatRGBA(rgb, a); + }, + getAlphaOklch: (step: number, alphaLevel: AlphaLevel) => { + const s = steps.find(st => st.step === step); + if (!s?.alpha?.[alphaLevel]) { + return undefined; + } + const {l, c, h} = s.oklch; + const a = ALPHA_LEVELS[alphaLevel]; + return `oklch(${l.toFixed(4)} ${c.toFixed(4)} ${h.toFixed(2)} / ${a})`; + }, + }; +} + +/** + * Generates a neutral (low-chroma) palette from a base hue. + * Useful for creating background/surface color scales. + * + * @param hue - Base hue value (0-360) or a color string to extract hue from + * @param options - Optional configuration for palette generation + * @returns An AccessiblePalette with neutral (desaturated) colors + * + * @example + * ```tsx + * // Generate a neutral blue-tinted gray scale + * const neutralPalette = generateNeutralPalette(220); + * + * // Or extract hue from an existing color + * const neutralPalette = generateNeutralPalette('#0875E1'); + * ``` + */ +export function generateNeutralPalette( + hue: number | string, + options: Omit = {} +): AccessiblePalette { + const baseHue = typeof hue === 'number' ? hue : parseColorToOklch(hue).h; + const baseColor = `oklch(0.5 0.1 ${baseHue})`; + + return generateAccessiblePalette(baseColor, { + ...options, + paletteType: 'neutral', + }); +} + +export {SCALE as paletteSteps}; diff --git a/modules/labs-react/common/lib/theming/palette/index.ts b/modules/labs-react/common/lib/theming/palette/index.ts new file mode 100644 index 0000000000..272dd19704 --- /dev/null +++ b/modules/labs-react/common/lib/theming/palette/index.ts @@ -0,0 +1,53 @@ +/** + * Accessible Palette Generator + * + * Utilities for generating WCAG-compliant color palettes from any input color. + * + * @example + * ```tsx + * import { generateAccessiblePalette } from '@workday/canvas-kit-labs-react/common'; + * + * const palette = generateAccessiblePalette('#0875E1'); + * const primaryColor = palette.getHex(500); + * ``` + */ + +export { + generateAccessiblePalette, + generateNeutralPalette, + paletteSteps, +} from './generateAccessiblePalette'; + +export type { + GamutType, + PaletteType, + AlphaLevel, + PaletteStepAlpha, + RGB, + OklchColor, + PaletteStep, + AccessiblePalette, + GeneratePaletteOptions, +} from './types'; + +// Re-export conversion utilities for advanced use cases +export { + parseColorToOklch, + hexToOklch, + oklchToHex, + hexToRgb, + meetsWCAGContrast, + createOklchColor, + checkContrastRatio, +} from './conversion'; + +// Re-export gamut utilities for advanced use cases +export {computeMaxChroma, computeMaxChromaForGamuts, isInGamut, mapToGamut} from './gamut'; + +// Re-export alpha/transparency utilities +export {minAlpha, alpha, composite, formatRGBA, rgbToHex, alphaColor} from './alpha'; + +export type {AlphaColorResult} from './alpha'; + +// Re-export Color class for advanced use cases +export {default as Color} from 'colorjs.io'; diff --git a/modules/labs-react/common/lib/theming/palette/types.ts b/modules/labs-react/common/lib/theming/palette/types.ts new file mode 100644 index 0000000000..62843ae4c9 --- /dev/null +++ b/modules/labs-react/common/lib/theming/palette/types.ts @@ -0,0 +1,127 @@ +/** + * Types for the accessible palette generator + */ + +/** + * Supported color gamuts for palette generation + * - sRGB: Standard RGB color space (default, widest support) + * - P3: Display P3 color space (wider gamut, supported on modern devices) + */ +export type GamutType = 'sRGB' | 'P3'; + +/** + * Type of palette to generate + * - accessible: Optimized for WCAG contrast requirements + * - neutral: Low chroma palette for backgrounds/surfaces + */ +export type PaletteType = 'accessible' | 'neutral'; + +/** + * Alpha level for brand tokens (alpha channel = true transparency). + * - a25: 8% opacity (0.08) + * - a50: 11% opacity (0.11) + * - a100: 17% opacity (0.17) + * - a200: 31% opacity (0.31) + */ +export type AlphaLevel = 'a25' | 'a50' | 'a100' | 'a200'; + +/** + * Color representation as RGB tuple (values 0-1) + */ +export type RGB = [number, number, number]; + +/** + * OKLCH color representation + */ +export interface OklchColor { + /** Lightness (0-1) */ + l: number; + /** Chroma (0-0.4+) */ + c: number; + /** Hue (0-360) */ + h: number; +} + +/** + * Alpha variants for a palette step (hex with alpha #RRGGBBAA) + */ +export type PaletteStepAlpha = Record; + +/** + * A single step in the color palette + */ +export interface PaletteStep { + /** Step number (25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950, 975) */ + step: number; + /** Hex color value */ + hex: string; + /** OKLCH color values */ + oklch: OklchColor; + /** Contrast ratio against the background */ + contrastRatio: number; + /** Whether the step meets WCAG AA contrast requirements (4.5:1) */ + wcagAA: boolean; + /** Whether the step meets WCAG AAA contrast requirements (7:1) */ + wcagAAA: boolean; + /** Alpha variants (a25, a50, a100, a200) — hex with alpha for compositing over palette background */ + alpha?: PaletteStepAlpha; +} + +/** + * Complete accessible color palette + */ +export interface AccessiblePalette { + /** All palette steps from lightest to darkest */ + steps: PaletteStep[]; + /** Input color that was used to generate the palette */ + inputColor: string; + /** Whether the palette is for dark mode */ + isDarkMode: boolean; + /** Get a specific step by number */ + getStep: (step: number) => PaletteStep | undefined; + /** Get hex color for a specific step */ + getHex: (step: number) => string | undefined; + /** Get hex with alpha for a step at the given alpha level (#RRGGBBAA) */ + getAlphaHex: (step: number, alphaLevel: AlphaLevel) => string | undefined; + /** Get rgba string for a step at the given alpha level */ + getAlphaRgba: (step: number, alphaLevel: AlphaLevel) => string | undefined; + /** Get oklch(L C H / alpha) string for a step at the given alpha level */ + getAlphaOklch: (step: number, alphaLevel: AlphaLevel) => string | undefined; +} + +/** + * Options for generating an accessible palette + */ +export interface GeneratePaletteOptions { + /** + * Color gamut to use for palette generation + * @default 'sRGB' + */ + gamut?: GamutType; + + /** + * Type of palette to generate + * @default 'accessible' + */ + paletteType?: PaletteType; + + /** + * Background luminance (0-1). Values below 0.18 are considered dark mode. + * - 1.0 = white background (light mode) + * - 0.0 = black background (dark mode) + * @default 1.0 + */ + backgroundLuminance?: number; + + /** + * Minimum chroma for the palette + * @default 0.02 + */ + minChroma?: number; + + /** + * Amount of hue shift to apply (0-10) + * @default 5 + */ + hueShiftAmount?: number; +} diff --git a/modules/labs-react/common/spec/Alpha.spec.tsx b/modules/labs-react/common/spec/Alpha.spec.tsx new file mode 100644 index 0000000000..7e6f9ebd8d --- /dev/null +++ b/modules/labs-react/common/spec/Alpha.spec.tsx @@ -0,0 +1,337 @@ +import { + alpha, + alphaColor, + composite, + formatRGBA, + minAlpha, + rgbToHex, +} from '../lib/theming/palette/alpha'; +import {hexToRgb} from '../lib/theming/palette/conversion'; +import type {RGB} from '../lib/theming/palette/types'; + +describe('alpha utilities', () => { + describe('minAlpha', () => { + it('should calculate minimum alpha for high contrast colors', () => { + const black: RGB = [0, 0, 0]; + const white: RGB = [1, 1, 1]; + expect(minAlpha(black, white)).toBeGreaterThan(0.9); + }); + + it('should calculate minimum alpha for low contrast colors', () => { + const lightGray: RGB = [0.9, 0.9, 0.9]; + const white: RGB = [1, 1, 1]; + expect(minAlpha(lightGray, white)).toBeLessThan(0.2); + }); + + it('should return minimum alpha for identical colors', () => { + const color: RGB = [0.5, 0.5, 0.5]; + const result = minAlpha(color, color); + expect(result).toBeCloseTo(0.02, 2); + expect(result).toBeGreaterThanOrEqual(0.01); + }); + + it('should handle dark color on dark background', () => { + const darkGray: RGB = [0.2, 0.2, 0.2]; + const black: RGB = [0, 0, 0]; + const result = minAlpha(darkGray, black); + expect(result).toBeGreaterThan(0); + expect(result).toBeLessThan(1); + }); + + it('should never exceed 0.99', () => { + const color1: RGB = [0.5, 0.3, 0.8]; + const color2: RGB = [0, 0, 0]; + expect(minAlpha(color1, color2)).toBeLessThanOrEqual(0.99); + }); + }); + + describe('alpha', () => { + it('should calculate transparent color that composites to target', () => { + const target: RGB = [0.5, 0.5, 0.5]; + const bg: RGB = [1, 1, 1]; + const a = 0.5; + const transparent = alpha(target, bg, a); + const composited = composite(transparent, bg, a); + expect(composited[0]).toBeCloseTo(target[0], 2); + expect(composited[1]).toBeCloseTo(target[1], 2); + expect(composited[2]).toBeCloseTo(target[2], 2); + }); + + it('should handle alpha = 1 (fully opaque)', () => { + const target: RGB = [0.3, 0.6, 0.9]; + const bg: RGB = [1, 1, 1]; + const transparent = alpha(target, bg, 1); + expect(transparent[0]).toBeCloseTo(target[0], 2); + expect(transparent[1]).toBeCloseTo(target[1], 2); + expect(transparent[2]).toBeCloseTo(target[2], 2); + }); + + it('should clamp values between 0 and 1', () => { + const target: RGB = [0.9, 0.9, 0.9]; + const bg: RGB = [0.8, 0.8, 0.8]; + const transparent = alpha(target, bg, 0.1); + expect(transparent[0]).toBeGreaterThanOrEqual(0); + expect(transparent[0]).toBeLessThanOrEqual(1); + expect(transparent[1]).toBeGreaterThanOrEqual(0); + expect(transparent[1]).toBeLessThanOrEqual(1); + expect(transparent[2]).toBeGreaterThanOrEqual(0); + expect(transparent[2]).toBeLessThanOrEqual(1); + }); + }); + + describe('composite', () => { + it('should composite color over background with alpha', () => { + const fg: RGB = [0, 0, 0]; + const bg: RGB = [1, 1, 1]; + const result = composite(fg, bg, 0.5); + expect(result[0]).toBeCloseTo(0.5, 2); + expect(result[1]).toBeCloseTo(0.5, 2); + expect(result[2]).toBeCloseTo(0.5, 2); + }); + + it('should return background color when alpha = 0', () => { + const fg: RGB = [0, 0, 0]; + const bg: RGB = [1, 1, 1]; + const result = composite(fg, bg, 0); + expect(result[0]).toBe(1); + expect(result[1]).toBe(1); + expect(result[2]).toBe(1); + }); + + it('should return foreground color when alpha = 1', () => { + const fg: RGB = [0.2, 0.4, 0.6]; + const bg: RGB = [1, 1, 1]; + const result = composite(fg, bg, 1); + expect(result[0]).toBeCloseTo(0.2, 2); + expect(result[1]).toBeCloseTo(0.4, 2); + expect(result[2]).toBeCloseTo(0.6, 2); + }); + + it('should clamp alpha values outside 0-1 range', () => { + const fg: RGB = [0.5, 0.5, 0.5]; + const bg: RGB = [1, 1, 1]; + expect(composite(fg, bg, -0.5)[0]).toBe(1); + expect(composite(fg, bg, 1.5)[0]).toBeCloseTo(0.5, 2); + }); + }); + + describe('formatRGBA', () => { + it('should format as rgb() when alpha omitted', () => { + const color: RGB = [0.031, 0.459, 0.882]; + expect(formatRGBA(color)).toBe('rgb(8, 117, 225)'); + }); + + it('should format as rgba() when alpha provided', () => { + const color: RGB = [0.031, 0.459, 0.882]; + expect(formatRGBA(color, 0.75)).toBe('rgba(8, 117, 225, 0.75)'); + }); + + it('should handle white and black', () => { + expect(formatRGBA([1, 1, 1])).toBe('rgb(255, 255, 255)'); + expect(formatRGBA([0, 0, 0])).toBe('rgb(0, 0, 0)'); + }); + + it('should handle alpha = 0 and 1', () => { + const color: RGB = [0.5, 0.5, 0.5]; + expect(formatRGBA(color, 0)).toBe('rgba(128, 128, 128, 0.00)'); + expect(formatRGBA(color, 1)).toBe('rgba(128, 128, 128, 1.00)'); + }); + + it('should clamp alpha values outside 0-1 range', () => { + const color: RGB = [0.5, 0.5, 0.5]; + expect(formatRGBA(color, -0.5)).toBe('rgba(128, 128, 128, 0.00)'); + expect(formatRGBA(color, 1.5)).toBe('rgba(128, 128, 128, 1.00)'); + }); + + it('should clamp RGB values outside 0-1 range', () => { + const invalid: RGB = [-0.5, 1.5, 0.5]; + expect(formatRGBA(invalid)).toBe('rgb(0, 255, 128)'); + }); + }); + + describe('rgbToHex', () => { + it('should output #RRGGBB when alpha omitted', () => { + const color: RGB = [0.031, 0.459, 0.882]; + expect(rgbToHex(color)).toBe('#0875e1'); + }); + + it('should output #RRGGBBAA when alpha provided', () => { + const color: RGB = [0.031, 0.459, 0.882]; + expect(rgbToHex(color, 0.75)).toBe('#0875e1bf'); + }); + + it('should handle white and black', () => { + expect(rgbToHex([1, 1, 1])).toBe('#ffffff'); + expect(rgbToHex([0, 0, 0])).toBe('#000000'); + }); + + it('should handle alpha = 0 and 1', () => { + expect(rgbToHex([1, 1, 1], 0)).toBe('#ffffff00'); + expect(rgbToHex([0, 0, 0], 1)).toBe('#000000ff'); + }); + + it('should clamp alpha values', () => { + const color: RGB = [0.5, 0.5, 0.5]; + expect(rgbToHex(color, -0.5)).toBe('#80808000'); + expect(rgbToHex(color, 1.5)).toBe('#808080ff'); + }); + + it('should pad single digit hex values', () => { + const color: RGB = [0.02, 0.02, 0.02]; + const result = rgbToHex(color); + expect(result).toMatch(/^#[0-9a-f]{6}$/); + expect(result.length).toBe(7); + }); + + it('should clamp out-of-range RGB values', () => { + const invalid: RGB = [-0.5, 1.5, 0.5]; + expect(rgbToHex(invalid)).toBe('#00ff80'); + expect(rgbToHex(invalid, 0.5)).toBe('#00ff8080'); + }); + }); + + describe('alphaColor', () => { + describe('with RGB tuples', () => { + it('should calculate transparent color with minimum alpha', () => { + const target: RGB = [0.5, 0.5, 0.5]; + const bg: RGB = [1, 1, 1]; + const result = alphaColor(target, bg); + + expect(result.alpha).toBeGreaterThan(0); + expect(result.alpha).toBeLessThanOrEqual(0.99); + expect(Array.isArray(result.color)).toBe(true); + expect(result.color.length).toBe(3); + expect(result.rgba).toMatch(/^rgba\(\d+, \d+, \d+, \d+\.\d+\)$/); + expect(result.hex).toMatch(/^#[0-9a-f]{8}$/); + }); + + it('should use provided target alpha', () => { + const target: RGB = [0.5, 0.5, 0.5]; + const bg: RGB = [1, 1, 1]; + const result = alphaColor(target, bg, 0.6); + expect(result.alpha).toBeCloseTo(0.6, 2); + }); + + it('should default to white background', () => { + const target: RGB = [0.5, 0.5, 0.5]; + const result = alphaColor(target); + expect(result).toBeDefined(); + expect(result.alpha).toBeGreaterThan(0); + }); + + it('should produce compositable result', () => { + const target: RGB = [0.3, 0.6, 0.9]; + const bg: RGB = [1, 1, 1]; + const result = alphaColor(target, bg); + const composited = composite(result.color, bg, result.alpha); + expect(composited[0]).toBeCloseTo(target[0], 1); + expect(composited[1]).toBeCloseTo(target[1], 1); + expect(composited[2]).toBeCloseTo(target[2], 1); + }); + + it('should return original at 100% alpha when targetAlpha cannot achieve target', () => { + const white: RGB = [1, 1, 1]; + const gray: RGB = [0.5, 0.5, 0.5]; + const result = alphaColor(white, gray, 0.5); // White on gray at 50% is impossible + expect(result.alpha).toBe(1); + expect(result.color[0]).toBeCloseTo(1, 5); + expect(result.color[1]).toBeCloseTo(1, 5); + expect(result.color[2]).toBeCloseTo(1, 5); + }); + }); + + describe('with hex strings', () => { + it('should calculate transparent color from hex strings', () => { + const result = alphaColor('#808080', '#FFFFFF'); + expect(result.alpha).toBeGreaterThan(0); + expect(result.rgba).toMatch(/^rgba\(\d+, \d+, \d+, \d+\.\d+\)$/); + expect(result.hex).toMatch(/^#[0-9a-f]{8}$/); + }); + + it('should default to white background', () => { + const result = alphaColor('#808080'); + expect(result).toBeDefined(); + expect(result.alpha).toBeGreaterThan(0); + }); + + it('should use provided target alpha', () => { + const result = alphaColor('#808080', '#FFFFFF', 0.7); + expect(result.alpha).toBeCloseTo(0.7, 2); + }); + + it('should handle 3-character hex codes', () => { + const result = alphaColor('#FFF', '#000'); + expect(result).toBeDefined(); + expect(result.alpha).toBeGreaterThan(0.9); + }); + }); + }); + + describe('round-trip conversions', () => { + it('should preserve color through alphaColor → composite cycle', () => { + const original: RGB = [0.2, 0.4, 0.8]; + const bg: RGB = [1, 1, 1]; + const result = alphaColor(original, bg); + const composited = composite(result.color, bg, result.alpha); + expect(composited[0]).toBeCloseTo(original[0], 1); + expect(composited[1]).toBeCloseTo(original[1], 1); + expect(composited[2]).toBeCloseTo(original[2], 1); + }); + + it('should match hex and RGB inputs', () => { + const hexResult = alphaColor('#0875E1', '#FFFFFF'); + const rgbResult = alphaColor(hexToRgb('#0875E1'), hexToRgb('#FFFFFF')); + expect(hexResult.alpha).toBeCloseTo(rgbResult.alpha, 2); + expect(hexResult.hex).toBe(rgbResult.hex); + }); + }); + + describe('input validation', () => { + describe('minAlpha', () => { + it('should throw for invalid color tuples', () => { + const valid: RGB = [0.5, 0.5, 0.5]; + expect(() => minAlpha(['invalid', 0.5, 0.5] as any, valid)).toThrow(TypeError); + }); + + it('should throw for non-array input', () => { + const valid: RGB = [0.5, 0.5, 0.5]; + expect(() => minAlpha({r: 0.5, g: 0.5, b: 0.5} as any, valid)).toThrow('RGB tuple'); + }); + + it('should throw for wrong array length', () => { + const valid: RGB = [0.5, 0.5, 0.5]; + expect(() => minAlpha([0.5, 0.5] as any, valid)).toThrow(TypeError); + }); + + it('should throw for NaN values', () => { + const valid: RGB = [0.5, 0.5, 0.5]; + expect(() => minAlpha([NaN, 0.5, 0.5], valid)).toThrow(RangeError); + }); + + it('should throw for Infinity values', () => { + const valid: RGB = [0.5, 0.5, 0.5]; + expect(() => minAlpha([Infinity, 0.5, 0.5], valid)).toThrow(RangeError); + }); + }); + + describe('alphaColor', () => { + it('should throw for invalid target alpha', () => { + expect(() => alphaColor('#FFF', '#000', NaN)).toThrow(TypeError); + expect(() => alphaColor('#FFF', '#000', NaN)).toThrow('finite number'); + }); + + it('should throw for invalid hex colors', () => { + expect(() => alphaColor('invalid', '#FFFFFF')).toThrow(); + }); + + it('should throw for invalid background hex', () => { + expect(() => alphaColor('#FFFFFF', 'invalid')).toThrow(); + }); + + it('should throw for invalid RGB tuple', () => { + expect(() => alphaColor([NaN, 0.5, 0.5])).toThrow(RangeError); + expect(() => alphaColor([1, 2] as any)).toThrow(TypeError); + }); + }); + }); +}); diff --git a/modules/labs-react/common/spec/generateAccessiblePalette.spec.ts b/modules/labs-react/common/spec/generateAccessiblePalette.spec.ts new file mode 100644 index 0000000000..b529fa4651 --- /dev/null +++ b/modules/labs-react/common/spec/generateAccessiblePalette.spec.ts @@ -0,0 +1,82 @@ +import {hexToRgb} from '../lib/theming/palette/conversion'; +import {generateAccessiblePalette} from '../lib/theming/palette/generateAccessiblePalette'; + +describe('generateAccessiblePalette', () => { + const testColor = '#0875E1'; + + describe('alpha tokens', () => { + it('getAlphaHex(step, "a100") should match getHex(step) RGB with alpha channel 0.17', () => { + const palette = generateAccessiblePalette(testColor); + const solidHex = palette.getHex(600); + const alpha100Hex = palette.getAlphaHex(600, 'a100'); + + expect(alpha100Hex).toBeDefined(); + // a100 is #RRGGBBAA with same RGB as solid, alpha = 0.17 (0x2b) + expect(alpha100Hex!.slice(0, 7)).toBe(solidHex); + expect(alpha100Hex!.slice(7, 9).toLowerCase()).toBe('2b'); + }); + + it('getAlphaHex(step, "a25") should return hex with alpha (#RRGGBBAA)', () => { + const palette = generateAccessiblePalette(testColor); + const alpha25Hex = palette.getAlphaHex(600, 'a25'); + + expect(alpha25Hex).toBeDefined(); + expect(alpha25Hex).toMatch(/^#[0-9a-fA-F]{8}$/); + }); + + it('getAlphaHex and getAlphaRgba should return values for all alpha levels', () => { + const palette = generateAccessiblePalette(testColor); + const levels = ['a25', 'a50', 'a100', 'a200'] as const; + + for (const level of levels) { + const hex = palette.getAlphaHex(600, level); + const rgba = palette.getAlphaRgba(600, level); + + expect(hex).toBeDefined(); + expect(hex).toMatch(/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/); + expect(rgba).toBeDefined(); + expect(rgba).toMatch(/^rgba?\(/); + } + }); + + it('alpha variant has same RGB as solid step with correct alpha channel', () => { + const palette = generateAccessiblePalette(testColor); + const solidHex = palette.getHex(600)!; + const solidRgb = hexToRgb(solidHex) as [number, number, number]; + + // a50: same RGB as step, alpha = 0.11 + const alpha50Hex = palette.getAlphaHex(600, 'a50')!; + const alpha50R = parseInt(alpha50Hex.slice(1, 3), 16) / 255; + const alpha50G = parseInt(alpha50Hex.slice(3, 5), 16) / 255; + const alpha50B = parseInt(alpha50Hex.slice(5, 7), 16) / 255; + const alpha50A = parseInt(alpha50Hex.slice(7, 9), 16) / 255; + + const eps = 1 / 255; + expect(Math.abs(alpha50R - solidRgb[0])).toBeLessThanOrEqual(eps); + expect(Math.abs(alpha50G - solidRgb[1])).toBeLessThanOrEqual(eps); + expect(Math.abs(alpha50B - solidRgb[2])).toBeLessThanOrEqual(eps); + expect(Math.abs(alpha50A - 0.11)).toBeLessThanOrEqual(eps); + }); + + it('getAlphaOklch returns oklch(L C H / alpha) string', () => { + const palette = generateAccessiblePalette(testColor); + const oklch = palette.getAlphaOklch(600, 'a50'); + + expect(oklch).toBeDefined(); + expect(oklch).toMatch(/^oklch\([\d.]+ [\d.]+ [\d.]+ \/ 0\.11\)$/); + }); + }); + + describe('step.alpha', () => { + it('each step should have alpha record with a25, a50, a100, a200', () => { + const palette = generateAccessiblePalette(testColor); + const step = palette.getStep(600); + + expect(step?.alpha).toBeDefined(); + expect(step?.alpha?.a25).toMatch(/^#[0-9a-fA-F]{8}$/); + expect(step?.alpha?.a50).toMatch(/^#[0-9a-fA-F]{8}$/); + expect(step?.alpha?.a100).toMatch(/^#[0-9a-fA-F]{8}$/); + expect(step?.alpha?.a200).toMatch(/^#[0-9a-fA-F]{8}$/); + }); + }); +}); diff --git a/modules/labs-react/package.json b/modules/labs-react/package.json index 0a130d8cc2..f57fadd3f9 100644 --- a/modules/labs-react/package.json +++ b/modules/labs-react/package.json @@ -46,6 +46,7 @@ "react": ">=17.0" }, "dependencies": { + "colorjs.io": "^0.6.0", "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", "@workday/canvas-kit-react": "^14.2.31", diff --git a/modules/react/common/lib/theming/index.ts b/modules/react/common/lib/theming/index.ts index 0277053d58..aaf3b8cb13 100644 --- a/modules/react/common/lib/theming/index.ts +++ b/modules/react/common/lib/theming/index.ts @@ -34,3 +34,7 @@ export * from './useIsRTL'; * For more information, view our [Theming Docs](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs#-preferred-approach-v14). */ export * from './getObjectProxy'; +/** + * Palette generator has moved to @workday/canvas-kit-labs-react/common. + * Import from '@workday/canvas-kit-labs-react/common' for generateAccessiblePalette, palette types, and alpha utilities. + */ diff --git a/modules/react/common/stories/mdx/PaletteGenerator.mdx b/modules/react/common/stories/mdx/PaletteGenerator.mdx new file mode 100644 index 0000000000..4974af96fd --- /dev/null +++ b/modules/react/common/stories/mdx/PaletteGenerator.mdx @@ -0,0 +1,299 @@ +import {Meta} from '@storybook/blocks'; +import {ExampleCodeBlock} from '@workday/canvas-kit-docs'; +import {PaletteGenerator} from './examples/PaletteGenerator'; + + + +# Accessible Palette Generator (Experimental) + +Generate WCAG-compliant color palettes from any input color. The palette generator creates a 13-step color scale optimized for accessibility, ensuring that key steps meet contrast requirements. + +## Overview + +The `generateAccessiblePalette` function takes any valid CSS color and generates a complete palette with: + +- **13 color steps**: 25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950, 975 +- **Alpha (transparency)**: Each step has transparent variants (a25, a50, a100, a200) that composite over the palette background +- **WCAG contrast guarantees**: Steps 500 and 600 are optimized to meet AA contrast requirements +- **Gamut mapping**: Colors stay within sRGB or P3 color gamuts +- **Multiple input formats**: Supports hex, rgb, hsl, oklch, and more + +## Interactive Example + +Try entering different colors to see how the palette generator creates accessible color scales: + + + +## Installation + +The palette generator is in `@workday/canvas-kit-labs-react/common`: + +```tsx +import {generateAccessiblePalette} from '@workday/canvas-kit-labs-react/common'; +``` + +## Basic Usage + +```tsx +import {generateAccessiblePalette} from '@workday/canvas-kit-labs-react/common'; + +// Generate a palette from a hex color +const palette = generateAccessiblePalette('#0875E1'); + +// Access specific steps +const primary = palette.getHex(500); // Main brand color +const light = palette.getHex(100); // Light variant +const dark = palette.getHex(800); // Dark variant + +// Get full step data with accessibility info +const step = palette.getStep(500); +console.log(step.hex); // "#0875E1" +console.log(step.contrastRatio); // 4.52 +console.log(step.wcagAA); // true +console.log(step.wcagAAA); // false +``` + +## Input Formats + +The palette generator accepts any valid CSS color format: + +```tsx +// Hex colors +generateAccessiblePalette('#0875E1'); +generateAccessiblePalette('#08e'); + +// RGB colors +generateAccessiblePalette('rgb(8, 117, 225)'); +generateAccessiblePalette('rgba(8, 117, 225, 1)'); + +// HSL colors +generateAccessiblePalette('hsl(210, 93%, 46%)'); + +// OKLCH colors (recommended for perceptual uniformity) +generateAccessiblePalette('oklch(0.55 0.2 250)'); +``` + +## Configuration Options + +```tsx +interface GeneratePaletteOptions { + // Color gamut: 'sRGB' (default) or 'P3' + gamut?: 'sRGB' | 'P3'; + + // Palette type: 'accessible' (default) or 'neutral' + paletteType?: 'accessible' | 'neutral'; + + // Background luminance (0-1). Values < 0.18 = dark mode + // Default: 1.0 (white background) + backgroundLuminance?: number; + + // Minimum chroma for the palette + // Default: 0.02 + minChroma?: number; + + // Amount of hue shift (0-10) + // Default: 5 + hueShiftAmount?: number; +} +``` + +### Dark Mode Palettes + +For dark mode interfaces, set `backgroundLuminance` to a low value: + +```tsx +const darkModePalette = generateAccessiblePalette('#0875E1', { + backgroundLuminance: 0.1, // Dark background +}); + +// The palette will be inverted, with lighter colors at higher step numbers +``` + +### Neutral Palettes + +Generate low-chroma palettes for backgrounds and surfaces: + +```tsx +import {generateNeutralPalette} from '@workday/canvas-kit-labs-react/common'; + +// Generate neutral grays with a blue tint +const neutrals = generateNeutralPalette(220); + +// Or extract hue from an existing color +const neutrals = generateNeutralPalette('#0875E1'); +``` + +### P3 Wide Gamut + +For displays that support Display P3, you can generate more vibrant colors: + +```tsx +const p3Palette = generateAccessiblePalette('#0875E1', { + gamut: 'P3', +}); +``` + +## Palette Step Structure + +Each step in the palette contains detailed information: + +```tsx +interface PaletteStep { + step: number; // 25, 50, 100, 200, etc. + hex: string; // Hex color value + oklch: { + // OKLCH color values + l: number; // Lightness (0-1) + c: number; // Chroma (0-0.4+) + h: number; // Hue (0-360) + }; + contrastRatio: number; // Against background + wcagAA: boolean; // Meets 4.5:1 + wcagAAA: boolean; // Meets 7:1 + alpha?: Record; // Hex with alpha (#RRGGBBAA) for a25, a50, a100, a200 +} +``` + +## Mapping to Canvas Tokens + +The primary use case for this tool is to generate values for `@workday/canvas-tokens-web` brand tokens. The accessible palette includes solid steps and **alpha (transparency)** variants for each step with true alpha channel: a25 (8% opacity), a50 (11%), a100 (17%), a200 (31%). Alpha tokens use the same step color with an alpha channel (e.g. `oklch(L C H / 0.11)` for a50). + +**Solid steps:** + +| Palette Step | Brand Token | CSS Variable | +|-------------|-------------|--------------| +| 25 | `brand.primary.lightest` | `--cnvs-brand-primary-lightest` | +| 50 | `brand.primary.lighter` | `--cnvs-brand-primary-lighter` | +| 200 | `brand.primary.light` | `--cnvs-brand-primary-light` | +| 600 | `brand.primary.base` | `--cnvs-brand-primary-base` | +| 700 | `brand.primary.dark` | `--cnvs-brand-primary-dark` | +| 800 | `brand.primary.darkest` | `--cnvs-brand-primary-darkest` | + +**Alpha (transparency) levels (per step) — alpha channel values:** + +| Alpha level | Alpha channel | Example CSS variable | +|-------------|---------------|----------------------| +| `a25` | 8% opacity (0.08) | `--cnvs-brand-primary-a25` | +| `a50` | 11% opacity (0.11) | `--cnvs-brand-primary-a50` | +| `a100` | 17% opacity (0.17) | `--cnvs-brand-primary-a100` | +| `a200` | 31% opacity (0.31) | `--cnvs-brand-primary-a200` | + +Use the palette API for both solid and alpha (alpha channel = true transparency): + +```tsx +const palette = generateAccessiblePalette('#0875E1'); + +// Solid steps +palette.getHex(600); // "#0875e1" + +// Alpha channel: same step color with opacity (a25=8%, a50=11%, a100=17%, a200=31%) +palette.getAlphaHex(600, 'a25'); // #RRGGBB + alpha 0.08 +palette.getAlphaHex(600, 'a50'); // #RRGGBB + alpha 0.11 +palette.getAlphaHex(600, 'a100'); // #RRGGBB + alpha 0.17 +palette.getAlphaHex(600, 'a200'); // #RRGGBB + alpha 0.31 +palette.getAlphaRgba(600, 'a50'); // e.g. "rgba(8, 117, 225, 0.11)" +palette.getAlphaOklch(600, 'a50'); // e.g. "oklch(0.6152 0.2108 256.1 / 0.11)" +``` + +For custom alpha over a custom background, use `alphaColor` from the same package (see alpha compositing utilities). + +### Configuring Brand Tokens via CSS + +The recommended approach is to set brand tokens in your root CSS file: + +```css +/* index.css */ +@import '@workday/canvas-tokens-web/css/base/_variables.css'; +@import '@workday/canvas-tokens-web/css/system/_variables.css'; +@import '@workday/canvas-tokens-web/css/brand/_variables.css'; + +:root { + /* Generated from palette generator with brand color #E91E63 */ + --cnvs-brand-primary-lightest: #fef1f4; + --cnvs-brand-primary-lighter: #fde3ea; + --cnvs-brand-primary-light: #f9a8be; + --cnvs-brand-primary-base: #d81b5c; + --cnvs-brand-primary-dark: #b8164d; + --cnvs-brand-primary-darkest: #99123f; +} +``` + +### Configuring Brand Tokens via JavaScript + +You can also use the palette generator with `createStyles` for runtime theming: + +```tsx +import {generateAccessiblePalette} from '@workday/canvas-kit-labs-react/common'; +import {createStyles} from '@workday/canvas-kit-styling'; +import {brand} from '@workday/canvas-tokens-web'; + +// Generate a palette from your brand color +const palette = generateAccessiblePalette('#E91E63'); + +// Create a theme using the generated colors (solid + alpha) +const customBrandTheme = createStyles({ + [brand.primary.lightest]: palette.getHex(25), + [brand.primary.lighter]: palette.getHex(50), + [brand.primary.light]: palette.getHex(200), + [brand.primary.base]: palette.getHex(600), + [brand.primary.dark]: palette.getHex(700), + [brand.primary.darkest]: palette.getHex(800), + // Alpha variants (use your token names for brand.primary.a25, etc.) + // palette.getAlphaHex(600, 'a25'), palette.getAlphaHex(600, 'a50'), ... +}); +``` + +> **Note:** The interactive example above includes a "Canvas Tokens Mapping" section with copy buttons for each token, making it easy to grab the exact CSS you need. + +## Advanced Usage + +For advanced use cases, additional utilities are exported: + +```tsx +import { + // Main functions + generateAccessiblePalette, + generateNeutralPalette, + paletteSteps, // [25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950, 975] + + // Color conversion utilities + parseColorToOklch, + hexToOklch, + oklchToHex, + hexToRgb, + + // Accessibility utilities + meetsWCAGContrast, + checkContrastRatio, + + // Gamut utilities + computeMaxChroma, + isInGamut, + + // Alpha compositing (custom alpha over custom background) + alphaColor, + formatRGBA, + rgbToHex, + + // colorjs.io Color class for advanced manipulation + Color, +} from '@workday/canvas-kit-labs-react/common'; + +// Example: Check if a color meets contrast requirements +const color = parseColorToOklch('#0875E1'); +const contrast = checkContrastRatio(color, 1.0); // Against white background +console.log(meetsWCAGContrast(contrast, 'AA')); // true +``` + +## How It Works + +The palette generator uses the OKLCH color space for perceptually uniform color manipulation: + +1. **Parse Input**: Converts any CSS color to OKLCH using colorjs.io +2. **Extract Hue**: Uses the input color's hue as the base for the palette +3. **Compute Lightness**: Maps each step to a lightness value (light to dark) +4. **Compute Chroma**: Applies a curve that peaks around step 500-600 +5. **Gamut Mapping**: Ensures colors stay within the target gamut (sRGB/P3) +6. **Contrast Adjustment**: Fine-tunes steps 500 and 600 for WCAG compliance + +The result is a palette that maintains the hue of your input color while providing a full range of accessible shades. diff --git a/modules/react/common/stories/mdx/examples/PaletteGenerator.tsx b/modules/react/common/stories/mdx/examples/PaletteGenerator.tsx new file mode 100644 index 0000000000..4d4c477750 --- /dev/null +++ b/modules/react/common/stories/mdx/examples/PaletteGenerator.tsx @@ -0,0 +1,782 @@ +import * as React from 'react'; + +import { + AccessiblePalette, + type AlphaLevel, + PaletteStep, + generateAccessiblePalette, + generateNeutralPalette, +} from '@workday/canvas-kit-labs-react/common'; +import {TertiaryButton} from '@workday/canvas-kit-react/button'; +import {FormField} from '@workday/canvas-kit-react/form-field'; +import {Box, Flex} from '@workday/canvas-kit-react/layout'; +import {Switch} from '@workday/canvas-kit-react/switch'; +import {Text} from '@workday/canvas-kit-react/text'; +import {TextInput} from '@workday/canvas-kit-react/text-input'; +import {Tooltip} from '@workday/canvas-kit-react/tooltip'; + +// Contrast pairs to display - common combinations for UI design +const CONTRAST_PAIRS = [ + {light: 100, dark: 600, label: 'Light bg + Text'}, + {light: 200, dark: 700, label: 'Surface + Text'}, + {light: 500, dark: 'white', label: 'Primary + White'}, + {light: 600, dark: 'white', label: 'Dark + White'}, +] as const; + +// Mapping from palette steps to @workday/canvas-tokens-web brand tokens +const BRAND_TOKEN_MAPPING = [ + {step: 25, token: 'brand.primary.lightest', cssVar: '--cnvs-brand-primary-lightest'}, + {step: 50, token: 'brand.primary.lighter', cssVar: '--cnvs-brand-primary-lighter'}, + {step: 200, token: 'brand.primary.light', cssVar: '--cnvs-brand-primary-light'}, + {step: 600, token: 'brand.primary.base', cssVar: '--cnvs-brand-primary-base'}, + {step: 700, token: 'brand.primary.dark', cssVar: '--cnvs-brand-primary-dark'}, + {step: 800, token: 'brand.primary.darkest', cssVar: '--cnvs-brand-primary-darkest'}, +] as const; + +// Alpha token mapping for brand.primary (step 600 = base) — a25, a50, a100, a200 +const ALPHA_LEVELS: AlphaLevel[] = ['a25', 'a50', 'a100', 'a200']; +const BRAND_ALPHA_STEP = 600; +const BRAND_ALPHA_TOKEN_MAPPING = ALPHA_LEVELS.map(level => ({ + step: BRAND_ALPHA_STEP, + alphaLevel: level, + token: `brand.primary.${level}`, + cssVar: `--cnvs-brand-primary-${level}` as const, +})); + +const getWcagLevel = (ratio: number): {level: string; color: string} => { + if (ratio >= 7) { + return {level: 'AAA', color: '#16a34a'}; + } + if (ratio >= 4.5) { + return {level: 'AA', color: '#2563eb'}; + } + if (ratio >= 3) { + return {level: 'A', color: '#ca8a04'}; + } + return {level: 'Fail', color: '#dc2626'}; +}; + +const ColorSwatch = ({ + step, + isSelected, + onClick, +}: { + step: PaletteStep; + isSelected?: boolean; + onClick?: () => void; +}) => { + const textColor = step.oklch.l > 0.6 ? '#000' : '#fff'; + + return ( + + + {step.step} + + + {step.hex} + + + + {step.contrastRatio.toFixed(2)}:1 + + {step.wcagAA && ( + + AA + + )} + {step.wcagAAA && ( + + AAA + + )} + + + ); +}; + +const TokenRow = ({ + step, + token, + cssVar, + hex, + isDarkMode, + onCopy, + copied, +}: { + step: number; + token: string; + cssVar: string; + hex: string; + isDarkMode: boolean; + onCopy: (text: string) => void; + copied: boolean; +}) => { + const textColor = isDarkMode ? '#e5e5e5' : '#333'; + const mutedColor = isDarkMode ? '#888' : '#666'; + + return ( + + + + + {token} + + + Step {step} → {hex} + + + + onCopy(`${cssVar}: ${hex};`)}> + {copied ? '✓' : 'Copy'} + + + + ); +}; + +const AlphaTokenRow = ({ + step, + alphaLevel, + token, + cssVar, + hexWithAlpha, + isDarkMode, + onCopy, + copied, +}: { + step: number; + alphaLevel: AlphaLevel; + token: string; + cssVar: string; + hexWithAlpha: string; + isDarkMode: boolean; + onCopy: (text: string) => void; + copied: boolean; +}) => { + const textColor = isDarkMode ? '#e5e5e5' : '#333'; + const mutedColor = isDarkMode ? '#888' : '#666'; + + return ( + + + + + {token} + + + Step {step} · {alphaLevel} → {hexWithAlpha} + + + + onCopy(`${cssVar}: ${hexWithAlpha};`)}> + {copied ? '✓' : 'Copy'} + + + + ); +}; + +const ContrastPair = ({ + palette, + lightStep, + darkStep, + label, +}: { + palette: AccessiblePalette; + lightStep: number | 'white' | 'black'; + darkStep: number | 'white' | 'black'; + label: string; +}) => { + const lightHex = + lightStep === 'white' + ? '#ffffff' + : lightStep === 'black' + ? '#000000' + : palette.getHex(lightStep); + const darkHex = + darkStep === 'white' ? '#ffffff' : darkStep === 'black' ? '#000000' : palette.getHex(darkStep); + + if (!lightHex || !darkHex) { + return null; + } + + // Calculate contrast ratio manually + const getLuminance = (hex: string) => { + const rgb = hex + .replace('#', '') + .match(/.{2}/g)! + .map(x => { + const c = parseInt(x, 16) / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; + }; + + const l1 = getLuminance(lightHex); + const l2 = getLuminance(darkHex); + const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); + const {level, color} = getWcagLevel(ratio); + + return ( + + + + vs + + + + {ratio.toFixed(1)}:1 + + + {level} + + + {label} + + + ); +}; + +export const PaletteGenerator = () => { + const [inputColor, setInputColor] = React.useState('#0875E1'); + const [isDarkMode, setIsDarkMode] = React.useState(false); + const [isNeutral, setIsNeutral] = React.useState(false); + const [hueShift, setHueShift] = React.useState(5); + const [minChroma, setMinChroma] = React.useState(0.02); + const [copiedStep, setCopiedStep] = React.useState(null); + const [copiedToken, setCopiedToken] = React.useState(null); + + const palette = React.useMemo(() => { + try { + const options = { + backgroundLuminance: isDarkMode ? 0.1 : 1.0, + hueShiftAmount: hueShift, + minChroma, + }; + + if (isNeutral) { + return generateNeutralPalette(inputColor, options); + } + return generateAccessiblePalette(inputColor, options); + } catch { + return generateAccessiblePalette('#0875E1'); + } + }, [inputColor, isDarkMode, isNeutral, hueShift, minChroma]); + + const handleColorChange = (event: React.ChangeEvent) => { + setInputColor(event.target.value); + }; + + const copyToClipboard = (step: PaletteStep) => { + navigator.clipboard.writeText(step.hex); + setCopiedStep(step.step); + setTimeout(() => setCopiedStep(null), 1500); + }; + + const copyAllColors = () => { + const colorMap = palette.steps.reduce( + (acc, step) => { + acc[step.step] = step.hex; + return acc; + }, + {} as Record + ); + navigator.clipboard.writeText(JSON.stringify(colorMap, null, 2)); + setCopiedStep(-1); + setTimeout(() => setCopiedStep(null), 1500); + }; + + const copyTokenValue = (text: string, token: string) => { + navigator.clipboard.writeText(text); + setCopiedToken(token); + setTimeout(() => setCopiedToken(null), 1500); + }; + + const copyAllBrandTokens = () => { + const solidCss = BRAND_TOKEN_MAPPING.map( + ({step, cssVar}) => ` ${cssVar}: ${palette.getHex(step)};` + ).join('\n'); + const alphaCss = BRAND_ALPHA_TOKEN_MAPPING.map( + ({alphaLevel, cssVar}) => ` ${cssVar}: ${palette.getAlphaHex(BRAND_ALPHA_STEP, alphaLevel)};` + ).join('\n'); + const fullCss = `:root {\n${solidCss}\n${alphaCss}\n}`; + navigator.clipboard.writeText(fullCss); + setCopiedToken('all'); + setTimeout(() => setCopiedToken(null), 1500); + }; + + const bgColor = isDarkMode ? '#1a1a1a' : '#ffffff'; + const textColor = isDarkMode ? '#ffffff' : '#000000'; + const borderColor = isDarkMode ? '#333' : '#e5e5e5'; + + return ( + + {/* Controls Section */} + + {/* Color Input */} + + + Brand Color + + setInputColor(e.target.value)} + style={{ + width: '48px', + height: '40px', + padding: 0, + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + }} + /> + + + + + + {/* Toggles */} + + + setIsDarkMode(!isDarkMode)} /> + Dark Mode + + + setIsNeutral(!isNeutral)} /> + Neutral Palette + + + + {/* Sliders */} + + + + Hue Shift: {hueShift}° + + setHueShift(Number(e.target.value))} + style={{width: '100%'}} + /> + + + + Min Chroma: {minChroma.toFixed(3)} + + setMinChroma(Number(e.target.value))} + style={{width: '100%'}} + /> + + + + + {/* Palette Display */} + + + {isNeutral ? 'Neutral' : 'Accessible'} Palette {isDarkMode && '(Dark Mode)'} + + + + {copiedStep === -1 ? 'Copied!' : 'Copy All'} + + + + + + {palette.steps.map(step => ( + + copyToClipboard(step)} + /> + + ))} + + + {/* Alpha (transparency) — under the accessible palette */} + + Alpha (transparency) + + + Transparent variants with alpha channel (a25 = 8%, a50 = 11%, a100 = 17%, a200 = 31% + opacity). Shown over background so transparency is visible. Example for step{' '} + {BRAND_ALPHA_STEP} (base): + + + {ALPHA_LEVELS.map(level => { + const hexWithAlpha = palette.getAlphaHex(BRAND_ALPHA_STEP, level); + return ( + hexWithAlpha && ( + + + + + + + {level} + + + {level === 'a25' + ? '8%' + : level === 'a50' + ? '11%' + : level === 'a100' + ? '17%' + : '31%'}{' '} + opacity + + + + ) + ); + })} + + + {/* Brand Token Mapping Section */} + + + Canvas Tokens Mapping + + + + {copiedToken === 'all' ? 'Copied!' : 'Copy All CSS'} + + + + + Use these values to configure @workday/canvas-tokens-web brand tokens (solid + + transparent alpha) in your CSS: + + + {BRAND_TOKEN_MAPPING.map(({step, token, cssVar}) => ( + copyTokenValue(text, token)} + copied={copiedToken === token} + /> + ))} + {BRAND_ALPHA_TOKEN_MAPPING.map(({step, alphaLevel, token, cssVar}) => ( + copyTokenValue(text, token)} + copied={copiedToken === token} + /> + ))} + + + {/* Contrast Ratios Section */} + + Contrast Ratios + + + {CONTRAST_PAIRS.map(({light, dark, label}) => ( + + ))} + + + {/* Usage Example */} + + Usage Example + + +
+          {`// Configure brand tokens in your CSS (e.g., index.css)
+:root {
+  ${BRAND_TOKEN_MAPPING.map(({step, cssVar}) => `${cssVar}: ${palette.getHex(step)};`).join('\n  ')}
+  ${BRAND_ALPHA_TOKEN_MAPPING.map(({alphaLevel, cssVar}) => `${cssVar}: ${palette.getAlphaHex(BRAND_ALPHA_STEP, alphaLevel)};`).join('\n  ')}
+}
+
+// Or generate dynamically with JavaScript
+import { generateAccessiblePalette${
+            isNeutral ? ', generateNeutralPalette' : ''
+          } } from '@workday/canvas-kit-labs-react/common';
+
+${
+  isNeutral
+    ? `const palette = generateNeutralPalette('${inputColor}', {
+  backgroundLuminance: ${isDarkMode ? '0.1' : '1.0'},
+});`
+    : `const palette = generateAccessiblePalette('${inputColor}', {
+  backgroundLuminance: ${isDarkMode ? '0.1' : '1.0'},
+  hueShiftAmount: ${hueShift},
+  minChroma: ${minChroma},
+});`
+}
+
+// Map to brand tokens (solid + alpha)
+const brandTokens = {
+  lightest: palette.getHex(25),   // ${palette.getHex(25)}
+  lighter: palette.getHex(50),    // ${palette.getHex(50)}
+  light: palette.getHex(200),     // ${palette.getHex(200)}
+  base: palette.getHex(600),      // ${palette.getHex(600)}
+  dark: palette.getHex(700),      // ${palette.getHex(700)}
+  darkest: palette.getHex(800),   // ${palette.getHex(800)}
+  a25: palette.getAlphaHex(600, 'a25'),   // ${palette.getAlphaHex(600, 'a25')}
+  a50: palette.getAlphaHex(600, 'a50'),   // ${palette.getAlphaHex(600, 'a50')}
+  a100: palette.getAlphaHex(600, 'a100'), // ${palette.getAlphaHex(600, 'a100')}
+  a200: palette.getAlphaHex(600, 'a200'), // ${palette.getAlphaHex(600, 'a200')}
+};`}
+        
+
+
+ ); +}; diff --git a/modules/react/package.json b/modules/react/package.json index 5ae9399c83..8d5d81e0ea 100644 --- a/modules/react/package.json +++ b/modules/react/package.json @@ -58,6 +58,7 @@ "@workday/canvas-system-icons-web": "^3.0.36", "@workday/canvas-tokens-web": "4.1.2", "@workday/design-assets-types": "^0.2.10", + "colorjs.io": "^0.6.0", "chroma-js": "^2.2.0", "csstype": "^3.0.2", "react-innertext": "^1.1.5", diff --git a/modules/react/side-panel/spec/SSR.spec.tsx b/modules/react/side-panel/spec/SSR.spec.tsx index b2a70bbc47..39172ee563 100644 --- a/modules/react/side-panel/spec/SSR.spec.tsx +++ b/modules/react/side-panel/spec/SSR.spec.tsx @@ -3,6 +3,7 @@ */ import React from 'react'; import {renderToString} from 'react-dom/server'; + import {SidePanel} from '@workday/canvas-kit-react/side-panel'; describe('SidePanel', () => { diff --git a/package.json b/package.json index 6a1314bc74..f4d54cc0db 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "@workday/canvas-applet-icons-web": "^2.0.15", "@workday/canvas-system-icons-web": "^3.0.36", "@workday/canvas-tokens-web": "4.1.2", + "colorjs.io": "^0.6.0", "resolutions": { "ansi-regex": "3.0.1", "braces": "3.0.3", diff --git a/yarn.lock b/yarn.lock index 1bc22c5e61..e1d53efeac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5547,6 +5547,11 @@ colorette@^2.0.20: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colorjs.io@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/colorjs.io/-/colorjs.io-0.6.1.tgz#5e9ca77c9cdd070ae009e008f7e3eb47985c1855" + integrity sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg== + colors@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"