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:
committed by
brooklyn!
parent
f298612429
commit
6dbd7d11d3
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user