feat(desktop): re-seed any theme's accent from one colour

A palette's accent is not one value, it is a family: the seed plus the soft
surfaces mixed from it — seven slots per appearance in nous, all derived
from one colour. `retintTheme` moves the whole family at once, reusing the
converter's own mix ratios so re-seeding a theme with its existing accent
returns the identical object.

The colour work this needed is the interesting half. Mixing toward white in
gamma-encoded sRGB bends hue: a saturated blue lands 7.6 degrees violet of
where it started, which is how a clean blue accent produced a lavender
selection row. `mixOklab` holds the hue and moves only chroma and lightness.
`ensureContrastOklch` adapts a seed for an appearance that cannot carry it
by walking lightness rather than blending toward white, which would gut the
chroma and wash the brand colour out.

`readableOn` picked text colour from a luminance threshold, and got five
shipped accents wrong in the direction that matters — white on GitHub's own
dark green measured 3.29:1, below AA, where near-black measures 5.50:1. It
now measures both candidates and takes the better one.
This commit is contained in:
Brooklyn Nicholson
2026-08-20 01:15:41 -05:00
committed by brooklyn!
parent f298612429
commit 6dbd7d11d3
4 changed files with 635 additions and 2 deletions
@@ -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 | string>(null)
export function setAccentOverride(color: null | string): void {
$accentOverride.set(color === null ? null : (normalizeHex(color) ?? $accentOverride.get()))
}
+258 -2
View File
@@ -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
}
+200
View File
@@ -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')
})
})
+153
View File
@@ -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
}