import { BackgroundColor, Color, Theme, type CssColor } from '@adobe/leonardo-contrast-colors'; import { canonicalizeRgbHex, hexToHsl, hslToHex } from './utils/color'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** Palette step keys produced by `generateColorScale`. */ export type PaletteStep = (typeof PALETTE_STEPS)[number] | 'input'; /** Token category that supports palette generation. */ export type PaletteCategory = 'brand' | 'accent' | 'base'; /** Map of palette step → resolved hex color. */ export type PaletteScale = Record; /** Map of token dot-path → resolved hex color (e.g. `color.brand.500` → `#548cdc`). */ export type PaletteTokenMap = Record; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const REFERENCE_BACKGROUND = '#ffffff'; export const PALETTE_STEPS = [ '50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950', ] as const; /** * Contrast ratios for each palette step against white (#ffffff). * * Step 700 targets WCAG AA for normal text (≥ 4.5:1). */ const CONTRAST_RATIOS: Record<(typeof PALETTE_STEPS)[number], number> = { '50': 1.05, '100': 1.1, '200': 1.2, '300': 1.5, '400': 2.25, '500': 3.2, '600': 4.8, '700': 6.1, '800': 9.3, '900': 13.8, '950': 17, }; // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- /** * Validate and normalise a hex color string. * * Accepts `#RGB` (4 chars) or `#RRGGBB` (7 chars). Throws a descriptive * `TypeError` for any other input. * * @returns The normalised 7-character hex string (e.g. `#aabbcc`). */ function validateHex(hex: string): CssColor { if (typeof hex !== 'string') { throw new TypeError(`Invalid hex color: expected a string, received ${typeof hex}`); } const canonical = canonicalizeRgbHex(hex); if (!canonical) { throw new TypeError( `Invalid hex color "${hex}": must be a 4-character (#RGB) or 7-character (#RRGGBB) hex string starting with "#"`, ); } return canonical as CssColor; } // --------------------------------------------------------------------------- // Core generation — wraps Leonardo behind a thin interface // --------------------------------------------------------------------------- /** * Generate an 11-step contrast-based color scale from a single hex color. * * Uses `@adobe/leonardo-contrast-colors` internally. The generation algorithm * is wrapped behind this function so the underlying library is swappable * without changing the public API. * * @param hex - A valid hex color string (`#RGB` or `#RRGGBB`). * @param category - The token namespace (`'brand'` or `'accent'`). * @returns A `PaletteTokenMap` keyed by token dot-paths * (e.g. `"color.brand.50"` through `"color.brand.950"` plus `"color.brand.input"`). */ export function generateColorScale(hex: string, category: PaletteCategory): PaletteTokenMap { const normalised = validateHex(hex); const ratiosObject: Record = {}; for (const step of PALETTE_STEPS) { ratiosObject[step] = CONTRAST_RATIOS[step]; } const color = new Color({ name: 'palette', colorKeys: [normalised], colorSpace: 'OKLCH', ratios: ratiosObject, smooth: false, output: 'HEX', }); const bg = new BackgroundColor({ name: 'background', colorKeys: [REFERENCE_BACKGROUND], colorSpace: 'CAM02p', smooth: true, ratios: [], output: 'HEX', }); const theme = new Theme({ colors: [color], backgroundColor: bg, lightness: 100, contrast: 1, saturation: 100, output: 'HEX', formula: 'wcag2', }); const pairs = theme.contrastColorPairs; const tokenPrefix = `color.${category}`; const result: PaletteTokenMap = {}; for (const step of PALETTE_STEPS) { result[`${tokenPrefix}.${step}`] = pairs[step].toLowerCase(); } result[`${tokenPrefix}.input`] = normalised; return result; } // --------------------------------------------------------------------------- // Base color-key derivation // --------------------------------------------------------------------------- /** Saturation (0–1) applied to the brand hue to produce the base color key. */ const BASE_SATURATION = 0.05; /** * Derive the base palette color key from a brand color. * * The base scale shares the brand's hue and lightness but is nearly neutral: * the brand color is converted to HSL and its saturation is lowered to * {@link BASE_SATURATION} (5%). The resulting hex is the color key fed to * {@link generateColorScale} for the `base` category. * * @param brandHex - A valid hex color string (`#RGB` or `#RRGGBB`). * @returns The normalised hex color key for the base scale. */ export function deriveBaseColorKey(brandHex: string): string { const { h, l } = hexToHsl(validateHex(brandHex)); return hslToHex({ h, s: BASE_SATURATION, l }); }