diff --git a/CLAUDE.md b/CLAUDE.md index aea9d10..e761005 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,10 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a characters. `node.unknown` is deliberately not `muted`: muted is chrome and may sit a step darker, while an unidentified node is content you can still act on. Our own node is the pure-white `you` style; a context colouring (chart quality) may still win. - Recency heat colours heard/first-heard ages, never names. A key lane (via + Recency heat colours heard/first-heard ages, never names — seven steps on plain human + boundaries (`widgets._HEAT_STEPS`), each `heat.*` style named for how old the node it + colours is: white under 5 minutes, then yellow, light red, brown, red, light grey, and + one cold grey shared by "over a year" and "never heard". A key lane (via `highlighted_hash`) lights its hash in the same key-derived hue; the rest of the key, and any key standing in as a name, stays muted. UX text says "key" for the lane and "hash" only for the short derived id — never "hash" for a truncated key. diff --git a/meshterm/ui/logo.ansi b/meshterm/ui/logo.ansi index 61508f5..c146453 100644 --- a/meshterm/ui/logo.ansi +++ b/meshterm/ui/logo.ansi @@ -1,6 +1,6 @@ ███╗ ███╗███████╗███████╗██╗ ██╗████████╗███████╗██████╗ ███╗ ███╗ ████╗ ████║██╔════╝██╔════╝██║ ██║╚══██╔══╝██╔════╝██╔══██╗████╗ ████║ -██╔████╔██║█████╗ ███████╗███████║ ██║ █████╗ ██████╔╝██╔████╔██║ -██║╚██╔╝██║██╔══╝ ╚════██║██╔══██║ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║ -██║ ╚═╝ ██║███████╗███████║██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║ +██╔████╔██║█████╗ ███████╗███████║ ██║ █████╗ ██████╔╝██╔████╔██║ +██║╚██╔╝██║██╔══╝ ╚════██║██╔══██║ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║ +██║ ╚═╝ ██║███████╗███████║██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ diff --git a/meshterm/ui/specimen.py b/meshterm/ui/specimen.py index 5b22c2f..6ba3f1a 100644 --- a/meshterm/ui/specimen.py +++ b/meshterm/ui/specimen.py @@ -40,11 +40,15 @@ def _palette_row() -> Text: def _ages_row() -> Text: - """The heat scale over its own anchors, each age drawn in its heat colour.""" + """The heat scale over its own anchors — one age just inside each of the seven steps.""" row = Text() - for label, secs in (("now", 0), ("5m", 300), ("3h", 10800), ("2d", 172800), ("never", None)): + anchors = ( + ("now", 0), ("20m", 1200), ("3h", 10800), ("2d", 172800), + ("2w", 1209600), ("3mo", 7776000), ("never", None), + ) + for label, secs in anchors: if row.plain: - row.append(" ") + row.append(" ") row.append(label, style=_recency_style(secs)) return row diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index c06da47..741174f 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -103,14 +103,21 @@ MESH_THEME = Theme( "type.repeater": "#a78bfa", "type.room": "#ffffff", "type.sensor": "#fb923c", - # The heard-age heat scale's quantized steps (hot → cold). The regular platform - # interpolates a continuous gradient instead (ui.widgets._recency_style); these - # exist in both themes so the quantized impl's names resolve everywhere. - "heat.hot": "#ffffff", - "heat.warm": "#facc15", - "heat.cool": "#fb923c", - "heat.cold": "#94a3b8", - "heat.never": "#64748b", + # The heard-age heat scale's steps, named for how old the node they colour is: + # a node heard eight minutes ago is ``heat.minutes``, one heard eight days ago is + # ``heat.days``. Seven steps from white-hot to cold ash (JP's spec) — a cooling + # ember, then what's left of it. ``heat.never`` is both "over a year" and "never + # heard": past a year the distinction stops being worth a colour. + # The regular platform interpolates a continuous gradient over the same anchors + # rather than stepping (see ui.widgets._recency_style), so these are its ladder + # spelled out — every style name resolves on both platforms either way. + "heat.now": "#ffffff", # under 5 minutes + "heat.minutes": "#facc15", # 5 minutes + "heat.hours": "#f87171", # 1 hour + "heat.days": "#b45309", # 1 day + "heat.weeks": "#dc2626", # 1 week + "heat.months": "#94a3b8", # 1 month + "heat.never": "#64748b", # 1 year, and never heard # The PicoCalc F-key lane's chip fills (see ui.tui.fkeys): gray for the plain # F1-F5 bank, green while the Shift watcher reports F6-F10. White text on both — # defined here too so the style names resolve on every platform, even though @@ -252,10 +259,16 @@ MESH_THEME_16 = Theme( "type.repeater": "not bold color(5)", "type.room": "color(15)", "type.sensor": "not bold color(3)", - "heat.hot": "bold color(15)", - "heat.warm": "color(11)", - "heat.cool": "not bold color(3)", - "heat.cold": "not bold color(7)", + # JP's ladder, slot by slot: white-hot, yellow, the ember's light red, brown as it + # chars, dark red as it dies, then ash — light grey, and cold grey for what may as + # well never have been heard. Six of the sixteen slots do the whole scale; the + # three in the dim bank say ``not bold`` or a selected row would jump them a rung. + "heat.now": "bold color(15)", + "heat.minutes": "color(11)", + "heat.hours": "color(9)", + "heat.days": "not bold color(3)", + "heat.weeks": "not bold color(1)", + "heat.months": "not bold color(7)", "heat.never": "color(8)", # The F-key lane's chip fills: light-grey background (the palette's only "gray" # addressable as a background — see _VT_SLOTS) for the plain bank, green for the diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index a3dbaff..aa19074 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -16,7 +16,7 @@ from rich.panel import Panel from rich.text import Text from ...platforms import Platform, get_platform, on_platform -from ..theme import hint_style, title_style +from ..theme import fold_text, hint_style, title_style from .glow import apply_corner_glow from .render import render_lines from .screen import Screen @@ -321,12 +321,17 @@ def _banner_lines(banner: Sequence[str], cols: int) -> list[str]: Each row is padded to the block's widest display width first, so the shared left margin keeps the art internally aligned rather than centering every row on its own axis. + + The rows are art, not renderables, so they never pass through + :func:`~meshterm.ui.tui.render.render_to_ansi` — the fold is applied here instead, and + the wordmark's truecolour lands on the palette slots the art was drawn against rather + than on whatever the console's own downsample picks. """ if not banner: return [] widths = [cell_len(Text.from_ansi(row).plain) for row in banner] width = max(widths) - padded = [row + " " * (width - w) for row, w in zip(banner, widths)] + padded = [fold_text(row) + " " * (width - w) for row, w in zip(banner, widths)] return _center(padded, cols) diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index cc6a8ea..afd8a8c 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -887,24 +887,32 @@ def _recency_gradient(secs: Optional[float]) -> str: return f"#{r:02x}{g:02x}{b:02x}" -def _recency_quantized(secs: Optional[float]) -> str: - """PicoCalc's heat: the gradient collapsed onto the theme's four ``heat.*`` steps. +#: The heat ladder as ``(younger than, style)``, hottest first — JP's spec. Every boundary +#: is a plain human unit, so a colour change always lands on a number you can say out loud +#: ("under five minutes", "over a week"). Past a year nothing is worth distinguishing from +#: never heard, so the two share the coldest step. +_HEAT_STEPS: tuple[tuple[float, str], ...] = ( + (300, "heat.now"), # under 5 minutes — white + (3600, "heat.minutes"), # 5 minutes — yellow + (86400, "heat.hours"), # 1 hour — light red + (604800, "heat.days"), # 1 day — brown + (2592000, "heat.weeks"), # 1 week — red + (31536000, "heat.months"), # 1 month — light grey +) # 1 year / never — dark grey (heat.never) - The boundaries are the plain human units (JP's spec: useful, easily understood): - white within the **quarter hour** (fresh — inside a typical advert interval), - yellow within the **hour**, orange within the **day**, default-grey beyond. No red - step — on the console red is reserved for errors. ``heat.never`` keeps never-heard - a shade below ever-heard. + +def _recency_quantized(secs: Optional[float]) -> str: + """PicoCalc's heat: the gradient stepped onto the theme's ``heat.*`` rungs. + + Same scale as the regular platform's gradient and the same anchors — a console that + can't spend a hue per second spends one per unit instead (see :data:`_HEAT_STEPS`). """ if secs is None: return "heat.never" - if secs <= 900: - return "heat.hot" - if secs <= 3600: - return "heat.warm" - if secs <= 86400: - return "heat.cool" - return "heat.cold" + for limit, style in _HEAT_STEPS: + if secs < limit: + return style + return "heat.never" _recency_impl: Callable[[Optional[float]], str] = _recency_gradient diff --git a/tests/test_theme16.py b/tests/test_theme16.py index e379279..b0ef24e 100644 --- a/tests/test_theme16.py +++ b/tests/test_theme16.py @@ -270,6 +270,34 @@ def test_node_type_marks_stay_distinct_on_the_console() -> None: assert theme.mark_rgb("type.repeater") != theme.mark_rgb("muted") +def test_heat_ladder_walks_jps_seven_slots_in_order() -> None: + """The heard-age scale, pinned to its spec: seven steps on plain human boundaries. + + White under 5 minutes, then yellow, light red, brown, red, light grey, and one cold + grey shared by "over a year" and "never heard". + """ + from meshterm.ui.widgets import _recency_style + + set_platform(PICOCALC) + slots = {hex_: slot for slot, _, hex_ in theme._VT_SLOTS} + expected = [ + (60, 15), (299, 15), # under 5 minutes — white + (300, 11), (3599, 11), # 5 minutes — yellow + (3600, 9), (86399, 9), # 1 hour — light red + (86400, 3), (604799, 3), # 1 day — brown + (604800, 1), (2591999, 1), # 1 week — red + (2592000, 7), (31535999, 7), # 1 month — light grey + (31536000, 8), (None, 8), # 1 year, and never — dark grey + ] + for secs, slot in expected: + style = MESH_THEME_16.styles[_recency_style(secs)] + assert style.color is not None and style.color.number == slot, (secs, style) + assert not style.bold or slot >= 8, f"bold on dim slot {slot} would jump a rung" + # The regular platform answers the same question with a continuous hue instead. + set_platform(REGULAR) + assert _recency_style(1200).startswith("#") + + def test_canvas_drops_emphasis_where_bold_means_brightness() -> None: """A braille canvas may not embolden on the console: the VT would recolour the run.