session accent: derive from theme mode with collision avoidance

- Dark themes: warm hues (0-120), Light themes: cool hues (180-300)
- Collision check against 17 major theme colors with 10° threshold,
  clamped to the hue band so it never drifts into an unrelated range
- Hex values stored at construction instead of round-tripping through
  ANSI escape parsing (removes #ansiToHex)
- WCAG AA contrast bisection on light surfaces
- Per-session uniqueness via name hash within the hue band
This commit is contained in:
Brit
2026-06-10 14:12:07 +02:00
parent bbe85b6661
commit 0160d0dbf2
8 changed files with 270 additions and 27 deletions
@@ -1,4 +1,4 @@
import { hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils";
import { hexToHsv, hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils";
/**
* Derive a stable hue (0-359) from a string using djb2 hash.
@@ -25,23 +25,97 @@ function accentLuminanceCap(surfaceLuminance: number): number {
return Math.max(0, (surfaceLuminance + 0.05) / ACCENT_MIN_CONTRAST - 0.05);
}
/** Minimum angular distance in hue degrees from any theme color to avoid visual collision. */
const MIN_HUE_DISTANCE = 10;
/** Saturation threshold below which hue is meaningless (near-gray). */
const MIN_SATURATION_FOR_HUE = 0.1;
/** Angular distance between two hue values (0-360). */
function hueDistance(a: number, b: number): number {
const d = Math.abs(a - b);
return Math.min(d, 360 - d);
}
/**
* Derive a stable CSS hex accent color from a session name.
* Parse hue (0-360) from a hex color string.
* Returns undefined for near-gray colors where hue is not meaningful.
*/
function hexToHue(hex: string): number | undefined {
const hsv = hexToHsv(hex);
if (hsv.s < MIN_SATURATION_FOR_HUE) return undefined;
return hsv.h;
}
/**
* Find a hue at least {@link MIN_HUE_DISTANCE} from all occupied hues,
* clamped to [lo, hi] to prevent leaving the intended hue band.
* Returns `target` unchanged if no safe hue exists within bounds.
*/
function findSafeHue(target: number, occupied: number[], lo: number, hi: number): number {
if (occupied.length === 0) return target;
if (occupied.every(h => hueDistance(target, h) >= MIN_HUE_DISTANCE)) {
return target;
}
for (let d = 1; d <= hi - lo; d++) {
for (const dir of [1, -1]) {
const candidate = Math.max(lo, Math.min(hi, target + d * dir));
if (occupied.every(h => hueDistance(candidate, h) >= MIN_HUE_DISTANCE)) {
return candidate;
}
}
}
// fallback: keep the original target if no safe spot exists within the band
return target;
}
/** Hue range low and high for dark themes (warm: red → yellow → green). */
const DARK_HUE_START = 0;
const DARK_HUE_END = 120;
/** Hue range low and high for light themes (cool: cyan → blue → purple). */
const LIGHT_HUE_START = 180;
const LIGHT_HUE_END = 300;
/**
* Derive a stable CSS hex accent color from a session name and the active theme.
*
* Picks a hue from a **dark/light-specific range** so the accent feels natural
* for the theme type (warm on dark, cool on light). The session name hash
* determines the exact hue within the range. The result is checked against
* all theme color hues and shifted if it lands within {@link MIN_HUE_DISTANCE}
* of an existing theme hue, but is clamped to the hue band so it never
* drifts into an unrelated part of the spectrum.
*
* On dark themes (`surfaceLuminance` undefined) the accent is vivid (high
* saturation, high lightness). On light themes the lightness is reduced until the
* accent's perceived luminance clears {@link ACCENT_MIN_CONTRAST} against the
* actual surface it renders on — so it stays legible on near-white *and* mid-light
* backgrounds — while keeping the same per-session hue.
* backgrounds.
*
* @param name — session name for per-session uniqueness.
* @param themeColorHexes — all theme colors to check collision against.
* @param surfaceLuminance — undefined on dark themes; WCAG luminance of the
* status-line background on light themes.
*/
export function getSessionAccentHex(name: string, surfaceLuminance?: number): string {
const hue = nameToHue(name);
export function getSessionAccentHex(name: string, themeColorHexes: string[], surfaceLuminance?: number): string {
// 1. Pick hue range based on theme mode
const hueStart = surfaceLuminance === undefined ? DARK_HUE_START : LIGHT_HUE_START;
const hueEnd = surfaceLuminance === undefined ? DARK_HUE_END : LIGHT_HUE_END;
const range = hueEnd - hueStart;
// 2. Session name picks within the range
let targetHue = hueStart + (nameToHue(name) % range);
// 3. Shift away if too close to any theme color — stays within [hueStart, hueEnd]
const themeHues = themeColorHexes.map(hexToHue).filter((h): h is number => h !== undefined);
targetHue = findSafeHue(targetHue, themeHues, hueStart, hueEnd);
// 4. Lightness/contrast — vivid on dark, bisected for AA on light
if (surfaceLuminance === undefined) {
return hslToHex(hue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
return hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
}
const cap = accentLuminanceCap(surfaceLuminance);
const top = hslToHex(hue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
const top = hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
if ((relativeLuminance(top) ?? 0) <= cap) return top;
// Bisect lightness: `lo` always yields luminance <= cap, `hi` always above it.
@@ -49,13 +123,13 @@ export function getSessionAccentHex(name: string, surfaceLuminance?: number): st
let hi = ACCENT_DARK_LIGHTNESS;
for (let i = 0; i < 20; i++) {
const mid = (lo + hi) / 2;
if ((relativeLuminance(hslToHex(hue, ACCENT_SATURATION, mid)) ?? 0) > cap) {
if ((relativeLuminance(hslToHex(targetHue, ACCENT_SATURATION, mid)) ?? 0) > cap) {
hi = mid;
} else {
lo = mid;
}
}
return hslToHex(hue, ACCENT_SATURATION, lo);
return hslToHex(targetHue, ACCENT_SATURATION, lo);
}
/**