Heat: seven steps on human boundaries; recolour the wordmark

JP's spec for the heard-age scale, replacing the four-step ladder: white
under 5 minutes, then yellow at 5m, light red at 1h, brown at 1d, red at
1w, light grey at 1mo, and one cold grey for over a year — which is also
"never heard", since past a year the distinction stops earning a colour.

The `heat.*` styles are renamed for what they describe: a node heard
eight minutes ago is `heat.minutes`, one heard eight days ago is
`heat.days`. The old hot/warm/cool/cold read as temperature but had to
carry an age, and at seven rungs that stopped being sayable. The steps
themselves move into a table, so the ladder is one thing to read.

Three of the seven land in the VT's dim bank, so they say `not bold` — a
selected row would otherwise jump them a rung (brown to yellow, red to
light red), which is the one way a heat colour can lie about an age.

The splash wordmark's vertical ramp becomes white / yellow / light red /
red / light black, each picked to quantize onto exactly one palette slot
(15, 11, 9, 1, 8) while staying a warm fade in truecolour. Its rows are
art rather than renderables, so they had never passed through the fold
and the console was downsampling them on its own — they do now, which is
what makes the slot choice hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-07 21:16:46 -04:00
1 parent d79d85b7ee
commit 5aeb034c06
7 files changed
+96 -35

No files matched your search

+4 -1
View File
@@ -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.
+3 -3
View File
@@ -1,6 +1,6 @@
███╗ ███╗███████╗███████╗██╗ ██╗████████╗███████╗██████╗ ███╗ ███╗
████╗ ████║██╔════╝██╔════╝██║ ██║╚══██╔══╝██╔════╝██╔══██╗████╗ ████║
██╔████╔██║█████╗ ███████╗███████║ ██║ █████╗ ██████╔╝██╔████╔██║
██║╚██╔╝██║██╔══╝ ╚════██║██╔══██║ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║
██║ ╚═╝ ██║███████╗███████║██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║
██╔████╔██║█████╗ ███████╗███████║ ██║ █████╗ ██████╔╝██╔████╔██║
██║╚██╔╝██║██╔══╝ ╚════██║██╔══██║ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║
██║ ╚═╝ ██║███████╗███████║██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║
╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝
+7 -3
View File
@@ -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
+25 -12
View File
@@ -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
+7 -2
View File
@@ -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)
+22 -14
View File
@@ -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
+28
View File
@@ -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.