From 4956ff0cb9646aaf894c228e6dc932b126d1f927 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:28:40 -0600 Subject: [PATCH] fix(cli): keep journey labels readable --- hermes_cli/journey.py | 61 ++++++++++++++++++- tests/hermes_cli/test_journey_render.py | 79 +++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/hermes_cli/journey.py b/hermes_cli/journey.py index 1e404baa22..27fb9d1ce3 100644 --- a/hermes_cli/journey.py +++ b/hermes_cli/journey.py @@ -18,6 +18,7 @@ from functools import lru_cache from typing import Any, Optional _TITLE_COLOR = "#E8C463" +_CHARTED_SIGNAL_MIN_CONTRAST = 4.5 def _build_payload() -> dict[str, Any]: @@ -60,6 +61,57 @@ def _resolve(style: str, alpha: float) -> Optional[str]: return _fade(_palette().get(style), alpha) +def _relative_luminance(rgb: tuple[int, int, int]) -> float: + def channel(value: int) -> float: + normalized = value / 255 + return ( + normalized / 12.92 + if normalized <= 0.03928 + else ((normalized + 0.055) / 1.055) ** 2.4 + ) + + red, green, blue = (channel(value) for value in rgb) + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + + +def _contrast_ratio( + foreground: tuple[int, int, int], background: tuple[int, int, int] +) -> float: + foreground_luminance = _relative_luminance(foreground) + background_luminance = _relative_luminance(background) + high, low = sorted((foreground_luminance, background_luminance), reverse=True) + return (high + 0.05) / (low + 0.05) + + +def _ensure_contrast( + color: Optional[str], background: str, minimum: float +) -> Optional[str]: + """Lift a foreground toward the readable pole until it clears ``minimum``.""" + if not color: + return None + + from agent.learning_graph_render import hex_to_rgb, mix_rgb, rgb_to_hex + + foreground_rgb = hex_to_rgb(color) + background_rgb = hex_to_rgb(background) + if _contrast_ratio(foreground_rgb, background_rgb) >= minimum: + return color + + pole = (0, 0, 0) if _relative_luminance(background_rgb) > 0.5 else (255, 255, 255) + for step in range(1, 21): + candidate = mix_rgb(foreground_rgb, pole, step * 0.05) + if _contrast_ratio(candidate, background_rgb) >= minimum: + return rgb_to_hex(candidate) + return rgb_to_hex(pole) + + +def _resolve_charted_signal(style: str, alpha: float) -> Optional[str]: + """Keep age tinting without allowing explanatory labels to disappear.""" + return _ensure_contrast( + _resolve(style, alpha), _palette()["bg"], _CHARTED_SIGNAL_MIN_CONTRAST + ) + + def _row_to_text(row: list, color: bool): from rich.text import Text @@ -155,8 +207,13 @@ def _frame_renderable(payload, *, cols, rows, reveal, color): def label_row(item) -> Text: row = Text(" ") row.append(f"{item['key']} ", style="grey70" if color else None) - row.append(f"{item['glyph']} ", style=_resolve(item["style"], float(item.get("alpha", 1.0))) if color else None) - row.append(str(item["label"]), style=_resolve(item["style"], float(item.get("alpha", 1.0))) if color else None) + signal_style = ( + _resolve_charted_signal(item["style"], float(item.get("alpha", 1.0))) + if color + else None + ) + row.append(f"{item['glyph']} ", style=signal_style) + row.append(str(item["label"]), style=signal_style) meta = str(item["meta"]) row.append(f" {meta if len(meta) <= 32 else meta[:29] + '…'}", style="grey54" if color else None) return row diff --git a/tests/hermes_cli/test_journey_render.py b/tests/hermes_cli/test_journey_render.py index f456c38252..382b97fa43 100644 --- a/tests/hermes_cli/test_journey_render.py +++ b/tests/hermes_cli/test_journey_render.py @@ -12,6 +12,23 @@ import contextlib import io +def _relative_luminance(color: str) -> float: + value = color.lstrip("#") + channels = [int(value[index : index + 2], 16) / 255 for index in (0, 2, 4)] + red, green, blue = ( + channel / 12.92 if channel <= 0.03928 else ((channel + 0.055) / 1.055) ** 2.4 + for channel in channels + ) + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + + +def _contrast_ratio(foreground: str, background: str) -> float: + high, low = sorted( + (_relative_luminance(foreground), _relative_luminance(background)), reverse=True + ) + return (high + 0.05) / (low + 0.05) + + def _capture(argv: list[str], *, force: bool) -> str: from hermes_cli.journey import register_cli @@ -36,3 +53,65 @@ def test_default_capture_is_plain_for_chat_bubbles(): # Rich auto-detects the StringIO as non-tty → no color, no raw escapes. assert "\x1b[" not in _capture([], force=False) assert "\x1b[" not in _capture(["list"], force=False) + + +def test_charted_signal_labels_clear_readable_contrast_floor(monkeypatch): + from rich.text import Text + + from hermes_cli import journey + + palette = { + "bg": "#08080C", + "dim": "#525255", + "label": "#A9A9AA", + "memory": "#FFDC1F", + "skill": "#043D92", + } + monkeypatch.setattr(journey, "_palette", lambda: palette) + payload = { + "nodes": [ + { + "id": "old-skill", + "label": "old-skill", + "kind": "skill", + "timestamp": 1_700_000_000, + "category": "research", + "useCount": 0, + }, + { + "id": "new-skill", + "label": "new-skill", + "kind": "skill", + "timestamp": 1_800_000_000, + "category": "research", + "useCount": 1, + }, + ], + "edges": [], + "clusters": [{"category": "research", "count": 2}], + "stats": { + "learned_skills": 2, + "memory_nodes": 0, + "related_edges": 0, + "memory_skill_edges": 0, + }, + } + + output = journey._frame_renderable( + payload, cols=72, rows=24, reveal=1.0, color=True + ) + label_row = next( + renderable + for renderable in output.renderables + if isinstance(renderable, Text) and "old-skill" in renderable.plain + ) + label_offset = label_row.plain.index("old-skill") + label_span = next( + span for span in label_row.spans if span.start <= label_offset < span.end + ) + + assert isinstance(label_span.style, str) + assert ( + _contrast_ratio(label_span.style, palette["bg"]) + >= journey._CHARTED_SIGNAL_MIN_CONTRAST + )