Commit Graph
412 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 a409fd4e9b Hubs keep their place, and peers nest instead of flattening
Node detail, the contacts list and the trophy case were each rebuilt every time
something they opened closed. Node detail was the sharpest: its constructor sets
the tab to Info, so Tab to Routes, pick a route, trace it, come back — and you
were on Info at the top again, two keypresses from the route you were working
through, every single time.

All three now stay pushed for the whole visit. The screen object is what carries
the tab, the cursor, the sort and the filter, so returning is a pop rather than
a rebuild. It also retires the pop-then-re-push-as-a-backdrop dance in three
places: a screen that never left the stack is already the backdrop its dialogs
draw over. Contacts still rebuilds after a purge or a removal, and the trophy
case after the record count moves — the rows they were holding a place in are
gone, which is the one thing worth losing the cursor over.

The two peer hand-offs stop flattening. Trace → trophy case used to resolve the
whole trace screen with a sentinel so it unwound before the trophy case opened;
trophy case → trace ran the trace only after the browser came down and then
returned outright. Both landed the reader on the main menu, both said in their
comments that this was to avoid a stack the user would have to Esc through.
That was a hand-built escape from a stack with no other way out, and ^W is the
real one, so both nest now: Esc goes back one, to the screen that opened it.

The trace screen takes an `open_trophy_case` callback rather than resolving a
sentinel the opener has to interpret, which is also what lets the two screens
open each other — a cycle a strict stack allows on purpose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:27:53 -04:00
Jean-Pierre MartineauandClaude Opus 5 ea68b9f890 Navigation: the stack is the model, and ^W leaves it all at once
Navigation was almost a stack already, and the survey in docs/survey-navigation.md
catalogues where it wasn't. This is the machinery for the strict version, plus
the one shortcut past it.

A screen that owns a loop can now keep itself pushed for the whole visit:

    async with session.stay(screen) as visit:
        while True:
            chosen = await visit.result()

One push, one pop, and the *object* survives its sub-screens — so the cursor,
the sort, the scroll offset and any live filter are simply still there on the
way back, rather than being approximated by a `default=` restore that can only
recover the cursor and only when its row still exists. It also retires the
pop-then-re-push-as-a-backdrop workaround: a screen that never left the stack
already is one.

^W unwinds every frame at once, back to the main menu. It resolves the top
screen with POP_ALL, which each navigation boundary turns into PopToMenu, and
every frame pops itself in a finally on the way out — the same single-pop
discipline applied N times. PopToMenu derives from BaseException so it crosses
the `except Exception` guards that stop a broken tool from taking the menu down;
those must not report an unwind as a tool failure. The flag it sets matters
independently of the future: pressed during a device read there is nothing
armed to carry the sentinel, so the next screen the flow tries to open refuses
to open instead.

It declines to fire over anything modal — an unanswered dialog, a progress bar,
a busy splash — which is what `modal` is for. `floating` is only about being
drawn as a box; a select list floats and is not modal, the busy splash is modal
and does not float.

^Q quits from anywhere. Both chords are deliberately unadvertised: a global verb
has no screen to belong to, so a footer atom or an F-key chip would ride on
every screen, and the lane only has three free slots to begin with. A test
walks every string literal in the package to keep them out of the UI.

Quitting mid-screen used to abandon the coroutine driving the app: the chord
exits the application straight from the key handler, so `run_async` returned
while main() was still parked on a screen, and its finally — which closes the
history and chat runs, stops the services and arms the exit watchdog — never
ran. It is cancelled and awaited now.

The menu is the root: built once, kept for the session, and the one frame that
catches the unwind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:20:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 b6f3218af9 Mesh walk: just the name
The title is the screen's name now, with no status atoms at all. Everything it
used to carry is already on the screen below it: the breadcrumb trail ends on
the focus, the line under that names the node in colour with its hash and its
ring, and how big the neighbourhood is, is what the canvas and the link list are
for. Set once at construction rather than composed per render, so the top row
holds still while the walk moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 02:59:01 -04:00
Jean-Pierre MartineauandClaude Opus 5 2252f2622e Mesh walk: the title says how big the mesh is, not where you are
"Mesh walk — YUL-Cartierville · 42 nodes · 61 links · 3 matches" ran 62 cells,
which a 53-column title bar clips rather than wraps — the link count went, and
so did the bar's clip arrows and its Esc hint. The focus was the longest atom,
the one that rewrote the top row at every step of the walk, and the only
redundant one: the breadcrumb trail ends on it and the line under that names it
in colour, with its hash, its ring and when it was last heard.

So the title reads like the map's — the other spatial screen, whose centre is
likewise the body's to name — status atoms only, and the match count swaps for
the node count instead of joining it, the same way the map's does:

  Mesh walk · 42 nodes · 61 links
  Mesh walk · 3 of 42 match · 61 links

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 02:53:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 7c36f809fd PicoCalc: the title bar says how to leave the screen
This platform has no footer hint line — the F-key lane stands where it would be,
and the lane only advertises what its five slots do — so Esc, the key every
screen answers to and the reason no screen spends a row on a Back item, was
nowhere on the screen.

It now rides the title bar's tail, last, after the clip arrows and in the same
"↑↓ · hint" shape a floating dialog's subtitle already uses. The verb is the
screen's own: the atom is lifted off its footer hint (the grammar puts Esc last,
so the tail of the hint is the clause), which is how a value picker says keep and
the main menu says quit, and a screen naming no way out advertises none. It gives
way before it crowds the title — the verb first, then the key, then the cells
back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 02:20:44 -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 8e8a73fc36 Busy card: the chip already says "working", so the LED bar goes
The skeleton card floated between screens carried a Knight-Rider scanning bar
under its caption: eighteen braille lamps, a red head and a persistence-of-vision
trail sweeping back and forth. It drew the eye harder than the words did, on a
card the reader is only ever passing through, so it and its colour ramp are gone
— the one-cell spinner beside the caption is the whole animation, and the card is
two lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:36:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 0fa8290add Contacts: a contact the device never held is still ours to remove
The list a screen removes from is the union of the device's contact table and
the contacts MeshTerm remembers for it, so an entry can exist only on our side
— or the firmware can drop one since we last read. Removing such a contact hit
ERR_CODE_NOT_FOUND and reported "couldn't remove", leaving a row the reader had
no way to get rid of.

The device layer now raises ContactNotOnDeviceError for that code, the same
class (and the same table lookup) the send path already raises. The single
removal reports it as a warning naming what happened, forgets our half and ends
the visit; the purge sweep counts it as purged rather than failed. The
simulator refuses the same way, so the path is walkable under --mock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:24:11 -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 9d524a27a2 Emoji: a joined sequence is one glyph, so it costs one glyph's cells
A ZWJ sequence — 🤷‍♂️, 👨‍👩‍👧, 🏳️‍🌈 — is a single grapheme the terminal draws
in the width of its base, and Rich's stock measurement already knows that.
prompt_toolkit's does not: it adds the codepoints up, reserving three cells for
the shrug and six for the family, and it is prompt_toolkit that places the right
border. Every joined emoji has been pulling a row's border in by the excess.

Correct it in both authorities the same way, by dropping what the joiner folds
in before measuring, so a sequence inherits whatever its base is worth and the
two curated sets still reach it. Rich needed this too: the selector-skipping
replacement was losing the stock loop's own ZWJ handling and counting the male
sign as a cell.

Measuring is only half of it — prompt_toolkit lays out one codepoint at a time,
so the sequence has to arrive as one fragment or it is still laid out in pieces.
ClusterTextControl merges the run on the finished lines (the last point they are
still ours: split_lines returns plain str and drops the marking), and the window
makes one two-cell Char of it. Every codepoint still reaches the terminal, in
order, so the glyph composes exactly as before.

The rule holds at either emoji width, so the width-2 path installs it alone —
no narrowing, no flag category, Rich untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:27:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 80a2a9bd0b Contacts: a contact is dropped one at a time, from its own page
The list could only sweep contacts by age — a purge or nothing. Removing one
named contact now lives where the reader is already looking at it: the last
action on its Node detail page, behind a red Cancel/Remove confirm, and the
list it returns to rebuilds without the row.

The deletion lands in both halves of what the list shows — the device's table
and the cross-session store — because forgetting only the radio's half merges
the contact straight back on the next read. A refusal is put in a dialog and
nothing is forgotten: a contact the radio still holds must not vanish from the
list. Only the contact goes; its reception history and chat messages stay, and
a later send offers to write it back.

An action row's tint now survives the icon lane being dropped — it lands on the
label, as marked_label already does for menu rows — so the delete still reads
as a delete on the PicoCalc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:39:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 c66e2c86b6 Frames: the border is one colour again, so drop the corner light
Every framed box was post-processed on the way to the screen: the top-left
corner blended toward white and the highlight decayed along the top and left
edges, so a box read as lit from that corner. It reads as a gradient laid over
the chrome rather than as the box being a box. The borders draw flat now.

The pass goes with it — ui/tui/glow.py, and the three composers that ran it
(base, startup, dialog), each of which also carried the platform gate that kept
it off the 16-slot console where it could only ever hand back the same lines.
Nothing else called it, and its memo went with the module. Text colour is
untouched: the pass only ever recoloured box-drawing glyphs, and titles and
subtitles were already skipped.

The four glow tests become one that pins the outcome instead of the mechanism —
every box glyph in a composed frame, nested panels and dialogs included, carries
the border's own colour and no other. Verified against the old pass: with it
back, that set holds sixty-five.

Platform.effects keeps its other two claims (the header blink, the braille
spinner); it just no longer mentions a pass that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:18:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 ad2b852018 Wordmark: a mark's file says how wide it is, and the folder is the ladder
The two marks sat beside the written pages in assets/ under names that spelled
their widths differently and only by accident — logo.ans and logo_53.ans, where
the first is 71 columns and says so nowhere. They move into assets/splash/ as
logo.71.ans and logo.53.ans: one file per canvas, each named for the canvas.

So the size ladder is now the folder listing. _variants() reads the widths out
of the names and walks them widest-first, which means a new size is a file
dropped in rather than a file dropped in *and* a tuple edited. The name only
orders the ladder — which mark fits is still settled by measuring the art, so a
name that overstates its canvas can't draw a torn mark. A test keeps the two
honest with each other: every mark is as wide as its name says.

The old 8.3 spelling goes with the rename. It was there for art editors that
predate long filenames, which manage a second dot fine, and a name that states
its width is worth more than a name DOS could have opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 00:38:53 -04:00
Jean-Pierre Martineau bbd7ab362c Updated splash screen (ANSI art, CP437) 2026-08-26 00:13:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 c6e6ee8a60 Chat: a contact the device forgot is offered back, not a wire error
Sending to a contact the firmware no longer holds came back as
`send failed: failed to send message to 'bob_caribou': {'error_code': 2,
'code_string': 'ERR_CODE_NOT_FOUND'}` — a wire constant in a status line, and
no hint of what to do about it.

It is reachable for a contact MeshTerm itself listed. The list a screen sees is
the union of the device's live table and the contacts MeshTerm remembers for
that device (core/contact_store), which exists so a RAM-only radio bridge still
shows the nodes you know. The store claims those stay messageable without
writing anything back — true for the bridge, false for a firmware companion,
which addresses a direct message by looking the recipient up in its *own* table
by a key prefix and refuses when nothing matches. So a contact the firmware has
dropped still lists, still opens a chat, and only the send finds out it is gone.

Now the rejection is its own class carrying the contact, and the chat offers the
one write that fixes it: "bob_caribou isn't in this device's contacts, so the
radio can't address it. Add it back and send?" — Cancel or Add & send, then the
same send runs again. Declining reports the refusal in words. The write is
offered rather than silent because it changes what the device stores, and a full
contact table refuses it, which is worth seeing. Retry (^R) takes the same path.

Adding a contact is new to the Device contract (the inverse of remove_contact,
ADD_UPDATE_CONTACT on the wire): the record carries the key, name, type and last
advertised position we already hold, with no learned route, so the first message
floods exactly as it would for a freshly-heard node. The name is clipped to the
32 *bytes* the firmware field holds, not 32 characters.

Every other rejection now reads as a sentence too — reject_reason maps the six
error codes to plain words, so a send, an admin command, or any _ok()'d command
says "the device's table is full" rather than printing its payload dict. The
simulator refuses a recipient it doesn't hold, exactly as hardware does, so the
whole offer is walkable under --mock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:43:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 5f4ad341c5 Splash: a short terminal thins the mark instead of clipping the box
The block was laid out at its full height and whatever overran the screen was
cut off the bottom — which is the box: the device list, the PIN prompt, the
thing the user opened the app to use. The mark kept every row it had.

So a terminal too short for all of it now gives rows back, cheapest first: the
blank line under the mark, which is breathing room and nothing else, and then
the top of the mark itself, up to nine rows of it. Nine is the globe standing
over the lettering — decoration, where the lettering is what says whose splash
this is. Cropping from the top means the lettering and its copyright hold their
place while the mark thins above them, rather than the whole block sliding.

The bound is the art's own height too, so a mark drawn without a globe is never
sheared into its letters, and past the bound the old clip still applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 02:26:17 -04:00
Jean-Pierre MartineauandClaude Opus 5 e0a681b293 Busy screens: stop the spinner starving the work it reports on
On the PicoCalc a repaint costs about 154ms, and these two loops waited 60ms
(the overlay) and 120ms (the splash) between them — rates hardcoded past
spinner_interval(), which every other animated wait in the app already sleeps
on. The animation asked for repaints faster than the console could finish them,
so it took the event loop from the device read it was covering for: the wait got
slower for being animated, and the spinner stuttered anyway. Both defer to the
platform now (0.5s there, 0.12s on the desktop).

Two calls were also blocking the loop outright, which stops the animation dead
however it is paced. Enumerating serial ports is a SetupAPI/sysfs walk — behind
the "Scanning for companion devices…" splash, and again on every poll of the
reconnect dialog — so both go to a thread. The UI itself cannot: prompt_toolkit
owns the loop for render and keys. Moving the blocking work off it is the same
fix from the other side.

Two tests: neither busy screen may carry a spin rate of its own, and the
spinner advances *during* an awaited read rather than only side of it.

Also drops the "· experimental" tag from the network-device row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 02:12:26 -04:00
Jean-Pierre Martineau fae342c2bd Updated splash screen (ANSI art, CP437) 2026-08-25 01:47:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 ed06492bd0 Splash: the wordmark states its own copyright, and blank is a space
The mark's art signs itself now, so no splash adds a line under it — the
picker, the smoke-test spinner, the PIN prompt, the failure notices, the
scan and the settings offer all drop the footnote they were passing, and
nothing draws the notice twice. The written About pages keep their {copyright}
colophon: that is a page of prose, not a screen with the mark on it.

Moebius spells an untouched cell as a NUL, which a DOS console draws blank and
Rich measures as nothing at all — so every one swallowed a column and slid the
rest of its row leftwards, eighteen of them on the worst line. They are spaces
in the file now, and the loader translates any that come back (with codepage
437's hard space) so re-exporting the art can't reintroduce the drift.

Those NULs were also the only reason git guessed the file was binary. With them
gone, .gitattributes has to say so outright, before core.autocrlf starts
rewriting CRLFs that are part of the canvas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 01:15:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 f870bff990 Node pickers: a repeater is still its own identity
The repeater-admin picker drew every name in the repeater violet, so a section of them
read as one node repeated — the one list in the app where telling two repeaters apart is
the whole point, and the only one that refused to.

Nothing regressed it; it was built that way. The row was `Text(glyph, style=type_style)`
with the name appended after, and a style on the Text *is* the row's base — Rich lays it
under every span, so the type colour that should have coloured one mark coloured the whole
line. The name never carried a style of its own to argue with it. The TX-optimize "measure
at" picker was the same row, copied.

So both build the row the way every other list of nodes does: the glyph takes its type
style as a span and the name takes `name_style(name, key)`, hued by the node's key like a
contact row, a watchlist row, a relay hop. That also puts the row back inside the selection
language — with no base style of its own, the cursor row's `cursor` white now reaches the
name span and folds it, which a type-styled base was quietly blocking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:41:56 -04:00
Jean-Pierre MartineauandClaude Opus 5 4e3208b1c2 Admin login: the repeater is not charged for the queue ahead of it
Remote administration still failed. My last two commits classified the failure correctly
and fixed the credential policy, but the login itself kept coming back as no reply from
repeaters that were up, traced, and holding the right password.

The wait I added was counted from the wrong moment. `budget` is the *node's* answer window,
but the clock started before `send_login_sync`, and almost none of what that call spends is
the node's: it first waits its turn on meshcore's mesh-request lock — which every telemetry
poll, status read and courier retry holds for a whole round trip — and only then blocks
until a MSG_SENT the library correlates by event type alone arrives. Whatever that came to
was subtracted from the repeater's window, so the fix read as "wait longer" and behaved as
"wait the same ten seconds, minus the queue". A send that outlasted the budget left the node
no window at all, and the log bears the shape out: both attempts to YUL-UpperSalaberry ended
at 9985 ms and 10015 ms — the budget exactly, spent from before the request was on the air.

So the answer is timed from the send, the way `run_trace` times its reply. And the route
sizing is a floor rather than a verdict: a login is an admin exchange out and back, not a
trace's single small packet, so a contact we hold a one-hop route for was being given a
narrower window (7.2 s) than the routeless contact beside it that floods (10 s). Knowing
where a node is must not make us give up on it sooner.

The rest is making the next failure readable, because this one was not. A no-reply has three
causes that the old line could not tell apart, so it now reports where the time went — the
send's own duration, the budget, and how long the node had — and quotes the frames it heard
but could not act on: a LOGIN_SUCCESS/LOGIN_FAILED naming some other node (a key prefix that
did not match, not a quiet node), and a companion ERROR, which is the one failure that never
reaches the mesh and which `send_login_sync` erases into the same bare `None`. Uncorrelated,
so it is evidence in the log and never a verdict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:18:04 -04:00
Jean-Pierre MartineauandClaude Opus 5 7633f5153d Admin login: hear the answer, even when the library has stopped listening
A repeater that is up, with the right password, was answering "no reply". My last commit
did not cause that — it renamed it. The failure was already there, mislabelled as a wrong
password (and clearing the credential on its way past, which is how it got reported in
the first place).

`send_login_sync` does not start listening until its own send returns, and that send
blocks until a MSG_SENT arrives — which the library correlates by nothing but its event
type. So a scheduled advert, a telemetry poll or the courier can consume ours and leave
the send sitting on its 15-second default, and every answer that lands during the stall
is dispatched to no listener and dropped. The login the repeater accepted is recorded as
silence. The mirror case is as bad: catching another command's MSG_SENT takes its
`suggested_timeout` with it, and a neighbour's second or two is nowhere near a multi-hop
repeater's round trip.

`run_trace` already documents this exact trap and already solves it, so the login now
does what a trace does: subscribe to LOGIN_SUCCESS *and* LOGIN_FAILED before transmitting,
and keep listening across the whole exchange. `send_login_sync` still transmits — it is
the supported path, and its own listener is harmless, the dispatcher delivers to every
matching subscription — but ours is armed first and outlives it. When it gives up early
we keep waiting for the rest of a budget sized to the route: a login walks out along the
contact's hops and back, which is the shape `trace_timeout` already describes, so the
wire carries twice the stored one-way route. A contact we hold no route for floods, and
takes the flood budget.

The classification falls out of that — the two real frames now reach us, so ACCEPTED and
REFUSED are read off the frame instead of inferred from a `None`. The outcome is logged
at debug (budget, verdict, elapsed) so the next "no reply" is diagnosable from the JSONL
rather than by argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:55:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 667c0321e4 Admin login: silence from a node is not a wrong password
Administering a repeater that happened to be down made MeshTerm forget its admin
password. The node had said nothing at all, and we took the silence as a denial.

`admin_login` returned a bool, and all five callers read `False` as *wrong password* and
dropped the credential. Underneath, they could not have done better: meshcore's
`send_login_sync` waits for LOGIN_SUCCESS and only LOGIN_SUCCESS, so a refusal — which
the firmware does send, as a LOGIN_FAILED frame the library parses and dispatches like
any other event — times out exactly like an unreachable node and comes back as the same
`None`. Our own `etype in (ERROR, LOGIN_FAILED)` branch could never fire.

So the device now watches for that frame itself, subscribed before the request goes out
because the refusal can land the moment the request does, and answers with a three-way
`LoginResult`: ACCEPTED, REFUSED (the node said no), NO_REPLY (nothing came back — which
includes a companion-side error, where the request never even left the radio). It is
truthy only when logged in, so `if not await device.admin_login(...)` still reads "we are
not in"; a caller that acts on the failure has to name which failure.

What that means for the stored password now lives in one place, `AdminStore.record`:
remember on accepted, forget on refused, leave it exactly as it is on silence. It was
five copies of the policy, and every copy was wrong the same way. Each surface still
phrases its own message — the sweep says "sweep again", the CLI says "re-run" — and a
no-reply now says so plainly ("No reply from X — it may be out of reach, asleep, or busy.
The saved password was kept") instead of accusing the operator of a typo. The trace
screen's forget-on-DeviceAuthenticationError goes with it: the credential is settled
where the outcome is known, and an exception there is only a message to show.

A source-level test walks the package's AST and holds the line: anything that calls
`admin_login` records the outcome through the store, and nothing reaches for `forget()`
to decide this on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 04:09:33 -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 9ff25b3709 Device config: a coupled setting must not re-send a stale sibling
Restoring a backup onto a freshly flashed companion put the identity and
the channels back, reported all eighteen operations applied, and left the
node advertising from latitude 0.0 on the factory frequency.

Latitude and longitude reach the firmware through one command, and so do
the four radio parameters, so changing one means re-sending its siblings
unchanged. Those siblings are read from the caller's snapshot — taken
once, before the first write. Applying adv_lat preserved the stale
longitude; applying adv_lon then preserved the stale *latitude*, undoing
the first write. Same shape for radio_freq followed by radio_sf. Nothing
errored: each op did exactly what it was told, with a picture of the
device that was two writes out of date.

So each coupled applier now records what it set. Any later sibling in the
same batch preserves the new value rather than the one that was true
before the batch began — whether the batch comes from config restore or
from the editor's staged changes, which had the same hole.

And the Device PIN row, found while writing the restore instructions: it
read 'device_pin' out of a snapshot that only ever held SELF_INFO, while
real firmware reports the code as 'ble_pin' in the device-query frame. It
could therefore only ever render "?" on hardware — writable, with no way
to see what you wrote, so a typo sat in NVS until the next pairing. The
snapshot now folds in that frame (SELF_INFO still wins where they
overlap, as probe_device already had it), the row reads the firmware's
own name while keeping the key its backups and CLI use, and the simulator
reports it the same way so --mock shows the same screen.

Verified on the reflashed device: the row reads 701307, and model and
firmware version arrive with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 03:30:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 337792d4d1 BLE: closing the client is not closing the link
Owning the meshcore client was necessary but not sufficient, and the
device said so: the same probe still went off the air.

ConnectionManager.disconnect closes its transport only if _is_connected,
a flag it sets *after* connection.connect() returns. The GATT auth error
is thrown from start_notify, long after bleak brought the link up and
well before the manager records anything — so the manager is certain
there is nothing to close while BLEConnection is still holding a live,
connected BleakClient. Calling mc.disconnect() and stopping there looks
exactly like a teardown and leaks the link anyway.

So _discard_meshcore now runs both halves: the graceful call, which stops
the dispatcher and cancels any reconnect task, then the transport closed
directly, which is what actually drops the link. BLEConnection.disconnect
re-checks client.is_connected, so the second close is a no-op whenever the
first one sufficed. That forced path already existed inside disconnect()
for the dispatcher's queue.join() deadlock; it is factored out as
_force_close_transport and shared, so the two teardowns cannot drift.

The tests grow a fake that models the manager's guard rather than counting
disconnect() calls, so they assert the link went down instead of asserting
a method was invoked — the distinction the first attempt failed on. A
test named for that mistake pins it; reverting to graceful-only fails
seven of the ten.

Verified on hardware, instrumented: the auth path now shows
is_connected True -> False inside the discard, the client ends at None,
and the peripheral keeps advertising. Full lifecycle clean in one session
with no power-cycle — PIN-less probe, PIN retry, bonded session with real
traffic, mid-session bond query, Unpair & quit, and a relaunch after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 02:03:17 -04:00
Jean-Pierre MartineauandClaude Opus 5 d3735c8f7b BLE: a connect that fails must put the link back down
MeshCore.create_ble builds its client, calls connect() on it, and hands
it back only on success. A connect that *raises* therefore left that
client — and the bleak link it had already opened — orphaned inside the
library with no reference we could close. Our _mc was never assigned, so
disconnect() was a no-op and the probe's _safe_disconnect closed nothing.

That is not a corner: a PIN-protected companion answers the unbonded
notify-subscribe with a GATT authentication error, and the picker's first
attempt is always PIN-less. So selecting an unbonded companion leaked the
link, Windows held it for the life of the process, and the peripheral —
still believing it had a peer — stopped advertising. The PIN dialog then
asked for a code it could no longer deliver: the retry paired at the OS
level (the bond really is created) but could not form a GATT session, and
the user was told their correct PIN belonged to something that "isn't a
MeshCore companion". Since Unpair & quit is what leaves you unbonded, the
whole thing read as unpairing having changed the PIN.

Assemble the two objects here instead. _connect_owned_ble keeps the
handle, so both failure shapes close it: a raise (closed, then re-raised)
and a None handshake (closed, then reported as "not a companion"). The
teardown is shielded — the other way in is a probe whose wait_for expired
and cancelled the handshake, where a plain await would be cancelled the
moment it suspended and abandon the very close it was called to perform.

The picker's accepted PIN now also rides on the context, so a mid-session
reconnect doesn't re-run the whole "needs a PIN" dance against a companion
we already authenticated to. Session-lifetime only; nothing reaches disk.

Tests stop naming a real companion: the addresses and pairing code are
stand-ins now, and the fakes model the class we construct rather than the
create_ble we no longer call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 01:32:42 -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 Martineau 1b6a5cc1e7 Packet viewer: a walked trace is not a direct shot
Emptying a trace's relay path — right, since those bytes were readings all
along — left the card contradicting itself: three legs on the links row,
"direct — no relays" two lines under it, and a note crediting the reading
to a sender nothing had relayed for.

An empty chain means different things by class. For every other frame it
means the sender was heard straight off the air. For a trace it means the
relays went unrecorded, which is not the same claim at all — the readings
are proof something forwarded it. So the row says how far the packet got,
which is the one thing those readings do establish, and the reception note
goes back to naming the last relay.
2026-08-22 14:27:15 -04:00
Jean-Pierre Martineau e86d6a705a Path composer: Esc already cancels, so the Cancel row goes
The row's whole implementation was `super().handle("escape")` — Esc, spelled
out as a cursor stop. It cost a keypress on every platform (End then Enter,
against one Esc), sat below the row that commits so End landed on the way
out rather than on the thing you came to do, and taught nothing: Esc leaves
every other screen in the app without being told to.

The action group now ends where the actions do — Use this path, then Auto
in target mode. Cancel stays where it earns its place: a dialog asking a
real question, where the choice is the point.
2026-08-22 14:08:22 -04:00
Jean-Pierre Martineau 71f60c0eeb Packet viewer: show a trace's links, and say whose signal the reading is
A trace reports on the mesh as it crosses it — every node that forwards
one records the SNR it heard its predecessor at — so an overheard trace
arrives carrying a reading per hop travelled. Those are now a `links` row,
in walk order and coloured by quality like every other reception figure,
which for a walk out and back through a repeater reads as one strong
outbound leg and two weaker ones home.

The card's standing caveat also stopped being true. "Reception describes
the last relay, not the origin" is right for a relayed frame and wrong for
one that crossed nothing — those are only listed at all as of this
branch — where the reading *is* the origin's, and worth saying so: a frame
heard straight off its sender is the best evidence a link ever gives.
2026-08-22 14:03:23 -04:00
Jean-Pierre Martineau c246fb8080 Persistence: a trace's link readings get a column, not a borrowed one
The readings a trace collects arrive where every other class keeps its
relay hashes, so `path` was exactly the wrong place to keep them — and
keeping them there fed the topology graph links nobody observed. They get
`observations.trace_snrs` instead, written and read like the crypto trio
and the addressing columns before them, so a replayed frame shows the same
links a live one does rather than a blank lane.

The 1606 rows already stored were migrated in place: 2454 readings
recovered, every one of them at the 1-byte width the wire uses, and every
fabricated path cleared. The pairs read as what they always were — one
trace heard twice, once a hop out (+13.25 dB) and once on the way home
(+13.25, -5.0, -4.25), the return legs weaker exactly as a longer walk
should be.
2026-08-22 14:00:48 -04:00
Jean-Pierre Martineau 6217a7dc58 Packets: the frames a neighbour sends were being thrown away
Two silent drops in the RX-log mapper, and between them a device sitting
beside this one could trace all day and never appear in the feed.

A frame that crossed no relays was discarded as having "no topology
content". It has the most there is: nothing stood between us, so we heard
the transmitter itself. Only an advert survived, and only because it
carries an origin key — every trace, message, request and ack from an
adjacent node went into the bin. Now a frame is kept unless the library
could not even give it a payload class, and the empty path says what it
means: zero hops crossed.

The other is a trace reading its own header wrong. Every class keeps
relay hashes in the path field; a trace keeps one signed SNR byte per hop
it has traversed. Read as hashes those bytes resolved to whichever nodes
shared their leading digits and entered the topology graph as links that
were never observed. The population says so plainly: across 1604 stored
traces, all 2221 path entries land inside the LoRa SNR band when read as
signed bytes, spanning -10.5 to +15.0 dB, where hashes would put five in
six outside it. So a trace now yields no hops, and its readings — which
are worth having — ride alongside as trace_snrs.

The 1604 rows already stored keep their bad paths; a one-shot cleanup
follows separately.
2026-08-22 13:57:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 c19711c463 Map: don't build a street name the frame is about to throw away
_compose will not place a label under its own min_zoom, and street names
carry one of 15. _draw_tile built them anyway, at every zoom: a named
road's label is the expensive half of it — the line clipped to the canvas
segment by segment, each surviving piece measured, the pieces sorted, and
feature_to_dot called once per vertex, which is the per-vertex function
call the geometry path beside it deliberately inlined away. A central
Montréal tile carries 270 of them.

Measured on the desktop against the committed renderer, one tile at
PicoCalc geometry: 16.8 ms of a 46 ms frame at z13, all of it discarded a
moment later. Below the gate a frame is now 27-52% cheaper across z9-z14,
which on the device is a third of a second off the two zooms the map
most often sits at — it opens on the region fit, and z13/z14 is where a
mesh-sized frame lands.

The zoom is the display zoom, not the tile's: an overzoomed view reads
its ground off a lower tile but is still zoomed in, and still wants its
names. The 15 is a constant now, read by the queue-time gate and the
place-time test both, so the two cannot drift.

Nothing in the picture moves. Rendered against the pre-change renderer at
z9-z18 on three geometries, coarse pass included: every frame
byte-identical, every Ghost raster identical (a label was never in the
raster — that is the overlay). The skipped candidates never reached the
max_labels budget either, since the placement loop continues past an
under-zoom label rather than counting it, and sorted() is stable, so what
does get placed is unchanged. Crossing the gate changes the viewport and
so the ground key, which means a zoom across it always redraws rather
than serving a frame short of its names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:58:58 -04:00
Jean-Pierre MartineauandClaude Opus 5 c79ade3fab Map: the rough first pass is off, behind a switch rather than a deletion
_COARSE_PREVIEW turns off the two-pass draw: every view now waits for the
whole picture, standing on the last frame's ground reprojected until it
lands, or on nothing where the view has run past that too.

None of the machinery goes. Both passes still draw, render_ground still
takes coarse=, and the abandonment point between them is still where a
held pan key gets off — what the switch decides is only whether a moved
view is offered the first pass, which is why it is read where the raster
is asked for and not inside the renderer. Turning it back on is one line.

The abandonment test needed to say what it means to survive the change:
it asserted no finished pass appeared in a list it read after the loop
closed, which quietly depended on the *next* view's draw not getting a
scheduler step in time. It reads the list where the abandoned key's draw
returns now, and asks only about that key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:09:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 b4db55961f Map: three optimisations that were undoing each other's work
The rough first pass and the prefetcher each state a rule about when they
must stand down, and each was reading that rule off the wrong thing.

The prefetcher's rule is that speculation is never the thing in the way:
no guessing while a tile is in flight or a raster is running or queued.
It was asked from inside _ensure_tiles, which runs before _ground — so
what it read was the *previous* paint's answer. On a settled map a find
keystroke moves nothing, so the view is still the one it is entitled to
guess from, and it started a tile decode in the very paint that queued
the frame the reader was waiting for: the ~110 ms in front of the next
frame the pacing was written to avoid. It is asked at the end of the
paint now, once _ground has said what this frame owes.

The rough pass's rule is that it is skipped where the ground is already
drawn, because a coarse picture of streets the reader can see takes
detail away. It decided that on _aligned, which also compares the find
query and the marker count — the *overlay*. Change one and the ground
underneath is the same ground, still drawn, still correct; but the
answer came back "not aligned", so every letter of a find query flattened
the streets to the rough pass and drew them back, once per keystroke.
That question is now its own — _ground_drawn, which asks about the view
alone — and _aligned goes back to being only about the picture.

And a guess must not push past a silence: a tile the source has just gone
quiet about is on a cooldown so an offline map isn't retrying it on a
loop, and the prefetcher was walking straight past that to re-ask it off
its own initiative. It leaves cooling tiles alone; when the cooldown
expires the tile is in the plan again, and if the reader has panned onto
it by then the fetch is theirs, made on their behalf, which is the one
the cooldown was always sized for.

Two constants went with them, both describing mechanisms that aren't
there: _PREFETCH_LEAD, which measured the lead in pan steps the plan
stopped working in, and _COARSE_ROADS, whose class list left out the two
classes (rail, and the unrecognised default) the priority test it was
supposed to mirror actually drops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 00:39:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 98e8e5f260 Map: fetch the ground the next move will want, while nobody is waiting
A tile is 1.4 s off the PicoCalc's Wi-Fi on a good day and a few hundred
milliseconds to decode the first time, so ground fetched only once it is
looked at arrives after it was wanted. It does not have to: whatever the
reader presses next, the ground it reveals comes from the ring of tiles
around the ones on screen, or from one of the two neighbouring zooms —
the only moves whose tiles are a different set entirely. That is a plan,
not a prediction, and what the reader just did orders it.

A run of pans in one direction is a heading, and the side of the ring it
points at is fetched first; once the run is long enough to be a heading
rather than a nudge, the corners flanking it come too, because a diagonal
is two keys and readers steer. Then the step out and the step in. Then the
rest of the ring, which with no heading at all is the whole of the plan,
nearest neighbours before corners. A zoom or a jump home clears the
heading: that is looking around, not travelling on.

Working in tiles rather than in pan steps matters at the zooms where one
keypress uncovers no new tile at all — the plan asks for the ground a move
reaches, not the ground one press lands on.

The pacing is the load-bearing part, because speculation must never be the
thing in the way. One fetch at a time; never while a visible tile is in
flight or a raster is running or queued; and not until a second of quiet
has passed since the last of that cleared — measured from when the screen
went quiet, not from when the view stopped moving, since a frame that took
a second to draw has not been looked at for a second. Without that wait a
raster landing between two keypresses of a pan reads as a pause, and the
tile decode it starts lands in front of the next frame: measured at ~110 ms
on the device, which is exactly what this must not cost.

What a prefetch leaves behind is a tile in the source's cache, on disk and
in its small resident memo. The screen keeps only what it draws.

Measured on the device, panning on after a pause, onto ground never
fetched (zoom 12, three runs):

  without: 0/1 tiles ready, view complete after 2297-2324 ms
  with:    1/1 tiles ready, view complete after   81-112 ms

and the pan-after-idle path is unchanged: 621 ms to ground without, 535 with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:56:08 -04:00
Jean-Pierre MartineauandClaude Opus 5 8b5c27909b Map: draw a rough frame first, so a moved view is never black for long
A coarse pan step is 30% of the view, so four keypresses leave nothing of
the last frame under the screen — there is no ground to reproject, and the
map went black. It then stayed black for a whole raster, which is most of
a second on the PicoCalc, and often for two: the raster in flight was
being drawn for a view the user had already left, and the one they were
actually looking at could not start until it finished.

So the raster is two passes now. The first draws the ground fills, the
watercourses and the through-roads (tertiary and up, derived from the road
styles so the two sets cannot drift) — no buildings, no back streets, no
text at all. It is a quarter of the work: 249 ms against 962 at zoom 13 on
the device, 120 against 529 at zoom 16. The second draws the finished
frame over it.

The gap between them is where a moving view gets off. Checking _wanted
there cuts the abandonable unit of work from a whole raster to the rough
one, so a pan being held is answered with real ground about four times as
often and the finished frame is drawn for where the user actually stopped.

Measured on the device, four quick pan steps from a settled map to the
first ground back on screen:

  zoom 13:  1984 ms -> 617 ms
  zoom 15:   970 ms -> 348 ms

The rough pass is skipped where a frame for this view is already on screen
and merely waiting on a tile: there a coarser picture would take away
detail the reader can already see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:19:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 d12873694e Map: magnify the stand-in ground by the dot, not by the glyph
Zooming in reprojected the last frame's ground a cell at a time, which
means a source cell that now covers a 2x2 block of cells was stamped into
all four of them whole. That is double vision: the same 2x4 dot pattern
side by side and one above the other, four times over at two steps. It
reads as a rendering fault rather than as the coarse preview it is, and
it puts the ground up to a cell away from where it really is.

A braille cell is 2x4 dots, so the honest mapping is fixed arithmetic:
each cell of the block shows its own quarter (or sixteenth) of the source,
enlarged. That is 256 patterns by n*n sub-positions and it never varies,
so _magnified() builds it once per factor and the paste reads it with a
single index — the same one lookup per cell the naive version cost, and
none of it on the paint path. _ghost_axis now carries the remainder of the
cell division alongside the quotient, which is the sub-position.

Measured against the real frame that lands a moment later, the stand-in's
dots now sit on the ground's dots 85% of the time at one step (was 83%)
and 64% at two (was 61%) — and at two steps its best fit moved to a zero
offset, from a dot column out.

Reduction is deliberately left alone: a view that zoomed out keeps the
whole source glyph in the one cell it shrank to, because dropping three
quarters of its dots would break every thin line into dashes exactly as
it gets harder to see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:54:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 f5d0fb3677 Map: silence about a tile is not the source saying it's empty
A tile fetch that gets no answer was being remembered as one that did.
BasemapSource is careful about the difference — a timeout is never written
to disk, precisely so the next pan can ask again — and both callers then
threw it away, filing every None as "fetched, empty/absent" and never
asking again for the life of the screen. The same latch sat one level up:
_resolve() marked itself done whether or not it had resolved anything, so
one failed TileJSON round-trip made `available` False, and _ensure_tiles
skipped every fetch, for the whole session.

That is a desktop no-op and a PicoCalc bug. The device brings its Wi-Fi up
some forty seconds into the boot, well after an app started alongside it
reaches the menu and warms the tile source — so the map answers "offline"
before the answer can be true and draws the entire session from whatever
the cache already held. JP, 2026-08-18: black tiles at the two highest
zooms, where one z14 tile is the whole screen and a hole is the picture.
VE2RAD_FH_50m's tile (14/4842/5861) was simply absent from the device
cache; nothing there had been written in ten days of using the map, and it
fetched in 1.4s the moment it was asked for.

So: `available` reports what is known and never itself goes to the network
(it is read on the paint path), a failed resolve stands for 30s rather than
for ever, `answered_empty` lets a caller tell the two Nones apart, and a
tile nobody answered about waits out a 20s cooldown instead of a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:28:34 -04:00
Jean-Pierre Martineau 64d285a986 Revert "Battery: a frame a second, bought where the tick won't give it"
This reverts commit 4b491bb04f33bc040298d5ec19d079b4349d1776.
2026-08-13 23:52:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 385654ab5f Battery: a frame a second, bought where the tick won't give it
The sweep read a frame per idle repaint, so it climbed at half speed on the
handheld — a platform that ticks every 2 s because a frame costs 7-11% of a
core there. Pacing it off the tick was solving the wrong problem: the gauge
should climb at a second a frame everywhere, and the repaints that takes are
worth buying, since a pack only sweeps while it is plugged in.

So the poller now buys them, and only while all three hold: the gauge is
actually sweeping (a pack sitting full on the charger is not — which is where
a forgotten handheld spends the night), the platform idles slower than a
frame, and a surface is listening. Everything else sleeps out the interval as
before. BATTERY_ANIM_S moves to the widget as the one cadence both read, and
Ui.invalidate gives a service its first channel to repaint live chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:30:39 -04:00
Jean-Pierre MartineauandClaude Opus 5 2731af12cb Battery: the sweep is the charging state, so it runs everywhere
The gauge says charging by sweeping its fill bottom-to-full, holding the
charge colour. That is the state itself, not decoration, so gating it on
Platform.effects left the PicoCalc with no way to say it at all — and,
worse, frozen on the sweep's empty frame. Only the low-battery blink is
decoration over a charge the cell already shows; that alone stays behind
`effects`, and `animate` now says so.

The sweep steps on the platform's own idle repaint interval rather than a
fixed second, so each repaint advances it one clean step instead of
aliasing across skipped frames: 1 s on the desktop, 2 s on the handheld.

A pack at 100% draws as not charging whichever way the flag reads — a full
cell resting full is the honest picture, and a charger left plugged in
shouldn't leave the gauge sweeping forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:16:09 -04:00
Jean-Pierre MartineauandClaude Opus 5 88b2d347dd Battery: a gauge that can't animate still says it's charging
The PicoCalc reads its charging flag straight off the handheld's own BMS
— the kernel's power_supply status, backed by the keyboard MCU's battery
register — so the state was known all along; the header simply never drew
it. Charging replaces the fill with a sweep frame, and a platform without
effects holds frame 0, so a charging pack drew an *empty* cell: the gauge
under-read the pack outright, exactly while it was filling.

Animation is now the caller's claim rather than something inferred from a
held frame. Where nothing animates the gauge draws its resting state — the
true fill, no blink — and says charging with an arrow beside it instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:04:31 -04:00
Jean-Pierre MartineauandClaude Opus 5 ec4836f63d Live feed: the top of the list is two stops, watching and reading
The newest row now carries a pin — the stop above row 0, which holds the
topmost position rather than a packet. Every arrival takes the highlight
with it, so the cursor stays on the traffic instead of being pushed down
into history by it, and it draws ^ rather than ❯: the mark points past
the top of the list, at the packets still to come, because that is what
it is attached to.

One ↓ off the pin does not move a row. It lands on that same newest
packet, now normally selected under a plain ❯, and from there the
highlight belongs to that packet and rides down as newer ones arrive. So
the pin follows the stream and a selection follows a packet, and the
cursor lane says which you are doing. ↑ from the newest packet re-pins,
Home jumps back to the pin from anywhere, and the F-lane's Top chip is
how the PicoCalc reaches it with no hint line to name it.

The pin is a real cursor stop, not a flag beside one, so every move
stays a clamp on the single index _cursor already was — which is what
makes ↓ off it land on the newest packet rather than the second. Opening
a packet drops the pin: Enter names *this* packet, and arrivals during a
long read would otherwise walk the cursor off it and land the closing
viewer somewhere the reader never chose.

^ is plain ASCII, so it needs no console-font glyph. It is deliberately
not ↑, which this screen already spends on the window's "↑ n more" and
would read here as "there is more above" — the one thing the top of a
feed can never mean; nor ▲, which is the repeater in the node-type set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:05:48 -04:00
Jean-Pierre MartineauandClaude Opus 5 3c714e4c84 Map picker: draw the frame the crosshair was in, not the one after it
The picker's crosshair is a per-frame marker: render_body appends it to
_markers, calls up, and takes it straight back out in a finally. But the
real ground raster runs off the paint path, and _draw_ground read
self._markers when the *thread* ran — after that restore. So every
finished basemap frame was drawn without the crosshair, and landing it
erased the pick: pan and the crosshair rides the ghost, stop and the
streets arrive over it.

Snapshot the whole scene — viewport, markers, find query — when the
raster is requested, and hand it to the draw. A frame filed under a key
has to be the scene that key stands for.

Two things fall out of it. The "same view, only tiles differ" shortcut
now compares everything but the tiles, since a frame drawn for a
different marker set is a picture missing a node, not one missing
streets. And _ensure_viewport splits out of render_body, so the picker
settles its view without rendering a whole crosshair-less frame first —
one less full raster on open, and the crosshair rides a resize too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:58:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 f22ffa5011 Chat: a sender label is a name drawn as a path line's chip
A name standing alone above its messages was a coloured word with nothing
to say it was a label — at a glance it read like something someone typed.
It now sits in a chip, and not a chip of its own invention: ui/widgets.
name_chip builds a one-hop PathLine and asks it for its line, so the fill,
the ink, the padding, the rounded cap and the closing point are whatever a
route's segments are wearing today and cannot drift from them. A sender
wears exactly what that node wears as a hop — hue in the fill under dark
ink, the keyless grey where no key places them, and our own messages
headed by the ★ on its neutral grey rather than by the word "you".

Only the group header. An @mention inside a body is part of what was said,
and framing it would put a chip in the middle of a sentence.

Degrading comes free with the borrowing: where the terminal can't draw
separators — the PicoCalc console among them — a path line is arrow text
and one hop is just the name in its hue, which is what a sender label was
before it was framed. The specimen card grows a chips row, which doubles
as a one-hop sample of the route language.

And the unknown sender takes node.unknown rather than muted: one hex apart
here, a whole slot apart on the console, and an unidentified sender is
content you can still act on, not chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:58:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 366719771f Chat: a sender label is a name inscribed in a chip
A name standing alone above its messages was a coloured word with nothing
to say it was a label — at a glance it read like something someone typed.
It now sits on a field: the node's own hue as ink on the neutral dark grey
pathline already fills our ★ chip with, closed at both ends by the rounded
caps a path line closes on, or by half blocks where the font has none.

Only the group header. An @mention inside a body is part of what was said,
and framing it would put a chip in the middle of a sentence.

The widget is ui/widgets.name_chip — the ink arrives as whatever style the
caller would have drawn the name in and is *added* to the fill as a Style,
because Rich silently drops "node.unknown on #3f3f46" and renders the name
bare. The console has no field to offer (its one dark grey is slot 8 and
backgrounds stop at 7), so the name draws unframed there, bound on
truecolor like the heat gradient. The specimen card grows a chips row so
both dresses are visible on the acceptance screen.

And the unknown sender takes node.unknown rather than muted: one hex apart
here, a whole slot apart on the console, and an unidentified sender is
content you can still act on, not chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:46:10 -04:00
Jean-Pierre MartineauandClaude Opus 5 ea9b7c1cb1 Main menu: Device config, Support MeshTerm
Two rows renamed, and the rename carried into the screens they open —
the config editor's table title and its staged-count header, the support
page's own title (it comes from the tool, so it followed on its own).

"Support this project" never said which project; the menu is MeshTerm's
and the section above it is already About MeshTerm, so the name is the
useful half of that phrase. "Device configuration" was the longest label
in the list and set the width of the whole name column: at 19 cells
instead of 23, every description now gets 51 and the widest row is 68.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:15:00 -04:00
Jean-Pierre MartineauandClaude Opus 5 9f5d337cae About MeshTerm: the page says something now
The three sections were placeholders holding a shape. This writes the
first one: what MeshTerm is (one screen for what the radio can tell you,
the five menu sections as a list, and the standing promise that nothing
transmits unasked), where it came from (June 2026, a few serial scripts,
grown by the recording; the mesh's scarce airtime is why it listens more
than it speaks; later the PicoCalc), and the licence — MIT, as the
package metadata declares, with the credit MeshCore and OpenStreetMap
are owed, the ODbL by name.

The layout is unchanged: title, standfirst, three sections, rule,
colophon. It just runs longer than a screenful now, which is what the
sticky headings and the section jumps were for.

Link targets also drop a leading www., so the OSM copyright URL reads as
the host it is rather than a subdomain nobody types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:55:41 -04:00