fix(ui-tui): light mode renders the vivid palette RAW, not WCAG-darkened

Pixel-sampled against the reference screenshots: the beloved classic
light-mode look is the vivid authored golds rendered essentially raw
(#FFD700 shows as bright #F5C242) — transparent terminal profiles apply no
contrast lift of their own, and pre-darkening every foreground to WCAG 4.5
produced the reported mustard mud. Light-mode display floors become
near-invisible rescues only (1.18 display / 1.6 semantic; dark keeps
1.45/2.2); the default skin ships a fills-only light_colors OVERLAY
(polarity-flip the navy menu/status fills, foregrounds inherit the vivid
colors), and themeForSkin merges polarity overlays instead of replacing.
Palette audit reworked: base colors audited fully, overlays for valid keys
and fill polarity.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 17:36:59 -05:00
parent 296303302d
commit 28e05a3c85
5 changed files with 144 additions and 82 deletions

View file

@ -215,35 +215,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"shell_dollar": "#4dabf7",
"voice_status_bg": "#1a1a2e",
},
# Hand-tuned light palette (mirrors the TUI's LIGHT_THEME golds).
# No paired light_colors. On a light (in practice transparent) terminal
# the beloved classic look is these vivid golds rendered RAW — xterm
# applies no contrast lift over a transparent bg, so #FFD700 shows as
# bright #F5C242, not a WCAG-darkened mustard. The TUI's display shim
# only flips fills to light polarity and rescues genuinely-invisible
# near-white text; the golds pass through untouched. A hand-authored
# dark "light palette" here is exactly what made it read as mud.
"light_colors": {
"banner_border": "#7A4F1F",
"banner_title": "#8B6914",
"banner_accent": "#A0651C",
"banner_dim": "#7A5A0F",
"banner_text": "#3D2F13",
"ui_accent": "#A0651C",
"ui_label": "#7A5A0F",
"ui_ok": "#2E7D32",
"ui_error": "#C62828",
"ui_warn": "#E65100",
"prompt": "#2B2014",
"input_rule": "#7A4F1F",
"response_border": "#8B6914",
"status_bar_bg": "#F5F5F5",
"status_bar_text": "#333333",
"status_bar_strong": "#8B6914",
"status_bar_dim": "#8A8A8A",
"status_bar_good": "#2E7D32",
"status_bar_warn": "#8B6914",
"status_bar_bad": "#D84315",
"status_bar_critical": "#B71C1C",
"session_label": "#7A5A0F",
"session_border": "#7A5A0F",
# Fills only: on a light terminal the dark navy menu/status fills
# must flip to light. Foregrounds intentionally inherit the vivid
# `colors` above so they render raw.
"completion_menu_bg": "#F5F5F5",
"completion_menu_current_bg": "#E0D1BF",
"selection_bg": "#D4E4F7",
"shell_dollar": "#1565C0",
"status_bar_bg": "#F5F5F5",
"voice_status_bg": "#F5F5F5",
},
"spinner": {

View file

@ -126,41 +126,57 @@ def contrast(a: str, b: str) -> float:
LIGHT_AUTHORED = frozenset({"daylight", "warm-lightmode"})
def _palettes():
"""Yield (skin, palette_name, palette, is_light) for every built-in."""
# A `light_colors`/`dark_colors` block is an OVERLAY on `colors`, not a full
# replacement — a skin may ship a fills-only light overlay (flip the dark navy
# menu/status fills to light) while its vivid foregrounds keep coming from
# `colors` and render raw. So only the base `colors` block is held to the
# completeness + full foreground-contrast contract; overlays are audited for
# valid keys and fill polarity only.
def _base_palettes():
"""Yield (skin, palette, is_light) for every built-in's base `colors`."""
for name, skin in _BUILTIN_SKINS.items():
yield name, "colors", skin.get("colors", {}), name in LIGHT_AUTHORED
yield name, skin.get("colors", {}), name in LIGHT_AUTHORED
def _overlays():
"""Yield (skin, block, palette, is_light) for every partial overlay block."""
for name, skin in _BUILTIN_SKINS.items():
if skin.get("light_colors"):
yield name, "light_colors", skin["light_colors"], True
if skin.get("dark_colors"):
yield name, "dark_colors", skin["dark_colors"], False
ALL_PALETTES = list(_palettes())
PALETTE_IDS = [f"{skin}:{block}" for skin, block, _, _ in ALL_PALETTES]
BASE_PALETTES = list(_base_palettes())
BASE_IDS = [f"{skin}:colors" for skin, _, _ in BASE_PALETTES]
OVERLAYS = list(_overlays())
OVERLAY_IDS = [f"{skin}:{block}" for skin, block, _, _ in OVERLAYS]
@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS)
def test_palette_is_complete(skin, block, palette, is_light):
@pytest.mark.parametrize(("skin", "palette", "is_light"), BASE_PALETTES, ids=BASE_IDS)
def test_base_palette_is_complete(skin, palette, is_light):
missing = REQUIRED_KEYS - palette.keys()
assert not missing, f"{skin}.{block} missing keys: {sorted(missing)}"
assert not missing, f"{skin}.colors missing keys: {sorted(missing)}"
@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS)
def test_palette_contrast_and_polarity(skin, block, palette, is_light):
@pytest.mark.parametrize(("skin", "palette", "is_light"), BASE_PALETTES, ids=BASE_IDS)
def test_base_palette_contrast_and_polarity(skin, palette, is_light):
pole = LIGHT_POLE if is_light else DARK_POLE
problems = []
for key in STRONG_FG:
ratio = contrast(palette[key], pole)
if ratio < STRONG_MIN:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {STRONG_MIN} vs {pole}")
# Light-authored bases render on a light pole where the classic look is the
# vivid palette rendered RAW (transparent terminals apply no lift), so the
# firm STRONG/SOFT floors only apply to dark-authored bases on a dark pole.
if not is_light:
for key in STRONG_FG:
ratio = contrast(palette[key], pole)
if ratio < STRONG_MIN:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {STRONG_MIN} vs {pole}")
for key in SOFT_FG:
ratio = contrast(palette[key], pole)
if ratio < SOFT_MIN:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {SOFT_MIN} vs {pole}")
for key in SOFT_FG:
ratio = contrast(palette[key], pole)
if ratio < SOFT_MIN:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {SOFT_MIN} vs {pole}")
status_bg = palette["status_bar_bg"]
for key in ON_STATUS_BAR:
@ -169,13 +185,35 @@ def test_palette_contrast_and_polarity(skin, block, palette, is_light):
if ratio < floor:
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {floor} vs status_bar_bg {status_bg}")
for key in FILLS:
_check_fills(palette, is_light, FILLS, problems)
_check_chip(palette, problems)
assert not problems, f"{skin}.colors:\n " + "\n ".join(problems)
@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), OVERLAYS, ids=OVERLAY_IDS)
def test_overlay_keys_and_fill_polarity(skin, block, palette, is_light):
unknown = palette.keys() - REQUIRED_KEYS
assert not unknown, f"{skin}.{block} has unknown keys: {sorted(unknown)}"
problems = []
_check_fills(palette, is_light, [k for k in FILLS if k in palette], problems)
if "completion_menu_current_bg" in palette and "completion_menu_bg" in palette:
_check_chip(palette, problems)
assert not problems, f"{skin}.{block}:\n " + "\n ".join(problems)
def _check_fills(palette, is_light, keys, problems):
for key in keys:
lum = luminance(palette[key])
if is_light and lum < 0.4:
problems.append(f"{key}={palette[key]} is a dark fill (lum {lum:.2f}) in a light palette")
if not is_light and lum > 0.35:
problems.append(f"{key}={palette[key]} is a light fill (lum {lum:.2f}) in a dark palette")
def _check_chip(palette, problems):
# The selection chip must remain distinguishable from the menu surface.
chip = contrast(palette["completion_menu_current_bg"], palette["completion_menu_bg"])
if chip < 1.15:
@ -183,5 +221,3 @@ def test_palette_contrast_and_polarity(skin, block, palette, is_light):
f"completion_menu_current_bg={palette['completion_menu_current_bg']} "
f"indistinguishable from completion_menu_bg (contrast {chip:.2f})"
)
assert not problems, f"{skin}.{block}:\n " + "\n ".join(problems)

View file

@ -307,9 +307,11 @@ describe('fromSkin', () => {
const theme = fromSkin({ banner_text: '#FFF8DC' }, {})
// No ansi256 bucketing on truecolor terminals — a truly invisible cream
// (1.08:1 on white) still gets the display shim's gentle rescue.
// (1.08:1 on white) still gets the display shim's gentle light-mode rescue
// (floor 1.18: enough to make near-white text appear, not enough to crush
// the vivid golds into mud).
expect(theme.color.text).toMatch(/^#[0-9a-f]{6}$/i)
expect(contrastRatio(theme.color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.45)
expect(contrastRatio(theme.color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18)
})
it('normalizes Apple Terminal names before matching', async () => {
@ -398,8 +400,10 @@ describe('derived tone ladder', () => {
[dark.DARK_THEME.color.completionBg, '#1a1a2e', 'dark surface'],
[dark.DARK_THEME.color.completionCurrentBg, '#333355', 'dark chip'],
[dark.DARK_THEME.color.selectionBg, '#3a3a55', 'dark selection'],
[light.LIGHT_THEME.color.muted, '#7A5A0F', 'light muted'],
[light.LIGHT_THEME.color.statusFg, '#333333', 'light statusFg'],
// Light canon = liftForContrast(dark literal, white, 4.5): the exact
// colors xterm's minimumContrastRatio rendered on light hosts.
[light.LIGHT_THEME.color.muted, '#946C08', 'light muted'],
[light.LIGHT_THEME.color.statusFg, '#6F6F6F', 'light statusFg'],
[light.LIGHT_THEME.color.completionBg, '#F5F5F5', 'light surface'],
[light.LIGHT_THEME.color.completionCurrentBg, '#e0d1bf', 'light chip'],
[light.LIGHT_THEME.color.selectionBg, '#D4E4F7', 'light selection']
@ -457,14 +461,17 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => {
expect(color.accent.toLowerCase()).toBe('#7eb8f6')
expect(color.muted.toLowerCase()).toBe('#4b5563')
// Colors below the display floor get xterm's hue-preserving rescue.
// Light mode renders the authored palette essentially RAW: a transparent
// terminal (the common Cursor case) applies no contrast lift of its own,
// and the beloved classic look is the vivid palette, not a WCAG-darkened
// one. Foregrounds only clear the near-invisible floor (1.18).
for (const key of ['text', 'prompt', 'accent', 'label', 'primary', 'muted', 'border'] as const) {
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.45)
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.18)
}
// Semantic alert colors carry meaning — firmer floor.
// Semantic alert colors carry meaning — firmer floor, still gentle on light.
for (const key of ['ok', 'error', 'warn', 'statusGood', 'statusCritical'] as const) {
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(2.15)
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.6)
}
// Background roles the skin never defined must be light-polarity fills,
@ -480,7 +487,7 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => {
const { color } = fromSkin({ banner_text: '#FFF8DC' }, {})
expect(color.text.toLowerCase()).not.toBe('#fff8dc')
expect(contrastRatio(color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.45)
expect(contrastRatio(color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18)
// Multiplicative lift preserves channel ordering (warm stays warm).
const [r, g, b] = [1, 3, 5].map(i => parseInt(color.text.slice(i, i + 2), 16))

View file

@ -39,11 +39,15 @@ const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready')
let lastSkin: GatewaySkin | null = null
const themeForSkin = (s: GatewaySkin) => {
// Prefer the skin's hand-tuned palette for the terminal's polarity
// (desktop colors/darkColors contract). Without a paired block, `colors`
// goes through fromSkin's automatic contrast adaptation instead.
// Polarity overrides OVERLAY the base palette, they don't replace it: a skin
// can ship a fills-only `light_colors` (flip the dark navy menu/status fills
// to light on a light terminal) while its vivid foreground golds keep coming
// from `colors` and render raw through fromSkin's shim. A full paired block
// still works — it just overrides every key it lists.
const paired = detectLightMode() ? s.light_colors : s.dark_colors
const colors = paired && Object.keys(paired).length ? paired : (s.colors ?? {})
const colors =
paired && Object.keys(paired).length ? { ...(s.colors ?? {}), ...paired } : (s.colors ?? {})
return fromSkin(colors, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '', s.tool_prefix ?? '', s.help_header ?? '')
}

View file

@ -340,21 +340,28 @@ export const DARK_SEEDS: ThemeSeeds = {
}
// Light-terminal seeds: darker golds/ambers that stay legible on white.
// The classic light-mode Hermes look was never hand-authored: for years the
// TUI emitted the DARK golds and hosts with xterm's minimumContrastRatio
// (Cursor defaults to 4.5) lifted them against white — hue and saturation
// kept, luminance clamped. These seeds are those exact lifts
// (liftForContrast(dark, '#ffffff', 4.5)), so hosts WITHOUT a contrast pass
// render the same thing Cursor always showed. Text/prompt stay ink — body
// copy historically rendered in the terminal's default near-black fg.
export const LIGHT_SEEDS: ThemeSeeds = {
accent: '#A0651C',
accent: '#956E00',
bg: '#ffffff',
border: '#7A4F1F',
error: '#C62828',
ok: '#2E7D32',
primary: '#8B6914',
border: '#A56628',
error: '#C14240',
ok: '#367E39',
primary: '#867000',
prompt: '#2B2014',
shellDollar: '#1565C0',
statusBad: '#D84315',
statusCritical: '#B71C1C',
statusGood: '#2E7D32',
statusWarn: '#8B6914',
shellDollar: '#377BB3',
statusBad: '#A65A00',
statusCritical: '#B94D4D',
statusGood: '#5C7A5C',
statusWarn: '#867000',
text: '#3D2F13',
warn: '#E65100'
warn: '#956115'
}
export const DARK_THEME: Theme = {
@ -401,8 +408,20 @@ export const LIGHT_THEME: Theme = {
// The lift itself is xterm.js's own multiplicative algorithm
// (liftForContrast), so on hosts that run their own minimumContrastRatio the
// two adjustments agree instead of fighting.
// Foreground floors are polarity-aware. On a DARK background the authored
// palette is already bright, so a modest floor only rescues the rare dark
// tone. On a LIGHT background — which in practice means a TRANSPARENT Cursor/
// terminal window compositing over a light editor, where xterm applies NO
// contrast lift of its own (there is no solid bg to measure against) — the
// beloved classic look is the authored palette rendered essentially RAW:
// vivid #FFD700 gold (~1.36:1), not a WCAG-darkened mustard. So the light
// floor is a near-invisible rescue only (catches cream #FFF8DC at 1.08 but
// leaves the golds untouched). Pixel-sampled target: #F5C242 (L61 S90),
// which the previous 1.45 floor crushed to #867000 (L26) — the reported mud.
const DISPLAY_MIN_CONTRAST = 1.45
const SEMANTIC_MIN_CONTRAST = 2.2
const LIGHT_DISPLAY_MIN_CONTRAST = 1.18
const LIGHT_SEMANTIC_MIN_CONTRAST = 1.6
const DISPLAY_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
'primary',
@ -444,13 +463,15 @@ const DARK_BG_MAX_LUMINANCE = 0.35
function adaptColorsToBackground(colors: ThemeColors, isLight: boolean, base: ThemeColors, bg: string): ThemeColors {
const out = { ...colors }
const displayFloor = isLight ? LIGHT_DISPLAY_MIN_CONTRAST : DISPLAY_MIN_CONTRAST
const semanticFloor = isLight ? LIGHT_SEMANTIC_MIN_CONTRAST : SEMANTIC_MIN_CONTRAST
for (const key of DISPLAY_FOREGROUNDS) {
out[key] = liftForContrast(out[key], bg, DISPLAY_MIN_CONTRAST)
out[key] = liftForContrast(out[key], bg, displayFloor)
}
for (const key of SEMANTIC_FOREGROUNDS) {
out[key] = liftForContrast(out[key], bg, SEMANTIC_MIN_CONTRAST)
out[key] = liftForContrast(out[key], bg, semanticFloor)
}
for (const key of ADAPTIVE_BACKGROUNDS) {
@ -521,11 +542,16 @@ export interface ThemeTones {
* dark muted #CC9B1F desaturate(mix(accent, bg, .19), .16) (err 3)
* dark label #DAA520 desaturate(mix(accent, bg, .13), .16) (err 3)
* dark status #C0C0C0 = grayOf(mix(text, bg, .24)) (err 0)
* light muted/label #7A5A0F mix(primary, text, .27) (err 5)
* light status #333333 = grayOf(mix(text, bg, .01)) (err 1)
* light muted #946C08 desaturate(accent, .05) (err 2)
* light label #8E6B13 desaturate(mix(accent, text, .03), .15) (err 2)
* light status #6F6F6F = grayOf(mix(text, bg, .30)) (err 1)
* light surface #F5F5F5 bg + softened accent (err 5)
* light chip #E0D1BF = mix(surface, accent, .25) (err 3)
* light selection #D4E4F7 mix(bg, shellDollar, .17) (err 3)
* light chip #E0D1BF = mix(surface, accent, .25) (err 8)
* light selection #D4E4F7 mix(bg, shellDollar, .20) (err 7)
*
* The light targets are the LIFT CANON: liftForContrast(dark literal,
* white, 4.5) what xterm's minimumContrastRatio showed on light hosts
* for years not hand-picked browns (those read as desaturated mud).
*
* The classic dark navy fills (#1a1a2e/#333355/#3a3a55) are IRREDUCIBLE from
* gold seeds the search bottoms out at gray, err 1017 so they remain
@ -538,21 +564,24 @@ export function deriveTones(seeds: {
shellDollar?: string
text: string
}): ThemeTones {
const { accent, bg, primary, text } = seeds
const { accent, bg, text } = seeds
const isLight = (relativeLuminance(bg) ?? 0) > 0.5
const inkBlend = mix(primary, text, 0.27)
// Fill tint keeps most of the accent's chroma — a heavier desaturate here
// read as washed-out ("a little too desat") next to authored fills.
const surface = mix(bg, desaturate(accent, 0.15), isLight ? 0.045 : 0.09)
return {
muted: isLight ? inkBlend : desaturate(mix(accent, bg, 0.19), 0.16),
label: isLight ? inkBlend : desaturate(mix(accent, bg, 0.13), 0.16),
statusFg: grayOf(mix(text, bg, isLight ? 0.01 : 0.24)),
// Light knobs are fitted to the lift canon (xterm minimumContrastRatio
// 4.5 of the classic dark golds against white — see LIGHT_SEEDS), not
// to ink blends: muted #946C08 ≈ desat(accent .05), label #8E6B13 ≈
// desat(mix(accent, text, .03), .15), statusFg #6F6F6F ≈ gray 30% lift.
muted: isLight ? desaturate(accent, 0.05) : desaturate(mix(accent, bg, 0.19), 0.16),
label: isLight ? desaturate(mix(accent, text, 0.03), 0.15) : desaturate(mix(accent, bg, 0.13), 0.16),
statusFg: grayOf(mix(text, bg, isLight ? 0.3 : 0.24)),
surface,
activeRow: mix(surface, accent, 0.25),
selection:
isLight && seeds.shellDollar ? mix(bg, seeds.shellDollar, 0.17) : mix(surface, accent, 0.28),
isLight && seeds.shellDollar ? mix(bg, seeds.shellDollar, 0.2) : mix(surface, accent, 0.28),
border: mix(accent, bg, 0.25)
}
}