Commit Graph
69 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5.5 6b13755c2b Every store writes only the fields its records know
The admin and remote stores round-tripped the raw JSON they read, so any
field a past version wrote survived forever. Both now read into typed
records (AdminCredential; _RemoteNode holding CachedValue settings) and
write out of them, keeping the file's existing spelling. The settings store
keeps only keys device_config still registers: it offers remembered values
back to the radio, so an unregistered key must not reach a restore.

Every persisted record now declares a RETIRED set (all empty: the history
holds no removed fields), device_config gains its own RETIRED registry, and
test_retired pins each store's records so the sweep cannot go vacuous.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:07:48 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 6a8efa2566 A retired name is never reused, and files keep only what the code knows
A value can outlive its key in a file nobody saved since; if the name came
back with a new meaning, an old value that passes the new spec would load
cleanly and mean something else. So every name that leaves is recorded as
retired: preference keys in preferences.RETIRED (the two old advert cadence
keys), stored fields in their record dataclass's own RETIRED set. A test
walks meshterm.core for such records and fails any live name found retired.

Loading preferences refuses retired keys and records every drop (retired,
unknown, invalid); report_dropped logs them once logging exists, INFO for a
retired key and WARNING naming a typo. adverts.json now reads into typed
records and writes out of them, so unknown fields die at the next write.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:00:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 a446da2683 A type-1 node is a companion, everywhere
0.9.0 called the same advert type three things: `companion` in info, `client` in
diagnostics, and `node` in contacts and every other screen that names a node's type.
Room servers were `room server` in two places and `room` in the rest. JP picked
companion. It is the ecosystem's word -- companion firmware, companion apps -- and the
one MeshTerm's own docs already used most. "client" now belongs to MeshTerm itself, which
the one-liner calls a MeshCore client, and "node" is the lexicon's umbrella for every type,
so a TYPE column that says "node" for one of them tells a reader nothing.

There is now one table, NODE_TYPE_LABELS, and one helper, node_type_label(), which gives
"type N" for an advert type MeshTerm does not know. info and diagnostics lose the two
role tables that had drifted apart, and the packet viewer its own copy of the fallback.

The longer names made the node-type legend 59 cells wide, which the PicoCalc's 53 would
have cropped, cutting "sensor" off. node_type_legend() now takes the width it has and
breaks between entries, never inside one, so it stays on one line on a desktop and takes
two on the PicoCalc. The four screens that draw it pass their width and take its lines.

This changes --json output, so it goes under [Unreleased] for 0.9.1. docs/cli.md's samples,
the value lists and CLAUDE.md's lexicon say companion and room server. Two tests pin the
names and the PicoCalc legend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:45:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 6a060a0451 One line says what MeshTerm is, everywhere
Four different one-liners were in circulation: the repository's "interactive, scriptable
CLI toolkit", the README's "full-screen terminal companion", the About page's "client that
lives in your terminal", and the package summary's "TUI-based client ... companion
connection". JP picked one:

  A full-featured TUI MeshCore client for your terminal

It now opens the README, the About page, the package summary, meshterm.__doc__ and the
`meshterm --help` header, which cli.py already says must stay the same as the summary.
The summary's second sentence loses its grammar slip on the way: "Connects to a companion
over USB, Bluetooth or TCP." CLAUDE.md opens with the same line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:37:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 d38aca6c3c A contact can be locked against archiving
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:35:59 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 1838ee3768 A blurred seam is the chip's own fill shaded, and "blurred" is measured by eye in OKLab
The thin chevron between two chips the reader can't tell apart is now drawn in the
previous chip's own fill stepped SEAM_SHADE darker (lighter for a dark fill) on the
fill ahead, so nothing on the ribbon is a colour that isn't a chip's. The step is
measured from whichever fill is further along, so the mark clears the fill it sits
on by the full shade however close the two are.

Whether two fills blur is now a perceptual distance, not a hue gap: ui/oklab.py is
the reference OKLab transform (from_hex, to_hex, distance, shaded), and the seam
interlocks only where the fills sit SEAM_BLUR (0.10) or more apart. The hue rule
mis-sized the wheel badly: sixteen first-byte steps measure 0.026 across the greens
and 0.165 across the cyans, so it blurred too little on one side and too much on the
other, and an sRGB distance would only have restated the hue gap, a fixed byte step
being a near-constant 55-60 of 8-bit RGB all the way round. 0.10 is five noticeable
differences, the typical 0x10 pair with a little to spare, erring toward the thin
chevron because a wrong blur still draws a legible seam and a wrong interlock draws
none. The greys need no exemption any more: the keyless grey and the faded slate sit
0.18 apart and keep their interlock on the metric alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 00:30:50 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 1da1c9db0d A blurred chip seam is the thin chevron in ink, not a wedge cut out of the route
Where two consecutive chips share a fill or sit under SEAM_HUE_GAP apart, the seam
was the solid point drawn bare, so the page cut a wedge out of the ribbon: a run of
near hues read as dashes, and on a light terminal the wedge was a hole. It is now
the thin chevron (U+E0B1, core powerline set) in the chip's ink on the fill ahead,
powerline's own mark for a join inside one colour. The ribbon runs on unbroken, the
join is a line drawn on it, and it wears no fill of its own so it can't be taken
for a chip; on a faded leg it takes the dim ink and recedes with what it joins.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 00:04:15 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 2404c5286c A chip seam falls back to the page when two hues are too close to tell apart
Two consecutive chips with near-identical fills — first key bytes under 0x10 apart
on the hue wheel, measured circularly — used to interlock on a chevron the eye
couldn't find, one shade of green laid on the next. They now take the same exception
an exact match does: the point drops its background and the page cuts the wedge.
The wheel is recovered from the fill itself, so an override colour and a console
slot are placed on it too; the greys (keyless, dim, our own neutral) blend only with
their exact selves, since two distinct greys still read as two chips.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 23:48:23 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 12941b8f3c scripts/ is sorted by handheld: picocalc/ and uconsole/, indexed from the hardware manual
The Calculinux bring-up, both console-font builds, the GPL-2.0 text that belongs
beside the 6x8 one, and the xiao-radio firmware kit move under scripts/picocalc/;
the SPI bridge is scripts/uconsole/meshterm-spi-bridge. The folder's own README is
gone: its per-file index is now a section of docs/hardware.md, next to the table
that says which manual is yours. Every path in the manuals, NOTICE, CLAUDE.md, the
tests, the package docstrings and the scripts' own headers follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 22:36:49 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 49a609fabf The 6x12 console-font script carries its size in its name, like the 6x8 one
scripts/calculinux-console-font.sh is now calculinux-console-font-6x12.sh.
Every reference follows: the setup script's hand-off, the 6x8 script's
SHARED default and comments, the theme16 test that parses it, the module
docstrings, the two manuals, the scripts index and CLAUDE.md. The font files
it builds keep their names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:00:46 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 8a1bb875a7 On a bordered frame the map's credit moves into the bottom rule, out of the map
The credit was stamped over the right end of the drawing's own last row on both
platforms. That is the traditional corner and it is where a frame with no bottom
border has to put it, but where there *is* a bottom border there is already a rule
under the body doing nothing except closing the box — and a caption set into it
costs the picture nothing at all. JP, looking at the desktop map: "that way it's
not in the map."

So a screen may now hand the frame a bottom_caption, which _panel_box sets into the
bottom rule right-justified through Rich's own subtitle machinery — landing as
"└──… © OpenStreetMap ─┘", one rule cell before the corner, the mirror of the way a
title sits in the top rule. The rule's other occupant keeps its place: the clip
arrows still lead, centred when they are alone and joined by the caption, right, when
both are wanted, because dropping either would be the frame deciding something
neither screen asked of it. The caption joins the base box memo's key as well — the
credit collapses on a keystroke that need not change a single body line, and a memo
blind to it would go on serving a rule the reader has already grown out of.

The map asks attribution for both halves and tests nothing itself: rule_caption gives
the caption where the platform's frame has a rule, map_body stamps the drawing where
it hasn't, and exactly one of them draws. Which it is, is bound once at platform-switch
time rather than asked per paint.

Nothing about the PicoCalc changes — same strings, same corner, same collapse, and the
minimap still stamps on both platforms, having no frame of its own to set anything
into. JP read that form on the device's own panel on 13 September and signed it off as
it stands; the docstring now says so, so nobody goes tidying it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:47:28 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 8916544bfb CLAUDE.md states what every file and every build carries: the SPDX line, the map credit, and what NOTICE is for
The three rules the licensing pass settled, pinned where the other binding standards live so
a new file, a new map surface or a new NOTICE paragraph is held to them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:21:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 266aca08fb Preferences are kept in TOML, like every other file a person edits by hand
preferences.yaml becomes preferences.toml: config.toml and the device-config
backups were already TOML, and tomli/tomli-w already dependencies, so PyYAML
existed for this one file and is dropped. The file keeps its shape - flat
keys, only the overrides, grouped under comment headings with each entry's
help and default - and one name, PREFERENCES_FILENAME, for both call sites.

TOML refuses a whole document over one bad line, so a refused file is read
again a line at a time: a valid line keeps its typed value, a refused one
hands its right-hand side to parse_value as text. One broken hand edit still
costs only its line, and a word left unquoted (log_level = DEBUG) still reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 12:45:22 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 356ca6a94a The widgets CLAUDE.md names as THE widgets have public names
A leading underscore says "internal to this file, don't import me", and the
column-age formatter and the node glyph table wore one while seventeen modules
imported them and CLAUDE.md named them as the one place each concept lives.
format_age sits beside format_ago now (prose age, column age), NODE_GLYPHS and
DEFAULT_GLYPH beside them, age_seconds with the ages. A mechanical rename, 140
references across 24 files, CLAUDE.md's three lines with them so the guide and
the code move as one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 01:13:18 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 b56987b24b A QR code is white on black, and the whole screen when it is the answer
A camera reads contrast, and a light-on-dark code is what a scanner expects
of a screen, so every code MeshTerm draws is now pure white ink on a pure
black field (the theme's `qr` style, both ends named on both themes), never
black on white.

The share views — a channel's share link, a contact card, the CLI's share
commands run in the menu — are no longer a titled popup with an instruction
over the code. They are `QrScreen`, a **bare frame**: `Screen.bare` and
`frame.compose_bare` draw the body alone on blank rows, with no header, no
footer, no title bar, no box, no hint and no F-key lane, and nothing on it
but the code and the URL it encodes, sat a little above centre. Esc leaves.

The code fits itself to the frame every paint, width and rows both: the
standard fit first, then a lighter error level, then a narrower quiet zone,
before it would ever be cut — a contact card is 57 cells and 29 rows at the
standard fit, the PicoCalc is 53 across and a regular terminal 24 tall. When
code and URL can't share the frame the code is whole at the top and the URL
a page down. The frame states its height before it asks for the body, which
is what lets the screen size to it.

A `qr` fence in a written page is the one exception and keeps its place in
the prose — the About pages' codes flip to white on black like the rest.

Found on the way: the PicoCalc's SGR quantizer matched a colour only when it
was a whole sequence, and Rich writes a style's foreground and background as
one sequence (`ESC[38;2;…;48;2;…m`), so a white-on-black module reached the
console in truecolor. It now walks every sequence's parameter list.

The standard is written into CLAUDE.md under Screens and lists; the gallery
carries the share screen as a specimen on both platforms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 03:05:34 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 13f0c647de The navigation standard says what a popup is: a question, not a place
Two flows were rebuilt on it today and nothing had it written down — the stack is for
places, a popup informs, confirms or asks and is then gone, and a question draws as a
box even as the only frame on the stack. The entry-chain rule gains its list form,
run_wizard, one popup turning its page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 02:05:49 -04:00
Jean-Pierre MartineauandClaude Opus 5 10f48dbffc A relay chain opens and closes on the chevron, since its ends are not the route's
A packet's via field names the repeaters that forwarded it and neither the
node that sent it nor the one that heard it, yet its ribbon opened and closed
square, the mark that says the route began and ended there. PathLine takes
from_origin / to_destination: a cleared end opens on the notch or closes on
the point, and arrow mode spells it with a bare leading or trailing arrow.
The packet viewer's via chain and the TX sweep's composed relays draw both
ends open. Under the chain, the "reception describes..." note gives way to
the hop count atom the node page and trace scenarios already carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 04:29:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 398a184eed The splash says bye, and keeps the reader's place when a row goes
Three passes over what landed yesterday.

The hidden-count line stops standing in the list. With devices still
listed the footer already names ⇧H on every row, so the line was a row
of the box spent saying it twice; it stays only for a list hiding has
emptied, where there is no footer atom to read it off and "no
companion devices detected" would be a lie about a machine with a
radio plugged into it.

Hiding now leaves the highlight where the row was - the next device
down, or the one above at the end, never rolling round to the top - so
a run of adapters clears with a run of presses instead of the cursor
jumping back to the remembered default after each one.

And Esc says bye here rather than quit: the splash is the door, and
the reader leaving it has not started anything to quit out of.

One consequence, on the console: the splash border is 47 cells and the
sentence now sheds the hide key too when it must. Between the two
shortcuts the survivor is the way back - ⇧H only appears once
something is hidden, which is exactly when a reader needs to be told
how to undo it, and by then they have found h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:45:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 3d3506be4f A route that has finished does not end on the mark for "there is more"
The chevron is the ribbon's continuation cue: it is what a wrapped line
closes with to say the path runs on past the edge, and it is the same
shape as every seam between two hops. So the last chip of a finished
route wearing one read as a route cut off mid-walk. Where the font has
the rounded caps nothing changed - they close the lozenge and always
did. Where it hasn't, the widget already squared the *opening* off and
documented that it squares both; only the close never followed. It now
appends nothing at all and the last chip's own pad is the edge.

The cell that frees up goes back to the route. `_measure` used to
report one closing figure for every line, which was true while the
close was always a point; it reports two now - what a line the path
runs on past closes with, and what the line that *ends* it closes with
- so a wrapped path measures its own last line honestly instead of
holding a cell back for a glyph that is no longer drawn. The one caller
that flows a leg of a route rather than a whole one says so, because
its final line is a continuation and still pays the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:36:03 -04:00
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