Files
hermes-agent/apps/shared/src/color.ts
T
teknium1 c764d6d354 fix(shared): ensureContrast keeps the desktop's 0.2-step ladder; TUI chain opts into 0.05
The shared ensureContrast shipped the TUI's fine 0.05×20 ladder, which
changed --dt-primary-solid for 7 of 15 desktop presets (nous #3b6acb →
#3f70d8, cyberpunk #00661a → #008021, slate #505457 → #6f7377) while the PR
body said no preset VALUE changed. The ladder is now the desktop's original
algorithm exactly — pole by luminance < 0.5, accumulating 0.2 steps up to
1.0001, re-mixed from the source colour — with `step` as a parameter. The
only pre-refactor TUI caller (ColorChain.ensureContrast) passes 0.05, so
the terminal palette is byte-identical too.

Test: apps/desktop context.test.tsx iterates every builtin preset × mode,
paints it through ThemeProvider and asserts --dt-primary-solid equals the
value a reference copy of the old desktop algorithm computes. Sabotage
(default step 0.05): 11/30 rows fail. Docs: the SDK table now lists
contrastRatio as `number | null` under sRGB measures, not OKLCH.
2026-09-13 06:50:57 -07:00

171 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* THE sRGB color primitive for every TypeScript surface (desktop themes, the
* Ink TUI). Parse, mix, measure and fix colors here so tone ladders, contrast
* floors and one-off UI needs share one set of semantics — and so the two
* surfaces can never drift apart on what "readable" means again.
*
* Mixing is an sRGB lerp — deliberately, for byte parity with the desktop's
* `color-mix(in srgb, ...)` ladder in styles.css. Perceptual (OKLCH) math is
* desktop-only and lives in `apps/desktop/src/themes/color.ts`.
*
* Everything that measures returns `null` for unparseable input rather than 0:
* a silent 0 makes `ansi256(245)` look like pure black and passes garbage
* through contrast checks as if it were a real measurement.
*/
export type Rgb = readonly [number, number, number]
const HEX6_RE = /^#?([0-9a-f]{6})$/i
const HEX3_RE = /^#?([0-9a-f]{3})$/i
const RGB_FN_RE = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i
const clampChannel = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
/** Parse `#rgb`, `#rrggbb` or `rgb(r,g,b)` → channels. Null for anything else. */
export function parseColor(input: string): Rgb | null {
const value = input.trim()
let m = HEX6_RE.exec(value)
if (m) {
const n = parseInt(m[1]!, 16)
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
}
m = HEX3_RE.exec(value)
if (m) {
const [r, g, b] = m[1]!
return [parseInt(r! + r!, 16), parseInt(g! + g!, 16), parseInt(b! + b!, 16)]
}
m = RGB_FN_RE.exec(value)
if (m) {
return [clampChannel(Number(m[1])), clampChannel(Number(m[2])), clampChannel(Number(m[3]))]
}
return null
}
export const toHex = (rgb: Rgb): string => '#' + rgb.map(c => clampChannel(c).toString(16).padStart(2, '0')).join('')
/** sRGB lerp `a → b` by `t` in [0,1]. Unparseable inputs return `a` unchanged. */
export function mix(a: string, b: string, t: number): string {
const pa = parseColor(a)
const pb = parseColor(b)
if (!pa || !pb) {
return a
}
return toHex([pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t, pa[2] + (pb[2] - pa[2]) * t])
}
function channelLuminance(value: number): number {
const normalized = value / 255
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
}
/** WCAG relative luminance in [0,1]. Null when unparseable. */
export function relativeLuminance(color: string): null | number {
const rgb = parseColor(color)
return rgb
? 0.2126 * channelLuminance(rgb[0]) + 0.7152 * channelLuminance(rgb[1]) + 0.0722 * channelLuminance(rgb[2])
: null
}
/** WCAG contrast ratio between two colors (1–21). Null when unparseable. */
export function contrastRatio(a: string, b: string): null | number {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
if (la === null || lb === null) {
return null
}
const [hi, lo] = la >= lb ? [la, lb] : [lb, la]
return (hi + 0.05) / (lo + 0.05)
}
const DEFAULT_INKS = ['#000000', '#ffffff'] as const
/**
* The readable ink for a background: whichever candidate MEASURES better.
*
* Splitting on a luminance threshold 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.
* Ties and an unparseable background (nothing to measure against) go to the
* LAST candidate — the light pole in both the default pair and the desktop's,
* which is what both surfaces previously fell back to.
*/
export function readableOn(bg: string, inks: readonly [string, ...string[]] = DEFAULT_INKS): string {
let best: string = inks[inks.length - 1]!
let bestRatio = -1
for (const ink of inks) {
const ratio = contrastRatio(bg, ink)
if (ratio !== null && ratio >= bestRatio) {
best = ink
bestRatio = ratio
}
}
return best
}
/**
* Step-mix `color` toward the pole opposite `bg` (white on a dark background,
* black on a light one) until the contrast ratio clears `min`. Each rung
* re-mixes from the ORIGINAL color, so hue decays linearly, not
* exponentially, and the color stops at the first rung that passes. Returns
* the original when it already passes or isn't parseable.
*
* `step` is the rung size. The default 0.2 (5 rungs) is the desktop's ladder
* and MUST stay: `--dt-primary-solid` for every shipped preset is derived
* from it and a finer ladder lands visibly different fills (nous `#3b6acb` vs
* `#3f70d8`). The TUI's chainable form opts into 0.05 for less hue loss.
* The accumulating loop (rather than `i * step`) is deliberate — it is the
* exact float sequence the old desktop ladder produced.
*/
export function ensureContrast(color: string, bg: string, min: number, step = 0.2): string {
const bgLuminance = relativeLuminance(bg)
if (bgLuminance === null || parseColor(color) === null) {
return color
}
const ratio = contrastRatio(color, bg)
if (ratio === null || ratio >= min) {
return color
}
const pole = bgLuminance < 0.5 ? '#ffffff' : '#000000'
let best = color
for (let amount = step; amount <= 1.0001; amount += step) {
best = mix(color, pole, Math.min(amount, 1))
const stepRatio = contrastRatio(best, bg)
if (stepRatio !== null && stepRatio >= min) {
return best
}
}
return best
}
/** Recede toward the background pole (opposite of `readableOn`). */
export const lighten = (color: string, t: number) => mix(color, '#ffffff', t)
export const darken = (color: string, t: number) => mix(color, '#000000', t)