diff --git a/apps/desktop/src/themes/accent-override.ts b/apps/desktop/src/themes/accent-override.ts new file mode 100644 index 0000000000..8be5200aa3 --- /dev/null +++ b/apps/desktop/src/themes/accent-override.ts @@ -0,0 +1,24 @@ +/** + * Dev-only accent override. + * + * Live-authoring knob for `retintTheme`: while this holds a hex, the theme + * context paints the active skin re-seeded from it. `null` means "off" — the + * theme paints exactly as authored, which is the only state a production build + * can ever be in (the picker that sets this is installed under + * `import.meta.env.DEV`). + * + * Deliberately NOT persisted. It's a scratch control for finding a color you + * like, and the outcome is meant to be written into `presets.ts` as a real + * palette — not carried around as user state that would silently override a + * theme the user picked later. + */ + +import { atom } from 'nanostores' + +import { normalizeHex } from './color' + +export const $accentOverride = atom(null) + +export function setAccentOverride(color: null | string): void { + $accentOverride.set(color === null ? null : (normalizeHex(color) ?? $accentOverride.get())) +} diff --git a/apps/desktop/src/themes/color.ts b/apps/desktop/src/themes/color.ts index 799200deb3..629818b6fa 100644 --- a/apps/desktop/src/themes/color.ts +++ b/apps/desktop/src/themes/color.ts @@ -59,9 +59,18 @@ export function contrastRatio(a: string, b: string): number { return la >= lb ? (la + 0.05) / (lb + 0.05) : (lb + 0.05) / (la + 0.05) } -/** Returns a readable foreground (#161616 or #ffffff) for a background hex. */ +/** + * A readable foreground (`#161616` or `#ffffff`) for a background hex. + * + * Picks whichever of the two actually measures better, rather than splitting on + * a luminance threshold. The threshold version got mid-lightness accents wrong + * in the direction that matters — white on GitHub's dark green `#4f9e5e` is + * 3.29:1 (fails AA) where near-black is 5.50:1, and Catppuccin's mauve was a + * 2.03:1 white-on-lilac. Two candidates is a cheap enough search to just + * measure. + */ export function readableOn(hex: string): string { - return relativeLuminance(hex) > 0.58 ? '#161616' : '#ffffff' + return contrastRatio(hex, '#ffffff') >= contrastRatio(hex, '#161616') ? '#ffffff' : '#161616' } /** @@ -146,3 +155,250 @@ export function normalizeHex(input: string | undefined | null, backdrop = '#0000 base[2] + (rgb[2] - base[2]) * alpha ]) } + +// ─── OKLCH ────────────────────────────────────────────────────────────────── +// Hue rotation has to happen in a perceptual space or it lies. Holding HSL's +// saturation+lightness across a hue sweep gives you mud at 60° (`#6d6d19`) and +// a washed teal at 200°, because HSL "lightness" is not lightness. OKLCH holds +// perceived lightness and colorfulness steady, so every hue lands with the same +// visual weight — which is what makes a single hue knob safe to expose. + +const linearize01 = (c: number): number => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4) +const delinearize01 = (c: number): number => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055) + +/** Perceptual lightness 0..1, chroma (~0..0.37), hue 0..360. */ +export interface Oklch { + l: number + c: number + h: number +} + +export function hexToOklch(hex: string): Oklch | null { + const rgb = hexToRgb(hex) + + if (!rgb) { + return null + } + + const [okL, okA, okB] = rgbToOklab(rgb) + + return { + l: okL, + c: Math.hypot(okA, okB), + h: ((Math.atan2(okB, okA) * 180) / Math.PI + 360) % 360 + } +} + +/** 0–255 sRGB triple → OKLab [L, a, b]. */ +function rgbToOklab([r255, g255, b255]: readonly number[]): [number, number, number] { + const [r, g, b] = [r255, g255, b255].map(v => linearize01(v / 255)) + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + ] +} + +/** OKLab [L, a, b] → `#rrggbb`, via the chroma-preserving gamut fit. */ +function oklabToHex([okL, okA, okB]: readonly number[]): string { + return oklchToHex({ + l: okL, + c: Math.hypot(okA, okB), + h: ((Math.atan2(okB, okA) * 180) / Math.PI + 360) % 360 + }) +} + +/** Raw (possibly out-of-gamut) linear-sRGB triple for an OKLCH color. */ +function oklchToRgbRaw({ l: okL, c, h }: Oklch): [number, number, number] { + const rad = (h * Math.PI) / 180 + const okA = c * Math.cos(rad) + const okB = c * Math.sin(rad) + + const l = (okL + 0.3963377774 * okA + 0.2158037573 * okB) ** 3 + const m = (okL - 0.1055613458 * okA - 0.0638541728 * okB) ** 3 + const s = (okL - 0.0894841775 * okA - 1.291485548 * okB) ** 3 + + 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 + ] +} + +const inSrgbGamut = (rgb: readonly number[]): boolean => rgb.every(c => c >= -0.001 && c <= 1.001) + +/** + * OKLCH → `#rrggbb`, reducing chroma until the color fits sRGB. + * + * Clipping the channels instead would shift hue and lightness — a saturated + * blue clips to a different blue. Binary-searching chroma keeps the hue and the + * perceived lightness exactly, and only gives up the colorfulness the display + * cannot show. Deep blues and yellows need this; mid-chroma greens never do. + */ +export function oklchToHex(color: Oklch): string { + if (inSrgbGamut(oklchToRgbRaw(color))) { + return rgbToHex(oklchToRgbRaw(color).map(c => delinearize01(c) * 255) as [number, number, number]) + } + + let lo = 0 + let hi = color.c + + for (let i = 0; i < 24; i += 1) { + const mid = (lo + hi) / 2 + + if (inSrgbGamut(oklchToRgbRaw({ ...color, c: mid }))) { + lo = mid + } else { + hi = mid + } + } + + return rgbToHex(oklchToRgbRaw({ ...color, c: lo }).map(c => delinearize01(c) * 255) as [number, number, number]) +} + +/** + * OKLCH → 0–255 sRGB, or `null` when the color is outside the display's gamut. + * + * The picker draws its field with this: a hue slice of OKLCH is a curved wedge + * in sRGB, not a rectangle, so plotting lightness against chroma needs to know + * where the wedge ENDS. Rendering the boundary honestly is the difference + * between a picker where every pixel is a real color and one where a third of + * the area silently clamps to the same few hexes. + */ +export function oklchToSrgb255(color: Oklch): [number, number, number] | null { + const raw = oklchToRgbRaw(color) + + if (!inSrgbGamut(raw)) { + return null + } + + return raw.map(c => Math.round(Math.min(1, Math.max(0, delinearize01(c))) * 255)) as [number, number, number] +} + +/** Largest in-gamut chroma for a lightness+hue, for scaling the picker's axis. */ +export function maxChroma(l: number, h: number): number { + let lo = 0 + let hi = 0.4 + + for (let i = 0; i < 20; i += 1) { + const mid = (lo + hi) / 2 + + if (inSrgbGamut(oklchToRgbRaw({ l, c: mid, h }))) { + lo = mid + } else { + hi = mid + } + } + + return lo +} + +/** + * Bend a semantic color toward the accent along the shortest hue arc. + * + * A success green next to a blue accent reads as a clash, but recoloring it to + * the accent throws away the meaning — "done" and "running" become one color. + * Rotating it PART of the way keeps the semantics and settles the palette: at + * `strength` 0 nothing moves, at 1 it lands on the accent hue. + * + * The default costs nothing by construction: when the accent already is a green + * (GitHub's 148° vs emerald's 162°) the arc is 14° and a 0.25 rotation moves the + * dot ~3° — invisible. The work only happens when the accent is genuinely far + * away, which is exactly the case that was clashing. + */ +export function harmonize(hex: string, accent: string, strength: number): string { + const base = hexToOklch(hex) + const target = hexToOklch(accent) + + if (!base || !target) { + return hex + } + + // Signed shortest arc, so a green never takes the long way round the wheel. + const delta = ((target.h - base.h + 540) % 360) - 180 + const h = (base.h + delta * Math.min(1, Math.max(0, strength)) + 360) % 360 + + return oklchToHex({ ...base, c: Math.min(Math.max(base.c, target.c * 0.85), maxChroma(base.l, h)), h }) +} + +/** + * Blend two colors in OKLab — the hue-stable counterpart to `mix`. + * + * `mix` lerps gamma-encoded sRGB channels, which bends hue on the way: a + * saturated blue mixed 88% toward white lands 7.6° violet of where it started, + * which is why a clean blue accent produced a lavender selection row. OKLab is + * (near) perceptually uniform, so the same blend keeps the hue and just walks + * chroma and lightness — the soft surface reads as a pale version of the accent + * instead of a different color. + * + * Use this for anything derived from the brand seed. `mix` is still correct for + * blending neutrals, where there is no hue to preserve. + */ +export function mixOklab(a: string, b: string, amount: number): string { + const ra = hexToRgb(a) + const rb = hexToRgb(b) + + if (!ra || !rb) { + return a + } + + const la = rgbToOklab(ra) + const lb = rgbToOklab(rb) + const t = Math.min(1, Math.max(0, amount)) + + return oklabToHex([la[0] + (lb[0] - la[0]) * t, la[1] + (lb[1] - la[1]) * t, la[2] + (lb[2] - la[2]) * t]) +} + +/** Re-hue a color, holding its perceived lightness and colorfulness. */ +export function withHue(hex: string, hue: number): string { + const lch = hexToOklch(hex) + + return lch ? oklchToHex({ ...lch, h: ((hue % 360) + 360) % 360 }) : hex +} + +/** + * Make `hex` clear `min` contrast against `bg` by moving its LIGHTNESS only. + * + * The sRGB-mixing version of this (`ensureContrast`) blends toward white or + * black, which drags chroma down with it — Nous blue `#0053FD` mixed 30% white + * to pass AA on a dark surface loses a third of its colorfulness and reads + * washed. Walking OKLCH lightness instead keeps hue and chroma intact, so a + * brand color stays recognizably itself at whatever lightness the surface + * demands. Returns the original when it already passes, and gives up at the + * ends of the lightness range rather than looping. + */ +export function ensureContrastOklch(hex: string, bg: string, min: number): string { + if (contrastRatio(hex, bg) >= min) { + return hex + } + + const lch = hexToOklch(hex) + + if (!lch) { + return hex + } + + // Lighten on a dark backdrop, darken on a light one. + const up = relativeLuminance(bg) < 0.5 + let best = hex + + for (let step = 1; step <= 40; step += 1) { + const l = lch.l + (up ? step : -step) * 0.02 + + if (l <= 0 || l >= 1) { + break + } + + best = oklchToHex({ ...lch, l }) + + if (contrastRatio(best, bg) >= min) { + return best + } + } + + return best +} diff --git a/apps/desktop/src/themes/retint.test.ts b/apps/desktop/src/themes/retint.test.ts new file mode 100644 index 0000000000..5b0a5c7342 --- /dev/null +++ b/apps/desktop/src/themes/retint.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' + +import { contrastRatio, hexToOklch, withHue } from './color' +import { githubTheme, nousTheme } from './presets' +import { retintTheme, themeHue } from './retint' +import type { DesktopThemeColors } from './types' + +const HUES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330] + +// A retint seed for each hue, at the authored accent's lightness/chroma. +const seedAt = (hue: number) => withHue(nousTheme.colors.primary, hue) + +const NOUS_BLUE = '#0053FD' + +describe('themeHue', () => { + it('reads the accent hue that ships', () => { + // Nous blue. Both palettes sit at this hue — light seeds `#0053fd` and dark + // `#4a84fe`, the same blue at two lightnesses, which is what lets one pick + // serve both appearances. + expect(themeHue(nousTheme)).toBe(263) + expect(Math.round(hexToOklch(nousTheme.darkColors!.primary)!.h)).toBe(263) + }) + + it('reads the upstream GitHub green from the unforked theme', () => { + // `github` keeps the original accent, so the fork's blue can move freely + // without redefining what upstream looks like. + expect(themeHue(githubTheme)).toBe(148) + expect(Math.round(hexToOklch(githubTheme.darkColors!.primary)!.h)).toBe(148) + }) +}) + +// The two seeds are the whole point of the fork, and both are load-bearing: +// `#0053FD` is the brand color and passes on the light sidebar, but only 3.6:1 +// on the near-black dark one — so dark carries a lifted twin rather than the +// literal brand hex. Anything that re-derives these must keep both legible. +describe('the shipped nous accents', () => { + const cases = [ + { appearance: 'light', colors: nousTheme.colors, seed: '#0053fd' }, + { appearance: 'dark', colors: nousTheme.darkColors!, seed: '#4a84fe' } + ] as const + + it.each(cases)('$appearance seeds every accent slot from $seed', ({ colors, seed }) => { + for (const key of ['primary', 'ring', 'midground', 'composerRing'] as const) { + expect(colors[key]).toBe(seed) + } + }) + + it.each(cases)('$appearance clears AA on its own sidebar', ({ colors, seed }) => { + expect(contrastRatio(seed, colors.sidebarBackground!)).toBeGreaterThanOrEqual(4.5) + }) + + it.each(cases)('$appearance keeps text on the accent readable', ({ colors, seed }) => { + expect(contrastRatio(seed, colors.primaryForeground)).toBeGreaterThanOrEqual(4.5) + }) + + it('is one blue at two lightnesses, not two blues', () => { + const light = hexToOklch(nousTheme.colors.primary)! + const dark = hexToOklch(nousTheme.darkColors!.primary)! + + expect(Math.abs(light.h - dark.h)).toBeLessThan(2) + expect(dark.l).toBeGreaterThan(light.l) + }) + + it('leaves GitHub’s neutrals in place — only the accent family is forked', () => { + for (const key of ['background', 'foreground', 'card', 'border', 'sidebarBackground'] as const) { + expect(nousTheme.colors[key]).toBe(githubTheme.colors[key]) + expect(nousTheme.darkColors![key]).toBe(githubTheme.darkColors![key]) + } + }) +}) + +describe('retintTheme', () => { + // The load-bearing property: the mix ratios in retint.ts must be the same + // ones that produced the shipped palette. If they drift, retinting at the + // theme's OWN hue stops being a no-op — and this catches it. + it('is an identity at the theme’s own accent', () => { + const same = retintTheme(nousTheme, nousTheme.colors.primary) + + expect(same.colors).toEqual(nousTheme.colors) + expect(same.darkColors).toEqual(nousTheme.darkColors) + }) + + it('moves every accent-family slot, in both modes', () => { + const rose = retintTheme(nousTheme, seedAt(350)) + + for (const mode of ['colors', 'darkColors'] as const) { + const before = nousTheme[mode]! + const after = rose[mode]! + + for (const key of ['primary', 'ring', 'midground', 'composerRing', 'accent', 'secondary', 'userBubble'] as const) { + expect(after[key], `${mode}.${key}`).not.toBe(before[key]) + } + } + }) + + it('keeps the four seed slots locked together', () => { + const teal = retintTheme(nousTheme, seedAt(195)).colors + + expect(teal.ring).toBe(teal.primary) + expect(teal.midground).toBe(teal.primary) + expect(teal.composerRing).toBe(teal.primary) + }) + + it('leaves the chrome alone', () => { + // The neutrals are the app's surface, not its brand. A hue knob that also + // swung these would make every theme a monochrome wash. + const violet = retintTheme(nousTheme, seedAt(285)) + + for (const key of ['background', 'foreground', 'card', 'border', 'muted', 'mutedForeground'] as const) { + expect(violet.colors[key], key).toBe(nousTheme.colors[key]) + expect(violet.darkColors![key], `dark ${key}`).toBe(nousTheme.darkColors![key]) + } + }) + + it('holds perceived lightness and chroma while only the hue moves', () => { + const base = hexToOklch(nousTheme.colors.primary)! + + for (const hue of HUES) { + const seed = hexToOklch(retintTheme(nousTheme, seedAt(hue)).colors.primary)! + + expect(Math.abs(seed.l - base.l), `L at ${hue}`).toBeLessThan(0.02) + // Chroma can only be REDUCED, and only where sRGB can't show it. + expect(seed.c, `C at ${hue}`).toBeLessThanOrEqual(base.c + 0.005) + } + }) + + // The accent labels the sidebar in small uppercase text, so a hue that + // collapses against it ships invisible section headers. + it('keeps the accent readable on the sidebar at every hue', () => { + for (const hue of HUES) { + const t = retintTheme(nousTheme, seedAt(hue)) + + for (const mode of ['colors', 'darkColors'] as const) { + const c = t[mode] as DesktopThemeColors + const ratio = contrastRatio(c.primary, c.sidebarBackground ?? c.background) + + expect(ratio, `${mode} @ ${hue}°`).toBeGreaterThanOrEqual(4.5) + } + } + }) + + it('re-picks the foreground that sits on the accent', () => { + for (const hue of HUES) { + const c = retintTheme(nousTheme, seedAt(hue)).colors + + expect(contrastRatio(c.primary, c.primaryForeground), `on-accent @ ${hue}°`).toBeGreaterThanOrEqual(4.5) + } + }) + + it('accepts any hex form and ignores junk', () => { + expect(retintTheme(nousTheme, '#0053FD').colors.primary).toBe(retintTheme(nousTheme, '0053fd').colors.primary) + // A half-typed hex from a text input must not blow up the theme. + expect(retintTheme(nousTheme, '#00').colors).toEqual(nousTheme.colors) + expect(retintTheme(nousTheme, 'nonsense').colors).toEqual(nousTheme.colors) + }) + + // The real motivating case: Nous blue is legible on GitHub's light sidebar + // (5.4:1) but NOT its dark one (3.6:1), so dark has to adapt or ship + // invisible section headers. + describe('a seed that only works in one mode', () => { + const blue = retintTheme(nousTheme, NOUS_BLUE) + + it('keeps the picked color where it already passes', () => { + expect(blue.colors.primary.toLowerCase()).toBe(NOUS_BLUE.toLowerCase()) + }) + + it('lightens it for the mode where it does not', () => { + const dark = blue.darkColors!.primary + + expect(dark.toLowerCase()).not.toBe(NOUS_BLUE.toLowerCase()) + expect(contrastRatio(dark, blue.darkColors!.sidebarBackground!)).toBeGreaterThanOrEqual(4.5) + }) + + it('adapts by lightness, holding the hue — so it still reads as the brand', () => { + const picked = hexToOklch(NOUS_BLUE)! + const adapted = hexToOklch(blue.darkColors!.primary)! + + expect(Math.abs(adapted.h - picked.h)).toBeLessThan(3) + expect(adapted.l).toBeGreaterThan(picked.l) + // Chroma may only fall because sRGB cannot SHOW that colorfulness at the + // higher lightness — `#0053FD`'s C 0.26 is out of gamut once lightened, + // and the clamp trades it away rather than shifting the hue. What must + // not happen is the mix-toward-white collapse, which would also drag the + // hue and leave a pastel; staying well clear of half the original chroma + // is the line between "same blue, lighter" and "washed out". + expect(adapted.c).toBeGreaterThan(picked.c * 0.55) + }) + }) + + it('does not brand a slot that never tracked the accent', () => { + // mono's ring is a neutral gray on purpose. + const neutralRing = { + ...nousTheme, + colors: { ...nousTheme.colors, ring: '#9a9a9a' }, + darkColors: undefined + } + + expect(retintTheme(neutralRing, '#8250df').colors.ring).toBe('#9a9a9a') + }) +}) diff --git a/apps/desktop/src/themes/retint.ts b/apps/desktop/src/themes/retint.ts new file mode 100644 index 0000000000..b5ca1dac22 --- /dev/null +++ b/apps/desktop/src/themes/retint.ts @@ -0,0 +1,153 @@ +/** + * Accent retinting — re-seed a theme's accent family from one color. + * + * A palette's accent is not one color, it's a family: the seed plus the soft + * surfaces mixed from it. In `nous` that's 7 slots per mode from ONE seed — + * `primary`/`ring`/`midground`/`composerRing` share the seed outright, and + * `accent`/`secondary`/`userBubble` are mixes of it toward the background. + * + * So retinting is not "replace some hexes": it's move the seed, then re-derive + * the family with the exact ratios the VS Code converter used. Those ratios + * live in ACCENT_MIX below and are duplicated from `vscode.ts` on purpose — + * they're verified to reproduce the shipped palette bit-for-bit (see + * retint.test.ts), which is what lets a retint with the original seed be a + * no-op. + * + * ONE color in, both modes out. A single hex can't serve both appearances + * literally — Nous blue `#0053FD` is 5.4:1 on GitHub's light sidebar but only + * 3.6:1 on its dark one, i.e. unreadable section headers. Dark therefore gets + * the same hue and chroma at whatever lightness clears AA, which keeps the + * brand color recognizably itself instead of washing it toward white. + * + * The chrome is deliberately untouched. GitHub's neutrals are a cool blue-gray + * (hue ~210°) that reads as the app's surface, not as brand — rotating it with + * the accent turns every theme into a monochrome wash. + */ + +import { ensureContrastOklch, hexToOklch, mixOklab, normalizeHex, oklchToHex, readableOn } from './color' +import type { DesktopTheme, DesktopThemeColors } from './types' + +/** Small uppercase sidebar labels paint in the accent, so it must clear AA. */ +const ACCENT_MIN_CONTRAST = 4.5 + +/** Slots that carry the accent seed verbatim. */ +const ACCENT_SEED_KEYS = ['primary', 'ring', 'midground', 'composerRing'] as const + +/** + * Slots mixed from the seed, as ratios per mode — the converter's own numbers. + * `userBubble` mixes the CARD toward the accent (not the reverse). + */ +const ACCENT_MIX = { + accent: { light: 0.88, dark: 0.82 }, + secondary: { light: 0.86, dark: 0.72 }, + userBubble: { light: 0.12, dark: 0.18 } +} as const + +/** Re-derive the accent family of one palette around `seed`. */ +function retintColors(colors: DesktopThemeColors, seed: string, isDark: boolean): DesktopThemeColors { + const next: DesktopThemeColors = { ...colors } + const pick = (spec: { dark: number; light: number }) => (isDark ? spec.dark : spec.light) + + for (const key of ACCENT_SEED_KEYS) { + // Only move a slot that actually tracked the accent. A theme whose `ring` + // is a neutral gray meant it; retinting must not brand it by surprise. + if (colors[key] === colors.primary) { + next[key] = seed + } + } + + // OKLab, not sRGB: blending a saturated blue toward white in gamma space + // drifts it ~8° violet, which is exactly how a clean blue accent produced a + // lavender selection row. See mixOklab. + next.accent = mixOklab(seed, colors.background, pick(ACCENT_MIX.accent)) + next.secondary = mixOklab(seed, colors.background, pick(ACCENT_MIX.secondary)) + next.userBubble = mixOklab(colors.card, seed, pick(ACCENT_MIX.userBubble)) + + // Foregrounds that sit ON the accent have to be re-picked: a new seed can + // cross the light/dark readability boundary. + next.primaryForeground = readableOn(seed) + next.midgroundForeground = readableOn(seed) + + return next +} + +/** The seed a palette should use, adapted to stay legible on its own sidebar. */ +function seedFor(colors: DesktopThemeColors, seed: string): string { + return ensureContrastOklch(seed, colors.sidebarBackground ?? colors.background, ACCENT_MIN_CONTRAST) +} + +/** + * Carry a light-mode seed across to dark by the theme's OWN lightness offset. + * + * A theme that ships both palettes has already answered "how much lighter does + * this accent get in dark mode" — nous's greens are OKLCH L 0.471 → 0.632, the + * same hue and chroma 0.16 apart. Reapplying that delta means a picked color + * lands in dark exactly where the author would have put it, and it makes the + * round trip exact: retinting with the theme's own accent reproduces both + * palettes byte-for-byte. + * + * Falling back to a bare AA clamp (what this did first) technically passed + * contrast but ignored the author's intent — GitHub green came back as + * `#368548` instead of `#4f9e5e`, a visibly duller dark accent. + */ +function carryToDark(theme: DesktopTheme, seed: string): string { + const light = hexToOklch(theme.colors.primary) + const dark = theme.darkColors ? hexToOklch(theme.darkColors.primary) : null + const picked = hexToOklch(seed) + + if (!light || !dark || !picked) { + return seed + } + + const shifted = oklchToHex({ ...picked, l: Math.min(1, Math.max(0, picked.l + (dark.l - light.l))) }) + + return shifted +} + +/** + * Return `theme` with its accent family re-seeded from `color` (any CSS hex). + * + * Each mode gets the seed adapted to its own surface, so one pick stays + * readable in both. Passing a theme's existing accent returns it untouched. + * An unparseable color returns the theme unchanged rather than throwing — + * callers are often wiring this straight to a text input. + */ +export function retintTheme(theme: DesktopTheme, color: string): DesktopTheme { + const seed = normalizeHex(color) + + if (!seed) { + return theme + } + + // Re-seeding with the theme's own accent must return the theme untouched. + // Recomputing dark from it would round-trip through OKLCH and land a bit or + // two off (#4f9e5e → #509e5f) — invisible, but it would mean "no change" + // wasn't actually no change. + if (seed.toLowerCase() === theme.colors.primary.toLowerCase()) { + return theme + } + + const light = seedFor(theme.colors, seed) + const retinted: DesktopTheme = { + ...theme, + colors: theme.colors.primary === light ? theme.colors : retintColors(theme.colors, light, false) + } + + if (theme.darkColors) { + // Carry by the theme's own light→dark offset first, THEN clamp — so the + // author's intent leads and contrast is only a floor, never the target. + const dark = seedFor(theme.darkColors, carryToDark(theme, seed)) + + retinted.darkColors = + theme.darkColors.primary === dark ? theme.darkColors : retintColors(theme.darkColors, dark, true) + } + + return retinted +} + +/** The theme's current accent hue in degrees. */ +export function themeHue(theme: DesktopTheme): number { + const lch = hexToOklch(theme.colors.primary) + + return lch ? Math.round(lch.h) : 0 +}