Node colour: the hue now rides the key, and heat moves to the age

The per-node palette hue was keyed on the display name's characters; it
is now derived from the node's key — the first byte picks the colour, so
a 2-hex path hop, the stored 12-hex id, and the full 64-hex public key
all agree, and a rename keeps its colour. name_style grows a key
parameter (theme.node_style is the raw derivation); the name-character
fallback remains only for genuinely keyless senders (a channel message's
inline name). Keys are threaded through the path widget, the route
graph's labels, the atlas lists, the trophy case's far point and area
pins, and the packet viewer's node lane.

In the Nodes screen and the Time Machine (the shared node list, the
static CLI table, and the mesh page's arrivals), names drop their
recency-heat colouring for the node's palette hue; the heat now glows on
the HEARD/first-heard age lane instead, where the recency actually
lives. A nameless node's "unknown" placeholder stays muted — colour
marks a name, and the hash lane already carries the identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-18 20:37:10 -04:00
1 parent b40caf6891
commit 79d00defdd
8 files changed
+113 -63

No files matched your search

+9 -5
View File
@@ -22,8 +22,9 @@ them instead of hand-rolling:
- `ui/menus.py` — `back_rows`, `exit_rows`, `menu_rows`, `lane_row`, `section_heading`,
`confirm_discard`, `fit_cells`.
- `ui/widgets.py` — `highlighted_hash` (THE hash widget), `format_ago` (prose ages),
`_format_age` (column ages), `channel_glyph`, `_NODE_GLYPHS`, node heat colouring.
- `ui/theme.py` — `name_style` (per-name hues), `snr_style`, the `you` white.
`_format_age` (column ages), `channel_glyph`, `_NODE_GLYPHS`, heard-age heat colouring.
- `ui/theme.py` — `name_style`/`node_style` (per-node hues, hash-derived), `snr_style`,
the `you` white.
### Lexicon — one term per concept
@@ -104,9 +105,12 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a
### Colour
- Node names are always coloured: `name_style(name)` palette hue; our own node is the
pure-white `you` style; a context colouring (recency heat, chart quality) may win.
Bare hashes stay muted — colour is the "this is a name" signal.
- Node names are always coloured: `name_style(name, key)` palette hue, derived from the
node's key (its first byte — any known prefix agrees) so a rename keeps the colour;
the name's characters seed it only when no key exists (a channel sender). 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. Bare hashes stay muted —
colour is the "this is a name" signal.
- SNR always through `snr_style`; timelines oldest→now left-to-right, grey baseline = 0,
drawn via `ui/braillechart` only.
+2 -2
View File
@@ -794,13 +794,13 @@ class AtlasScreen(Screen):
return row
def _list_name_style(self, node: str) -> str:
"""The list's name colour: the app-wide palette hue, us in pure white."""
"""The list's name colour: the app-wide palette hue (hash-derived), us in pure white."""
if node == self._topo.self_id:
return "you"
label = self._label(node)
if label == node[:8]: # a bare hash is not a name — colour is the name signal
return "muted"
return name_style(label)
return name_style(label, node)
def _empty_state(self, width: int) -> list[str]:
"""A friendly explanation while the evidence graph is still empty."""
+24 -18
View File
@@ -21,6 +21,7 @@ from rich.text import Text
from .map_render import _SELF
from .menus import fit_cells
from .theme import name_style
from .tui.select import Choice, SelectScreen, Separator
from .widgets import (
_DEFAULT_GLYPH,
@@ -79,14 +80,15 @@ class NodeRow:
value: What the row's :class:`~meshterm.ui.tui.select.Choice` resolves with —
also the identity a re-sort uses to keep the highlight on its node, so it
should be unique across the list.
name: The display name; ``None`` renders the heat-coloured ``unknown`` (an
own-node row falls back to a bare ``you`` instead).
name: The display name, drawn in the node's hash-derived palette hue; ``None``
renders a muted ``unknown`` (an own-node row falls back to a bare ``you``
instead).
key: The hex the hash lane shows — as full a key as the caller could resolve;
empty renders a muted ``?``.
also the seed of the name's palette hue. Empty renders a muted ``?``.
node_type: The node's type for the leading glyph (``None`` = the plain-node
``●``; ignored on the own-node row, which always leads with the yellow ``★``).
last_seen: When the node was last heard (aware UTC) — colours the name by recency
heat and fills the heard lane; ``None`` reads ``never`` and the cold style.
last_seen: When the node was last heard (aware UTC) — fills the heard lane,
coloured by recency heat; ``None`` reads ``never`` in the cold style.
count: The packet tally; ``None`` renders a faint ``—`` (never overheard).
you: Whether this is our own node: the ``★`` marker, the pure-white ``you`` name
style with a muted ``(you)`` tag, faint ``—`` heard/packet lanes (we never
@@ -182,14 +184,15 @@ def _lane(row: NodeRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
"""One node as fixed, colour-coded lanes under :func:`_header`'s columns.
The type glyph leads (the app's shared marker palette), so the mark reads ``▲`` for a
repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` for a plain node. The name is
coloured by recency heat, ``unknown`` included, so a freshly heard mystery node still
reads hot, and takes the flexing name lane (``name_w`` cells). Last-heard age and
packet count follow, right-aligned under their headers — a node never overheard reads
a faint ``—`` in the packets lane; the hash closes the row in the shared hash widget,
its path-hash prefix lit at the device's routing width, or a muted ``?`` when no key
is known at all. An own-node row (:attr:`NodeRow.you`) takes its own drawing —
see :func:`_you_lane`.
repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` for a plain node. The name takes
the node's hash-derived palette hue (a nameless node's ``unknown`` placeholder stays
muted — colour marks a name, and the hash lane already carries the identity) and the
flexing name lane (``name_w`` cells). The last-heard age follows, glowing with recency
heat (brighter = fresher) so a freshly heard node still reads hot at a glance; then the
packet count, right-aligned — a node never overheard reads a faint ``—``; the hash
closes the row in the shared hash widget, its path-hash prefix lit at the device's
routing width, or a muted ``?`` when no key is known at all. An own-node row
(:attr:`NodeRow.you`) takes its own drawing — see :func:`_you_lane`.
Args:
row: The node's lane data.
@@ -205,9 +208,12 @@ def _lane(row: NodeRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
text = Text(no_wrap=True, overflow="ellipsis")
text.append(glyph, style=glyph_style)
text.append(" ")
text.append(fit_cells(row.name or "unknown", name_w), style=_recency_style(secs))
text.append(
fit_cells(row.name or "unknown", name_w),
style=name_style(row.name, row.key) if row.name else "muted",
)
text.append(" ")
text.append(f"{_format_age(secs):>5}", style="muted")
text.append(f"{_format_age(secs):>5}", style=_recency_style(secs))
text.append(" ")
if row.count is None:
text.append(f"{'—':>5}", style="faint")
@@ -252,9 +258,9 @@ def _ordered(rows: list[NodeRow], sort: NodesSort) -> list[NodeRow]:
class NodeListScreen(SelectScreen):
"""A full-screen, sortable, filterable node list in the shared lane layout.
The rows run in aligned lanes — name (coloured by recency heat), last-heard age,
packet count, and key hash — own-node rows first, then the rest in the active sort
order. The lanes anchor to the left: the name lane is sized to its widest name (not
The rows run in aligned lanes — name (in the node's hash-derived palette hue),
last-heard age (coloured by recency heat), packet count, and key hash — own-node rows
first, then the rest in the active sort order. The lanes anchor to the left: the name lane is sized to its widest name (not
the terminal), so the columns stay put as the window widens and the freed width flows
to the hash lane, which shows each key as fully as it fits (see :meth:`_lane_widths`).
It is a full-screen list, not a floating popup.
+1 -1
View File
@@ -221,7 +221,7 @@ def node_label(
return (entry.node or "?", "muted")
if self_name and name == self_name:
return (name, "you")
return (name, name_style(name))
return (name, name_style(name, entry.node))
class PacketViewer(Screen):
+7 -3
View File
@@ -132,6 +132,7 @@ class RecordDialog(Screen):
device_label: str,
device_hash: Optional[str],
far_label: Optional[str] = None,
far_id: Optional[str] = None,
shape: Optional[Sequence[WalkVertex]] = None,
reliability: Optional[tuple[float, int, int]] = None,
type_of: Optional[TypeOf] = None,
@@ -147,6 +148,8 @@ class RecordDialog(Screen):
device_hash: Our public key, annotated at the record's width.
far_label: The farthest node's name, shown beside its distance; ``None`` when
no positioned hop was named.
far_id: The farthest node's id, seeding the name's hash-derived hue; ``None``
leaves the label on the name-derived fallback.
shape: The walk's positioned circuit, projected for the area drawing; ``None``
below the three points a polygon needs.
reliability: ``(rate, successes, total)`` of traces to the walk's far node, or
@@ -163,6 +166,7 @@ class RecordDialog(Screen):
self._device_label = device_label
self._device_hash = device_hash
self._far_label = far_label
self._far_id = far_id
self._shape = list(shape) if shape else None
self._reliability = reliability
self._type_of = type_of
@@ -284,7 +288,7 @@ class RecordDialog(Screen):
value = Text(f"{far:.1f} km")
if self._far_label:
value.append(" ")
value.append(self._far_label, style=name_style(self._far_label))
value.append(self._far_label, style=name_style(self._far_label, self._far_id))
lanes.append(self._lane("far point", value))
area = stats.get("area_km2")
if area is not None:
@@ -533,7 +537,7 @@ async def open_records(ctx: "AppContext") -> dict:
# the neutral shape tone so it never masquerades as a coloured name.
name = resolve(node_id)
hop_glyph = node_marker(ntype)[0]
hop_color = name_rgb(name) if name and name != node_id else _AREA_TONE
hop_color = name_rgb(name, node_id) if name and name != node_id else _AREA_TONE
verts.append(WalkVertex(east, north, hop_glyph, hop_color, False))
dist = haversine_km(self_pos[0], self_pos[1], pos[0], pos[1])
if dist > far_dist:
@@ -706,7 +710,7 @@ async def open_records(ctx: "AppContext") -> dict:
action = await session.run_screen(RecordDialog(
record, category, rank,
resolve=resolve, device_label=device_label, device_hash=device_hash,
far_label=far_label, shape=shape,
far_label=far_label, far_id=far_id, shape=shape,
reliability=walk_reliability(far_id, far_label),
type_of=lambda node_id: node_geo(node_id)[1],
))
+42 -12
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Optional
from rich.console import Console
from rich.theme import Theme
@@ -128,13 +130,14 @@ def hint_style(border_style: str) -> str:
return name if name in MESH_THEME.styles else "muted"
#: Palette of distinct, dark-theme-friendly colors cycled through to give each node or
#: sender name its own stable hue (red is reserved for errors, so it's excluded). The
#: app-wide rule: a node's *name* is always coloured — by this palette, unless the
#: context already colours it (a picker's recency heat, a chart's quality tint) — and
#: our own node is always the pure-white ``you`` style instead, so "us" never blends
#: into the crowd. Shared by the chat transcript, the dashboard feed, and the packet
#: viewer, so one node reads as one colour everywhere.
#: Palette of distinct, dark-theme-friendly colors that give each node its own stable hue
#: (red is reserved for errors, so it's excluded). The app-wide rule: a node's *name* is
#: always coloured — by this palette, keyed on the node's key (see :func:`node_style`) so
#: the colour is the node's identity, surviving renames and colouring every surface that
#: knows any prefix of the key identically — and our own node is always the pure-white
#: ``you`` style instead, so "us" never blends into the crowd. Shared by the node lists,
#: the chat transcript, the dashboard feed, and the packet viewer, so one node reads as
#: one colour everywhere.
NAME_COLORS = (
"bold #f472b6", # pink
"bold #60a5fa", # blue
@@ -147,16 +150,43 @@ NAME_COLORS = (
)
def name_style(name: str) -> str:
"""The stable per-name colour a node or sender is drawn in, keyed on its characters.
def node_style(key: str) -> str:
"""The stable palette hue a node's *key* selects — the hash-derived node colour.
Only the first byte picks the colour, so any prefix of the key a surface happens to
hold — a 2-hex path hop, the stored 12-hex id, the full 64-hex public key — lands on
the same hue: one node, one colour, however it was learned.
Args:
name: The display name (not a hash — hashes stay muted).
key: The node's key/hash as hex (any length ≥ 1 byte, ``0x``/mixed-case tolerated).
Returns:
A style string from :data:`NAME_COLORS`; the same name always maps to the
same hue, so a node keeps its colour across screens and sessions.
A style string from :data:`NAME_COLORS`.
"""
raw = key.lower().removeprefix("0x")
try:
return NAME_COLORS[int(raw[:2], 16) % len(NAME_COLORS)]
except ValueError: # not hex — fall back to the character sum so *something* stable shows
return NAME_COLORS[sum(map(ord, raw)) % len(NAME_COLORS)]
def name_style(name: str, key: Optional[str] = None) -> str:
"""The stable colour a node or sender name is drawn in — keyed on the node's key.
Args:
name: The display name.
key: Any known prefix of the node's key/hash; when given, the hue is
:func:`node_style`'s — hash-derived, so a rename keeps the colour and every
surface that knows the key agrees. ``None``/empty marks a genuinely keyless
sender (a channel message's inline name), coloured by the name's characters
as the stable fallback.
Returns:
A style string from :data:`NAME_COLORS`; the same node always maps to the
same hue, so it keeps its colour across screens and sessions.
"""
if key:
return node_style(key)
return NAME_COLORS[sum(map(ord, name)) % len(NAME_COLORS)]
+9 -5
View File
@@ -33,7 +33,7 @@ from ..core.models import utcnow
from .braillechart import _TICK_GAP, GAP, axis_chart, chart_span, timeline_rows
from .menus import fit_cells, section_heading
from .nodelist import SORT_COLUMNS, SORT_OPENS_ASCENDING, NodeListScreen, NodeRow
from .theme import snr_style
from .theme import name_style, snr_style
from .tui.render import render_lines
from .tui.screen import CANCEL, Screen
from .tui.select import Choice, Separator
@@ -896,8 +896,9 @@ def _mesh_sections(
if not arrivals:
out.append(Text("none in this window", style="muted"))
else:
# Aligned lanes under column labels, the picker's presentation: the name
# coloured by heat ("unknown" included), the hash lit at the routing width.
# Aligned lanes under column labels, the picker's presentation: the name in
# the node's hash-derived hue (a nameless arrival's "unknown" stays muted),
# the hash lit at the routing width, the age glowing with recency heat.
# The FIRST HEARD header carries what used to be repeated on every row.
# A nameless arrival first asks the resolver (the device may know the node
# as a contact even though its stored observations never carried a name).
@@ -921,12 +922,15 @@ def _mesh_sections(
for node, name, first in listed:
secs = _age_seconds(first)
line = Text(" ", no_wrap=True, overflow="ellipsis")
line.append(fit_cells(name or "unknown", name_w), style=_recency_style(secs))
line.append(
fit_cells(name or "unknown", name_w),
style=name_style(name, node) if name else "muted",
)
line.append(" ")
line.append_text(highlighted_hash(node, prefix_bytes, width=_PICK_HASH_W))
line.append(" ")
line.append(_when_label(first))
line.append(f" ({format_ago(secs)})", style="muted")
line.append(f" ({format_ago(secs)})", style=_recency_style(secs))
out.append(line)
all_days = ctx.repo.daily_activity()
+19 -17
View File
@@ -350,7 +350,7 @@ def _path_node(
elif self_name and named == self_name:
style = "you"
else:
style = name_style(named)
style = name_style(named, hop)
text = Text(named, style=style)
if show_hash:
text.append(f" ({_shorten_hash(hop, hash_bytes)})", style=note_style)
@@ -437,19 +437,20 @@ def _shorten_hash(value: str, hash_bytes: Optional[int]) -> str:
_SELF_RGB: RGB = (255, 255, 255)
def _name_rgb(name: str) -> RGB:
"""The RGB of a name's stable palette hue (``theme.name_style`` minus its bold)."""
return parse_hex(name_style(name).split()[-1])
def _name_rgb(name: str, key: Optional[str] = None) -> RGB:
"""The RGB of a node's stable palette hue (``theme.name_style`` minus its bold)."""
return parse_hex(name_style(name, key).split()[-1])
def name_rgb(name: str) -> RGB:
"""The truecolour of a node name's stable palette hue, for a braille canvas.
def name_rgb(name: str, key: Optional[str] = None) -> RGB:
"""The truecolour of a node's stable palette hue, for a braille canvas.
The public face of :func:`_name_rgb` — the same hue :func:`~meshterm.ui.theme.name_style`
paints a name in, as an ``(r, g, b)`` tuple a raster can plot. A caller colouring node
markers by their mesh name (the trophy case's area drawing) mints them through here.
paints a name in (hash-derived when ``key`` is given), as an ``(r, g, b)`` tuple a
raster can plot. A caller colouring node markers by their mesh identity (the trophy
case's area drawing) mints them through here.
"""
return _name_rgb(name)
return _name_rgb(name, key)
#: Maps a relay hash to its node type (see the ``NODE_TYPE_*`` constants), or ``None`` when
@@ -523,7 +524,7 @@ def route_graph_style(
return _name_rgb(source) if source else parse_hex(_UNKNOWN[1])
named = resolve(node)
if named and named != node:
return _name_rgb(named)
return _name_rgb(named, node)
return parse_hex(glyph_of(node)[1])
return glyph_of, label_of, label_rgb_of
@@ -682,7 +683,7 @@ def self_marker() -> tuple[str, RGB]:
"""Our own node's map marker — the yellow ``★`` — as a ``(glyph, rgb)`` pair."""
return _SELF[0], parse_hex(_SELF[1])
# A heat-map gradient for a contact's name, hottest (most recently heard) to coldest: white
# A heat-map gradient for a node's heard age, hottest (most recently heard) to coldest: white
# → yellow → orange → red → grey. Each stop pairs an age anchor (log10 of seconds since heard)
# with an RGB colour; :func:`_recency_style` interpolates continuously between them, so the
# colour glides with recency rather than snapping between a handful of discrete shades.
@@ -733,7 +734,7 @@ def format_ago(secs: Optional[float]) -> str:
def _recency_style(secs: Optional[float]) -> str:
"""The heat-map name colour for a contact last heard ``secs`` ago (hotter = more recent).
"""The heat-map colour for a node's heard age of ``secs`` (hotter = more recent).
Interpolates the RGB channels between the two :data:`_HEAT_STOPS` bracketing ``secs`` (in
log-age space), clamping to white below the first stop and cold slate above the last.
@@ -901,9 +902,10 @@ def nodes_table(
Our own node is the first row (``★``, name in the white ``you`` style); contacts follow
in ``sort`` order.
A per-type glyph marks each node in the app's shared colours, the name is coloured by how
recently it was last heard (brighter = fresher), and the full key is shown with its
path-hash prefix lit — chopped with an ellipsis only when the terminal is too narrow.
A per-type glyph marks each node in the app's shared colours, the name takes the node's
hash-derived palette hue, the heard age glows with recency heat (brighter = fresher),
and the full key is shown with its path-hash prefix lit — chopped with an ellipsis only
when the terminal is too narrow.
Args:
self_name: This node's advertised name.
@@ -962,8 +964,8 @@ def nodes_table(
pkts = _contact_pkts(c, counts)
table.add_row(
Text(glyph, style=glyph_style),
Text(c.name, style=_recency_style(secs)),
Text(_format_age(secs), style="muted"),
Text(c.name, style=name_style(c.name, c.public_key or c.key_prefix)),
Text(_format_age(secs), style=_recency_style(secs)),
Text(str(pkts), style="muted") if pkts else Text("—", style="faint"),
highlighted_hash(c.public_key, prefix_bytes) if c.public_key else unknown,
)