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:
1 parent
d79d85b7ee
commit
5aeb034c06
7 files changed
+96
-35
No files matched your search
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[38;2;255;255;255m███[38;2;80;93;111m╗[0m [38;2;255;255;255m███[38;2;80;93;111m╗[38;2;255;255;255m███████[38;2;80;93;111m╗[38;2;255;255;255m███████[38;2;80;93;111m╗[38;2;255;255;255m██[38;2;80;93;111m╗[0m [38;2;255;255;255m██[38;2;80;93;111m╗[38;2;255;255;255m████████[38;2;80;93;111m╗[38;2;255;255;255m███████[38;2;80;93;111m╗[38;2;255;255;255m██████[38;2;80;93;111m╗[0m [38;2;255;255;255m███[38;2;80;93;111m╗[0m [38;2;255;255;255m███[38;2;80;93;111m╗[0m
|
||||
[38;2;251;217;80m████[38;2;80;93;111m╗[0m [38;2;251;217;80m████[38;2;80;93;111m║[38;2;251;217;80m██[38;2;80;93;111m╔════╝[38;2;251;217;80m██[38;2;80;93;111m╔════╝[38;2;251;217;80m██[38;2;80;93;111m║[0m [38;2;251;217;80m██[38;2;80;93;111m║╚══[38;2;251;217;80m██[38;2;80;93;111m╔══╝[38;2;251;217;80m██[38;2;80;93;111m╔════╝[38;2;251;217;80m██[38;2;80;93;111m╔══[38;2;251;217;80m██[38;2;80;93;111m╗[38;2;251;217;80m████[38;2;80;93;111m╗[0m [38;2;251;217;80m████[38;2;80;93;111m║[0m
|
||||
[38;2;250;175;40m██[38;2;80;93;111m╔[38;2;250;175;40m████[38;2;80;93;111m╔[38;2;250;175;40m██[38;2;80;93;111m║[38;2;250;175;40m█████[38;2;80;93;111m╗[0m [38;2;250;175;40m███████[38;2;80;93;111m╗[38;2;250;175;40m███████[38;2;80;93;111m║[0m [38;2;250;175;40m██[38;2;80;93;111m║[0m [38;2;250;175;40m█████[38;2;80;93;111m╗[0m [38;2;250;175;40m██████[38;2;80;93;111m╔╝[38;2;250;175;40m██[38;2;80;93;111m╔[38;2;250;175;40m████[38;2;80;93;111m╔[38;2;250;175;40m██[38;2;80;93;111m║[0m
|
||||
[38;2;250;138;73m██[38;2;80;93;111m║╚[38;2;250;138;73m██[38;2;80;93;111m╔╝[38;2;250;138;73m██[38;2;80;93;111m║[38;2;250;138;73m██[38;2;80;93;111m╔══╝[0m [38;2;80;93;111m╚════[38;2;250;138;73m██[38;2;80;93;111m║[38;2;250;138;73m██[38;2;80;93;111m╔══[38;2;250;138;73m██[38;2;80;93;111m║[0m [38;2;250;138;73m██[38;2;80;93;111m║[0m [38;2;250;138;73m██[38;2;80;93;111m╔══╝[0m [38;2;250;138;73m██[38;2;80;93;111m╔══[38;2;250;138;73m██[38;2;80;93;111m╗[38;2;250;138;73m██[38;2;80;93;111m║╚[38;2;250;138;73m██[38;2;80;93;111m╔╝[38;2;250;138;73m██[38;2;80;93;111m║[0m
|
||||
[38;2;248;113;113m██[38;2;80;93;111m║[0m [38;2;80;93;111m╚═╝[0m [38;2;248;113;113m██[38;2;80;93;111m║[38;2;248;113;113m███████[38;2;80;93;111m╗[38;2;248;113;113m███████[38;2;80;93;111m║[38;2;248;113;113m██[38;2;80;93;111m║[0m [38;2;248;113;113m██[38;2;80;93;111m║[0m [38;2;248;113;113m██[38;2;80;93;111m║[0m [38;2;248;113;113m███████[38;2;80;93;111m╗[38;2;248;113;113m██[38;2;80;93;111m║[0m [38;2;248;113;113m██[38;2;80;93;111m║[38;2;248;113;113m██[38;2;80;93;111m║[0m [38;2;80;93;111m╚═╝[0m [38;2;248;113;113m██[38;2;80;93;111m║[0m
|
||||
[38;2;248;113;113m██[38;2;80;93;111m╔[38;2;248;113;113m████[38;2;80;93;111m╔[38;2;248;113;113m██[38;2;80;93;111m║[38;2;248;113;113m█████[38;2;80;93;111m╗[0m [38;2;248;113;113m███████[38;2;80;93;111m╗[38;2;248;113;113m███████[38;2;80;93;111m║[0m [38;2;248;113;113m██[38;2;80;93;111m║[0m [38;2;248;113;113m█████[38;2;80;93;111m╗[0m [38;2;248;113;113m██████[38;2;80;93;111m╔╝[38;2;248;113;113m██[38;2;80;93;111m╔[38;2;248;113;113m████[38;2;80;93;111m╔[38;2;248;113;113m██[38;2;80;93;111m║[0m
|
||||
[38;2;185;28;28m██[38;2;80;93;111m║╚[38;2;185;28;28m██[38;2;80;93;111m╔╝[38;2;185;28;28m██[38;2;80;93;111m║[38;2;185;28;28m██[38;2;80;93;111m╔══╝[0m [38;2;80;93;111m╚════[38;2;185;28;28m██[38;2;80;93;111m║[38;2;185;28;28m██[38;2;80;93;111m╔══[38;2;185;28;28m██[38;2;80;93;111m║[0m [38;2;185;28;28m██[38;2;80;93;111m║[0m [38;2;185;28;28m██[38;2;80;93;111m╔══╝[0m [38;2;185;28;28m██[38;2;80;93;111m╔══[38;2;185;28;28m██[38;2;80;93;111m╗[38;2;185;28;28m██[38;2;80;93;111m║╚[38;2;185;28;28m██[38;2;80;93;111m╔╝[38;2;185;28;28m██[38;2;80;93;111m║[0m
|
||||
[38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m██[38;2;80;93;111m║[38;2;80;93;111m███████[38;2;80;93;111m╗[38;2;80;93;111m███████[38;2;80;93;111m║[38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m███████[38;2;80;93;111m╗[38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m██[38;2;80;93;111m║[38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m██[38;2;80;93;111m║[0m
|
||||
[38;2;80;93;111m╚═╝[0m [38;2;80;93;111m╚═╝╚══════╝╚══════╝╚═╝[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m╚══════╝╚═╝[0m [38;2;80;93;111m╚═╝╚═╝[0m [38;2;80;93;111m╚═╝[0m
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user