diff --git a/configurator/src/components/Preview.svelte b/configurator/src/components/Preview.svelte index 4109bdd0..c18435ee 100644 --- a/configurator/src/components/Preview.svelte +++ b/configurator/src/components/Preview.svelte @@ -12,6 +12,12 @@ const brand = ['primary', 'secondary', 'tertiary', 'action', 'neutral', 'base']; const status = ['success', 'warning', 'error', 'info', 'danger']; + const surfaces = [ + { var: 'inset', label: 'Inset' }, + { var: 'bg', label: 'Background' }, + { var: 'surface', label: 'Surface' }, + { var: 'raised', label: 'Raised' }, + ]; const spaceSteps = ['2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', '3xl']; @@ -34,53 +40,124 @@
-

The quick brown fox

-

- Jumps over the lazy dog. This paragraph is rendered with the framework's - body font, text size and color tokens. A sample link - sits inline, and --sf-color-code styles inline code. -

- -
- - - -
- -
- {#each brand as c} -
- {c} -
- {/each} -
-
- {#each status as c} -
- {c} -
- {/each} -
+ +
+

Typography

+

The quick brown fox

+

+ Jumps over the lazy dog. This paragraph uses the framework's body font, + text size and an inline link, with + --sf-color-code styling inline code. +

+

Muted caption · secondary hierarchy · auto-contrasting.

+
-
-
- Card -

Surface, border, radius & shadow tokens.

+ +
+

Surfaces

+
+ {#each surfaces as s (s.var)} +
+ {s.label} +
+ {/each} +
+
+ + +
+

Brand & status

+
+ {#each brand as c (c)} +
+ {c} +
+ {/each}
-
- Inset -

Inset surface variant.

+
+ {#each status as c (c)} +
+ {c} +
+ {/each} +
+
+ + +
+

Buttons

+
+ + + + + +
+
+ + +
+

Feedback

+
+ {#each status as c (c)} +
+ {c} + {c} message — readable on its own subtle tint. +
+ {/each}
-
+ -
- {#each spaceSteps as s} -
- --sf-space-{s} - + +
+

Card component

+
+
+
+
+
S
+
+

Card Component

+

Design token preview

+
+ Active +
+

+ Radius, shadow, colours, surfaces and typography working together — + every value updates live as you adjust tokens. +

+
- {/each} -
+
+ + + +
+

Form field

+ +
+ + +
+

Spacing scale

+
+ {#each spaceSteps as s (s)} +
+ --sf-space-{s} + +
+ {/each} +
+
@@ -133,7 +210,16 @@ padding: clamp(16px, 3vw, 32px); display: flex; flex-direction: column; - gap: var(--sf-space-m, 16px); + gap: var(--sf-space-l, 24px); + } + .pv__block { display: flex; flex-direction: column; gap: 8px; margin: 0; } + .pv__eyebrow { + margin: 0; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--sf-color-text--muted, #888); } .pv__h { font-family: var(--sf-font-heading, inherit); @@ -146,6 +232,11 @@ font-size: var(--sf-text-m, 1rem); margin: 0; max-width: 60ch; + color: var(--sf-color-text--secondary, inherit); + } + .pv__muted { + margin: 0; + font-size: var(--sf-text-s, 0.85rem); color: var(--sf-color-text--muted, inherit); } .pv__a { @@ -158,6 +249,27 @@ border-radius: var(--sf-radius-s, 4px); color: var(--sf-color-code, inherit); } + + /* Surfaces / elevation */ + .pv__surfaces { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--sf-space-s, 8px); + } + .pv__surface { + min-height: 54px; + border: 1px solid var(--sf-color-border, rgba(127, 127, 127, 0.3)); + border-radius: var(--sf-radius-s, 4px); + box-shadow: var(--sf-shadow-s, 0 1px 3px rgba(0, 0, 0, 0.1)); + display: flex; + align-items: flex-end; + padding: 6px 8px; + font-size: 11px; + font-weight: 600; + color: var(--sf-color-text--secondary, inherit); + } + + /* Buttons */ .pv__btns { display: flex; gap: var(--sf-space-s, 8px); @@ -169,20 +281,34 @@ padding: 0.5em 1.1em; font-weight: 600; font-size: var(--sf-text-s, 0.9rem); + cursor: default; } .pv__btn--primary { background: var(--sf-color-primary, #4f8cff); color: var(--sf-color-text--on-primary, #fff); } + .pv__btn--secondary { + background: var(--sf-color-secondary, #6b7280); + color: var(--sf-color-text--on-secondary, #fff); + } .pv__btn--action { background: var(--sf-color-action, #0891b2); color: var(--sf-color-text--on-action, #fff); } + .pv__btn--outline { + background: transparent; + border-color: var(--sf-color-primary, currentColor); + color: var(--sf-color-primary, inherit); + } .pv__btn--ghost { background: transparent; border-color: var(--sf-color-border, currentColor); color: inherit; } + .pv__btn--sm { padding: 0.35em 0.9em; font-size: var(--sf-text-xs, 0.8rem); } + .pv__btn--push { margin-inline-start: auto; } + + /* Swatches */ .pv__swatches { display: grid; grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); @@ -198,27 +324,85 @@ font-size: 12px; text-transform: capitalize; } - .pv__cards { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--sf-space-s, 8px); + + /* Alerts */ + .pv__alerts { display: flex; flex-direction: column; gap: 6px; } + .pv__alert { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--sf-radius-s, 4px); + border-inline-start: 3px solid; + font-size: var(--sf-text-s, 0.85rem); + color: var(--sf-color-text, inherit); } + .pv__alert-text { color: var(--sf-color-text, inherit); } + .pv__badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--sf-radius-full, 999px); + font-size: 10px; + font-weight: 700; + text-transform: capitalize; + white-space: nowrap; + } + .pv__badge--push { margin-inline-start: auto; } + + /* Card */ .pv__card { background: var(--sf-color-surface, rgba(127, 127, 127, 0.06)); border: 1px solid var(--sf-color-border, rgba(127, 127, 127, 0.3)); border-radius: var(--sf-radius-m, 8px); box-shadow: var(--sf-shadow-m, 0 2px 8px rgba(0, 0, 0, 0.12)); - padding: var(--sf-space-m, 16px); + overflow: hidden; } - .pv__card p { - margin: 6px 0 0; - font-size: var(--sf-text-s, 0.9rem); - color: var(--sf-color-text--muted, inherit); + .pv__card-accent { height: 4px; background: var(--sf-color-primary, #4f8cff); } + .pv__card-body { padding: var(--sf-space-m, 16px); display: flex; flex-direction: column; gap: 10px; } + .pv__card-top { display: flex; align-items: center; gap: 10px; } + .pv__avatar { + width: 36px; + height: 36px; + border-radius: var(--sf-radius-full, 50%); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; } - .pv__card--inset { - background: var(--sf-color-inset, rgba(127, 127, 127, 0.12)); - box-shadow: none; + .pv__card-title { + margin: 0; + font-family: var(--sf-font-heading, inherit); + font-size: var(--sf-text-m, 1rem); + font-weight: 600; + color: var(--sf-color-heading, inherit); + line-height: 1.3; + } + .pv__card-sub { margin: 0; font-size: var(--sf-text-xs, 0.8rem); color: var(--sf-color-text--muted, inherit); line-height: 1.3; } + .pv__card-text { margin: 0; font-size: var(--sf-text-s, 0.9rem); color: var(--sf-color-text--secondary, inherit); } + .pv__card-footer { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding-top: 8px; + border-top: 1px solid var(--sf-color-border, rgba(127, 127, 127, 0.2)); + } + + /* Form field */ + .pv__field { display: flex; flex-direction: column; gap: 5px; max-width: 320px; } + .pv__field-label { font-size: var(--sf-text-xs, 0.8rem); font-weight: 600; color: var(--sf-color-text--secondary, inherit); } + .pv__field-input { + font-size: var(--sf-text-s, 0.9rem); + padding: 7px 10px; + border-radius: var(--sf-radius-s, 4px); + border: 1px solid var(--sf-color-border, rgba(127, 127, 127, 0.4)); + background: var(--sf-color-bg, #fff); + color: var(--sf-color-text, inherit); } + + /* Spacing */ .pv__space { display: flex; flex-direction: column; diff --git a/configurator/src/components/WcagPanel.svelte b/configurator/src/components/WcagPanel.svelte index 185cfeb5..d0f92c94 100644 --- a/configurator/src/components/WcagPanel.svelte +++ b/configurator/src/components/WcagPanel.svelte @@ -22,7 +22,7 @@ wcagLevelLarge, levelClass, resolveToRgb, - suggestAccessiblePalette, + suggestPalette, } from '../lib/color.js'; // Every color token, for the free pair picker. @@ -34,20 +34,48 @@ // Curated "text on background" matrix — the contrast pairs that actually // matter for legibility. Filtered to tokens present in this catalogue. const FG = ['--sf-color-text', '--sf-color-heading', '--sf-color-text--muted', '--sf-color-link', '--sf-color-action', '--sf-color-primary'].filter((n) => tokenByName.has(n)); - const BG = ['--sf-color-bg', '--sf-color-surface', '--sf-color-inset', '--sf-color-base', '--sf-color-primary', '--sf-color-action'].filter((n) => tokenByName.has(n)); + const BG = ['--sf-color-bg', '--sf-color-surface', '--sf-color-inset', '--sf-color-primary', '--sf-color-action'].filter((n) => tokenByName.has(n)); - // Optimizer source tokens. - const OPT = { base: '--sf-color-base', neutral: '--sf-color-neutral', action: '--sf-color-action' }; - const optAvailable = tokenByName.has(OPT.base) && tokenByName.has(OPT.neutral) && tokenByName.has(OPT.action); + // Unified palette generator — ONE surface, ONE lock set. Every role is a + // framework brand token; locked roles are kept, the rest generated coherently + // against the (possibly locked) base. Generation always works off the LIGHT + // source palette, since the framework derives dark mode from it. + const ROLE_TOKENS = { + base: '--sf-color-base', + neutral: '--sf-color-neutral', + primary: '--sf-color-primary', + secondary: '--sf-color-secondary', + tertiary: '--sf-color-tertiary', + action: '--sf-color-action', + }; + const PALETTE_ROLES = ['base', 'neutral', 'primary', 'secondary', 'tertiary', 'action'].filter( + (r) => tokenByName.has(ROLE_TOKENS[r]) + ); + const genAvailable = tokenByName.has(ROLE_TOKENS.base); + const paletteMeasured = PALETTE_ROLES.map((r) => ROLE_TOKENS[r]); // Pair-picker selection. let fg = $state(FG[0] ?? colorNames[0] ?? ''); let bg = $state(BG[0] ?? colorNames[1] ?? colorNames[0] ?? ''); - // Tokens we need a resolved RGB for this render. - const measured = $derived([...new Set([...FG, ...BG, fg, bg, OPT.base, OPT.neutral, OPT.action].filter(Boolean))]); + // "Used combinations" preview: auto-contrasting on-color text on each brand + // & status color — the pairing that actually ships (button labels, badges), + // which the text-on-surface matrix doesn't cover. Resolved in the preview + // theme, so it reflects light/dark. + const USAGE_ROLES = ['primary', 'secondary', 'tertiary', 'action', 'success', 'warning', 'error', 'info'].filter( + (r) => tokenByName.has(`--sf-color-${r}`) && tokenByName.has(`--sf-color-text--on-${r}`) + ); + const usageTokens = USAGE_ROLES.flatMap((r) => [`--sf-color-${r}`, `--sf-color-text--on-${r}`]).filter((n) => + tokenByName.has(n) + ); + + // Tokens we need a resolved RGB for this render (checker + matrix + usage). + const measured = $derived([...new Set([...FG, ...BG, ...usageTokens, fg, bg].filter(Boolean))]); const declStr = $derived(buildPreviewDeclarations(overrides, ui.previewTheme)); + // The generator resolves its inputs in LIGHT mode regardless of the preview + // theme, because brand overrides are written as the light source palette. + const declLight = $derived(buildPreviewDeclarations(overrides, 'light')); /** name -> [r,g,b] | null, read from the probe spans after each render. */ let resolved = $state({}); @@ -82,6 +110,22 @@ resolved = next; }); + // Light-mode resolution for the generator inputs (own probe root). + let paletteResolved = $state({}); + /** @type {Record} */ + let paletteProbes = {}; + $effect(() => { + void declLight; + const next = {}; + for (const name of paletteMeasured) { + const el = paletteProbes[name]; + if (!el) continue; + const computed = getComputedStyle(el).color; + next[name] = parseRgb(computed) ?? resolveToRgb(computed); + } + paletteResolved = next; + }); + const fgRgb = $derived(resolved[fg] ?? null); const bgRgb = $derived(resolved[bg] ?? null); const ratio = $derived(fgRgb && bgRgb ? contrastRatio(fgRgb, bgRgb) : null); @@ -103,25 +147,57 @@ ) ); - // ── Optimizer ────────────────────────────────────────────────────────── + // On-color usage chips: contrast of the on-color text against its color. + const usage = $derived( + USAGE_ROLES.map((role) => { + const bgN = `--sf-color-${role}`; + const fgN = `--sf-color-text--on-${role}`; + const c = resolved[bgN]; + const t = resolved[fgN]; + const r = c && t ? contrastRatio(t, c) : null; + return { role, bg: bgN, fg: fgN, ratio: r, level: r !== null ? wcagLevel(r) : null }; + }) + ); + + // ── Unified palette generator ──────────────────────────────────────────── let suggestion = $state(null); let optMsg = $state(''); - function runOptimizer() { + // Lock state across ALL roles: a locked role is kept as a fixed anchor, the + // rest are generated coherently against the (possibly locked) base. Lock the + // one or two brand colors the client gave you and generate the rest WCAG-safe. + let locked = $state({ + base: false, + neutral: false, + primary: false, + secondary: false, + tertiary: false, + action: false, + }); + + function toggleLock(role) { + locked[role] = !locked[role]; + if (suggestion) runGenerator(); + } + + function runGenerator() { optMsg = ''; - suggestion = suggestAccessiblePalette({ - baseRgb: resolved[OPT.base], - neutralRgb: resolved[OPT.neutral], - actionRgb: resolved[OPT.action], - }); - if (!suggestion) optMsg = 'Could not resolve base/neutral/action colors to optimize.'; + const roles = {}; + for (const role of PALETTE_ROLES) { + const rgb = paletteResolved[ROLE_TOKENS[role]]; + if (rgb) roles[role] = rgb; + } + suggestion = suggestPalette({ roles, locked: { ...locked } }); + if (!suggestion) optMsg = 'Could not resolve the base color to generate against.'; } function applySuggestion() { if (!suggestion) return; - if (suggestion.base) setOverride(OPT.base, suggestion.base.color); - if (suggestion.neutral) setOverride(OPT.neutral, suggestion.neutral.color); - if (suggestion.action) setOverride(OPT.action, suggestion.action.color); + // Write only generated (unlocked) roles — locked anchors keep their value. + for (const role of PALETTE_ROLES) { + const s = suggestion[role]; + if (s && s.color && !s.locked) setOverride(ROLE_TOKENS[role], s.color); + } optMsg = 'Applied ✓'; suggestion = null; setTimeout(() => (optMsg = ''), 2400); @@ -135,6 +211,13 @@ {/each}
+ + +

Accessibility

@@ -228,32 +311,87 @@ {/if} - - {#if optAvailable} + + {#if usage.length} +
+

Text on colors — used combinations

+

+ How the auto-contrasting on-color text reads on each brand & status color in + {ui.previewTheme} mode (button labels, badges). Switch the theme from the live preview bar. +

+
+ {#each usage as u (u.role)} +
+ Aa + {u.role} + {#if u.ratio !== null} + {u.ratio.toFixed(1)}:1 + {/if} +
+ {/each} +
+
+ {/if} + + + {#if genAvailable}
-

Palette optimizer

+

Accessible palette generator

- Keeps your brand hues but searches lightness for the most accessible - base, neutral and action values. + One coherent palette, generated against a single surface so the foundation + and brand accents never drift. Lock the one or two brand + colors a client gave you — the rest (and the neutral body text) are generated + to clear WCAG, with distinct hues spread around the unlocked colors.

+ +
+ {#each PALETTE_ROLES as role (role)} + + {/each} +
+
- + {#if optMsg}{optMsg}{/if}
{#if suggestion}
- {#each [['base', suggestion.base], ['neutral', suggestion.neutral], ['action', suggestion.action]] as [role, s] (role)} -
- - {role} - {#if s} - {s.color} - {#if s.ratio != null}{s.ratio.toFixed(2)}:1 {wcagLevel(s.ratio)}{:else}surface{/if} - {:else} - no accessible value on this hue - {/if} -
+ {#each PALETTE_ROLES as role (role)} + {@const s = suggestion[role]} + {#if s} +
+ + {role}{#if s.locked} 🔒{/if} + {#if s.color} + {s.color} + {:else} + locked — unchanged + {/if} + {#if s.ratio != null} + {s.ratio.toFixed(2)}:1 {wcagLevel(s.ratio)} + {:else} + surface + {/if} +
+ {:else if s === null} +
+ + {role} + no accessible value on this hue — left unchanged +
+ {/if} {/each}
@@ -383,15 +521,63 @@ .dot--aa-large { background: #fbe7a8; } .dot--fail { background: #f5c2c2; } + /* On-color usage chips */ + .usage { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; } + .usage__chip { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 12px; + border-radius: var(--cfg-radius-s); + border: 1px solid var(--cfg-border); + min-height: 52px; + } + .usage__aa { font-size: 20px; font-weight: 700; line-height: 1; } + .usage__role { font-size: 12px; text-transform: capitalize; flex: 1; opacity: 0.92; } + /* Optimizer */ .opt__actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .opt__msg { font-size: 13px; color: var(--cfg-ok); font-weight: 500; } + + /* Lock controls */ + .opt__locks { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 14px; } + .opt__lock { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + background: var(--cfg-bg); + border: 1px solid var(--cfg-border-strong); + border-radius: var(--cfg-radius-s); + color: var(--cfg-text-muted); + font-size: 12px; + text-transform: capitalize; + cursor: pointer; + transition: border-color 120ms, color 120ms, background 120ms; + } + .opt__lock:hover { color: var(--cfg-text); } + .opt__lock--on { + border-color: var(--cfg-accent-strong); + color: var(--cfg-text); + background: var(--cfg-surface-2); + font-weight: 600; + } + .opt__lock-ico { font-size: 13px; line-height: 1; } + .opt__lock-sw { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--cfg-border-strong); + } + .opt__locked-tag { font-size: 11px; } .opt__results { margin-top: 14px; border: 1px solid var(--cfg-border); border-radius: var(--cfg-radius); overflow: hidden; } .opt__row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--cfg-border); } .opt__row:last-child { border-bottom: none; } + .opt__row--warn { opacity: 0.75; } .opt__sw { width: 30px; height: 30px; border-radius: var(--cfg-radius-s); border: 1px solid var(--cfg-border-strong); flex-shrink: 0; } .opt__role { font-weight: 600; text-transform: capitalize; min-width: 70px; } .opt__val { font-family: var(--cfg-mono); font-size: 11px; background: var(--cfg-bg); padding: 2px 6px; border-radius: 3px; color: var(--cfg-text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .opt__val--locked { font-style: italic; color: var(--cfg-text-faint); } .opt__none { color: var(--cfg-text-faint); font-size: 12px; } .opt__apply { display: flex; gap: 8px; padding: 12px 14px; background: var(--cfg-surface-2); } diff --git a/configurator/src/data/api-index.generated.json b/configurator/src/data/api-index.generated.json index 11f59a54..12eda948 100644 --- a/configurator/src/data/api-index.generated.json +++ b/configurator/src/data/api-index.generated.json @@ -2,7 +2,7 @@ "_sync": { "generatedBy": "configurator/scripts/sync-api.mjs", "source": "docs/api-index.json", - "frameworkVersion": "0.5.27", + "frameworkVersion": "0.5.28", "tokensHash": "e3312bd5fde6", "bundles": [ "essential", diff --git a/configurator/src/lib/color.js b/configurator/src/lib/color.js index 1428d942..0d03de84 100644 --- a/configurator/src/lib/color.js +++ b/configurator/src/lib/color.js @@ -148,6 +148,121 @@ export function hslToRgb(h, s, l) { ]; } +/* ──────────────────────────────────────────────────────────────────────── + * RGB ↔ OKLab ↔ OKLCH (Björn Ottosson, 2020) + * + * The framework defines every main color in OKLCH (e.g. `oklch(0.47 0.27 264)`) + * and derives shades/dark-mode with `oklch(from …)`; OKLab is used only as the + * interpolation space inside `color-mix(in oklab, …)`. We mirror that here so + * the palette generator searches in the SAME perceptually-uniform space the + * framework renders in: equal L steps look equally bright across hues, so + * generated colors stay even and we can preserve hue while adjusting lightness. + * ──────────────────────────────────────────────────────────────────────── */ + +/** + * Convert one 0–1 linear-light channel back to a 0–255 sRGB channel, clamped + * to the displayable range. + * @param {number} c linear-light value + * @returns {number} 0–255 sRGB channel + */ +function linearToSrgbChannel(c) { + const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + return Math.round(Math.max(0, Math.min(1, v)) * 255); +} + +/** + * Convert 0–255 RGB to OKLCH [L(0–1), C(≥0), H(0–360)]. + * @param {number} r + * @param {number} g + * @param {number} b + * @returns {[number,number,number]} + */ +export function rgbToOklch(r, g, b) { + const lr = toLinear(r); + const lg = toLinear(g); + const lb = toLinear(b); + const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb; + const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb; + const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb; + const l_ = Math.cbrt(l); + const m_ = Math.cbrt(m); + const s_ = Math.cbrt(s); + const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_; + const A = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_; + const B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_; + let H = (Math.atan2(B, A) * 180) / Math.PI; + if (H < 0) H += 360; + return [L, Math.hypot(A, B), H]; +} + +/** + * Convert OKLab to UNCLAMPED linear-light sRGB (used for gamut testing). + * @param {number} L + * @param {number} A + * @param {number} B + * @returns {[number,number,number]} + */ +function oklabToLinearRgb(L, A, B) { + const l_ = L + 0.3963377774 * A + 0.2158037573 * B; + const m_ = L - 0.1055613458 * A - 0.0638541728 * B; + const s_ = L - 0.0894841775 * A - 1.291485548 * B; + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ]; +} + +/** + * Fit an OKLCH color to the sRGB gamut, preserving L and H. If the requested + * chroma is displayable it's kept; otherwise chroma is reduced (binary search) + * until the color fits. Returns BOTH the resulting 0–255 rgb AND the chroma + * actually used, so callers can serialise a `color` string that describes the + * exact same color whose contrast they measured (no requested-vs-rendered gap). + * + * @param {number} L lightness 0–1 + * @param {number} C requested chroma ≥ 0 + * @param {number} H hue 0–360 + * @returns {{ rgb: [number,number,number], chroma: number }} + */ +export function fitOklchToGamut(L, C, H) { + const h = (H * Math.PI) / 180; + const at = (c) => oklabToLinearRgb(L, c * Math.cos(h), c * Math.sin(h)); + const inGamut = (lin) => lin.every((v) => v >= -0.0001 && v <= 1.0001); + let chroma = C; + if (!inGamut(at(chroma))) { + let lo = 0; + let hi = C; + for (let i = 0; i < 24; i += 1) { + const mid = (lo + hi) / 2; + if (inGamut(at(mid))) lo = mid; + else hi = mid; + } + chroma = lo; + } + const lin = at(chroma); + return { + rgb: [linearToSrgbChannel(lin[0]), linearToSrgbChannel(lin[1]), linearToSrgbChannel(lin[2])], + chroma, + }; +} + +/** + * Convert OKLCH to a 0–255 [r,g,b] triplet, gamut-mapping chroma as needed + * (preserving L and H), matching how browsers gamut-map oklch(). + * + * @param {number} L lightness 0–1 + * @param {number} C chroma ≥ 0 + * @param {number} H hue 0–360 + * @returns {[number,number,number]} + */ +export function oklchToRgb(L, C, H) { + return fitOklchToGamut(L, C, H).rgb; +} + /* ──────────────────────────────────────────────────────────────────────── * Browser color resolution * ──────────────────────────────────────────────────────────────────────── */ @@ -187,70 +302,312 @@ export function resolveToRgb(cssValue) { * ──────────────────────────────────────────────────────────────────────── */ /** - * Suggest the most accessible BASE / Neutral / Action values for a palette, - * preserving each input's hue while searching lightness for the best WCAG - * score against the proposed BASE surface. Pure: takes and returns RGB so it - * is fully unit-testable (no canvas). + * Search the lightness axis of a fixed OKLCH (chroma, hue) line for the value + * that clears a target WCAG contrast ratio against a fixed surface, returning + * the *softest* passing value — the one closest in lightness to the surface + * that still clears the bar — so generated colors are never needlessly heavy. + * + * Hue is preserved exactly (OKLCH H) and chroma held constant (gamut-reduced + * only where a lightness can't physically hold it), so the result keeps the + * source's character and varies only perceived lightness — the approach used + * by perceptual tools (Leonardo, ColorBox) and consistent with the framework's + * own `oklch(from …)` derivations. + * + * The search direction adapts to the surface lightness: on a light surface it + * prefers darker values, on a dark surface lighter ones, so it works for ANY + * locked base. Falls back to the other direction if the preferred one can't + * clear the target. + * + * @param {number} chroma OKLCH chroma to hold (≥ 0) + * @param {number} hue OKLCH hue 0–360, preserved from the source color + * @param {[number,number,number]} surfaceRgb background to contrast against + * @param {number} target minimum contrast ratio to clear (e.g. 7 or 4.5) + * @returns {{ color: string, rgb: [number,number,number], ratio: number, locked: false } | null} + */ +export function bestOklchOnSurface(chroma, hue, surfaceRgb, target) { + const surfaceLok = rgbToOklch(...surfaceRgb)[0]; + // Use OKLab lightness for BOTH the direction choice and the in-direction + // test, so a surface near the crossover can't bias the search two ways. + const preferDark = surfaceLok >= 0.5; + let best = null; // softest passing value in the preferred direction + let bestAny = null; // softest passing value in any direction (fallback) + for (let L = 0; L <= 1.0000001; L += 0.0025) { + // Fit to gamut and capture the chroma actually used, so the emitted + // `color` string describes the exact color we measured the ratio on. + const { rgb, chroma: usedC } = fitOklchToGamut(L, chroma, hue); + const ratio = contrastRatio(rgb, surfaceRgb); + if (ratio < target) continue; + if (!bestAny || ratio < bestAny.ratio) bestAny = { L, rgb, ratio, usedC }; + const inDir = preferDark ? L <= surfaceLok : L >= surfaceLok; + if (inDir && (!best || ratio < best.ratio)) best = { L, rgb, ratio, usedC }; + } + const pick = best || bestAny; + if (!pick) return null; + return { + color: `oklch(${pick.L.toFixed(4)} ${pick.usedC.toFixed(4)} ${hue.toFixed(2)})`, + rgb: pick.rgb, + ratio: pick.ratio, + locked: false, + }; +} + +/** + * Suggest an accessible BASE / Neutral / Action palette. + * + * Each role can be LOCKED via `locked`: a locked color is treated as a fixed + * anchor — never regenerated, only echoed back with its measured ratio so the + * UI can keep the user's exact value. Unlocked roles are generated to clear + * WCAG against the surface while preserving their input hue. This is what lets + * a user lock one or two brand colors and have the rest generated to comply. + * + * The surface used for contrast is BASE: when BASE is locked the user's actual + * color becomes the surface (so a dark brand background still yields a legible + * neutral + action); when BASE is unlocked a very light, lightly-tinted surface + * is proposed (the previous default behavior). + * + * Pure: takes and returns RGB so it is fully unit-testable (no canvas). * * @param {object} input * @param {[number,number,number]} input.baseRgb current BASE/surface color * @param {[number,number,number]} input.neutralRgb current body-text color * @param {[number,number,number]} input.actionRgb current action/link color + * @param {{ base?: boolean, neutral?: boolean, action?: boolean }} [input.locked] + * roles to keep fixed instead of regenerating * @returns {{ - * base: { color: string, rgb: [number,number,number] }, - * neutral: { color: string, rgb: [number,number,number], ratio: number } | null, - * action: { color: string, rgb: [number,number,number], ratio: number } | null + * base: { color: string|null, rgb: [number,number,number], ratio: number|null, locked: boolean }, + * neutral: { color: string|null, rgb: [number,number,number], ratio: number, locked: boolean } | null, + * action: { color: string|null, rgb: [number,number,number], ratio: number, locked: boolean } | null * } | null} */ -export function suggestAccessiblePalette({ baseRgb, neutralRgb, actionRgb }) { +export function suggestAccessiblePalette({ baseRgb, neutralRgb, actionRgb, locked = {} }) { if (!baseRgb || !neutralRgb || !actionRgb) return null; - const [bH, bS] = rgbToHsl(...baseRgb); - const [nH, nS] = rgbToHsl(...neutralRgb); - const [aH, aS] = rgbToHsl(...actionRgb); - - // BASE: very light surface — keep the hue tint, cap saturation. - const baseS = Math.min(bS, 8); - const baseColor = `hsl(${bH.toFixed(1)} ${baseS.toFixed(1)}% 97%)`; - const baseResolved = hslToRgb(bH, baseS, 97); - - // NEUTRAL: lightest dark value reaching AAA (7:1) on the proposed BASE. - const neutralS = Math.min(nS, 15); - let neutralBest = null; - for (let l = 8; l <= 32; l += 0.25) { - const rgb = hslToRgb(nH, neutralS, l); - const r = contrastRatio(rgb, baseResolved); - if (r >= 7) { - neutralBest = { - color: `hsl(${nH.toFixed(1)} ${neutralS.toFixed(1)}% ${l.toFixed(2)}%)`, - rgb, - ratio: r, - }; - } else { - break; + const lockBase = !!locked.base; + const lockNeutral = !!locked.neutral; + const lockAction = !!locked.action; + + // BASE / surface: locked → the user's actual color is the surface; unlocked + // → a very light OKLCH surface that keeps the hue tint but caps chroma + // (mirrors the framework's `oklch(0.96 0.006 250)` base-light). + let baseColor; + let baseResolved; + if (lockBase) { + baseResolved = baseRgb; + baseColor = null; + } else { + const [, bC, bH] = rgbToOklch(...baseRgb); + const surC = Math.min(bC, 0.02); + baseColor = `oklch(0.96 ${surC.toFixed(4)} ${bH.toFixed(2)})`; + baseResolved = oklchToRgb(0.96, surC, bH); + } + + // NEUTRAL: near-neutral body text reaching AAA (7:1); locked → kept as-is. + let neutral; + if (lockNeutral) { + neutral = { color: null, rgb: neutralRgb, ratio: contrastRatio(neutralRgb, baseResolved), locked: true }; + } else { + const [, nC, nH] = rgbToOklch(...neutralRgb); + neutral = bestOklchOnSurface(Math.min(nC, 0.05), nH, baseResolved, 7); + } + + // ACTION: keeps its vivid chroma, reaching AA (4.5:1); locked → kept as-is. + let action; + if (lockAction) { + action = { color: null, rgb: actionRgb, ratio: contrastRatio(actionRgb, baseResolved), locked: true }; + } else { + const [, aC, aH] = rgbToOklch(...actionRgb); + action = bestOklchOnSurface(Math.max(aC, 0.12), aH, baseResolved, 4.5); + } + + return { + base: { color: baseColor, rgb: baseResolved, ratio: null, locked: lockBase }, + neutral, + action, + }; +} + +/** + * Find the integer hue (0–359) on the color wheel that is maximally distant + * from every hue already in use — i.e. the middle of the widest gap. Used to + * place generated brand colors so they stay visually distinct from the ones + * you locked. Distance is measured the short way around the circle. + * + * @param {number[]} usedHues hues (degrees) already taken + * @returns {number} hue in [0, 359] + */ +export function farthestHue(usedHues) { + if (!usedHues || usedHues.length === 0) return 0; + const norm = usedHues.map((u) => ((u % 360) + 360) % 360); + let bestHue = 0; + let bestDist = -1; + for (let h = 0; h < 360; h += 1) { + let min = 360; + for (const u of norm) { + const raw = Math.abs(h - u); + const d = Math.min(raw, 360 - raw); + if (d < min) min = d; + } + if (min > bestDist) { + bestDist = min; + bestHue = h; } } + return bestHue; +} + +/** + * Generate a full BRAND palette (primary / secondary / tertiary / action) from + * a partial one — the "the client gave us one or two brand colors, fill in the + * rest, WCAG-compliant" workflow. + * + * LOCKED roles are kept exactly as the user set them (echoed back with their + * measured ratio). UNLOCKED roles are generated so they: + * 1. sit in the widest hue gaps left by the locked colors, so the palette + * stays visually distinct and balanced around the wheel; and + * 2. clear a WCAG contrast target against the surface (default AA 4.5:1), so + * each generated color is usable as an accessible accent/text/link. + * + * Generated colors take their chroma from the average of the locked anchors + * (so the set feels cohesive), with a floor so they stay lively. Hue gaps are + * computed in OKLCH so the spread is perceptually even. Pure RGB in / out — no + * DOM — so it is fully unit-testable. + * + * @param {object} input + * @param {Record} input.roles resolved RGB per + * role; unresolvable roles may be omitted. + * @param {[number,number,number]} input.surfaceRgb background to contrast against + * @param {Record} [input.locked] roles to keep fixed + * @param {number} [input.target] minimum contrast vs surface (default 4.5 = AA) + * @returns {Record | null} + */ +export function suggestBrandPalette({ roles = {}, surfaceRgb, locked = {}, target = 4.5 }) { + if (!surfaceRgb) return null; + const order = ['primary', 'secondary', 'tertiary', 'action']; - // ACTION: lightest value reaching AA (4.5:1) on BASE, richer saturation. - const actionS = Math.max(aS, 80); - let actionBest = null; - for (let l = 15; l <= 58; l += 0.25) { - const rgb = hslToRgb(aH, actionS, l); - const r = contrastRatio(rgb, baseResolved); - if (r >= 4.5) { - actionBest = { - color: `hsl(${aH.toFixed(1)} ${actionS.toFixed(1)}% ${l.toFixed(2)}%)`, - rgb, - ratio: r, - }; + // Seed the scheme from the locked anchors' OKLCH hues + chromas. + const usedHues = []; + const lockedChromas = []; + for (const role of order) { + if (locked[role] && roles[role]) { + const [, C, H] = rgbToOklch(...roles[role]); + usedHues.push(H); + lockedChromas.push(C); + } + } + const avgChroma = lockedChromas.length + ? lockedChromas.reduce((a, b) => a + b, 0) / lockedChromas.length + : null; + + const result = {}; + + // 1. Locked anchors: echo untouched (their hues were seeded above). + for (const role of order) { + if (locked[role] && roles[role]) { + const rgb = roles[role]; + result[role] = { color: null, rgb, ratio: contrastRatio(rgb, surfaceRgb), locked: true }; + } + } + + // 2. Unlocked roles, PRESENT ones first so a real input anchors the scheme + // when nothing is locked; absent roles then fill the remaining gaps. This + // stops an absent leading role from seeding a phantom hue 0 that would + // shove the present roles off their own hues. + const unlocked = order.filter((r) => !(locked[r] && roles[r])); + const ordered = [...unlocked.filter((r) => roles[r]), ...unlocked.filter((r) => !roles[r])]; + for (const role of ordered) { + // Pick a hue in the widest gap (or this role's own hue if nothing taken). + const hue = usedHues.length + ? farthestHue(usedHues) + : roles[role] + ? rgbToOklch(...roles[role])[2] + : 0; + usedHues.push(hue); + // Cohesive, lively chroma (OKLCH chroma floor keeps accents vivid). + let chroma = avgChroma; + if (chroma == null) chroma = roles[role] ? rgbToOklch(...roles[role])[1] : 0.15; + chroma = Math.max(chroma, 0.12); + // Most surface-friendly value on that hue that still clears the target. + const hit = bestOklchOnSurface(chroma, hue, surfaceRgb, target); + result[role] = hit + ? { color: hit.color, rgb: hit.rgb, ratio: hit.ratio, locked: false } + : null; + } + return result; +} + +/** + * The single, unified accessible-palette generator. ONE surface, ONE lock set, + * everything generated coherently against each other so the foundation + * (base + neutral) and the brand accents (primary / secondary / tertiary / + * action) never drift apart. + * + * It composes the lower-level helpers rather than duplicating them: + * - the SURFACE is `base` (kept if locked, else a light tinted surface); + * - `neutral` becomes body text clearing AAA on that surface; + * - the brand accents are spread into the widest hue gaps left by the locked + * colors and each clears AA on that same surface. + * + * Any subset of roles can be locked; locked roles are echoed back untouched + * with their measured ratio, the rest are generated. Roles whose RGB isn't + * supplied are simply omitted from the result. Pure RGB in / out. + * + * @param {object} input + * @param {{ base?:[number,number,number], neutral?:[number,number,number], primary?:[number,number,number], secondary?:[number,number,number], tertiary?:[number,number,number], action?:[number,number,number] }} input.roles + * @param {Record} [input.locked] + * @param {number} [input.neutralTarget] body-text contrast target (default 7 = AAA) + * @param {number} [input.accentTarget] brand-accent contrast target (default 4.5 = AA) + * @returns {Record | null} + */ +export function suggestPalette({ roles = {}, locked = {}, neutralTarget = 7, accentTarget = 4.5 }) { + if (!roles.base) return null; + + // 1. Surface = base. Locked → the user's base IS the surface; unlocked → a + // light OKLCH surface (keeps the hue tint, caps chroma). + let surfaceRgb; + let baseColor; + if (locked.base) { + surfaceRgb = roles.base; + baseColor = null; + } else { + const [, bC, bH] = rgbToOklch(...roles.base); + const surC = Math.min(bC, 0.02); + baseColor = `oklch(0.96 ${surC.toFixed(4)} ${bH.toFixed(2)})`; + surfaceRgb = oklchToRgb(0.96, surC, bH); + } + + const out = {}; + out.base = { color: baseColor, rgb: surfaceRgb, ratio: null, locked: !!locked.base }; + + // 2. Neutral (body text) → AAA on the surface, near-neutral chroma. + if (roles.neutral) { + if (locked.neutral) { + out.neutral = { color: null, rgb: roles.neutral, ratio: contrastRatio(roles.neutral, surfaceRgb), locked: true }; } else { - break; + const [, nC, nH] = rgbToOklch(...roles.neutral); + out.neutral = bestOklchOnSurface(Math.min(nC, 0.05), nH, surfaceRgb, neutralTarget); } } - return { - base: { color: baseColor, rgb: baseResolved }, - neutral: neutralBest, - action: actionBest, - }; + // 3. Brand accents → distinct hues, AA on the SAME surface (shared logic). + const brand = suggestBrandPalette({ + roles: { + primary: roles.primary, + secondary: roles.secondary, + tertiary: roles.tertiary, + action: roles.action, + }, + surfaceRgb, + locked: { + primary: locked.primary, + secondary: locked.secondary, + tertiary: locked.tertiary, + action: locked.action, + }, + target: accentTarget, + }); + for (const role of ['primary', 'secondary', 'tertiary', 'action']) { + if (roles[role]) out[role] = brand[role]; + } + + return out; } diff --git a/configurator/tests/color.test.js b/configurator/tests/color.test.js index 2dfe220f..3c1990e6 100644 --- a/configurator/tests/color.test.js +++ b/configurator/tests/color.test.js @@ -15,6 +15,12 @@ import { rgbToHsl, hslToRgb, suggestAccessiblePalette, + bestOklchOnSurface, + rgbToOklch, + oklchToRgb, + suggestBrandPalette, + farthestHue, + suggestPalette, } from '../src/lib/color.js'; const WHITE = [255, 255, 255]; @@ -95,10 +101,235 @@ describe('palette optimizer', () => { actionRgb: [40, 110, 220], }); assert.ok(out, 'suggestion produced'); - assert.match(out.base.color, /^hsl\(/); + assert.match(out.base.color, /^oklch\(/); assert.ok(out.neutral, 'neutral found'); assert.ok(out.neutral.ratio >= 7, `neutral AAA: ${out.neutral.ratio}`); assert.ok(out.action, 'action found'); assert.ok(out.action.ratio >= 4.5, `action AA: ${out.action.ratio}`); }); }); + + +describe('palette optimizer — locks', () => { + test('an unlocked suggestion carries lock flags and keeps the proposed surface', () => { + const out = suggestAccessiblePalette({ + baseRgb: [240, 240, 245], + neutralRgb: [40, 44, 52], + actionRgb: [40, 110, 220], + }); + assert.equal(out.base.locked, false); + assert.equal(out.neutral.locked, false); + assert.equal(out.action.locked, false); + assert.match(out.base.color, /^oklch\(/); + }); + + test('a locked role is echoed back untouched with its measured ratio', () => { + const action = [40, 110, 220]; + const out = suggestAccessiblePalette({ + baseRgb: [240, 240, 245], + neutralRgb: [40, 44, 52], + actionRgb: action, + locked: { action: true }, + }); + assert.equal(out.action.locked, true); + assert.equal(out.action.color, null); + assert.deepEqual(out.action.rgb, action); + assert.ok(out.action.ratio > 1); + // the unlocked neutral is still generated to clear AAA + assert.equal(out.neutral.locked, false); + assert.ok(out.neutral.ratio >= 7); + }); + + test('a locked DARK base generates a lighter, legible neutral + action', () => { + const darkBase = [20, 22, 28]; + const out = suggestAccessiblePalette({ + baseRgb: darkBase, + neutralRgb: [40, 44, 52], + actionRgb: [40, 110, 220], + locked: { base: true }, + }); + assert.equal(out.base.locked, true); + assert.equal(out.base.color, null); + assert.deepEqual(out.base.rgb, darkBase); + assert.ok(out.neutral, 'neutral generated'); + assert.ok(out.neutral.ratio >= 7, `neutral AAA on dark base: ${out.neutral.ratio}`); + assert.ok(relativeLuminance(out.neutral.rgb) > relativeLuminance(darkBase)); + assert.ok(out.action, 'action generated'); + assert.ok(out.action.ratio >= 4.5, `action AA on dark base: ${out.action.ratio}`); + }); +}); + +describe('bestOklchOnSurface', () => { + test('finds a passing value and reports its true ratio', () => { + const hit = bestOklchOnSurface(0.15, 220, [255, 255, 255], 4.5); + assert.ok(hit, 'value found'); + assert.ok(hit.ratio >= 4.5); + assert.match(hit.color, /^oklch\(/); + assert.equal(hit.locked, false); + }); + + test('returns null when the target is unreachable on the line', () => { + // ~5.3:1 is the ceiling on mid-grey; 22 can never be met. + assert.equal(bestOklchOnSurface(0.05, 0, [128, 128, 128], 22), null); + }); +}); + +describe('farthestHue', () => { + test('opposite hue when one is taken', () => { + assert.equal(farthestHue([0]), 180); + }); + + test('picks a widest gap with two taken', () => { + const h = farthestHue([0, 180]); + // both 90 and 270 are 90° away; either is a valid widest-gap pick + assert.ok(h === 90 || h === 270, `got ${h}`); + }); + + test('zero when nothing taken', () => { + assert.equal(farthestHue([]), 0); + }); +}); + +describe('suggestBrandPalette', () => { + const surface = [255, 255, 255]; // white page + + test('keeps locked roles untouched and generates the rest', () => { + const roles = { + primary: [40, 110, 220], + secondary: [200, 40, 80], + tertiary: [40, 110, 220], + action: [40, 110, 220], + }; + const out = suggestBrandPalette({ + roles, + surfaceRgb: surface, + locked: { primary: true, secondary: true }, + }); + // locked roles are echoed back as-is + assert.equal(out.primary.locked, true); + assert.equal(out.primary.color, null); + assert.deepEqual(out.primary.rgb, roles.primary); + assert.equal(out.secondary.locked, true); + // generated roles are produced and clear AA on the surface + assert.equal(out.tertiary.locked, false); + assert.ok(out.tertiary.color); + assert.ok(out.tertiary.ratio >= 4.5, `tertiary AA: ${out.tertiary.ratio}`); + assert.equal(out.action.locked, false); + assert.ok(out.action.ratio >= 4.5, `action AA: ${out.action.ratio}`); + }); + + test('generated hues stay distinct from the locked anchors', () => { + const out = suggestBrandPalette({ + roles: { primary: [220, 40, 40], secondary: [40, 200, 90] }, + surfaceRgb: surface, + locked: { primary: true, secondary: true }, + }); + const hueOf = (rgb) => rgbToOklch(...rgb)[2]; + const lockedHues = [hueOf([220, 40, 40]), hueOf([40, 200, 90])]; + for (const role of ['tertiary', 'action']) { + const gh = hueOf(out[role].rgb); + const minDist = Math.min( + ...lockedHues.map((u) => { + const raw = Math.abs(gh - u); + return Math.min(raw, 360 - raw); + }) + ); + assert.ok(minDist > 15, `${role} hue ${gh} too close to a lock (${minDist}°)`); + } + }); + + test('can generate an entire palette from scratch (no locks)', () => { + const out = suggestBrandPalette({ + roles: { primary: [40, 110, 220] }, + surfaceRgb: surface, + }); + for (const role of ['primary', 'secondary', 'tertiary', 'action']) { + assert.ok(out[role], `${role} present`); + assert.ok(out[role].ratio >= 4.5, `${role} AA: ${out[role].ratio}`); + } + }); + + test('returns null without a surface', () => { + assert.equal(suggestBrandPalette({ roles: {}, surfaceRgb: null }), null); + }); +}); + +describe('suggestPalette (unified generator)', () => { + const roles = { + base: [245, 246, 250], + neutral: [40, 44, 52], + primary: [40, 110, 220], + secondary: [200, 40, 80], + tertiary: [20, 160, 110], + action: [120, 70, 210], + }; + + test('generates every supplied role against one shared surface', () => { + const out = suggestPalette({ roles }); + for (const r of ['base', 'neutral', 'primary', 'secondary', 'tertiary', 'action']) { + assert.ok(out[r], `${r} present`); + } + // neutral clears AAA, brand accents clear AA — all vs the SAME surface. + assert.ok(out.neutral.ratio >= 7, `neutral AAA: ${out.neutral.ratio}`); + for (const r of ['primary', 'secondary', 'tertiary', 'action']) { + assert.ok(out[r].ratio >= 4.5, `${r} AA: ${out[r].ratio}`); + } + }); + + test('locked roles are kept; only the rest are generated (action handled once)', () => { + const out = suggestPalette({ + roles, + locked: { base: true, primary: true }, + }); + // locked + assert.equal(out.base.locked, true); + assert.deepEqual(out.base.rgb, roles.base); + assert.equal(out.primary.locked, true); + assert.deepEqual(out.primary.rgb, roles.primary); + // generated against the locked base as the surface + assert.equal(out.action.locked, false); + assert.ok(out.action.ratio >= 4.5); + assert.equal(out.secondary.locked, false); + assert.equal(out.tertiary.locked, false); + }); + + test('omits roles whose RGB is not supplied', () => { + const out = suggestPalette({ roles: { base: [245, 246, 250], primary: [40, 110, 220] } }); + assert.ok(out.base); + assert.ok(out.primary); + assert.equal(out.neutral, undefined); + assert.equal(out.secondary, undefined); + }); + + test('returns null without a base', () => { + assert.equal(suggestPalette({ roles: { primary: [1, 2, 3] } }), null); + }); +}); + +describe('OKLCH generation — review fixes', () => { + test('bestOklchOnSurface emits a color whose chroma round-trips to its rgb', () => { + // Request a chroma well beyond sRGB so gamut-fitting must kick in; the + // emitted oklch() string must describe the SAME color we measured, not the + // (unreachable) requested chroma — otherwise the certified ratio is a lie. + const hit = bestOklchOnSurface(0.4, 30, [255, 255, 255], 4.5); + assert.ok(hit); + const m = /^oklch\(([\d.]+) ([\d.]+) ([\d.]+)\)$/.exec(hit.color); + assert.ok(m, `parseable: ${hit.color}`); + const [, L, C, H] = m.map(Number); + const rt = oklchToRgb(L, C, H); + assert.deepEqual(rt, hit.rgb, 'emitted color re-renders to the measured rgb'); + // and the requested chroma was actually reduced to fit the gamut + assert.ok(C < 0.4, `chroma fitted below request: ${C}`); + }); + + test('a single non-primary present role keeps its own hue (no phantom hue 0)', () => { + // Only secondary supplied (a red ~oklch H≈25); the absent primary must not + // seed hue 0 and shove secondary onto the opposite side of the wheel. + const red = [200, 40, 80]; + const ownHue = rgbToOklch(...red)[2]; + const out = suggestBrandPalette({ roles: { secondary: red }, surfaceRgb: [255, 255, 255] }); + const genHue = rgbToOklch(...out.secondary.rgb)[2]; + const dist = Math.min(Math.abs(genHue - ownHue), 360 - Math.abs(genHue - ownHue)); + assert.ok(dist < 20, `secondary kept ~its hue (own ${ownHue.toFixed(0)}, got ${genHue.toFixed(0)})`); + }); +});