Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## Unreleased

### Features
- **configurator:** colour fields now accept any CSS colour format — paste a hex, `rgb()`, `hsl()`, a named colour, `lab()`/`lch()`, `color()`, etc. and it is converted automatically into the token's canonical space (OKLCH, or OKLAB where that is the field's default). Conversion uses the browser's own colour engine, so it matches exactly what gets painted; `var()` references and already-canonical values pass through untouched.

## [0.7.24] - 2026-07-19

### Features
Expand Down
23 changes: 20 additions & 3 deletions configurator/src/components/inputs/ColorInput.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { tick } from 'svelte';
import { resolveColor, previewVersion } from '../../lib/previewResolver.svelte';
import { colorSpaceOf, normalizeColorInput, previewHex, type ColorSpace } from '../../lib/colorConvert';

let {
token,
Expand Down Expand Up @@ -34,13 +35,29 @@
return t.startsWith("--") && !t.startsWith("var(") ? `var(${t})` : t;
}

// Target colour space for this field — match whatever the current value is
// authored in (oklch/oklab), else default to oklch. A pasted hex/rgb/hsl/named
// colour is converted into this space so the stored token stays canonical.
let targetSpace = $derived<ColorSpace>(colorSpaceOf(value) === 'oklab' ? 'oklab' : 'oklch');

// Commit a value: expand the var() shorthand, then convert a foreign concrete
// colour into the target space (var() refs and same-space values pass through).
function commit(raw: string): void {
const norm = normalize(raw);
if (!norm) { onReset(); return; }
onSet(normalizeColorInput(norm, targetSpace));
}

function paint(expr: string): string {
void previewVersion.value;
const norm = normalize(expr);
return resolveColor(norm) || norm || "transparent";
}

let swatchColor = $derived(paint(value || `var(${token})`));
// Always-visible hex reference so a pasted hex stays recognisable after it is
// normalised to the token's canonical space.
let hex = $derived.by(() => { void previewVersion.value; return previewHex(value || `var(${token})`); });

// Detect if the current value is a CSS variable reference (can't use native picker)
let isVar = $derived(value.trim().startsWith("var(") || value.trim().startsWith("--"));
Expand Down Expand Up @@ -68,6 +85,7 @@
type="color"
value={toHex(swatchColor)}
oninput={(e) => onSet((e.target as HTMLInputElement).value)}
onchange={(e) => commit((e.target as HTMLInputElement).value)}
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
tabindex="-1"
/>
Expand All @@ -81,9 +99,7 @@
value={value}
onblur={(e) => {
if (cancelBlur) { cancelBlur = false; editing = false; return; }
const v = normalize((e.target as HTMLInputElement).value);
if (!v) onReset();
else onSet(v);
commit((e.target as HTMLInputElement).value);
editing = false;
}}
onkeydown={(e) => {
Expand All @@ -103,6 +119,7 @@
{:else}
{placeholder ?? "default"}
{/if}
{#if hex && !isVar}<span class="text-slate-400 dark:text-slate-600"> · {hex}</span>{/if}
</button>
{/if}

Expand Down
33 changes: 21 additions & 12 deletions configurator/src/components/inputs/OklchColorDesk.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script lang="ts">
import RangeWithNumber from './RangeWithNumber.svelte';
import { normalizeColorInput, previewHex } from '../../lib/colorConvert';
import { previewVersion } from '../../lib/previewResolver.svelte';

let { label, tokenName, value, overridden, onChange, onReset }: {
label: string;
Expand Down Expand Up @@ -44,10 +46,23 @@

let swatchColor = $derived(oklchToRgbApprox(parsed.l, parsed.c, parsed.h));
let shortName = $derived(tokenName.replace("--sf-", ""));
// Always-visible hex reference so a pasted hex stays recognisable after it's
// normalised to oklch(). Reactive via previewVersion (the probe recomputes).
let hex = $derived.by(() => { void previewVersion.value; return previewHex(value); });

function update(l: number, c: number, h: number) {
onChange(oklchToCSS(l, c, h));
}

// Commit the Raw value field. A pasted hex / rgb() / hsl() / named colour is
// converted to OKLCH (this desk's canonical space) so the L/C/H sliders stay
// in sync; oklch() input and var() references pass through unchanged.
function commitRaw() {
const raw = localRaw.trim();
if (!raw) return;
onChange(normalizeColorInput(raw, 'oklch'));
localRaw = "";
}
</script>

<div class={`rounded-xl border transition-all ${overridden ? "bg-indigo-500/8 border-indigo-500/20" : "bg-black/4 dark:bg-white/4 border-black/8 dark:border-white/8 hover:border-black/12 dark:hover:border-white/12"}`}>
Expand All @@ -62,7 +77,9 @@
<div class="flex-1 text-left min-w-0">
<div class="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{label}</div>
<div class="text-[9px] font-mono text-slate-500">{shortName}</div>
<div class="text-[9px] font-mono text-slate-400 dark:text-slate-600">{value}</div>
<div class="text-[9px] font-mono text-slate-400 dark:text-slate-600 truncate">
{value}{#if hex}<span class="text-slate-500 dark:text-slate-500"> · {hex}</span>{/if}
</div>
</div>
{#if overridden}
<div class="w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0"></div>
Expand Down Expand Up @@ -134,23 +151,15 @@
type="text"
value={localRaw || value}
oninput={(e) => { localRaw = (e.target as HTMLInputElement).value; }}
onblur={() => {
if (localRaw.trim()) {
onChange(localRaw.trim());
localRaw = "";
}
}}
onblur={commitRaw}
onkeydown={(e) => {
if (e.key === "Enter") {
if (localRaw.trim()) {
onChange(localRaw.trim());
localRaw = "";
}
commitRaw();
(e.currentTarget as HTMLInputElement).blur();
}
}}
class="flex-1 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-lg px-2 py-1.5 text-[10px] font-mono text-slate-800 dark:text-slate-200 focus:outline-none focus:border-indigo-500"
placeholder="oklch(0.6 0.15 264)"
placeholder="oklch(…) · paste #hex, rgb(), hsl()…"
/>
{#if overridden}
<button
Expand Down
127 changes: 127 additions & 0 deletions configurator/src/lib/colorConvert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* 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 <input> 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<ColorSpace, string> = {
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})`;
}
Comment thread
jackgranatowski marked this conversation as resolved.

/**
* 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;
}
Comment thread
jackgranatowski marked this conversation as resolved.

/**
* 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]);
}
93 changes: 93 additions & 0 deletions configurator/tests/colorConvert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Unit tests for src/lib/colorConvert.ts — the "paste any colour, store it
* canonical" helpers behind the colour inputs.
*
* The browser does the actual format conversion (via the live preview iframe),
* so we mock the resolver to drive `convertColor`/`normalizeColorInput`
* deterministically and pin the decision logic: what gets converted, what is
* left untouched, cross-space handling, the compact formatting (incl. the CSS
* `none` channel and alpha), and the raw-text fallback when the engine is
* unavailable.
*/
import { describe, test, expect, vi, beforeEach } from 'vitest';

// The relative-colour expression the resolver is asked to compute, keyed so a
// test can hand back whatever the browser "would" serialise.
let resolveImpl: (expr: string) => string;
vi.mock('../src/lib/previewResolver.svelte', () => ({
resolveColor: (expr: string) => resolveImpl(expr),
resolveRgb: () => null,
previewVersion: { value: 0 },
}));

import { colorSpaceOf, normalizeColorInput } from '../src/lib/colorConvert';

beforeEach(() => {
// Default: engine unavailable (returns "" — as with no preview registered).
resolveImpl = () => '';
});

describe('colorSpaceOf', () => {
test('classifies OKLCH / OKLAB literals by their own space', () => {
expect(colorSpaceOf('oklch(0.6 0.15 264)')).toBe('oklch');
expect(colorSpaceOf(' OKLAB(0.7 0.1 -0.1) ')).toBe('oklab');
});

test('classifies every other concrete colour literal as "other"', () => {
for (const v of ['#ff0000', '#f00', 'rgb(255 0 0)', 'hsl(120 100% 50%)', 'red', 'rebeccapurple', 'color(display-p3 1 0 0)']) {
expect(colorSpaceOf(v)).toBe('other');
}
});

test('returns null for references and expressions we must not touch', () => {
expect(colorSpaceOf('')).toBeNull();
expect(colorSpaceOf(' ')).toBeNull();
expect(colorSpaceOf('--sf-color-primary')).toBeNull();
expect(colorSpaceOf('var(--sf-color-primary)')).toBeNull();
expect(colorSpaceOf('oklch(from var(--sf-color-primary) l c h)')).toBeNull();
});
});

describe('normalizeColorInput', () => {
test('passes through same-space and reference values without touching the engine', () => {
const spy = vi.fn(() => 'oklch(0 0 0)');
resolveImpl = spy;
expect(normalizeColorInput('oklch(0.6 0.15 264)', 'oklch')).toBe('oklch(0.6 0.15 264)');
expect(normalizeColorInput('var(--sf-color-primary)', 'oklch')).toBe('var(--sf-color-primary)');
expect(normalizeColorInput(' oklab(0.7 0.1 -0.1) ', 'oklab')).toBe('oklab(0.7 0.1 -0.1)');
expect(spy).not.toHaveBeenCalled();
});

test('converts a foreign colour to the target space', () => {
resolveImpl = (expr) => (expr.startsWith('oklch(from') ? 'oklch(0.627966 0.257704 29.2346)' : '');
expect(normalizeColorInput('#ff0000', 'oklch')).toBe('oklch(0.628 0.258 29.2)');
});

test('converts the OTHER canonical space (oklab pasted into an oklch field)', () => {
// The regression Greptile caught: without conversion this stored oklab(…)
// verbatim and the desk's L/C/H sliders fell back to their defaults.
resolveImpl = (expr) => (expr.startsWith('oklch(from oklab(') ? 'oklch(0.7 0.141421 315)' : '');
expect(normalizeColorInput('oklab(0.7 0.1 -0.1)', 'oklch')).toBe('oklch(0.7 0.141 315)');
});

test('handles the CSS `none` channel (achromatic → 0)', () => {
resolveImpl = () => 'oklch(0.534 0 none)';
expect(normalizeColorInput('white', 'oklch')).toBe('oklch(0.534 0 0)');
});

test('preserves alpha below 1', () => {
resolveImpl = () => 'oklch(0.572549 0.233753 265.289 / 0.8)';
expect(normalizeColorInput('#3366ffcc', 'oklch')).toBe('oklch(0.573 0.234 265.3 / 0.8)');
});

test('keeps the raw text when the colour cannot be resolved', () => {
resolveImpl = () => ''; // engine unavailable
expect(normalizeColorInput('#ff0000', 'oklch')).toBe('#ff0000');
expect(normalizeColorInput('hsl(120 100% 50%)', 'oklch')).toBe('hsl(120 100% 50%)');
});

test('rejects a result that is not in the target space (invalid input fell back to rgb)', () => {
resolveImpl = () => 'rgb(0, 0, 0)';
expect(normalizeColorInput('notacolor', 'oklch')).toBe('notacolor');
});
});
Loading