- 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 [['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}
Apply to overrides
@@ -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)})`);
+ });
+});