From 5d7ef37880f0b3338c98fc55c7eaf105130438f0 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:48:04 +0000 Subject: [PATCH 1/5] feat(configurator): add WCAG color locks + richer live preview - suggestAccessiblePalette() gains a per-role lock contract: a locked role (base/neutral/action) is kept as a fixed anchor and only echoed back with its measured ratio, while unlocked roles are generated to clear WCAG against the (possibly locked) base surface. Lock one or two brand colors and have the rest derived compliant. Backward compatible. - New bestTextOnSurface() helper does an adaptive light/dark search so a locked dark base still yields a legible neutral + action. - WcagPanel: lock toggles per role, locked-aware result rows. - Preview: richer showcase (surfaces/elevation, tinted alerts, card with avatar/badges, form field, secondary/outline buttons) on real tokens. - Re-sync api-index.generated.json frameworkVersion 0.5.27 -> 0.5.28. - Tests cover lock passthrough, locked-dark-base generation, helper. Co-authored-by: Jack Granatowski --- configurator/src/components/Preview.svelte | 294 ++++++++++++++---- configurator/src/components/WcagPanel.svelte | 93 +++++- .../src/data/api-index.generated.json | 2 +- configurator/src/lib/color.js | 135 +++++--- configurator/tests/color.test.js | 66 ++++ 5 files changed, 479 insertions(+), 111 deletions(-) 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..005160f2 100644 --- a/configurator/src/components/WcagPanel.svelte +++ b/configurator/src/components/WcagPanel.svelte @@ -107,21 +107,36 @@ let suggestion = $state(null); let optMsg = $state(''); + // Lock state: a locked role is kept as a fixed anchor; the optimizer then + // generates the remaining roles to clear WCAG against it. Lock one or two + // brand colors and let the rest be derived compliant. + let locked = $state({ base: false, neutral: false, action: false }); + const OPT_ROLES = ['base', 'neutral', 'action']; + + function toggleLock(role) { + locked[role] = !locked[role]; + // Keep an open suggestion in sync with the new lock set. + if (suggestion) runOptimizer(); + } + function runOptimizer() { optMsg = ''; suggestion = suggestAccessiblePalette({ baseRgb: resolved[OPT.base], neutralRgb: resolved[OPT.neutral], actionRgb: resolved[OPT.action], + locked: { ...locked }, }); if (!suggestion) optMsg = 'Could not resolve base/neutral/action colors to optimize.'; } 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); + // Only write generated (unlocked) roles — locked anchors keep their value. + for (const role of OPT_ROLES) { + const s = suggestion[role]; + if (s && s.color && !s.locked) setOverride(OPT[role], s.color); + } optMsg = 'Applied ✓'; suggestion = null; setTimeout(() => (optMsg = ''), 2400); @@ -235,7 +250,27 @@

Keeps your brand hues but searches lightness for the most accessible base, neutral and action values. + Lock one or two colors to pin them as fixed anchors — the rest are + generated to clear WCAG against your locked base.

+ +
+ {#each OPT_ROLES as role (role)} + + {/each} +
+
{#if optMsg}{optMsg}{/if} @@ -243,13 +278,22 @@ {#if suggestion}
- {#each [['base', suggestion.base], ['neutral', suggestion.neutral], ['action', suggestion.action]] as [role, s] (role)} + {#each OPT_ROLES as role (role)} + {@const s = suggestion[role]}
- - {role} + + {role}{#if s && s.locked} 🔒{/if} {#if s} - {s.color} - {#if s.ratio != null}{s.ratio.toFixed(2)}:1 {wcagLevel(s.ratio)}{:else}surface{/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} no accessible value on this hue {/if} @@ -386,12 +430,45 @@ /* 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__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..331d9a22 100644 --- a/configurator/src/lib/color.js +++ b/configurator/src/lib/color.js @@ -187,70 +187,111 @@ 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 a hue+saturation line for the value that clears a target WCAG + * contrast ratio against a fixed surface, returning the *softest* passing + * value — the one closest to the surface that still clears the bar — so + * generated text/action colors are never needlessly heavy. + * + * The search direction adapts to the surface luminance: on a light surface it + * prefers darker values, on a dark surface lighter ones, so it produces a + * legible color for ANY locked base (not just near-white surfaces). Falls back + * to the opposite direction if the preferred one can't clear the target. + * + * @param {number} hue 0–360, preserved from the source color + * @param {number} sat 0–100 saturation held constant while scanning lightness + * @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 bestTextOnSurface(hue, sat, surfaceRgb, target) { + const surfaceL = rgbToHsl(...surfaceRgb)[2]; + const preferDark = relativeLuminance(surfaceRgb) >= 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 <= 100; l += 0.25) { + const rgb = hslToRgb(hue, sat, l); + const ratio = contrastRatio(rgb, surfaceRgb); + if (ratio < target) continue; + if (!bestAny || ratio < bestAny.ratio) bestAny = { rgb, ratio, l }; + const inDir = preferDark ? l <= surfaceL : l >= surfaceL; + if (inDir && (!best || ratio < best.ratio)) best = { rgb, ratio, l }; + } + const pick = best || bestAny; + if (!pick) return null; + return { + color: `hsl(${hue.toFixed(1)} ${sat.toFixed(1)}% ${pick.l.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 lockBase = !!locked.base; + const lockNeutral = !!locked.neutral; + const lockAction = !!locked.action; + 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; - } + // BASE / surface: locked → the user's actual color is the surface; unlocked + // → a very light surface that keeps the hue tint but caps saturation. + let baseColor; + let baseResolved; + if (lockBase) { + baseResolved = baseRgb; + baseColor = null; + } else { + const baseS = Math.min(bS, 8); + baseColor = `hsl(${bH.toFixed(1)} ${baseS.toFixed(1)}% 97%)`; + baseResolved = hslToRgb(bH, baseS, 97); } - // 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, - }; - } else { - break; - } - } + // NEUTRAL: value reaching AAA (7:1) on the surface; locked → kept as-is. + const neutral = lockNeutral + ? { color: null, rgb: neutralRgb, ratio: contrastRatio(neutralRgb, baseResolved), locked: true } + : bestTextOnSurface(nH, Math.min(nS, 15), baseResolved, 7); + + // ACTION: value reaching AA (4.5:1) on the surface, richer saturation; + // locked → kept as-is. + const action = lockAction + ? { color: null, rgb: actionRgb, ratio: contrastRatio(actionRgb, baseResolved), locked: true } + : bestTextOnSurface(aH, Math.max(aS, 80), baseResolved, 4.5); return { - base: { color: baseColor, rgb: baseResolved }, - neutral: neutralBest, - action: actionBest, + base: { color: baseColor, rgb: baseResolved, ratio: null, locked: lockBase }, + neutral, + action, }; } diff --git a/configurator/tests/color.test.js b/configurator/tests/color.test.js index 2dfe220f..ad618953 100644 --- a/configurator/tests/color.test.js +++ b/configurator/tests/color.test.js @@ -15,6 +15,7 @@ import { rgbToHsl, hslToRgb, suggestAccessiblePalette, + bestTextOnSurface, } from '../src/lib/color.js'; const WHITE = [255, 255, 255]; @@ -102,3 +103,68 @@ describe('palette optimizer', () => { 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, /^hsl\(/); + }); + + 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('bestTextOnSurface', () => { + test('finds a passing value and reports its true ratio', () => { + const hit = bestTextOnSurface(220, 80, [255, 255, 255], 4.5); + assert.ok(hit, 'value found'); + assert.ok(hit.ratio >= 4.5); + assert.match(hit.color, /^hsl\(/); + assert.equal(hit.locked, false); + }); + + test('returns null when the target is unreachable on the line', () => { + // 21:1 is the absolute ceiling (black on white); 22 can never be met. + assert.equal(bestTextOnSurface(0, 0, [128, 128, 128], 22), null); + }); +}); From aae6b0711bd6a4cb09de0ff7481b78078b521bf6 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:04:33 +0000 Subject: [PATCH 2/5] feat(configurator): unify into one palette generator + on-color usage preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses reviewer feedback that separate generators would drift. - New suggestPalette() composes the existing helpers into ONE coherent generator: a single surface (base), neutral body text at AAA, and the brand accents (primary/secondary/tertiary/action) spread into the widest hue gaps and cleared to AA — all against the same surface, so 'action' (which is both a brand and a foundation role) is handled once and nothing conflicts. - New suggestBrandPalette() + farthestHue() building blocks: lock one or two brand colors a client provides and generate the rest, distinct and WCAG-compliant. - WcagPanel: a single lock panel across all six roles, generating off the LIGHT source palette (own probe root) regardless of preview theme. - New 'Text on colors' preview: auto-contrasting on-color text on each brand & status color with its ratio, reflecting the current light/dark theme — the combinations that actually ship. - Tests for farthestHue, suggestBrandPalette and the unified suggestPalette. --- configurator/src/components/WcagPanel.svelte | 190 ++++++++++++++----- configurator/src/lib/color.js | 176 +++++++++++++++++ configurator/tests/color.test.js | 135 +++++++++++++ 3 files changed, 457 insertions(+), 44 deletions(-) diff --git a/configurator/src/components/WcagPanel.svelte b/configurator/src/components/WcagPanel.svelte index 005160f2..dde2f333 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. @@ -36,18 +36,46 @@ 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)); - // 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}`) + ); + 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,39 +147,56 @@ ) ); - // ── 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(''); - // Lock state: a locked role is kept as a fixed anchor; the optimizer then - // generates the remaining roles to clear WCAG against it. Lock one or two - // brand colors and let the rest be derived compliant. - let locked = $state({ base: false, neutral: false, action: false }); - const OPT_ROLES = ['base', 'neutral', 'action']; + // 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]; - // Keep an open suggestion in sync with the new lock set. - if (suggestion) runOptimizer(); + if (suggestion) runGenerator(); } - function runOptimizer() { + function runGenerator() { optMsg = ''; - suggestion = suggestAccessiblePalette({ - baseRgb: resolved[OPT.base], - neutralRgb: resolved[OPT.neutral], - actionRgb: resolved[OPT.action], - locked: { ...locked }, - }); - 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; - // Only write generated (unlocked) roles — locked anchors keep their value. - for (const role of OPT_ROLES) { + // 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(OPT[role], s.color); + if (s && s.color && !s.locked) setOverride(ROLE_TOKENS[role], s.color); } optMsg = 'Applied ✓'; suggestion = null; @@ -150,6 +211,13 @@ {/each}
+ + +

Accessibility

@@ -243,19 +311,41 @@ {/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. - Lock one or two colors to pin them as fixed anchors — the rest are - generated to clear WCAG against your locked base. + 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 OPT_ROLES as role (role)} + {#each PALETTE_ROLES as role (role)} {/each}
- + {#if optMsg}{optMsg}{/if}
{#if suggestion}
- {#each OPT_ROLES as role (role)} + {#each PALETTE_ROLES as role (role)} {@const s = suggestion[role]} -
- - {role}{#if s && s.locked} 🔒{/if} - {#if s} + {#if s} +
+ + {role}{#if s.locked} 🔒{/if} {#if s.color} {s.color} {:else} @@ -294,10 +384,8 @@ {:else} surface {/if} - {:else} - no accessible value on this hue - {/if} -
+
+ {/if} {/each}
@@ -427,6 +515,20 @@ .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; } diff --git a/configurator/src/lib/color.js b/configurator/src/lib/color.js index 331d9a22..67b554a3 100644 --- a/configurator/src/lib/color.js +++ b/configurator/src/lib/color.js @@ -295,3 +295,179 @@ export function suggestAccessiblePalette({ baseRgb, neutralRgb, actionRgb, locke 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 saturation from the average of the locked + * anchors (so the set feels cohesive), with a floor so they stay lively. Pure + * RGB in / RGB 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']; + + // Seed the scheme from the locked anchors' hues + saturations. + const usedHues = []; + const lockedSats = []; + for (const role of order) { + if (locked[role] && roles[role]) { + const [h, s] = rgbToHsl(...roles[role]); + usedHues.push(h); + lockedSats.push(s); + } + } + const avgLockedSat = lockedSats.length + ? lockedSats.reduce((a, b) => a + b, 0) / lockedSats.length + : null; + + const result = {}; + for (const role of order) { + // Locked role: keep as-is, report its measured contrast vs the surface. + if (locked[role] && roles[role]) { + const rgb = roles[role]; + result[role] = { color: null, rgb, ratio: contrastRatio(rgb, surfaceRgb), locked: true }; + continue; + } + // Pick a hue in the widest gap (or this role's own hue if nothing taken). + const hue = usedHues.length + ? farthestHue(usedHues) + : roles[role] + ? rgbToHsl(...roles[role])[0] + : 0; + usedHues.push(hue); + // Cohesive, lively saturation. + let sat = avgLockedSat; + if (sat == null) sat = roles[role] ? rgbToHsl(...roles[role])[1] : 70; + sat = Math.max(sat, 45); + // Most surface-friendly value on that hue that still clears the target. + const hit = bestTextOnSurface(hue, sat, 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, lightly-tinted surface (keeps the hue, caps saturation). + const [bH, bS] = rgbToHsl(...roles.base); + let surfaceRgb; + let baseColor; + if (locked.base) { + surfaceRgb = roles.base; + baseColor = null; + } else { + const s = Math.min(bS, 8); + baseColor = `hsl(${bH.toFixed(1)} ${s.toFixed(1)}% 97%)`; + surfaceRgb = hslToRgb(bH, s, 97); + } + + const out = {}; + out.base = { color: baseColor, rgb: surfaceRgb, ratio: null, locked: !!locked.base }; + + // 2. Neutral (body text) → AAA on the surface. + if (roles.neutral) { + if (locked.neutral) { + out.neutral = { color: null, rgb: roles.neutral, ratio: contrastRatio(roles.neutral, surfaceRgb), locked: true }; + } else { + const [nH, nS] = rgbToHsl(...roles.neutral); + out.neutral = bestTextOnSurface(nH, Math.min(nS, 15), surfaceRgb, neutralTarget); + } + } + + // 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 ad618953..dbdb9f0d 100644 --- a/configurator/tests/color.test.js +++ b/configurator/tests/color.test.js @@ -16,6 +16,9 @@ import { hslToRgb, suggestAccessiblePalette, bestTextOnSurface, + suggestBrandPalette, + farthestHue, + suggestPalette, } from '../src/lib/color.js'; const WHITE = [255, 255, 255]; @@ -168,3 +171,135 @@ describe('bestTextOnSurface', () => { assert.equal(bestTextOnSurface(0, 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) => rgbToHsl(...rgb)[0]; + 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); + }); +}); From bbdf09893ff20a50d4566259e7ccbba59d8bc43a Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:35:11 +0000 Subject: [PATCH 3/5] refactor(configurator): generate palettes in OKLCH, not HSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework defines every main color in OKLCH (oklch(0.47 0.27 264)) and derives shades/dark-mode via oklch(from …); OKLab is only the color-mix interpolation space. The generator now searches the SAME perceptually-uniform space instead of HSL, so: - equal lightness steps look equally bright across hues (no muddy or uneven results, esp. in dark mode); - hue is preserved exactly and chroma held constant (gamut-reduced only where a lightness can't hold it) — the 'preserve hue, move L/C' method used by Leonardo/ColorBox and by the framework's own derivations; - generated values are emitted as oklch(L C H), matching token authoring. - New rgbToOklch / oklchToRgb (gamut-fitting) conversions. - bestTextOnSurface(hue,sat) -> bestOklchOnSurface(chroma,hue): searches L on a fixed OKLCH line for the softest value clearing the target. - suggestAccessiblePalette / suggestBrandPalette / suggestPalette now work in OKLCH (base surface oklch(0.96 …), hue gaps spread on OKLCH hue). - WCAG contrast maths unchanged (sRGB luminance). Tests updated. --- configurator/src/lib/color.js | 227 +++++++++++++++++++++++-------- configurator/tests/color.test.js | 19 +-- 2 files changed, 178 insertions(+), 68 deletions(-) diff --git a/configurator/src/lib/color.js b/configurator/src/lib/color.js index 67b554a3..81dd9e44 100644 --- a/configurator/src/lib/color.js +++ b/configurator/src/lib/color.js @@ -148,6 +148,103 @@ 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, + ]; +} + +/** + * Convert OKLCH to a 0–255 [r,g,b] triplet. If the requested chroma is outside + * the sRGB gamut at this lightness/hue, chroma is reduced (binary search) until + * it fits — preserving L and H exactly, 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) { + 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 [linearToSrgbChannel(lin[0]), linearToSrgbChannel(lin[1]), linearToSrgbChannel(lin[2])]; +} + /* ──────────────────────────────────────────────────────────────────────── * Browser color resolution * ──────────────────────────────────────────────────────────────────────── */ @@ -187,39 +284,45 @@ export function resolveToRgb(cssValue) { * ──────────────────────────────────────────────────────────────────────── */ /** - * Search a hue+saturation line for the value that clears a target WCAG - * contrast ratio against a fixed surface, returning the *softest* passing - * value — the one closest to the surface that still clears the bar — so - * generated text/action colors are never needlessly heavy. + * 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. * - * The search direction adapts to the surface luminance: on a light surface it - * prefers darker values, on a dark surface lighter ones, so it produces a - * legible color for ANY locked base (not just near-white surfaces). Falls back - * to the opposite direction if the preferred one can't clear the target. + * 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. * - * @param {number} hue 0–360, preserved from the source color - * @param {number} sat 0–100 saturation held constant while scanning lightness + * 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 bestTextOnSurface(hue, sat, surfaceRgb, target) { - const surfaceL = rgbToHsl(...surfaceRgb)[2]; +export function bestOklchOnSurface(chroma, hue, surfaceRgb, target) { + const surfaceLok = rgbToOklch(...surfaceRgb)[0]; const preferDark = relativeLuminance(surfaceRgb) >= 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 <= 100; l += 0.25) { - const rgb = hslToRgb(hue, sat, l); + for (let L = 0; L <= 1.0000001; L += 0.0025) { + const rgb = oklchToRgb(L, chroma, hue); const ratio = contrastRatio(rgb, surfaceRgb); if (ratio < target) continue; - if (!bestAny || ratio < bestAny.ratio) bestAny = { rgb, ratio, l }; - const inDir = preferDark ? l <= surfaceL : l >= surfaceL; - if (inDir && (!best || ratio < best.ratio)) best = { rgb, ratio, l }; + if (!bestAny || ratio < bestAny.ratio) bestAny = { L, rgb, ratio }; + const inDir = preferDark ? L <= surfaceLok : L >= surfaceLok; + if (inDir && (!best || ratio < best.ratio)) best = { L, rgb, ratio }; } const pick = best || bestAny; if (!pick) return null; return { - color: `hsl(${hue.toFixed(1)} ${sat.toFixed(1)}% ${pick.l.toFixed(2)}%)`, + color: `oklch(${pick.L.toFixed(4)} ${chroma.toFixed(4)} ${hue.toFixed(2)})`, rgb: pick.rgb, ratio: pick.ratio, locked: false, @@ -261,33 +364,38 @@ export function suggestAccessiblePalette({ baseRgb, neutralRgb, actionRgb, locke const lockNeutral = !!locked.neutral; const lockAction = !!locked.action; - const [bH, bS] = rgbToHsl(...baseRgb); - const [nH, nS] = rgbToHsl(...neutralRgb); - const [aH, aS] = rgbToHsl(...actionRgb); - // BASE / surface: locked → the user's actual color is the surface; unlocked - // → a very light surface that keeps the hue tint but caps saturation. + // → 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 baseS = Math.min(bS, 8); - baseColor = `hsl(${bH.toFixed(1)} ${baseS.toFixed(1)}% 97%)`; - baseResolved = hslToRgb(bH, baseS, 97); + 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: value reaching AAA (7:1) on the surface; locked → kept as-is. - const neutral = lockNeutral - ? { color: null, rgb: neutralRgb, ratio: contrastRatio(neutralRgb, baseResolved), locked: true } - : bestTextOnSurface(nH, Math.min(nS, 15), baseResolved, 7); + // 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: value reaching AA (4.5:1) on the surface, richer saturation; - // locked → kept as-is. - const action = lockAction - ? { color: null, rgb: actionRgb, ratio: contrastRatio(actionRgb, baseResolved), locked: true } - : bestTextOnSurface(aH, Math.max(aS, 80), baseResolved, 4.5); + // 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 }, @@ -337,9 +445,10 @@ export function farthestHue(usedHues) { * 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 saturation from the average of the locked - * anchors (so the set feels cohesive), with a floor so they stay lively. Pure - * RGB in / RGB out — no DOM — so it is fully unit-testable. + * 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 @@ -353,18 +462,18 @@ export function suggestBrandPalette({ roles = {}, surfaceRgb, locked = {}, targe if (!surfaceRgb) return null; const order = ['primary', 'secondary', 'tertiary', 'action']; - // Seed the scheme from the locked anchors' hues + saturations. + // Seed the scheme from the locked anchors' OKLCH hues + chromas. const usedHues = []; - const lockedSats = []; + const lockedChromas = []; for (const role of order) { if (locked[role] && roles[role]) { - const [h, s] = rgbToHsl(...roles[role]); - usedHues.push(h); - lockedSats.push(s); + const [, C, H] = rgbToOklch(...roles[role]); + usedHues.push(H); + lockedChromas.push(C); } } - const avgLockedSat = lockedSats.length - ? lockedSats.reduce((a, b) => a + b, 0) / lockedSats.length + const avgChroma = lockedChromas.length + ? lockedChromas.reduce((a, b) => a + b, 0) / lockedChromas.length : null; const result = {}; @@ -379,15 +488,15 @@ export function suggestBrandPalette({ roles = {}, surfaceRgb, locked = {}, targe const hue = usedHues.length ? farthestHue(usedHues) : roles[role] - ? rgbToHsl(...roles[role])[0] + ? rgbToOklch(...roles[role])[2] : 0; usedHues.push(hue); - // Cohesive, lively saturation. - let sat = avgLockedSat; - if (sat == null) sat = roles[role] ? rgbToHsl(...roles[role])[1] : 70; - sat = Math.max(sat, 45); + // 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 = bestTextOnSurface(hue, sat, surfaceRgb, target); + const hit = bestOklchOnSurface(chroma, hue, surfaceRgb, target); result[role] = hit ? { color: hit.color, rgb: hit.rgb, ratio: hit.ratio, locked: false } : null; @@ -422,29 +531,29 @@ export function suggestPalette({ roles = {}, locked = {}, neutralTarget = 7, acc if (!roles.base) return null; // 1. Surface = base. Locked → the user's base IS the surface; unlocked → a - // light, lightly-tinted surface (keeps the hue, caps saturation). - const [bH, bS] = rgbToHsl(...roles.base); + // light OKLCH surface (keeps the hue tint, caps chroma). let surfaceRgb; let baseColor; if (locked.base) { surfaceRgb = roles.base; baseColor = null; } else { - const s = Math.min(bS, 8); - baseColor = `hsl(${bH.toFixed(1)} ${s.toFixed(1)}% 97%)`; - surfaceRgb = hslToRgb(bH, s, 97); + 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. + // 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 { - const [nH, nS] = rgbToHsl(...roles.neutral); - out.neutral = bestTextOnSurface(nH, Math.min(nS, 15), surfaceRgb, neutralTarget); + const [, nC, nH] = rgbToOklch(...roles.neutral); + out.neutral = bestOklchOnSurface(Math.min(nC, 0.05), nH, surfaceRgb, neutralTarget); } } diff --git a/configurator/tests/color.test.js b/configurator/tests/color.test.js index dbdb9f0d..ea4a4ef8 100644 --- a/configurator/tests/color.test.js +++ b/configurator/tests/color.test.js @@ -15,7 +15,8 @@ import { rgbToHsl, hslToRgb, suggestAccessiblePalette, - bestTextOnSurface, + bestOklchOnSurface, + rgbToOklch, suggestBrandPalette, farthestHue, suggestPalette, @@ -99,7 +100,7 @@ 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'); @@ -118,7 +119,7 @@ describe('palette optimizer — locks', () => { assert.equal(out.base.locked, false); assert.equal(out.neutral.locked, false); assert.equal(out.action.locked, false); - assert.match(out.base.color, /^hsl\(/); + assert.match(out.base.color, /^oklch\(/); }); test('a locked role is echoed back untouched with its measured ratio', () => { @@ -157,18 +158,18 @@ describe('palette optimizer — locks', () => { }); }); -describe('bestTextOnSurface', () => { +describe('bestOklchOnSurface', () => { test('finds a passing value and reports its true ratio', () => { - const hit = bestTextOnSurface(220, 80, [255, 255, 255], 4.5); + 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, /^hsl\(/); + assert.match(hit.color, /^oklch\(/); assert.equal(hit.locked, false); }); test('returns null when the target is unreachable on the line', () => { - // 21:1 is the absolute ceiling (black on white); 22 can never be met. - assert.equal(bestTextOnSurface(0, 0, [128, 128, 128], 22), null); + // ~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); }); }); @@ -222,7 +223,7 @@ describe('suggestBrandPalette', () => { surfaceRgb: surface, locked: { primary: true, secondary: true }, }); - const hueOf = (rgb) => rgbToHsl(...rgb)[0]; + 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); From 230bb8b1e9f8c7e796d3a8f94ab6e60284602c89 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:28:02 +0000 Subject: [PATCH 4/5] fix(configurator): address self-review of the OKLCH generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from an independent code review (CodeRabbit was out of org credits and produced no review): - color.js: bestOklchOnSurface now emits the oklch() string with the GAMUT-FITTED chroma actually used for the measured rgb (via new fitOklchToGamut), not the requested chroma — so a pair certified at 4.5/7 renders as the same color, no requested-vs-mapped gap. - color.js: suggestBrandPalette processes present roles before absent ones, so an absent leading role can't seed a phantom hue 0 that shoves the present roles off their own hues (partial-input path). - color.js: unify the search-direction measure on OKLab L (was WCAG luminance for direction, OKLab L for the in-direction test). - WcagPanel: restore an explicit 'no accessible value — left unchanged' row when a role can't reach its target (was silently dropped). - WcagPanel: drop the redundant Base matrix column (--c-surface == base). - Tests: chroma round-trip + single-non-primary-role hue guards. --- configurator/src/components/WcagPanel.svelte | 9 ++- configurator/src/lib/color.js | 59 +++++++++++++++----- configurator/tests/color.test.js | 29 ++++++++++ 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/configurator/src/components/WcagPanel.svelte b/configurator/src/components/WcagPanel.svelte index dde2f333..d947f0ae 100644 --- a/configurator/src/components/WcagPanel.svelte +++ b/configurator/src/components/WcagPanel.svelte @@ -34,7 +34,7 @@ // 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)); // Unified palette generator — ONE surface, ONE lock set. Every role is a // framework brand token; locked roles are kept, the rest generated coherently @@ -385,6 +385,12 @@ surface {/if}
+ {:else if s === null} +
+ + {role} + no accessible value on this hue — left unchanged +
{/if} {/each}
@@ -567,6 +573,7 @@ .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; } diff --git a/configurator/src/lib/color.js b/configurator/src/lib/color.js index 81dd9e44..0d03de84 100644 --- a/configurator/src/lib/color.js +++ b/configurator/src/lib/color.js @@ -217,16 +217,18 @@ function oklabToLinearRgb(L, A, B) { } /** - * Convert OKLCH to a 0–255 [r,g,b] triplet. If the requested chroma is outside - * the sRGB gamut at this lightness/hue, chroma is reduced (binary search) until - * it fits — preserving L and H exactly, matching how browsers gamut-map oklch(). + * 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 chroma ≥ 0 + * @param {number} C requested chroma ≥ 0 * @param {number} H hue 0–360 - * @returns {[number,number,number]} + * @returns {{ rgb: [number,number,number], chroma: number }} */ -export function oklchToRgb(L, C, H) { +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); @@ -242,7 +244,23 @@ export function oklchToRgb(L, C, H) { chroma = lo; } const lin = at(chroma); - return [linearToSrgbChannel(lin[0]), linearToSrgbChannel(lin[1]), linearToSrgbChannel(lin[2])]; + 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; } /* ──────────────────────────────────────────────────────────────────────── @@ -308,21 +326,25 @@ export function resolveToRgb(cssValue) { */ export function bestOklchOnSurface(chroma, hue, surfaceRgb, target) { const surfaceLok = rgbToOklch(...surfaceRgb)[0]; - const preferDark = relativeLuminance(surfaceRgb) >= 0.5; + // 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) { - const rgb = oklchToRgb(L, chroma, hue); + // 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 }; + 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 }; + 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)} ${chroma.toFixed(4)} ${hue.toFixed(2)})`, + color: `oklch(${pick.L.toFixed(4)} ${pick.usedC.toFixed(4)} ${hue.toFixed(2)})`, rgb: pick.rgb, ratio: pick.ratio, locked: false, @@ -477,13 +499,22 @@ export function suggestBrandPalette({ roles = {}, surfaceRgb, locked = {}, targe : null; const result = {}; + + // 1. Locked anchors: echo untouched (their hues were seeded above). for (const role of order) { - // Locked role: keep as-is, report its measured contrast vs the surface. if (locked[role] && roles[role]) { const rgb = roles[role]; result[role] = { color: null, rgb, ratio: contrastRatio(rgb, surfaceRgb), locked: true }; - continue; } + } + + // 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) diff --git a/configurator/tests/color.test.js b/configurator/tests/color.test.js index ea4a4ef8..3c1990e6 100644 --- a/configurator/tests/color.test.js +++ b/configurator/tests/color.test.js @@ -17,6 +17,7 @@ import { suggestAccessiblePalette, bestOklchOnSurface, rgbToOklch, + oklchToRgb, suggestBrandPalette, farthestHue, suggestPalette, @@ -304,3 +305,31 @@ describe('suggestPalette (unified generator)', () => { 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)})`); + }); +}); From 7ca016e87e4b8fc0689c9a067e0dd13820f254df Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:59:31 +0000 Subject: [PATCH 5/5] fix(configurator): gate usage-preview roles on the on-color token too Addresses CodeRabbit review on PR #295: USAGE_ROLES filtered only on `--sf-color-${role}`, but each chip also styles/measures `--sf-color-text--on-${role}`. In a partial catalogue a role with the color but no on-color token rendered a misleading chip (inherited text, no contrast result). Now require both tokens to exist. --- configurator/src/components/WcagPanel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configurator/src/components/WcagPanel.svelte b/configurator/src/components/WcagPanel.svelte index d947f0ae..d0f92c94 100644 --- a/configurator/src/components/WcagPanel.svelte +++ b/configurator/src/components/WcagPanel.svelte @@ -63,7 +63,7 @@ // 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}`) + (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)