-
Notifications
You must be signed in to change notification settings - Fork 1
feat(configurator): paste any colour format + always-visible hex reference #636
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jackgranatowski
merged 3 commits into
main
from
claude/configurator-controls-audit-zqi10q
Jul 19, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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})`; | ||
| } | ||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
|
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]); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.