From 28e05a3c85cdbec12fd4f02244ae8e8f5ef949dc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:36:59 -0500 Subject: [PATCH] fix(ui-tui): light mode renders the vivid palette RAW, not WCAG-darkened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/skin_engine.py | 36 +++------- tests/hermes_cli/test_skin_palettes.py | 78 +++++++++++++++------ ui-tui/src/__tests__/theme.test.ts | 25 ++++--- ui-tui/src/app/createGatewayEventHandler.ts | 12 ++-- ui-tui/src/theme.ts | 75 ++++++++++++++------ 5 files changed, 144 insertions(+), 82 deletions(-) diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 679416b8971d..3a89554f131e 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -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": { diff --git a/tests/hermes_cli/test_skin_palettes.py b/tests/hermes_cli/test_skin_palettes.py index 519cc8de65c4..f69846ed4d46 100644 --- a/tests/hermes_cli/test_skin_palettes.py +++ b/tests/hermes_cli/test_skin_palettes.py @@ -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) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index fe3ac5467795..c7a00c196329 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -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)) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 7343d5d55e0b..1a20dd2473f5 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 ?? '') } diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index cb29f93f7e40..28ce5c0f14dc 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -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 10–17 — 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) } }