A faded chip keeps its node's colours, with less chroma

The part of a route that the user does not compose (a mirrored return
leg, the return home) drew every hop as the same dark slate chip. Each
faded chip now has the colours of its own node at FADE_CHROMA of their
chroma, at the same lightness, so it still says which node it is. The
PicoCalc has no colour of less chroma: there a faded hop keeps its label
colour and drops its fill. Arrows keep faint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-14 00:44:17 -04:00
1 parent 796ff94dad
commit bd6df0b9fa
5 files changed
+97 -45

No files matched your search

+6 -1
View File
@@ -20,7 +20,12 @@ can change how things behave. It does not only add new things.
chat. Before, your messages showed only the `★`.
- **Your node's star is white.** The `★` that marks your node on the map, in routes, in
lists, and in a chat is white, the same white as your name in the lists. Before, it was
yellow. A star in a faded part of a route stays grey.
yellow.
- **The faded part of a route keeps the colours of its nodes.** The part of a route that
you do not choose, for example the mirrored way back of a trace, shows each node in the
colour of its key, but less vivid. Thus you can still see which node each one is.
Before, each of these nodes was the same grey box. When a route is drawn with arrows,
the faded part stays grey.
- **In a chat, a name that MeshTerm cannot match to a node is black on grey.** The box
around a sender or a mention with no known node has black text on a grey box. Thus it
does not stand out among the coloured names. Before, it was light blue on slate.
+8 -3
View File
@@ -423,7 +423,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
`marks.SELF_MARK` carries the theme name `you` — the white of our name — so the console
picks its own white, and a raster resolves it through `mark_rgb`. It was the map's
yellow, then briefly our key's hue (set from the self-info at run time), and both were
dropped. A star something *mutes* keeps its grey — a faded hop, a dimmed return leg.
dropped. In arrows, a star something *mutes* keeps its `faint` grey — a faded hop, a
dimmed return leg; a faded *chip* only loses chroma, and white has none to lose.
- Path cuts: a path line drawn as **chips** that gets cut — a lane that ran out, a row
scrolled past its edge — breaks the chip off on a half block in that chip's own fill
(`▐` ends a line, `▌` opens one; `pathline.cut_mark`/`cut_to`), so half the cell is
@@ -443,8 +444,12 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
merged neighbouring greens). The PicoCalc is the same rule at its resolution: the fill
is the bright slot's dim twin (`theme.DIM_TWIN`). A hash label and an annotation are
the label colour throughout, never a grey; on a truecolor terminal the lit bytes are
bold. A keyless chip is the same pair in grey; our `★` and a faded hop keep their dark
greys; the composer's slot keeps the white chip (on the PicoCalc, a white `+` in a
bold. A keyless chip is the same pair in grey; our `★` keeps its dark grey; a **faded
hop** (a mirrored return leg, the return home: what the user doesn't compose) is its own
chip at `pathline.FADE_CHROMA` of its chroma, same lightness, so it still says which node
it is (JP, 2026-10-14; it was one dark slate) — the PicoCalc, with no colour of less
chroma, keeps its label colour and drops its fill; arrows keep `faint`; the composer's
slot keeps the white chip (on the PicoCalc, a white `+` in a
break of the ribbon).
- Chip seams are **one** interlocked chevron (previous fill on next). Two chips of the
same fill are the exception the interlock can't draw — and so are two fills the eye
+10
View File
@@ -121,6 +121,16 @@ def distance(first: str, second: str) -> float:
return math.dist(from_hex(first), from_hex(second))
def desaturated(colour: str, keep: float) -> str:
"""``colour`` with its chroma multiplied by ``keep``. The lightness and the hue do not change.
``1`` returns the colour, and ``0`` returns the grey of the same lightness. Less chroma
at the same lightness always stays in the sRGB gamut, because the grey is in it.
"""
lightness, a, b = from_hex(colour)
return to_hex((lightness, a * keep, b * keep))
def shaded(colour: str, by: float, *, floor: float | None = None) -> str:
"""``colour`` moved ``by`` in lightness, away from the end of the scale that is nearer.
+36 -29
View File
@@ -17,9 +17,10 @@ surface that the survey found can in the end use it.
look, and each seam uses one cell. The label of the chip of a node has the hue that
comes from its hash (:func:`~meshterm.ui.theme.node_style`). This is the colour that its
name has on each surface. The fill is that hue, but darker (:data:`CHIP_LIGHTNESS`). Our
node is the white ``★`` of the map on a neutral dark grey. A faded hop is dark slate. A
keyless hop is light grey on grey. (On the 16-colour console of the PicoCalc, the fill
is the dim twin of the hue. Refer to :func:`_slot_colours`.)
node is the white ``★`` of the map on a neutral dark grey. A faded hop has the colours
of its own chip, with less chroma (:data:`FADE_CHROMA`). A keyless hop is light grey on
grey. (On the 16-colour console of the PicoCalc, the fill is the dim twin of the hue.
Refer to :func:`_slot_colours`.)
Sometimes two chips have the *same* fill (a mirrored return leg, or a stretch of
keyless greys). Sometimes they have two fills that are too close to tell apart (less
@@ -87,7 +88,8 @@ surface that the survey found can in the end use it.
``★`` of the app. The user already knows who both ends are, and the cells are for the
hops that *are* news. These stars fade where the route is being *composed*, because our
two ends are fixtures and not choices. A surface that only *shows* a walk passes
``dim_self=False`` and keeps our stars in the ``you`` white.
``dim_self=False`` and keeps our stars in the ``you`` white. (Only arrows show the
difference. A faded chip loses chroma, and the white star has no chroma to lose.)
The existing call sites still render through ``path_text``. A separate pass for each
surface will move them here. The insertion cursor of the composer is a hop in its own
@@ -101,7 +103,7 @@ from __future__ import annotations
import re
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import NamedTuple
from rich.cells import cell_len
@@ -215,18 +217,19 @@ _WHITE = "#ffffff"
#: route is a fixture that the user already knows. It stays back and lets the hops that
#: differ carry the colour.
_YOU_BG = "#3f3f46"
#: The chip of a dimmed hop: a dark slate fill with muted ink. It recedes like ``faint``
#: text.
_DIM_BG = "#334155"
_DIM_FG = "#94a3b8"
#: The part of its chroma (OKLab ``a`` and ``b``) that the chip of a faded hop keeps. A
#: faded hop is a hop that the user does not compose: a mirrored return leg, or the
#: automatic return home. Its chip has the colours that it has with no fade, with less
#: chroma and the same lightness. Thus each faded hop still says which node it is, and the
#: automatic part of the route recedes (JP, 2026-10-14). Before, each faded hop was the same
#: dark slate chip.
FADE_CHROMA = 0.35
#: On the 16-colour console, the page: slot 0, black. It is the fill of a chip that the
#: console cannot fill, because the VT has no dark grey background. That chip is our ``★``,
#: a faded hop, or the slot of the composer. Its text then sits on the page, and the chips
#: on each side close on a point and open on a notch.
_SLOT_PAGE = slot_hex(0)
#: On the console, the text of a faded hop: slot 8, the dark grey.
_SLOT_FAINT = slot_hex(8)
#: On the console, white: slot 15. It is the text of a keyless chip and of the slot of the
#: composer.
_SLOT_WHITE = slot_hex(15)
@@ -305,7 +308,8 @@ class PathHop:
lit_bytes: For a hash label: the leading *bytes* that are drawn in the hue (the two
tones of ``highlighted_hash``). The rest is muted or soft. ``0`` for names.
dim: Fade the hop (and the arrow that leads into it). It is the resolved return leg
of a planned route, which the user does not compose.
of a planned route, which the user does not compose. A chip keeps its colours,
with less chroma (:data:`FADE_CHROMA`). Arrows draw the hop ``faint``.
style: An explicit colour override. It is a hex style (``"#rrggbb"``, attributes are
permitted) or a theme name. It is for colourings of a context that win over the
identity. Plain mode uses it as the label style, and powerline uses it as the
@@ -1216,18 +1220,19 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours:
The label of a node is its hue. This is the colour that its name has in each list and
in arrow mode. The fill is that hue, but darker (:data:`CHIP_LIGHTNESS`). The whole
label is in the hue, also a hash label and an annotation. The lit bytes of a hash are
bold. A keyless chip is the same pair in grey. Our ``★`` and a faded hop keep their dark
greys, which already carry a light label. The PicoCalc draws the same rules at the
resolution of its console (:func:`_slot_colours`).
bold. A keyless chip is the same pair in grey. Our ``★`` keeps its dark grey, which
already carries a light label. The PicoCalc draws the same rules at the resolution of
its console (:func:`_slot_colours`).
The insertion slot wins over all other rules. It is the one chip that is not a node, and
it has the ``cursor`` white. Thus it reads as chrome among identities, and not as a hop
with a hue that is unlucky. (White is outside the node spectrum, and its dark text stays
readable on it.) It is the only light chip on the ribbon. The fade is next, and it wins
over the identity, also our own. A dimmed hop is a hop that nobody composed (an
automatic return home, a mirrored return leg). Our end must recede with the rest of
that automatic half, and not keep its hue among the greys. Plain mode says the same
thing when it fades the name.
readable on it.) It is the only light chip on the ribbon. The fade is next. A dimmed
hop is a hop that nobody composed (an automatic return home, a mirrored return leg). It
has the colours that it has with no fade, with less chroma (:data:`FADE_CHROMA`). Thus
each faded hop still says which node it is, and that automatic half recedes (JP,
2026-10-14). Our white star has no chroma, so its chip does not change. Plain mode
draws the same hops ``faint``.
Our chip in a chat (``white_ink``) has the fill of the chip of its key, and a white
label. A quiet chip (a node with no key in a chat) is black on a grey fill.
@@ -1238,7 +1243,8 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours:
if resolved:
return _ChipColours(_chip_fill(resolved), resolved, resolved)
if hop.dim:
return _ChipColours(_DIM_BG, _DIM_FG, _DIM_FG)
normal = _spectrum_colours(replace(hop, dim=False))
return _ChipColours(*(oklab.desaturated(c, FADE_CHROMA) for c in normal))
hue = _style_hex(node_style(hop.key)) if hop.key else None
if hop.white_ink and hue:
return _ChipColours(_chip_fill(hue), _WHITE, _WHITE)
@@ -1263,10 +1269,10 @@ def _slot_colours(hop: PathHop) -> _ChipColours:
A keyless chip is white on light grey, the same pair one bank down. Our ``★``, a faded
hop, and the slot of the composer have no fill of their own, because the console has no
dark grey background. They stand on the page (:data:`_SLOT_PAGE`): the star in our
hue, a faded hop in the dark grey, and the slot as a white ``+`` in a break of the
ribbon. The order of the rules is the same as on a truecolor terminal
(:func:`_spectrum_colours`).
dark grey background. They stand on the page (:data:`_SLOT_PAGE`): the white star, and
the slot as a white ``+`` in a break of the ribbon. The console also has no colour with
less chroma. Thus a faded hop keeps the colour of its label and loses its fill. The
order of the rules is the same as on a truecolor terminal (:func:`_spectrum_colours`).
Our chip in a chat (``white_ink``) is white on the dim twin of its hue, the same rule as
on a truecolor terminal. A quiet chip is black on the light grey of the keyless chip.
@@ -1280,7 +1286,7 @@ def _slot_colours(hop: PathHop) -> _ChipColours:
ink = resolved if resolved in DIM_TWIN else _SLOT_WHITE
return _ChipColours(fill, ink, ink)
if hop.dim:
return _ChipColours(_SLOT_PAGE, _SLOT_FAINT, _SLOT_FAINT)
return _slot_colours(replace(hop, dim=False))._replace(fill=_SLOT_PAGE)
if hop.you:
return _ChipColours(_SLOT_PAGE, _SLOT_WHITE, _SLOT_WHITE)
hue = _style_hex(node_style(hop.key)) if hop.key else None
@@ -1379,9 +1385,10 @@ def path_line(
between them need.
dim_self: Fade the bare stars (the default). On a surface where the user *composes*
the route, to set out from us and to come home to us are fixtures. The user does
not choose or remove them, the same as for a mirrored return leg. Thus they have
the same automatic grey, and the colour on the line is for the nodes that the
user really picks. Pass ``False`` where nobody composes anything (a record of a
not choose or remove them, the same as for a mirrored return leg. Thus they fade
as that leg does. (Arrows draw them ``faint``. A faded chip loses chroma, and our
white star has no chroma to lose, so its chip does not change.) Pass ``False``
where nobody composes anything (a record of a
walk that is already made). Then there is no "not yours" to say, and our node
reads in the ``you`` white of the app, as in other places. A ``dim_from`` that
reaches a star still fades it in both cases.
+37 -12
View File
@@ -9,6 +9,7 @@ cells. Thus these tests assert rendered strings and span styles, and not impress
from __future__ import annotations
import math
import random
import pytest
@@ -21,8 +22,6 @@ from meshterm.platforms import CARDPUTER_ZERO, PICOCALC_LYRA, set_platform
from meshterm.ui import oklab
from meshterm.ui.fontset import FONT_CODEPOINTS
from meshterm.ui.pathline import (
_DIM_BG,
_DIM_FG,
_YOU_BG,
CHIP_LIGHTNESS,
CRACK_HEAD,
@@ -30,6 +29,7 @@ from meshterm.ui.pathline import (
CURSOR_GLYPH,
ELIDE_HEAD,
ELIDE_TAIL,
FADE_CHROMA,
PATH_INK,
POWERLINE_ROUND_CLOSE,
POWERLINE_ROUND_OPEN,
@@ -279,7 +279,7 @@ def test_the_picocalc_draws_chips_from_its_eight_backgrounds(picocalc: None) ->
assert at("(0a)").fg == _rgb(red) # the annotation also has the colour of the label
assert (at("3d").fg, at("3d").bg) == (_rgb(slot_hex(15)), _rgb(slot_hex(7)))
assert at("Ridge").bg in (None, _rgb(slot_hex(0))) # faded: on the page
assert at("Ridge").fg == _rgb(slot_hex(8))
assert at("Ridge").fg == _rgb(yellow) # in the colour of its label, with no fill
assert at(SELF_GLYPH).bg in (None, _rgb(slot_hex(0)))
@@ -856,8 +856,33 @@ def test_a_seam_between_two_of_one_colour_is_the_thin_chevron_in_its_label() ->
f"{_fill('aa')} on {_fill('77')}",
) # a blend, one cell
faded = pathline._spectrum_colours(PathHop("B", dim=True))
glyph, style = _seams(PathHop("A", dim=True), PathHop("B", dim=True))[0]
assert (glyph, style) == (POWERLINE_THIN, f"{_DIM_FG} on {_DIM_BG}")
assert (glyph, style) == (POWERLINE_THIN, f"{faded.ink} on {faded.fill}")
def test_a_faded_chip_is_its_own_chip_with_less_chroma() -> None:
"""A faded hop keeps the colours of its node, with less chroma and the same lightness.
The faded part of a route is the part that the user does not compose. Each node there
still has its hue, so the user can see which node it is (JP, 2026-10-14). Before, each
faded hop was the same dark slate chip.
"""
def chroma(colour: str) -> float:
_lightness, a, b = oklab.from_hex(colour)
return math.hypot(a, b)
for key in ("00", "4c", "aa", None):
normal = pathline._spectrum_colours(PathHop("N", key=key))
faded = pathline._spectrum_colours(PathHop("N", key=key, dim=True))
for before, after in zip(normal, faded, strict=True):
assert after == oklab.desaturated(before, FADE_CHROMA)
assert abs(oklab.from_hex(after)[0] - oklab.from_hex(before)[0]) < 0.01
if key:
assert chroma(after) < chroma(before) * 0.5
star = pathline._spectrum_colours(PathHop(SELF_GLYPH, you=True, dim=True))
assert star.ink == "#ffffff" # white has no chroma to lose
def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None:
@@ -866,9 +891,9 @@ def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None:
The eye decides, and not the wheel. Sixteen steps of the first byte across the greens
are one colour to a user. Sixteen steps across the reds are two colours. Thus the same
gap of hue blurs on one side of the wheel and interlocks on the other. The test measures
the fills, because the solid point is a fill on a fill. The greys follow the same metric
with no exception. The keyless grey and the faded slate are far apart, and they keep
their seam.
the fills, because the solid point is a fill on a fill. A faded chip follows the same
metric with no exception. A hue and its faded chip are far apart, and they keep their
seam.
"""
green = _seams(PathHop("A", key="4c"), PathHop("B", key="5c"))[0]
red = _seams(PathHop("A", key="00"), PathHop("B", key="10"))[0]
@@ -880,16 +905,16 @@ def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None:
wrapped = _seams(PathHop("A", key="fc"), PathHop("B", key="02"))[0]
assert wrapped[0] == POWERLINE_THIN # neighbours across the seam of the wheel, also
glyph, style = _seams(PathHop("A"), PathHop("B", dim=True))[0]
assert glyph == POWERLINE_SEP and " on " in style # two greys, which still interlock
glyph, style = _seams(PathHop("A", key="00"), PathHop("B", key="00", dim=True))[0]
assert glyph == POWERLINE_SEP and " on " in style # a hue and its fade still interlock
def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None:
"""``bare_self`` puts a ★ for us at both ends, and fades both.
To set out from us and to land back on us are fixtures of the route and not choices.
Thus they have the same automatic grey as a mirrored return leg. In chips, they have the
same dark slate, and not the loud white of you.
Thus they fade as a mirrored return leg does: ``faint`` in arrows. A faded chip only
loses chroma, and the white star has no chroma to lose, so its chip stays white.
"""
hops = [None, *(f"{i:02x}aa" for i in range(6)), None]
line = path_line(
@@ -909,7 +934,7 @@ def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None:
assert [str(s.style) for s in stars] == ["faint", "faint"]
chips = PathLine(line.hops, mode="powerline").text()
star_fills = [str(s.style) for s in chips.spans if chips.plain[s.start : s.end] == SELF_GLYPH]
assert all("#ffffff" not in fill for fill in star_fills) # never the white of you
assert star_fills and all(fill.startswith("#ffffff on ") for fill in star_fills)
wrapped = path_line(
hops, prefix_bytes=2, self_name="Me", bare_self=True, mode="powerline"