/** * colorConvert — paste any CSS colour, store it in the token's canonical space. * * The configurator's colour tokens are authored in OKLCH (a few gradient * interpolations use OKLAB). Designers, though, usually have a brand colour as * a hex/rgb/hsl string. Rather than force them to pre-convert, these helpers * take whatever CSS colour they paste and normalise it into the field's target * space using the browser's own colour engine — via relative-colour syntax * (`oklch(from l c h)`) resolved against the live preview iframe. That * means every format CSS understands (hex 3/4/6/8, rgb(a), hsl(a), named, * hwb, lab/lch, color(), even oklab) is supported for free, with no colour-math * library and no drift from what the browser will actually paint. */ import { resolveColor, resolveRgb } from './previewResolver.svelte'; import { rgbToHex } from './colorUtils'; export type ColorSpace = 'oklch' | 'oklab'; const SPACE_PREFIX: Record = { oklch: 'oklch(', oklab: 'oklab(', }; /** * Classify a concrete value's colour space. Returns the space when it's already * OKLCH/OKLAB, `'other'` for any other concrete colour literal (hex, rgb, hsl, * named, …) that we can convert, or `null` for things we must not touch — an * empty string, a `var(--…)` reference, or any expression containing one. */ export function colorSpaceOf(value: string): ColorSpace | 'other' | null { const v = value.trim().toLowerCase(); if (!v) return null; if (v.startsWith('--') || /\bvar\(/.test(v)) return null; if (v.startsWith('oklch(')) return 'oklch'; if (v.startsWith('oklab(')) return 'oklab'; return 'other'; } /** Round to `dp` decimals without trailing zeros (12.300 → "12.3", 0.0 → "0"). */ function round(n: number, dp: number): string { return String(Number(n.toFixed(dp))); } /** * Reformat a browser-serialised `oklch(L C H[ / a])` / `oklab(L A B[ / a])` * string to the configurator's compact convention. Hue keeps one decimal; the * other channels keep three. Alpha is preserved only when below 1. */ function format(resolved: string, space: ColorSpace): string | null { // CSS Color 4 allows the `none` keyword for a missing/powerless channel // (e.g. an achromatic colour's hue). Accept it and treat it as 0 for storage. const CH = '(?:none|[-\\d.eE+]+)'; const m = new RegExp( `^okl(?:ch|ab)\\(\\s*(${CH})\\s+(${CH})\\s+(${CH})\\s*(?:\\/\\s*(none|[-\\d.eE+%]+))?\\s*\\)$`, 'i', ).exec(resolved.trim()); if (!m) return null; const ch = (s: string) => (s.toLowerCase() === 'none' ? 0 : parseFloat(s)); const c1 = ch(m[1]); const c2 = ch(m[2]); const c3 = ch(m[3]); if (!Number.isFinite(c1) || !Number.isFinite(c2) || !Number.isFinite(c3)) return null; // oklch: L C H (hue in degrees → 1dp). oklab: L A B (all → 3dp). const body = space === 'oklch' ? `${round(c1, 3)} ${round(c2, 3)} ${round(c3, 1)}` : `${round(c1, 3)} ${round(c2, 3)} ${round(c3, 3)}`; let alpha: number | null = null; if (m[4] !== undefined) { alpha = m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]); } return alpha !== null && Number.isFinite(alpha) && alpha < 1 ? `${space}(${body} / ${round(alpha, 3)})` : `${space}(${body})`; } /** * Convert any CSS colour the browser can parse into `target` space, using the * live preview iframe's colour engine. Returns `null` when the input isn't a * valid colour (invalid relative-colour syntax leaves the probe on its * inherited colour, which serialises as `rgb(…)` rather than the target space) * or when the preview isn't ready — callers should then keep the raw text. */ export function convertColor(input: string, target: ColorSpace): string | null { const src = input.trim(); if (!src) return null; const channels = target === 'oklab' ? 'l a b' : 'l c h'; const resolved = resolveColor(`${target}(from ${src} ${channels})`); if (!resolved || !resolved.toLowerCase().startsWith(SPACE_PREFIX[target])) return null; return format(resolved, target); } /** * Commit helper for a colour field: given raw user text and the field's target * space, return the value to store. A `var()`/reference or an already-in-space * value passes through untouched (so the user's exact text is respected); a * foreign but valid colour is converted; an unparseable string is kept as-is so * the user can fix it rather than lose their paste. */ export function normalizeColorInput(input: string, target: ColorSpace): string { const v = input.trim(); const space = colorSpaceOf(v); // Leave references/expressions (null) and already-in-target values untouched; // convert everything else — foreign colours AND the other canonical space // (e.g. a pasted oklab() into an oklch field, which would otherwise be stored // verbatim and leave the desk's L/C/H sliders stuck on their defaults). if (space === null || space === target) return v; return convertColor(v, target) ?? v; } /** * The sRGB hex a colour actually paints as, for an always-visible reference * next to the canonical (OKLCH/OKLAB) value — so a designer who pasted a hex * still recognises their colour after it's normalised. Resolved through the * preview iframe and read back off a canvas, so it's the real gamut-mapped * pixel (out-of-sRGB colours are clamped, same as the browser paints them). * Accepts a `--token` / `var()` reference too. Returns null when unavailable. */ export function previewHex(value: string): string | null { const v = value.trim(); if (!v) return null; const expr = v.startsWith('--') && !v.startsWith('var(') ? `var(${v})` : v; const rgb = resolveRgb(expr); if (!rgb) return null; return rgbToHex(rgb[0], rgb[1], rgb[2]); }