Commit Graph
50 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 de0f60cbe0 Rewrite the CLI standard for the two faces it now has
The section described a command line "read by awk", which was the premise that
changed: the plain face is read by a person and --json is read by the program.
It stated the quoting, the absolute times and the comma-joined path line as
rules, and all three now say the opposite.

What replaces it is the seam first — a tool states, a renderer draws, the
boundary picks — then each face with the rules an implementer follows and the
reason behind each one, then the exit statuses, with the two classifications
that were wrong somewhere stated out loud: nothing transmitted is not a device
failure, and a rendering flag never moves the status.

docs/cli.md is untouched and now stale in three places: the --json row still
says only two commands honour it, --absolute is undocumented, and every console
sample carries quoted names and ISO timestamps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:02:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 da05dd402f Give the CLI its own voice, meant for a pipe rather than an eye
The CLI had grown the menu's manners -- colour, boxes, section headings,
glyph columns, a closing check mark -- and every one of them is in the way
of the thing it is actually for. It now prints like a standard Unix
utility: plain text, no escape sequences at all, no frames, one record per
line, and nothing wrapped, so a long line runs off the right instead of
folding its fields onto a second one.

meshterm/ui/script.py is the whole vocabulary. Listings are an uppercase
header and space-aligned records; key/value blocks are sysctl's shape, and
a `get` prints the bare value alone. Names are quoted wherever they share a
line, because a name can hold a space or a comma and a bare one is not a
field. A route reads "Origin" (3d),"Relay" (f2),"Us" (a1) -- the hash
outside the quotes, our own node a hop like any other rather than a star,
since the hash is what carries the identity that colour carries on screen.
Times are absolute local ISO-8601 and "-" is the one token for absent.

The return value is now half the report: 0 ok, 1 failure, 2 usage, 3 no
device, 4 the device was reached and the operation failed, 5 nothing to
report. That last one is the point -- a command that ran fine and found
nothing prints nothing and says so in $?, so a script never has to count
output lines to tell an empty mesh from a full one. Errors and progress
moved to stderr, and an expected failure no longer arrives as a traceback.

Two things lose their scripted face rather than get a degraded one. The map
is a picture whose nodes are told apart by colour: monochrome it would be
unreadable, coloured it could not be piped anywhere useful, and the nodes
themselves are still in `contacts`. The QR codes are a second rendering of
a link the output already prints.

ui.ack joins ui.note to draw the line the change needed: a note is output,
an acknowledgement is reassurance a person wants and a script must filter
out, so the menu prints it and the CLI drops it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:52:48 -04:00
Jean-Pierre MartineauandClaude Opus 5 bcf387c620 The test fixtures stop being a picture of one person's mesh
Every node in the suite was named after hardware on JP's desk: YUL-Cartierville
and YUL-Poly are repeaters he runs, two more were the BLE
companions, another his own handle, and a long tail of YUL-Far / YUL-Me / bare
YUL besides. A stranger reading the tests learned his callsign, his city and his
gear before learning what the tests check.

They join the family the suite had already invented for exactly this — Alice,
Lounge, Yagi-Repeater, hub-1: Hilltop-Repeater, Lakeside, Waymarker, Homestead,
and Hub for the 3d-keyed node. Hub is not a new name; test_device_config already
called that contact Hub, so the suite was carrying two names for one fixture.

Replacements are the same length as what they replace, so every width budget,
truncation assertion and gallery line is unchanged. The two map tests that type
a literal query picked `yu`/`yul` to match the old name; they type `hi`/`hil`
now, same length, same single match.

Left alone on purpose: the About page and the donate link, where the handle and
the repeaters are the point, the hand-drawn splash art, and the XIAO README
reporting what JP's own radio advertises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 02:01:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 9de428f6ca A cursor stops at the end of its list
Down off the last row wrapped to the first on a good third of the app's
lists, and the select screen shipped that as its default -- twenty-one
call sites had already opted out one at a time, each writing wrap=False
for its own reason. The reasons were all the same one, so the flag is
gone and clamping is simply what a cursor does.

The window follows the highlight, so a roll doesn't move a pointer: it
hauls the whole list back to the other end and flips its edge markers,
which reads as the screen changing under you rather than as one step.
Held-down arrows now settle at an end instead of looping past what you
were trying to reach.

The hand-rolled cursors came along: the trophy case's, the trace and TX
optimize action rows, the reorder list -- where the ungrabbed cursor
wrapped while the grabbed one clamped, so the same two keys meant
opposite things in the two modes -- a prompt's completion list, and a
dialog's buttons.

Tab is the exception, and it stays a ring: it has no reverse of its own,
so on the last chip it comes round or it dead-ends. Same for the three
picocalc F-lane chips that are single-key advancers -- Sort, the node
page's tab strip, the Time Machine's window -- where the lane offers no
way back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:04:17 -04:00
Jean-Pierre MartineauandClaude Opus 5 cef7a764af How MeshTerm behaves gets a page of its own
Three kinds of "setting" were tangled into two files and one of them had no
screen at all. The radio's settings live on the radio and are edited on Device
config. Machine setup -- where the database is, which companion to talk to --
stays in config.toml, a text editor's job. Everything in between was app
behaviour: six keys sitting in config.toml with nothing to open them, and a
dozen more that were only module constants a reader could never reach.

Those are preferences now, and they have a page. core/preferences.py is a
PrefSpec registry in the shape of the device-config one -- group, type, bounds,
help, and a default -- and the page is built from the same parts as the device
editor, deliberately: one grouped lane-aligned list, current -> new staged in
warn, Apply/Back appearing only once something is staged, the shared discard
confirm on the way out. Two editors that stage values should not be two screens
to learn. What it adds is a Defaults section holding one row that stages every
changed preference back to its built-in value; it is drawn only while a reset
would do something, and it stages rather than writes, so it is as reviewable and
as Esc-discardable as any other change and the file is still only ever written
by the one action at the bottom. Its rows scroll sideways where the device
editor's don't: the explanation is the widest thing on the row and the only part
that runs past the edge, so <-> slide it under a pinned setting and value.

The file is preferences.yaml and holds only the values you disagree with the
code about -- grouped and commented, each entry above its help and its default.
That is what lets a later change to a default reach an install that never asked
to be exempt; delete a line and the code takes over again. A hand edit gone
wrong costs that line, never the session.

Every preference is wired to something that reads it. ctx.preferences is the
explicit path; preferences.current() is for the render layer, which has no
context to reach through, the same reason get_platform() exists.

The About pages come along into a This app section, the third scope after this
node and other nodes, with Preferences leading it -- the one row there that
changes MeshTerm rather than describing it. A section of one would have broken
the menu's own no-bucket-of-one rule, and the app's scope is the honest home for
both halves anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:36:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 8fc24d4901 The clip arrows lead the bar, and say it in colour
They rode the PicoCalc title bar's tail, appearing and disappearing with the
scroll — and a half-shown pair put a blank cell where the missing arrow would
be, so reading it meant comparing the row against a memory of itself. Both
arrows now sit at the left edge, where the eye starts, and never move: an arrow
whose direction has more takes the border's own accent, one with nothing that
way drops to muted. That is the live/dim language the F-key lane already draws
one row below, and the bar keeps its width whatever the body is doing.

The Esc atom keeps the tail it was given, now on its own — a hair more room for
a long title, since the two arrow cells left that end of the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:20:48 -04:00
Jean-Pierre MartineauandClaude Opus 5 d28240f658 An arrow atom names the arrows and nothing else
Surveying every popup dialog's hint turned up three that said the arrows were
something they aren't. The yes/no confirm's "←→/Y/N choose" put a key inside the
arrow atom that does not choose at all — Y/N answers outright — and the
autocomplete's "↑↓ Tab complete" did the same with Tab, which completes rather
than moves. Several keys may share an atom, but only when they do the same
thing: "↑↓ PgUp/PgDn scroll" and "Home/End ends" are the shape that is fine.

The composer's "←→ cursor" named the wrong thing rather than too much. The
cursor in this app is the row ❯ points at, which is what ↑↓ moves there; the
arrows move the insertion slot along the path, and "slot" is the word the rest
of the app already uses for it.

The confirm takes the ButtonDialog hint verbatim rather than a longer one of its
own — a yes/no prompt is a button row like any other, and its y/n accelerators
are already hinted by the Yes/No labels. It reads the same everywhere now, and
the Esc verb stops falling off the end of the box on a 53-column console.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:20:34 -04:00
Jean-Pierre MartineauandClaude Opus 5 7407358ede A button row's Enter belongs to whichever chip is lit
The quit confirm promised "Enter quit · Esc cancel", which held only while Quit
was the highlighted button — and on a bonded device there is an "Unpair & quit"
between Cancel and Quit, so Enter has three things it might do. The hint also
left out ←→, the one key that changes which. Both dialogs (the TX winner's
"Enter apply" is the same shape) drop back to the dialog default, which says the
mechanism instead of guessing the outcome: ←→ choose · Enter select · Esc cancel.

A row-picking list keeps its licence to name a committing verb, because every
row commits the same way. A lone-button acknowledgement keeps "Enter OK" too,
having nothing to choose between — that is the exception the new standards test
walks the package for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:02:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 2a7d01108b ^W is a stack verb, not a call-chain one
Pressing it in the packet viewer closed the viewer and stopped there, then fired
at whatever the reader pressed next. A screen opened from a key handler cannot
be awaited by its opener — a handler is sync, so the live feed floats the viewer
with a detached task — and the sentinel went only to the top screen, so the
unwind it raised had nowhere to propagate and died in that task. The live feed
below was meanwhile parked on a visit.result() that no press would resolve, and
the flag stayed armed to unwind at the next navigation boundary the reader
happened to cross. The chat's delivery paths and the trace screen's path flows
open the same way and had the same fault.

The unwind now arms every frame on the stack, top first, down to but never
including the root, stopping at anything modal for the reason the top-of-stack
refusal already gives. Every frame is on the stack whether or not something
awaits it, so the stack is the reliable path down; resolving a frame nothing is
waiting on is inert.

The detached launches go through session.run_detached, which absorbs the
PopToMenu its task cannot carry — a duplicate of an unwind already travelling
out through the screen that opened it — so a press no longer leaves an
unretrieved task error behind. Flows keep their own finally blocks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:19:56 -04:00
Jean-Pierre MartineauandClaude Opus 5 332f81583a A hint is drawn once per frame
The footer line already carries the top screen's hint, so a floating dialog was
saying the same sentence twice on one frame: once in its own border, once at the
bottom of the terminal. On the desktop the border now says nothing and the
footer is the single place hints live; its clip arrows fall back to the base
frame's own "more" wording, so a silent border still says the one thing every
border here says.

The PicoCalc keeps the border hint, because it has no hint line to defer to —
the lane stands where the footer would be and speaks only for its five chips.
But what the lane does say it says already, one row below, so the border drops
every atom whose keys are all chips: the packet viewer's "PgUp/PgDn scroll" and
"Home/End ends" go, its arrows and Esc stay. An atom survives unless *every* key
it names is covered, so a half-covered pair never tells half a truth, and one
riding in the verb half (the map's Home/^U) keeps the atom whole.

Resolved per paint rather than folded into a screen's hint, since both halves
move: the viewer rewrites its hint as the list grows past one entry, and every
screen reads its lane fresh each frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 01:38:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 71dee82fd3 An entry chain is a stack: Esc steps back, not out
Five flows asked two or more things in a row as a straight run of awaits, so Esc
anywhere in one abandoned the lot. Mistype the second half of a 32-hex channel
key and the name you typed first went with it; back out of the courier's
schedule and the message you had just written was gone.

menus.run_steps runs a chain as the stack it is. Each step is awaited with the
answers gathered so far — the same list every time, so a step can label itself
with an earlier answer and offer its own previous answer as its default when it
is come back to — and returns None to step back. None from the first step ends
the flow; there is nothing behind it.

Converted: joining a channel by key and editing one (name then key), the custom
firmware variable (name then value), and the courier's queue flow (recipient,
message, schedule). The courier's recipient list now stays pushed for the whole
flow, so stepping back from the message lands on the row it was being written
to, and the schedule's "At a time…" prompt steps back to its own rungs rather
than out of the queueing.

A step signals "back" with None, which is what _pick_schedule returns for the
real answer "when it's next heard" — so _schedule_step swaps the two, carrying
that answer as its own WHEN_HEARD sentinel.

A trailing Cancel/verb confirm is left alone: its left button says Cancel and
means "don't do this", which is a decision rather than a step. That line is now
in CLAUDE.md with the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:12:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 deea9ebdd2 Esc peels a typed filter before it leaves, everywhere
Four screens carry a find-as-you-type filter and Esc meant two things across
them: the map and the mesh walk cleared the query and stayed, while the select
list and the path composer left the screen, filter and all. Typing "yul" on the
map then pressing Esc showed you the map; typing "yul" on a contact list then
pressing Esc dropped you at the main menu. Same affordance, same glyphs,
opposite outcome — and the select list is by far the most-used of the four.

They peel now, all of them: the typed query is the most recent thing the reader
entered, so the first Esc takes it back and the second leaves. The footer says
which, swapping its trailing Esc verb for "Esc clear" while a filter stands —
the word the map and the walk have always used, via a _esc_verb helper beside
the two atom-splicers that already bend that line. The composer's Esc now
layers exactly as its own backspace does: the entry first, then the screen.

This was finding D in docs/survey-navigation.md, open since the survey was
written and the last thing left telling a reader what Esc does. The rule is now
stated in CLAUDE.md under Navigation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:02:21 -04:00
Jean-Pierre MartineauandClaude Opus 5 c31ada0c8b Write the stack down: CLAUDE.md, the About page, and the survey's ledger
The rules the last three commits established, stated where they bind: a
Navigation section in CLAUDE.md covering the strict stack, `stay`/`Visit` for a
hub, `replace_items` for a list whose content is data, and the modal-vs-floating
distinction ^W turns on.

The two global chords get their one statement on the About page, since neither
has a screen to be advertised on. It also says what happens when the host takes
Ctrl+W first (VS Code's integrated terminal reads it as close editor) — the key
is a convenience, and nothing depends on it.

docs/survey-navigation.md gets its ledger updated: A, B and C are closed, with
what was actually done to each. D and G stay open, and D is now recorded as the
reason Esc Esc was rejected as the pop-all key — on the map, the mesh walk and
chat, Esc Esc is already an ordinary peel-then-leave.

Also drops an asyncio import the watchtower no longer needs, and an escape
sequence in a new docstring that made ast.parse warn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:45:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 c1db8e5a31 Cursor row: a route's hues are content, so the highlight spares them
A picked row folds its node hues to the cursor white so the row reads as one
thing rather than as a name arguing with its own selection. On a path line that
is wrong: there the hue is not decoration on a name, it is what tells one hop
from the next — and what a route graph above the row is cross-referenced by,
its labels being a marker and one byte of hash. Chips were never folded (their
fills are outside the fold's vocabulary), so the bug was the arrow form: every
path line on the PicoCalc, and on any terminal without the powerline glyphs.

PathLine now stamps every shape it mints with PATH_INK, a style that draws
nothing; Text.append_text carries the stamp into the row as a span over the
hops, and the fold skips identity spans lying inside one. So the Message paths
row keeps each hop in the colour its graph label wears, and so do the TX
screen's "Route — via", the trophy board's walk and the feed's sender → recipient
lane. The node detail's Routes tab, which bought this by hand (no cursor base at
all), is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:42:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 972c0b6f03 Screens: Esc is the way out, so no row spends two lines saying so
The app-wide Back row existed because it duplicated Esc — two of its call
sites said as much in a comment. On both platforms Esc is a labelled key
and every footer hint names it, so the row bought nothing for the blank
separator and line it took out of every screen; on the PicoCalc's 26 rows
that is a row in thirteen.

back_rows() is gone, and with it the four hand-drawn copies: ReorderScreen's
clean-state row, the live feed's pinned foot stop (the cursor now clamps on
the oldest packet), node detail's tail action, and the action-list rows on
the trace and TX-sweep screens.

Kept, because there leaving is a choice rather than an exit — the same rule
that keeps a dialog's Cancel button: exit_rows()' staged pair, where Apply
has no key of its own and ✗ Back names what the other way out costs;
ReorderScreen's dirty-state pair; the two Quit rows, which initiate the
app's terminal action behind a confirm; and the new-record dialog's Close,
which is its default, so dropping it would let a reflex Enter navigate.

With no Back row there is no Back value: the six spellings callers used to
test three at a time (_BACK, "__back__", _CANCEL, two tuple shapes, bare
None) collapse to `is CANCEL` / `is None`. The gallery now asserts the
absence of an exit row on every screen, on both platforms.

Resolves findings E and §10 of docs/survey-navigation.md. Finding D — Esc
peeling a filter on three screens and leaving on two — still stands, and is
now the only thing left telling a user what Esc does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:15:53 -04:00
Jean-Pierre MartineauandClaude Opus 5 afe1424819 Selection: white says "this one", and a chip is grey
Teal was still answering three questions it had no business answering. The wordmark's
colour is the app's identity, and it is also a node hue — so a cyan-keyed node could
vanish into the very highlight pointing at it.

- A node's key-derived hue folds to the cursor white on the cursor row. A row is a
  column of identities right up until it is the row you are on, and then it is
  answering a different question; a hue on it argues with its own selection. Folded
  once at the render boundary for any Text whose *base* style is `cursor` (which is how
  every list and every screen drawing its own rows already marks it), so no screen has
  to remember the rule. `theme.is_identity_style` is node_style read backwards — every
  hue a key can mint, on both platforms — and only those fold: the heat of an age, an
  SNR reading, a red badge, a path line's chip fills all keep what they were given. So
  does the grey of a node no key could place: the highlight knows where you are, not who.

- The reverse-video chip — a dialog's committing button, a running action's Abort, the
  arrow-mode composer slot, the editor cursor — is grey. A chip marks where a press
  lands. It is chrome, and chrome does not get a hue. Light grey slot 7 on the VT: the
  one grey a reverse can put behind text there, and the fill the F-key lane already uses.

- The active sort column's heading and triangle take the same white as the cursor row,
  being the same claim one axis over. They were a bare `#22d3ee`, which put a selection
  cue back on the node spectrum and, on the console, straight onto the brand's own slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 03:56:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 602bceea3b Theme: the cursor row is white, not the wordmark's teal
The row `❯` points at borrowed `brand` — so the app's identity ink and
"the row you're on" were the same hue, and worse, teal/cyan is itself a
node hue: a cyan-keyed node's name vanished into its own highlight.

Give the highlight its own name. `cursor` is white on both platforms
(slot 15 on the VT, the top of the bright bank, so the row's bold cannot
promote it further and every dim-slot `not bold` pin inside a highlighted
row goes on meaning what it meant). White sits outside the per-node
spectrum, so it can never collide with an identity; it does share ink
with `you`, which is the accepted cost — spans that set their own colour
keep it over the base, so only unstyled text goes white, and only on the
row already under the reader's eye.

Every surface that draws its own cursor row follows: the select list and
its reorder mode, the feed, chat, routes and actions on the node page,
the composer, records, trace, tx, the walk, the paths screen, the prompt's
suggestions. The composer's insertion-slot chip takes the same white — its
docstring already tied it to "the colour the list cursor below it wears",
and the chip ink was already picked to stay readable on white.

Reverse-video chips keep `selected`: on the VT a reversed white lands on
the light grey the F-key lane already owns, so a white default button
would stop reading as the committing verb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:51:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 0314ed7702 About pages: prose the app writes, not rows it composes
The three About pages were scaffolding built out of Text() calls, which
made writing them a Python edit and capped what they could ever say. They
are markdown now — meshterm/assets/pages/*.md, drawn by ui/markdown.py
into the app's own language rather than markdown's: # the page's name in
brand, ## a section in the body accent (with the muted · aside), ###
indented with its prose, paragraphs and lists hanging as blocks, quotes
and fences behind a rail that survives wrapping, tables through Rich, and
links that say where they go — nothing is clickable on a framebuffer
console. Two paragraphs are page frame rather than body and sit flush and
muted: the standfirst under the title, the colophon under the closing
rule, which is where the copyright lands.

The pages stay scaffolded on purpose — the placeholders are unchanged —
but filling one in is now editing its .md, and the live package facts
arrive as {version}/{author}/{copyright} so a page still can't drift from
the build it describes.

Being a document earns the screen two things a block never had: its ##
headings are landmarks, so a heading pins while its own prose scrolls
under it and ^PgUp/^PgDn step by them, and the console lane claims
Sect ↑/Sect ↓ on the free F1/F2 pair once a page has more than one
section — dimmed while the whole page fits, since the lane never
advertises a dead key.

Parsing is markdown-it-py (already under Rich, now declared): a real
CommonMark parser, so an author writes ordinary markdown. The md.* styles
are defined in both themes; the new test renders the whole vocabulary at
53 cells and asserts it stays inside the console font and the 16 slots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:39:21 -04:00
Jean-Pierre MartineauandClaude Opus 5 3ccffd13c9 About MeshTerm: a sixth section, for the questions the five doings can't hold
The menu asks "What would you like to do?" and its five sections each
name a doing. "What is this thing, and who made it" is not one, and had
nowhere to go — so it gets the sixth section, the one that names a
subject, sorted last where a reader who has run out of things to do finds
it. Three pages, in the order a stranger asks:

  About MeshTerm        what this is, where it came from, its terms
  About the author      the person behind it, on the mesh and off it
  Support this project  what keeps it going, and the ways to help

Every page is scaffolding: the headings are the settled part, and each
section carries one lowercase muted "placeholder — …" line where its
prose will go. That is the app's own empty-state voice, so an unwritten
page reads as deliberately unwritten rather than as a screen that failed
to load, and filling one in means replacing its placeholder — the screen,
the menu rows and the CLI faces all follow unchanged. What is not a
placeholder is the version, the author and the copyright span: they come
from the package itself, so the About page can never describe a build it
isn't running inside.

The pages act on nothing — no radio, no database, no device — so each
opens straight to its screen with no prompt first, and each keeps a CLI
face (meshterm about / about-author / support) printing the same page to
a shell.

Two new icons, both funnelled for the console: 📖 read/about becomes ¶,
the page-of-prose mark, and 💰 support/donate becomes $.

ScrollScreen earns a derived footer hint on the way through. A read-only
body has only two keys to name, the pager and Esc, so passing "↑↓ PgUp/
PgDn scroll · Esc close" at every call site was both repetition and a lie
on a body that fits — the standing rule is that a footer never advertises
a key that would do nothing. Left unset, it now names the pager only
while the content actually overflows, and takes its Esc verb from the
surface: a floating view closes, a full screen goes back. Every existing
caller passes its hint explicitly and is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:55:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 7c68f50bf5 Chips: the ★ keeps its padding after all
Dropping the pads around our own star saved two cells and cost the chip its
shape — squeezed between two chevrons it read as a glyph wedged into the
seam rather than as a segment of the route. The pads are not room for a long
word, they are what makes a chip a chip.

The dark grey fill and the map's yellow ink stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:56:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 1ba44ad252 Chips: one chevron, a gap for the elision, and a star that fits
Three cuts to what a chip line spends its cells on.

The seam was two: the previous chip tapering onto the page, the next
notching back out of its own fill, a sliver of bare page between. That gap
was insurance against two neighbours landing on one colour and fusing into a
single block — but the hue wheel is 256 distinct values wide (measured: zero
collisions across all 256 first bytes), so it was insurance against
something that does not happen. One interlocked chevron now, the chip behind
as foreground and the chip ahead as background.

Where two fills really are equal the interlock cannot draw a chevron at all,
and that case is not bad luck: a mirrored return leg is a run of identically
faded hops by construction, and so is a stretch of keyless greys. There the
point drops its background and the page cuts the wedge — still one cell, the
old separation without the old width.

The elision was a chip: pads, a fill, and a seam on each side, seven cells to
say "some hops are missing" on a line that is short of room by definition. It
is not a node called ⋯. It now breaks the ribbon instead — the chip before
closes onto the page, the mark sits there bare, the chip after opens on its
notch. Three cells, and elision_hop() is the one definition the widget and
the mesh walk's trail share. Arrow mode needed no change: a bare label
between two arrows already was a gap.

The you chip was ` ★ ` in dark ink on pure white — five cells shouting the
one node the reader never has to be told. Now the map's own yellow star
(taken from marks.SELF_MARK, so the star in a route and the star on the map
cannot drift) on a neutral dark grey, and padless, because the pads exist to
keep a *word* off the chevrons and a single mark centred in its cell needs no
such room. Three cells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:37:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 6a12104cf7 Path lines: cut them all the same way, and end them where they end
Two fixes to what a path line shows at its edges and at its ends.

The edges: the crack only reached the two surfaces that drew their own marks.
Everywhere else a path still met render_to_ansi(no_wrap=True) or a plain
truncate, both of which ellipsize — so the Message paths rows you were not
on trailed off in dots while the one you were on cracked. Those cuts now go
through cut_to, which reads the line and decides: a chip breaks off on the
crack, prose keeps its ellipsis. That covers the unselected arrival rows,
every select-list row (the trophy walks and probe candidates), the TX sweep
action rows, and the composer's suggestion rows. The generic surfaces are
safe to convert precisely because the fallback is the old behaviour.

The ends: a path's ends are the nodes it went between, so a line that opens
on its first *relay* is naming a node that only passed the message on. The
Message paths rows now run origin → relays → us — the same two endpoints the
graph one row up already drew between, so the row and the picture finally
cross-read. A hopless arrival stops collapsing to the word "direct":
Alice ▶ ★ is what direct delivery looks like, on the same rails as every
relayed row.

And our own end is the ★ everywhere, not our name — it was already so on the
trace lane, the trophy card and the composer, and now also on the node
detail Routes rows, the TX sweep route, the mesh walk trail and the live
feed's subject lane. It is the one node the reader never has to be told, and
in an 18-cell feed lane or a width-starved trail the cells it gives back are
hops that stay in view.

Left alone deliberately: the packet viewer's `via` row (the dialog has a
`from` row naming the origin, so `via` really is just the relay chain), the
verbatim wire spec, and widgets._link_text — a table of FROM → TO *links*,
still on the legacy path_text builder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:36:43 -04:00
Jean-Pierre MartineauandClaude Opus 5 9e262d157d Path lines: a cut chip cracks, it doesn't trail off
An ellipsis at a path line's edge says a word was shortened. What actually
ran out is the lane — the route continues, and in chip mode there is a
segment right there to say so with. A cut chip now breaks off on a half
block drawn in that chip's own fill and no background, so the cell is half
segment and half bare page: the crack reads as a chip that carries on.
CRACK_TAIL (▌) closes a line the path runs past, CRACK_HEAD (▐) opens one
whose start is off to the left.

Only chips can crack, which is also the whole mode test: cut_mark reads the
fill from the nearest cell still drawn — scanning back for a tail cut,
forward for a head cut, so a cut landing on a seam still takes the colour
the reader can see — and falls back to the muted ellipsis when it finds
none. An arrow line has no fill to shear, so it keeps cutting exactly as
before and no caller has to know which mode drew the line.

Applied at the four places a path line is cut rather than folded: the
ellipsized() last resort, the over-wide lone hop in wrapped(), and the two
h-scroll surfaces whose edge marks were literal "…" — the Message paths
selected row and the node detail Routes row (whose unselected rows now go
through cut_to as well). Untouched: the ⋯ that marks whole hops elided out
of a path's middle, which is a different claim entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:09:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 1eda585c50 F-lane: You was standing in for a list end it isn't
The mesh walk's You had been living behind Page up, in the pager's Shift
bank, because that bank is where a screen's Home lands by default. But You
is not an end of the link list -- it drops the whole trail and refocuses our
own node -- so it had to pretend to be Top, and Bottom had to stay blank to
keep the pretence, and the walk was the one scrolling screen whose Home
didn't scroll.

So You takes a slot of the screen's own (F3, which is what F1-F3 are for),
and the pager's Shift bank goes back to meaning what it means everywhere
else: F10 Top, F9 Bottom, the two ends of the list. Home and End on a
keyboard follow the same correction and now move the highlight.

Its chord moves with it: locate is ^U, for you, on the map as well as here.
The letter is the mnemonic of the action rather than one screen's word for
it -- ^L only ever read as "locate" from inside the map's code, and the
action is the same one on both screens, so it should not have been two
mnemonics waiting to happen.

You dims once the focus is already us and no find is narrowing the list --
there is nowhere to come back from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:37:38 -04:00
Jean-Pierre MartineauandClaude Opus 5 0a6182b0e5 Wordmark: half the narrow mark's red row was a bank too bright
The 53-column mark's fourth row is the only row in either wordmark whose
colour lands on the dim bank (185;28;28 -> slot 1), and on the console it
came out part red, part light red: the M's right leg, E, S, H and the R's
right leg promoted, the M's left leg, T, E and the R's left leg not.

The split follows the bevels. The mark alternates letter blocks with slate
bevel spans, the bevel is slot 8 -- spelled 90, which on the VT sets the
intensity bit -- and the quantizer handed back a bare 31 for the blocks
after it. Bare, it keeps the bit and renders 91. Every leg that happened to
follow a bevel went light red; every leg that followed one of the row's few
resets stayed red. The .ansi art is one colour throughout; the promotion is
entirely on the way out.

So the fold's quantizer now states its intent the way the theme's dim-slot
styles already must: a dim slot leaves as 22;3N, never a bare 3N. Adjacent
art spans reset nothing between them, so this is the only place that can
know. It fixes the same latent promotion in a map raster, where two
neighbouring cells are exactly this pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 05:04:39 -04:00
Jean-Pierre MartineauandClaude Fable 5 6787aace3c F-key lane: a directional pair rises toward its outer key
F4/F5 keeps up on the right; an F1/F2 pair puts up on F1, so a select
list reads Sect ↑ then Sect ↓ — the hand anchors on the lane's edge and
rocks inward, whichever side the pair lives on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:10:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 cbdabef174 Find query: put it where the platform can actually show it
The map and the mesh walk echo the query you are typing in their footer
hint. The PicoCalc draws no footer hint -- the F-key lane took that row
-- so on the device both screens silently filtered themselves while the
reader had no way to see what they had typed.

The app already had an answer: the select list and the path composer
each draw the query as a warn-styled /query line above the rows it
narrows. That is now render.query_line, one definition, and the map and
the walk draw it too -- but only where the footer isn't, so the desktop
is untouched and no screen ever shows the query twice.

The row it costs comes from what the query narrows: the walk's link
list, the map's canvas. The map rebuilds its viewport a row shorter
through the ordinary resize path, so beginning a find loses a strip of
ground along the bottom rather than pushing the last row out of frame.

base_body_size was still mirroring only compose_base's bordered branch,
so a screen sizing itself against it left one row of a borderless frame
permanently blank -- which is the row the echo now wants back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 17:35:20 -04:00
Jean-Pierre MartineauandClaude Opus 5 8a80d47815 F-key lane: a jump rides the Shift half of the page it follows
Home and End are on the PicoCalc's keyboard. Paging is not. So the
jump-to-either-end pair was never the unreachable thing that earned it
F1/F2 -- it only ever wanted a chip for consistency with the pager it
belongs to. Now it rides that pager: Shift+F5 is Top because F5 is
Page up, Shift+F4 is Bottom because F4 is Page down. One axis, one pair
of keys, the second-grade verb sitting behind the first.

That frees F1-F3 on every screen, and the lane is the only place the
device can advertise anything -- there is no hint line to read a chord
off. So the freed slots went to the affordances that were invisible
there:

  select lists  Sect down/up, the section jumps. Ctrl+PgUp on a
                keyboard with no PgUp is the one binding in the app
                that was physically unreachable. Plus Delete, lit on
                exactly the rows the key acts on.
  map           Region (Home, renamed -- it frames the ground the mesh
                covers, it does not "reset" anything), You (new: Ctrl+L,
                recentre on our own node at the zoom you chose), and
                Frame for the find matches.
  node page     the tab it would switch you to.
  time machine  the window span it would take you to -- w was
                undiscoverable on the device.

The map is the one exception to the new rule: Region answers "where
should I be looking", not "how far down the body am I", so it keeps F1
beside the other two destinations rather than hiding behind a pager it
has nothing to do with.

Screens that never scroll (every dialog, the busy splash, the reorder
list) now say so with an empty lane instead of offering a pager that
does nothing. Screens whose Home/End mean something else say what:
Latest/Oldest in a chat, Newest/Oldest in the packet viewer, You on the
walk (where End is bound to nothing, so its slot stays blank).

The map's hint line finally fits 72: its basemap status was never a key
hint, so it moved to the title's status chain where the standards put
those -- which is what took the map off the gallery's pre-existing
overflow list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 14:56:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 5a9015662b F-key lane: a directional pair rises to the right, like a rocker
Where two adjacent chips are opposite ends of one axis, the up/in/more end
now takes the right slot: the map reads Zoom - then Zoom +, the shared lane
Bottom/Top and Page down/Page up, and the chat's relabelled jump pair follows
with Latest/Oldest. The actions move with their labels, so one handedness
carries across every screen and F1 always means the near end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 03:15:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 5aeb034c06 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>
2026-08-07 21:16:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 d79d85b7ee An unknown node is light grey, and bold stops recolouring it
Two reports on the PicoCalc's message paths, one root cause each.

The graph drew an unnamed hop's hash — and the sender's name — as white,
the "you" colour. MapCanvas emits its own bold for markers and labels,
and its colours arrive already quantized to a palette slot; the kernel VT
then draws bold as *brightness*, so anything that landed in the dim bank
was promoted to its bright partner. Light grey became white. A purple
repeater would have become pink the same way. The canvas now drops
emphasis where bold is brightness — the colour is the load-bearing part.

The same trap reaches the theme through inheritance: Rich merges a row's
base style into every span, so a dim-slot span left silent about bold
changes colour inside a selected row. Every dim-slot style now says which
it means — `not bold` to keep its colour, `bold` where the promotion is
the point (only title.muted) — and a test forbids staying silent.

The pathline drew the same hop dark grey, because `muted` collapses onto
the console's dark grey while the desktop's is light. That conflated two
things: `muted` is chrome and may recede a step, but a node we can't
identify is content you can still act on. It gets its own name,
`node.unknown` — light grey, identical to today's desktop muted, so
nothing moves there — and it is what `name_style` returns keyless, what
an unlit `highlighted_hash` wears, and what the `○` ring is.

Also fixes a crash from the previous commit: the graph's label colour
resolved a marker through `parse_hex`, which the type marks' theme names
are not. A repeater relay we couldn't name would have raised.

The gallery's node-detail header spelled a marker hex by hand; it goes
through the app's own styles now, so a palette bug can't hide behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:06:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 935b4fb3b6 PicoCalc: nodes colour by key, like everywhere else
A platform's ceiling may change a rule's resolution, never the rule. P3
had picocalc colour node names by *type* instead of by key, so a name's
colour there meant something different than it does on the desktop — the
node's kind, not its identity. Both platforms now run one rule: the key's
first byte picks the hue. The console just spends fewer of them.

node_style gains the binding the heat scale already had. Regular keeps
the full 256-hue wheel; picocalc snaps the same hue to its sixth of it —
the six chromatic bright slots, ~43 first bytes each. It returns the
slot's own hex rather than a color(N), so every downsample on the way out
(Rich's, and the fold's quantizer) lands on that exact slot instead of
guessing a neighbour, and the callers that read an RGB back out of a
style string keep working.

The type marks needed the opposite treatment: their hues are fixed, not
derived, and a naive nearest-RGB match drained the repeater's violet to
grey — a repeater read as an unknown node. They move to the theme's
type.* entries so each platform picks its slot deliberately, and a
braille raster resolves the same entry through theme.mark_rgb, so the
canvas marker and its Rich-drawn twin can't drift.

With names off the type registry, nothing reads it: Platform.name_colour
and core/nodetypes.py go, along with the three reception-layer feeders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:08:31 -04:00
Jean-Pierre MartineauandClaude Opus 5 b42375799a F-key lane: chips name actions, not keys
The lane read as a keyboard: Top, End, PgUp, PgDn — the names of keys
this keyboard doesn't even have, standing in for whatever the screen did
with them. A chip's job is to name the action it performs *here*.

The shared lane now speaks the vocabulary of the one thing it assumes —
a scrolling body: Top, Bottom, Page ↑, Page ↓. The chat speaks its own,
since neither end of a conversation is a "top": Oldest reaches back to
the first message, Latest returns to the newest and the compose line.
The map already got Reset and Zoom ±.

Empty and dim now make different claims. Dim says "a thing here, just
not right now"; empty says "not a thing here at all" — so a channel
chat's F8 carries no Retry, because a channel message is never
acknowledged and there is nothing a retry could mean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:24:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 790e17932e F-key lane: dim a slot whose action isn't available
The PicoCalc lane replaces the footer hint outright, so it inherits the
hint line's standing rule — never name a key that would do nothing. Until
now it named several: Retry with nothing unacknowledged, the paging pair
on a body that already fits whole, and, worst, the map's End, bound to
nothing at all while PgUp/PgDn were labelled as paging when they zoom.

An FPair now carries enabled/opp_enabled per bank. A cleared slot keeps
its label and drops its fill, landing in the same muted "nothing to
press" class as an unassigned one — so the key still reads as what it
would do, it just plainly isn't live. The dimming stays presentational:
action_for still resolves a dim slot, and the screen's own handle()
remains the authority (it no-ops), so a lane built from stale metrics can
never swallow a key that works.

Applied where a screen can tell honestly: default_lane(nav=…) for the
shared slots (result windows and the dashboard gate on content_overflows,
the chat on having a transcript), the chat's Retry on there being an
unacknowledged direct message, and the map gets its own lane — Reset,
Zoom +, Zoom - — with F2 left unassigned rather than dead. The chat's
regular-platform hint drops ^R on the same gate.

compose_base now takes the lane as a builder and calls it after the body
renders, so those gates read this paint's scroll metrics instead of the
previous paint's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:01:01 -04:00
Jean-Pierre MartineauandClaude Fable 5 e425e8fc41 P7: parity enforcement & docs - the platforms standard is binding
- CLAUDE.md gains the Platforms section: the two flavours, the
  on_platform binding rule, the glyph/palette/fold contracts, the
  fontset<->font-script same-commit coupling, F-lane grammar, and the
  specimen as the acceptance card. The 72-column rule becomes
  readable_cols-per-platform with the gallery as the enforcement point.
- README quick start: the auto-detected PicoCalc flavour, the desktop
  preview flag, meshterm specimen, and the bring-up scripts.
- The plan file is archived with per-phase outcomes, the P3 rework
  record, solo-decision log for JP, and the left-on-the-table list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:15:46 -04:00
Jean-Pierre MartineauandClaude Fable 5 0add80a821 Footers: one verb per Enter, settled in the standard
Five words had grown for one key — select, pick, open, choose, commit —
so CLAUDE.md now fixes the Enter verb by action (open pushes a screen,
select picks a value or action row, set commits a value picker) and the
outliers come along: the purge and discipline pickers say select, the
record card's action rows say select, "Esc keep current" and the three
"type filter"s were typos against existing rules (housekeeping H-008).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 23:13:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 0a41303366 Lists: what stays overhead is the section you are in
Two things a grouped list has to keep in view while it scrolls, and neither one
was staying: the lane names that read every row, and the heading that says which
group a row belongs to.

The lanes come first. A column header means the same thing in every section, so
it is not a section landmark at all — it pins for the *whole* list, above any
heading, once it scrolls off (`Separator(pinned=True)`). It is drawn outside the
body slice, so it stays exactly one row: too wide for the terminal it ellipsizes
rather than wrapping and stealing a second reserved row from the content. And it
can now size itself — a Separator title may be a callable taking the render
width — so `column_header`/`Lane` lay each label over its own lane and hand back
the shorter form (DESCRIPTION → DESC) only for the lanes that actually overrun,
from the right, where the trailing lane is the only one with a cell to give.
`lane_header` is that header for `lane_row`'s lanes, shared by the two editors so
they head identical lanes identically.

The headings were the other half, and they were pinning the wrong row. Every
non-pinned separator was a candidate, and the rule takes the last one at or above
the scroll — so what pinned was whatever muted line sat lowest under the heading.
In the Trophy case that meant a discipline's *description* stuck to the top and
the heading itself never did; at the foot of any list, the blank spacer above
Back pinned a blank row. So heading-ness is explicit now (`Separator.heading`,
conferred by `section_heading`, which is why every hand-rolled `── … ──` in the
app — the main menu's categories, the chat picker's Channels/Direct, Trace path's
candidate and ranked lists — now goes through it, and why three lanes headers
that genuinely *are* their block's only landmark say so).

A landmark is also no longer one line but a block: a heading plus the separators
written immediately under it, before the section's first row. Prose written there
is the section's preamble — the Trophy case's "what this discipline scores" — and
it belongs overhead with the heading rather than scrolling away from the rows it
explains. The block hands its rows over one at a time as they leave, so the
heading pins while its description is still the top content row and the two pin
together once both are gone: what is overhead always continues seamlessly into
the body. It never takes more than half the viewport, giving rows up from the
end, so the row that says *which* section this is is the last one dropped.

The frame settles the scroll against however many rows come back rather than
assuming one, and re-asks after sliding the window at the bottom — a heading
among the dropped lines would otherwise vanish instead of pinning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:13:14 -04:00
Jean-Pierre MartineauandClaude Opus 5 722c8f0e4f Mesh walk: the atlas is a walk, everywhere it's named
"Atlas" named a book of maps — a thing you leaf through — and this screen has
never been that. It holds one node in focus, fans its neighbourhood, and moves
when you press Enter. The trail across the top is a walk, the list heading says
Enter walks, the tests call the helper walk_to. Only the name disagreed.

So the name goes: the tool is `walk` / "Mesh walk", the screen is WalkScreen in
ui/walk_screen.py behind open_walk, and every prose mention follows — the
README feature table, the node-vs-contact boundary in CLAUDE.md, the
windowed-list pattern note that cites this screen as its origin, pathline's
separator example, topology's whole-graph view and its prefix-coalescing
rationale, and the purge-test-data skill that warns about fake nodes showing up
here.

No behaviour moves. The tool keeps its 🌐 icon, its Mesh category and its
order beside Map — the logical shape next to the geographic one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:59:25 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 733a68857c Contacts: rename the mislabeled Nodes feature; sharpen node vs contact
The "Nodes" menu was a contacts roster mislabeled — it lists the device's
contacts (get_contacts), which upstream meshcore calls "contact" too. Rename
throughout, no CLI alias:

- tools/nodes.py -> tools/contacts.py (ContactsTool, `meshterm contacts`)
- ui/nodes_screen.py -> ui/contacts_screen.py (ContactsScreen/open_contacts)
- ui/nodelist.py -> ui/contactlist.py (ContactListScreen/ContactRow)
- widgets: NodesSort->ContactsSort, nodes_table->contacts_table

Courier's recipient is a contact you address, so its prose and CLI arg
(`courier queue CONTACT ...`) follow; persisted node_key/node_name stay.

The reception/hardware layer keeps "node" — HeardNode, heard_nodes(),
observations.node, trace_hops.node, map/atlas participants, node types, admin
targets, and the Time Machine's reception picker (its "Nodes" heading stands).
CLAUDE.md's lexicon is rewritten to the refined definitions: node = any mesh
radio; contact = a node your device knows (discovered or added).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 11:42:11 -04:00
Jean-Pierre MartineauandClaude Fable 5 4dc9608761 Colour: every hue rides a key — name-seeding retires
A new make_name_key_resolver (beside its trace_runner siblings) maps a
display name back to the node's key — contacts win, the recorder's
stored advert names fill in strangers — and name_style's keyless
fallback becomes muted: colour now always marks a keyed identity,
never a character-sum accident that two spellings of one node disagree
on. Wired through every keyless call site: the chat transcript's
sender headers and @mentions (a direct thread falls back to the peer's
key), the picker previews, the live feed's channel-sender lane, and
the route graph's origin label (new key_of hook on route_graph_style,
threaded through the packet viewer and Message paths). The chat
picker's contact dot switches to the standard companion pink — shape
still marks history — and contact labels take their key-derived hue.
name_rgb guards the muted style so braille canvases render the grey.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:04:57 -04:00
Jean-Pierre MartineauandClaude Fable 5 24c70c487f Packet viewer: the class headlines the card, icon and all
Every packet now leads with its class — icon + UPPERCASE label — as the
first row of the viewer, whatever the kind: a raw frame shows its parsed
payload class, everything else its own kind. The new PAYLOAD_ICONS table
gives each raw payload class a concept glyph (📻 channel text, 🎯 trace,
…, matching the tools that own those concepts) and joins the CLAUDE.md
icon lexicon; the dialog title drops its emoji, per the title rule the
icon was violating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:45:06 -04:00
Jean-Pierre MartineauandClaude Fable 5 56cc220ee3 Lexicon: a lane shows the key, the hash is the lit slice
The node lists' HASH column header becomes KEY: the lane renders as
full a key as the caller resolved, and the hash is only the short
path-hash-mode prefix highlighted_hash lights inside it. CLAUDE.md's
key/hash lexicon rows now carry the sharper definitions so UX text
never again calls a truncated key a hash. The "hash" sort-ring id
stays, for saved-sort compatibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:43:07 -04:00
Jean-Pierre MartineauandClaude Fable 5 f7dcda0575 Hash widget: the addressed prefix lights in the node's own hue
highlighted_hash lit a key's path-hash prefix in the fixed brand cyan;
it now takes the node's hash-derived palette hue — the same colour the
node's name wears — so a hash carries its node's identity colour even
where no name is known, and a key's prefix, its name, and its map
labels all agree. The remainder (and a hash standing in as a name)
stays muted, and the truncation ellipsis keeps inheriting the colour
of the digit it replaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:39:42 -04:00
Jean-Pierre MartineauandClaude Fable 5 79d00defdd 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>
2026-07-18 20:37:10 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 115fe6895d Dialogs: multi-layer floats, red data-loss tier, unknown-relay hashes
Three independent consistency passes:

1. **Message paths: unknown relays as grey full-hash**
   The message-paths dialog showed unnamed relay hops as a single lit byte (e.g.
   `e8`). Now they render at the device's path-hash-mode width, muted grey, and
   annotated with their addressed byte — `e839f2 (e8)` — so the rows stay
   readable and cross-reference the route graph's byte labels (matching named
   hops' trace presentation). Added `hash_as_name` flag to path_text widget.

2. **Dialogs: multi-layer compositor + backdrop for empty stack**
   Opening a confirm over a floating popup used to stretch that popup to
   full-frame (the single-float compositor's bug). Now the compositor maintains
   a pool of _MAX_DIALOG_LAYERS=8 centered-box floats, each rendering one layer
   above the background — so a popup and its confirm both stay their own size.
   Also: button_dialog/typed_confirm/autocomplete now float over a blank base
   when the stack is empty (via _run_dialog_screen), guaranteeing popups render
   as small boxes on an empty stack, not full-frame chrome. Simplified
   message_dialog to delegate its backdrop logic to button_dialog.

3. **Data-loss dialogs: red `destructive` tier**
   Added a second caution tier to the dialog API: danger=True stays amber
   (disruptive: discard edits, reboot), destructive=True is red (irreversible
   data loss). Updated: single-record delete, channel "clear slot", courier
   "clear finished" (new confirm), and startup "remove device" (splash confirm).
   Per UX spec: bulk/irreversible deletions gate behind typed_confirm (also red);
   single-record delete is a red Cancel/[verb] button dialog.

All changes are backward-compatible. Full suite: 793 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:30:58 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 5020c924f3 Trophy case: browse the record-setting walks from the main menu
A read-only screen over the boards (tools/records.py opens
ui/records_screen.py): the six disciplines, each under its heading with a
one-line, word-wrapped description of the game, then that discipline's
records ranked best-first with the walked route through the shared path
widget. A discipline holding records at more than one hash width tags each
row with its width.

Opening a record floats its full story — every stat it was measured by, the
route and spec, when and by which app version it was set — with two actions
besides Back: Trace this path reopens Trace path with the record's route
prefilled (a claim worth re-testing), and Delete removes it. Deletion comes
in three grains: one record, one discipline, or everything (the last two
behind a confirm, the wholesale one typed). The CLI face prints the boards.
Adds the 🏆 icon to the lexicon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:59:53 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 32734cc51a Trace path: reverse a walk to measure it the other direction
Radio links rarely read the same both ways — a route that carries cleanly
outbound can be marginal on the return — but Trace path only ever walked a
route in the direction it was built. Add a Reverse path action that flips the
walk's hop order (us → a → b → c becomes us → c → b → a), so one composed
route can be measured in both directions.

It lives only in path mode: a target-mode route is a symmetric boomerang, so
reversing it changes nothing. Reverse sits in the build-path group beside
Compose and adopts the flipped spec exactly like any other path change — the
forward run's aggregates clear (they described the other direction) and Trace
stays idle, so you flip, then trace; reverse again to go back. Both readings
persist to history for side-by-side comparison. Disabled, with the same
"compose a path first" note as Trace, until there's a multi-hop path to flip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 03:11:28 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 d977aeb22a Channels: mute a channel's notifications from its detail screen
Some channels — an automated #wardriving beacon, a chatty public relay —
carry traffic worth recording but not worth being notified about. Muting
a channel now stops its new messages from raising the unread badge: the
recorder skips the unread bump for a muted channel (its transcript is
still written to history), and muting zeros whatever unread it had so the
badge drops immediately. The mute lives in a new MuteStore
(<config_dir>/mutes.json), keyed by the channel's intrinsic identity so
it follows the channel across slot moves and survives restarts.

The toggle is a state-reflecting row in the channel-detail subscreen
(🔕 Mute / 🔔 Unmute), and the manager list marks a muted channel with a
🔕 in its (then always-empty) unread lane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:18:41 -04:00
Jean-Pierre MartineauandClaude Haiku 4.5 534128ab84 Configure verify skill for a cheaper model
The commit and verify skills were burning the full parent-conversation
context on every routine invocation. Both now set model: haiku in
frontmatter so that context is billed at the cheap rate instead of
whatever model the main session is running.

context: fork was tried on both skills to avoid shipping the parent
context at all, but reverted: a bare "/commit" or "commit this" with no
explicit argument caused the forked subagent to skip the skill's steps
and return a generic greeting instead of committing. Fork only works
reliably when the caller always supplies a rich task description as the
argument, which isn't true for these skills' common invocation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-12 11:35:45 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 34c6ada1ae Write the UX standards down: CLAUDE.md
The consistency pass's decisions, recorded where every future session
reads them: the lexicon (node/contact/heard/key/hash/path/route/via,
Back vs Quit), the list furniture rules and their enforcement points in
ui/menus.py, title casing and dash semantics, the footer-hint grammar
and Esc verbs, the dialog conventions, the one-glyph-per-concept icon
table, and the colour rules the screens already share.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 04:06:10 -04:00