mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
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.
223 lines
7.8 KiB
Python
223 lines
7.8 KiB
Python
"""Built-in skin palette audit: completeness + WCAG contrast, per polarity.
|
|
|
|
Every built-in skin must be a complete, coherent palette with no accidental
|
|
fallbacks (a partial skin inherits the default skin's gold, which is how
|
|
"slate feels all over the place" happened), and every palette must be a
|
|
fixed point of the TUI's runtime readability adaptation — hand-tuned values
|
|
that already pass the same contrast floors the TUI enforces (strong >= 3.9,
|
|
soft >= 2.8, fills matching the background polarity). Mirrors the desktop
|
|
app's paired colors/darkColors contract.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.skin_engine import _BUILTIN_SKINS
|
|
|
|
# Union of the color keys consumed by the TUI (fromSkin) and the classic CLI
|
|
# (banner.py / display.py / prompt_toolkit overrides). completion_menu_meta_*
|
|
# intentionally excluded: they default to the base menu keys.
|
|
REQUIRED_KEYS = frozenset(
|
|
{
|
|
"banner_border",
|
|
"banner_title",
|
|
"banner_accent",
|
|
"banner_dim",
|
|
"banner_text",
|
|
"ui_accent",
|
|
"ui_label",
|
|
"ui_ok",
|
|
"ui_error",
|
|
"ui_warn",
|
|
"prompt",
|
|
"input_rule",
|
|
"response_border",
|
|
"status_bar_bg",
|
|
"status_bar_text",
|
|
"status_bar_strong",
|
|
"status_bar_dim",
|
|
"status_bar_good",
|
|
"status_bar_warn",
|
|
"status_bar_bad",
|
|
"status_bar_critical",
|
|
"session_label",
|
|
"session_border",
|
|
"completion_menu_bg",
|
|
"completion_menu_current_bg",
|
|
"selection_bg",
|
|
"shell_dollar",
|
|
"voice_status_bg",
|
|
}
|
|
)
|
|
|
|
# Foreground roles and their minimum contrast against the palette's pole.
|
|
# Matches ui-tui/src/theme.ts STRONG/SOFT tiers.
|
|
STRONG_FG = (
|
|
"banner_title",
|
|
"banner_accent",
|
|
"banner_text",
|
|
"ui_accent",
|
|
"ui_label",
|
|
"ui_ok",
|
|
"ui_error",
|
|
"prompt",
|
|
"status_bar_strong",
|
|
"status_bar_good",
|
|
"status_bar_bad",
|
|
"status_bar_critical",
|
|
"shell_dollar",
|
|
)
|
|
SOFT_FG = (
|
|
"banner_dim",
|
|
"banner_border",
|
|
"ui_warn",
|
|
"input_rule",
|
|
"response_border",
|
|
"status_bar_dim",
|
|
"status_bar_warn",
|
|
"session_label",
|
|
"session_border",
|
|
)
|
|
# status_bar_text renders on status_bar_bg, not the terminal background.
|
|
ON_STATUS_BAR = ("status_bar_text", "status_bar_strong", "status_bar_dim")
|
|
|
|
FILLS = (
|
|
"status_bar_bg",
|
|
"completion_menu_bg",
|
|
"completion_menu_current_bg",
|
|
"selection_bg",
|
|
"voice_status_bg",
|
|
)
|
|
|
|
STRONG_MIN = 3.9
|
|
SOFT_MIN = 2.8
|
|
# Assumed terminal poles, matching ui-tui/src/theme.ts referenceBackground().
|
|
DARK_POLE = "#101014"
|
|
LIGHT_POLE = "#ffffff"
|
|
|
|
|
|
def _rgb(hex_color: str):
|
|
h = hex_color.lstrip("#")
|
|
assert len(h) == 6, f"not a 6-digit hex: {hex_color!r}"
|
|
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4))
|
|
|
|
|
|
def _channel(v: float) -> float:
|
|
c = v / 255
|
|
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
|
|
|
|
def luminance(hex_color: str) -> float:
|
|
r, g, b = _rgb(hex_color)
|
|
return 0.2126 * _channel(r) + 0.7152 * _channel(g) + 0.0722 * _channel(b)
|
|
|
|
|
|
def contrast(a: str, b: str) -> float:
|
|
la, lb = luminance(a), luminance(b)
|
|
hi, lo = max(la, lb), min(la, lb)
|
|
return (hi + 0.05) / (lo + 0.05)
|
|
|
|
|
|
# Light-authored built-ins (everything else is dark-authored). Paired
|
|
# palettes are OPTIONAL, hand-authored refinements: cross-polarity rendering
|
|
# is handled by the TUI's display shim (xterm-style hue-preserving contrast
|
|
# lift), which reproduces exactly what minimumContrastRatio hosts display —
|
|
# the look the maintainers standardized on. Generated paired palettes are
|
|
# explicitly NOT wanted; only ship one when a human tuned it.
|
|
LIGHT_AUTHORED = frozenset({"daylight", "warm-lightmode"})
|
|
|
|
|
|
# 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, 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
|
|
|
|
|
|
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", "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}.colors missing keys: {sorted(missing)}"
|
|
|
|
|
|
@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 = []
|
|
|
|
# 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}")
|
|
|
|
status_bg = palette["status_bar_bg"]
|
|
for key in ON_STATUS_BAR:
|
|
floor = STRONG_MIN if key == "status_bar_strong" else SOFT_MIN
|
|
ratio = contrast(palette[key], status_bg)
|
|
if ratio < floor:
|
|
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {floor} vs status_bar_bg {status_bg}")
|
|
|
|
_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:
|
|
problems.append(
|
|
f"completion_menu_current_bg={palette['completion_menu_current_bg']} "
|
|
f"indistinguishable from completion_menu_bg (contrast {chip:.2f})"
|
|
)
|