Commit Graph
331 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 01ca242c98 Live feed: the lane is about the packet, whatever the packet is about
The NODE lane suited the two classes that name a node and left every
other one reading a dash. On this mesh that is half the traffic: the
direct messages, requests, responses, returned paths, acks and traces
that relay past us naming no origin. So the lane asks each class its own
question instead, and the header says SUBJECT — the word that stays true
down the whole column.

An advert or a telemetry frame is still about its node. A channel text is
about its channel, named by the key whose MAC confirms the frame — never
by the fingerprint alone, which 256 buckets make no proof at all; a
channel we hold no key for reads as that fingerprint and says so. A
direct message, request, response, returned path or anonymous request is
about the two nodes it travels between, drawn sender → recipient on the
hop-sequence widget, so endpoints read exactly as route hops do: resolved
names, key-derived hues, our own node white the moment a frame is
addressed to us. Too wide, and the sender drops to its hash — the frame
is addressed, so the addressee is the half worth the cells. An ack is
about the message it answers, a trace about its tag. And an unsigned
channel post is filed under its channel rather than the bare word
"channel", which named nothing.

Half of that was unreadable before because nobody had opened the frame.
The companion hands every overheard packet to the library, which decodes
the body for exactly two classes — advert and channel text — and leaves
the rest as bytes. Those bytes are not opaque: MeshCore addresses a frame
in the first few of them, and core/frames.py now reads them, class by
class, against the layouts the protocol's own docs/payloads.md publishes.
The trace tag is the little-endian uint32 the firmware writes, so it
reads as the same number a trace reply reports; an ack's checksum keeps
wire order, the form the companion reports its own delivery acks in. Two
checks guard every decode, because a wrong slice yields not an error but
a plausible-looking hash: the frame's payload version must be v1 (the
header reserves two bits for a v2 that widens both hash and MAC), and the
body must be long enough for the layout its class promises. Either
failing yields nothing at all.

The feed opens seeded from history, so any of this that lived only in a
live event's raw payload would leave the opening screen blank — the same
problem the channel-text crypto trio had. Same answer: three columns
(dest/src/tag), written on capture and restored to the very keys the
event used, so a replayed frame and a fresh one read identically all the
way up. Older rows carry NULL until re-heard.

The viewer gained the matching rows: to, from, tag, acks, and a channel
for a datagram — whose body is not text, but whose MAC still names it.

Verified against the MeshCore protocol documentation and firmware source,
then corroborated on the air: every frame captured off the radio has a
body length that decomposes exactly into its documented layout, an
anonymous request's 51 bytes (1 + 32 + 2 + one AES block) included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:11:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 64ac7dc2ed Live feed: column headers over the lanes, the app's own cursor
Two bits of chrome the screen had been going without.

A column header, pinned above the rows. It needs none of the base
screen's sticky-header machinery: the feed windows inside the space the
chrome leaves, so the body never scrolls and the header cannot travel off
the top of it. Nothing here sorts — a feed is a stream and its one order
is the status line's to state — so no column carries the contact list's
sort triangle or its lit active-column style. These are signposts, not
controls.

Getting a label to sit over its values meant the row and the header
having to agree about the lanes, so both now measure them from one set of
constants. That turned up a real defect: the RSSI lane rendered 11 cells
when a packet carried a reading and 10 when it didn't, so rows were
ragged by one and the class-label threshold was a cell short — at exactly
its own width the row lost its last character. Both readings now
right-align their number in a shared field, their labels right-align over
the same field, and an empty lane pads to what it would have filled.

And the highlight is the app's own at last: ❯ over a brand row, as every
select list draws it, in place of the ▸/accent this one screen had
invented. The lanes keep their colours over the tint — those are the
content; the pointer is what says "this row".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 03:35:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 db68a17e23 Live feed: the route is Enter's job, not a lane's
Drop the via chain from the feed row. Whatever the fixed lanes left it
was a handful of cells, so a route never actually arrived — it arrived
elided to a stub that named one hop and hinted at the rest, which is the
least useful form a route can take. A route is a shape rather than a
lane, and the packet viewer already draws it properly one keypress away:
a wrapped path line over the route graph, with the room to say the whole
thing. So the feed says what arrived and how well it was heard; Enter
says how it got here.

What that buys is the whole row fitting a 72-column screen — 69 cells,
class label included. The label was being dropped on exactly the screens
where it mattered most, to make room for a route that never fitted
either; now nothing competes for those cells and the class reads in words
down to the width where it genuinely can't.

←→ stays: it just has less to do, firing only on a terminal too narrow
for the lanes themselves, and its footer atom appears only there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 03:18:57 -04:00
Jean-Pierre Martineau c2c10aef33 Revert "Live feed: one trailing lane, for whatever the row is about"
This reverts commit 5a6bb3c8f4bba295dc78c288963bd19a893d8802.
2026-07-28 03:14:47 -04:00
Jean-Pierre MartineauandClaude Opus 5 f2330e5372 Live feed: one trailing lane, for whatever the row is about
The dashes were the symptom; the fixed node column was the disease. Most
of what a real mesh carries is relayed flood traffic that names no origin
at all — on live traffic roughly five rows in six — so an 18-cell node
column sat empty down the whole screen while the route, the one thing
those frames do say, was crushed against the right edge. Worse, the cells
it held hostage were exactly the ones the class label needed, so at 72
columns the label was dropped and the row degraded to an icon beside a
column of dashes.

So the node column is gone. Time, class and reception stay fixed; after
them comes one lane holding whatever the row is actually about — a node
that named itself followed by its detail, or, when nobody did, the via
chain given the whole width. The lane still opens at a fixed column, so
names remain scannable down the screen; what changed is that a nameless
row spends those cells on its route instead of on a placeholder.

At 72 columns that buys back both things at once: the class label now
reads in words, and a flood's route gets more room than it has ever had.
A name is capped rather than padded, so a short one hands its spare cells
straight to the route behind it. Nothing stands in for a node that isn't
there — a message with no sender prefix now reads as its conversation
alone rather than the bare word "channel", which also stops a direct
message being mislabelled as one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 03:13:14 -04:00
Jean-Pierre MartineauandClaude Opus 5 3de4f8d9f9 Live feed: file a packet under what it is, and say it once
The class lane and the node lane were saying the same thing. `packet`
names only the event family a frame arrived in, so a relayed flood —
which carries no origin, and so has no node to name — fell through to a
stand-in that showed its parsed payload class. The row read
`📦 packet  channel text`: a generic bucket beside the actual class, laid
out as though they were two facts.

The viewer had already settled this — `class_chrome()` headlines its card
with `📻 CHANNEL TEXT`, not `PACKET`. That decision now lives in
`class_marks()`, which both surfaces read: the card shouts it, the feed's
lane takes it in plain case. So a raw frame files under 📻 channel text /
🎯 trace / 📩 direct message, and even on a terminal too narrow for the
label the icon alone finally says which class it is.

With the class named once, the node lane goes back to being about the
node — and an origin-less flood admits it with a dash instead of
borrowing the class a second time. ANON_REQ's gloss becomes `anon
request`, the spelling the standards already use, which also lets the
lane hold every class whole at 14 cells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 02:50:03 -04:00
Jean-Pierre MartineauandClaude Opus 5 8afa592e9a Route graph: a hop heard twice is drawn twice, and said so
Merging a repeated hop into one marker is right across paths and wrong
within one. A via chain that names the same hop twice closes a cycle, and
the left-to-right flow has nowhere to seat it: the balanced-x relaxation
iterates a cyclic edge set, never reaches its fixed point, and returns
wherever the sweep budget stopped. The loop's relays then land in one
column — markers piled a cell apart, labels colliding or dropped, the back
edge falling as a bare vertical — while the hops outside it are crushed
against the ends. Worse, the residue depends on set iteration order, so
the pile's column swung with the hash seed: the very frame-to-frame jump
the first-appearance node order exists to prevent.

render_path_graph grows allow_duplicate_nodes, which hands each revisit
its own marker (occurrence-qualified ids the caller's callbacks never see,
so both markers draw as the same node). The walk is acyclic again, spreads
evenly, and reads in the order it was heard. Relays shared *between* paths
still merge — that fan is what the widget is for.

Opt-in, because the two situations want opposite things. The Trophy case
scores a path we composed, so collapsing a revisit is deliberate there —
re-walking a node must not make the shape look bigger than the ground it
covered. An overheard chain has no such duty: nothing in it is ours to
inflate, and a repeat is evidence.

Evidence we cannot read, though — hops are addressed by a one-byte hash,
so a repeat is as likely two colliding nodes as a genuine loop. The packet
card and the Message paths dialog now say exactly that under the via row
and the graph, naming the repeated hops through the shared path widget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 00:33:58 -04:00
Jean-Pierre MartineauandClaude Opus 5 50a3322b84 Live feed: rows slide sideways; the viewer's via folds
Three surfaces, one thread — a route should be readable where it is,
not only where it fits.

The feed's highlighted row now scrolls horizontally under ←→ (the
app-wide hscroll convention: the ▸ pointer lane pinned, the shift
abandoned the moment the selection moves on, the footer atom surfaced
only while the row overflows). A selected row carries its relay chain
whole so the scroll has something to reveal; every other row keeps
eliding its middle hops, so both ends of the route stay in view.

The packet viewer's `via` row leaves `path_text` for THE path line, and
folds instead of running off: a PathLine handed to Rich now renders
itself as the wrapped shape at whatever cell width the layout works out,
so a value whose lane is decided by the *other* rows' labels no longer
has to compute that width twice. Hops break at their own boundaries and
hang under the value block.

And the viewer's `from` row leads with the shared node-type mark — ★ us,
▲ repeater, ■ room, ◉ sensor, ● node, ○ when neither the packet nor the
contacts can type it — so a sender reads as the same kind of thing here
as it does on the map, in the contact list, and in the route graph three
rows down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 00:16:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 ebbaa5f825 Message paths: the route takes the row, its facts step under
An arrival was one line — time, SNR, then "via" and the route, all competing for
the same 72 cells, with the route (the only part that varies, and the only part
the graph above draws) last in line and first to be scrolled away.

Split in two. The route gets a lane of its own, introduced by nothing: on a line
that holds nothing else, "via" tells the reader what they can already see. Under
it hangs the frame's own reception facts — time heard, SNR, which resend — muted
and stepped in, so a pair reads as one arrival. The picked line is the route: it
is what one arrival differs from another by, so it is what ←→ scrolls and what
the ❯ points at, and the facts beneath hold their lane while it slides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:55:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 c537fa971e Path lines: the cursor is a hop; message paths read as names
The composer's insertion point was a reverse-video arrow living between two
hops, which forced the whole preview back to plain arrows — chips fuse the seam
it needed to sit in — and made a wrapped route carry the cursor by hand, riding
a line's trailing cue when the break landed on it.

An insertion point is a position in the route, so it is now a hop of its own: a
`+` slot in the brand accent (the same colour the row cursor below it wears),
spliced in by `path_line(cursor=…)`. It measures, wraps, and colours like every
other hop, so the preview keeps whichever mode the terminal earned and can never
strand its cursor on a fold. `cursor_arrow` goes with it, out of both `PathLine`
and `path_text`.

Message paths: the arrival rows draw through the path line too — chips in each
node's hue, names alone, no hash repeated after one. The hash bytes are the
graph's job one row up, and the shared node hue ties the two together without
spelling the hex twice. The graph loses the blank line above it; its canvas
already opens with air over the topmost lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:16:47 -04:00
Jean-Pierre MartineauandClaude Opus 5 fc9a459eda Path lines: notch every chip's left edge; merge duplicate hops
The gap between chips was one-sided: the previous chip tapered out to a
point onto the page, but the next one started on a flat edge. Both faces
now angle the way the path flows — the seam is two cells, a taper out and
the same point notched back inward out of the next chip's fill, with a
sliver of bare page between them. It is the notch a wrapped line already
opened on, so a fold now looks exactly like the seam it interrupted.

The composer's suggestion list could show one node twice. The evidence
graph keeps a hop hash it can't pin down as its own vertex — a 1-byte "3d"
matching two contacts stays "3d", apart from the "3d63c6429436" its wider
sightings landed on — and it is right not to guess; but once the name
resolver names them, they are one row drawn twice. They now fold onto the
longer id with their readings pooled, so the row's sample count and median
SNR describe the whole node. Merging takes both a shared name and a shared
prefix, so two unnamed hashes never fold and neither do two same-named
nodes on unrelated keys.

Folding the stub away must not fold its links away with it, so a step off
the merged node gathers from every id it reads as — otherwise merging would
quietly cost the composer whatever was only ever heard under the stub. Name
lookup and that alias walk are both memoized: they now run over the graph
on the way to each repaint, and neither the topology nor the resolver moves
while a composer is open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:28:18 -04:00
Jean-Pierre MartineauandClaude Opus 5 2c2f13ce41 Path lines: gapped chips, both ends automatic, routes name nodes
The trace surfaces' route lanes were spending their width on hex nobody
reads there: every name carried the hash it is addressed by, and the wire
spec one lane below already spells that out verbatim, at the width it goes
on the air. The route lane, the planned-route preview and the composer's
live preview now name nodes and nothing else; an unnamed hop still reads as
its hash, since that is the only identity it has.

The composer's preview becomes that same lane rather than its own dialect —
pathline chips, hop-boundary wrapping, our two ends the bare star.

Both stars fade. Setting out from us and landing back on us are fixtures of
every such route, no more composed (or removable) than a mirrored return
leg, so they wear the automatic grey — dark slate as a chip, never the loud
you white. Finding the turn a wrapped route folds at now reads the faded
*tail* rather than the first faded hop, which the leading star had become.

Chips are separated, not interlocked: every seam draws its point with no
background, so the page shows through the taper and a route reads as a row
of blocks the way oh-my-posh spaces its segments. Two neighbours landing on
one fill can no longer fuse, without that being a special case.

Finally, two places that had the right information and didn't use it: the
composer's suggestion list fell back to bare hex for a short hop matching
several contacts (the topology won't guess, so it names nothing), and the
not-a-trail confirm printed whole 12-hex ids. Both now go through the
owning screen's own resolver and its chosen path-hash width, so a node
reads the same in the dialog as in the window behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:05:43 -04:00
Jean-Pierre MartineauandClaude Opus 5 edbd7cdcfa Path lines: step a wrapped line in past its hanging indent
A folded route hung flush under the value column, so a two-line route and
two stacked values looked alike at a glance. Continuations now step in
WRAP_OFFSET columns past the indent; the packer charges for the step, so
every line still fits its width. The continuation marks — the trailing
arrow, the notched chip edge — still say it in detail; the step says it
from the block's shape alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:33:09 -04:00
Jean-Pierre MartineauandClaude Opus 5 141e3c9e51 Path lines: a star for us, a clean notch, rounded outer ends
bare_self now stands our node on the app-wide ★ rather than stripping it
to nothing, so both ends of a route are marked, not merely implied. The
star we set out from wears the you white; the one we land back on fades,
because coming home closes every route automatically — nobody composed
that leg, same reasoning that dims a mirrored return. A lone faded tail
no longer counts as the route's turn, so a walked boomerang still folds
at its own middle.

The wrapped line's opening cap carried the previous chip's colour into
the new line. It now draws the point in the line's own fill under reverse
video, which paints the notch in the terminal's own background — the only
way to name the page's colour without knowing it — so the carry-on mark
holds no trace of the chip above.

And the extended powerline block finally earns its detection: where the
font is a full Nerd Font patch (powerline_full), a path's two outer ends
round off into a lozenge. Core-only terminals square the opening and
point the close exactly as before, so nobody sees tofu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:25:33 -04:00
Jean-Pierre MartineauandClaude Opus 5 c393aabc52 Path lines: the point always faces the way the path flows
A wrapped line was opening on U+E0B2, which points backwards — it read as
an arrow going the wrong way, not as a carry-on. It now reopens the seam
the break interrupted: the same right-facing point, arriving out of the
previous line's last fill into this line's first chip. U+E0B0 is once
again the only glyph the widget draws; what varies is whether the point
lands on a field (a seam) or on the page (a closing edge), which is also
the same-fill gap rule, now factored into _seam().

A bare hop keeps the one-cell padding every chip wears — it has no words
between them, not no chip — so our end of a route reads as a stub of the
you white rather than as nothing at all.

Explore's candidate paths go bare too: every candidate starts from us, so
the word was the same on every row and the cells are the candidates'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:20:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 c7ad0c4ad3 Path lines: open the seam where two chips share a fill
An interlocked seam is the point in the previous chip's fill drawn on the
next one's — invisible when the two fills match, so a hue collision, a run
of dimmed hops, or two keyless greys read as one fused block instead of
two nodes. Those seams now draw over the page instead, leaving the sliver
of background that says where one chip ends and the next begins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:06:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 7c0c2c5a07 Path lines: bare us endpoints, female point opening a wrapped line
A hop with no label now draws as its seam alone — the arrow, no chip, no
word. The trace screens' route lane uses it (bare_self) for both ends of
every walk: a trace leaves us and comes back to us, so naming ourselves
twice only pushed the hops that *are* news out of the lane. Arrow mode
drops the padding the vacated label sat in ("→ 3d → f2 →"); chip mode
lets the seam point into the page.

Wrapped chip lines now open on the female point (U+E0B2, core set) from
the second line down, so a continuation reads as one — only the very
first chip of a path keeps its square left edge. The packer pays for that
cell, so lines still fit their column.

Also carries the round already sitting in the tree: the wrap evens hops
across the fewest lines it can take and folds at the route's turn, the
trace/node-detail/select surfaces route their rows through the widget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:04:49 -04:00
Jean-Pierre MartineauandClaude Fable 5 e33f30d6f8 Path composer: hop-boundary preview wrap, cursor riding its seam
The pinned preview breaks between hops under a 2-column hanging indent
instead of Rich-folding mid-name at column zero; the insertion cursor
stays visible on whichever line owns its seam, including a seam that is
the break itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:31:06 -04:00
Jean-Pierre MartineauandClaude Fable 5 22f0eb54dd TX optimize: route lane through the path-line widget, hop-wrapped header
The measured-route lane joins the app-wide colour rule — each name in its
key-derived hue (keys now passed in), us the white you, the mirrored
return dimmed — and wraps at hop boundaries under its value column. The
Route action row and the tuning lane wear the same hues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:31:06 -04:00
Jean-Pierre MartineauandClaude Fable 5 823c772c6e Node detail + live feed: route rows through the path-line widget
The Routes tab's rows are one PathLine end to end (chips where the
terminal draws them, the same characters where it doesn't), and a feed
row's via chain fits its lane by eliding middle hops behind a mark — the
origin and the last relay survive where the right edge used to eat them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:31:05 -04:00
Jean-Pierre MartineauandClaude Fable 5 719b1edce6 Trace screen: explore rows in key hues, hscroll pickers, hanging spec lane
Explore-paths candidate rows render through the path widget — every hop in
its own key hue, unnamed hops prefix-lit — instead of one brand-coloured
join; both the explore and probe-results pickers scroll their highlighted
row under arrows; and the path lane hangs under its value column like the
route above it instead of folding a long spec back to column zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:31:05 -04:00
Jean-Pierre MartineauandClaude Fable 5 3b1718fa4d Paths: arrow the TX sweep title, drop dead path_str, hscroll the trophy browser
The sweep table title used ASCII ->, TraceResult.path_str was an unused
ASCII-arrow renderer, and the trophy browser truncated long walks at the
fold — SelectScreen's own hscroll now slides the highlighted row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:31:05 -04:00
Jean-Pierre MartineauandClaude Fable 5 2c04fb5b96 Path line: path_text-parity factory, cursor-aware wrap, plain pin for tests
path_line() builds a PathLine from path_text's exact vocabulary (verified
character-for-character in tests), wrapped() carries the composer's
insertion cursor — riding the trailing arrow when its seam is the line
break — and conftest pins MESHTERM_POWERLINE=0 so screen assertions never
depend on which terminal runs the suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:30:50 -04:00
Jean-Pierre MartineauandClaude Fable 5 13dd79018d Path line: flexible hop-sequence widget, powerline chips or arrows
PathLine renders PathHop sequences three ways (full line, middle-elided to
a width, hop-boundary wrap under a hanging indent) in two looks: the
app-wide muted-arrow presentation, or interlocking powerline chips filled
with each node's key hue when the terminal can draw U+E0B0 (auto mode asks
termfont). Same words in both modes; call sites migrate separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:27:20 -04:00
Jean-Pierre MartineauandClaude Fable 5 8992e413de Term font: detect powerline support from a recommended-font list
MESHTERM_POWERLINE override, then the configured face (Windows Terminal
profile, VS Code settings, genuine conhost via Win32) matched against the
recommended fonts and their Nerd Font alias spellings, then renderer-backed
terminals that draw the triangles themselves; ssh and strangers stay an
honest unknown. Plus an installed-font scan for a future nudge screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:27:20 -04:00
Jean-Pierre MartineauandClaude Opus 5 8fdd7572bd Message paths: hold the fan's shape still across the selection
The dialog ranked layers by selection (priority 3 for the picked path, 2 for the
rest), so every ↑↓ re-elected the spine and re-laid the whole graph out. Rank by
first-heard order instead and move `emphasis`, the way the node detail's Routes
tab already does — the geometry is fixed by evidence, only the white lane moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:12:46 -04:00
Jean-Pierre MartineauandClaude Fable 5 18b1e034fd Node detail: light route labels through coalesced hop ids
The Routes tab mutes every label off the selected route, testing
membership against the route's raw draw hops. But the path graph folds a
short hop into the wide id it can only be among the drawn routes (a
1-byte trace hop beside the same relay's full contact id), so a route
recorded at the short width greyed the very marker its white line rides
through — TSFCT SUTTON-680M's bf→8e→3d route muted YUL-Cartierville.
Decide membership in the graph's own id space instead, by running the
layers through the same coalesce the widget applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:26:39 -04:00
Jean-Pierre MartineauandClaude Fable 5 ceab414ca9 Path graph: bypass a skipped relay through a virtual waypoint
A route that skips a relay another route rides (the A-B-C-D spine
beside the shorter A-C-D) drew its skip edge as a level run straight
through the skipped marker's cell — and under emphasis the subset's
white repainted the spine's own run, so the skipped relay read as
selected (Furthur -> YUL-Cartierville lighting C14903).

_bypass_vias now bends any edge that would run level through a marker
strictly between its ends — by construction a node that edge skips —
around it, through an unmarked via point in the skipped node's own
column on the innermost genuinely free lane: no seated marker, no other
route's level run through that cell, and a lane outside the band only
while _band_rows still fits max_rows (the same budget the detour fold
answers to). No room anywhere keeps the honest level pass-over. _route
threads the vias with its usual level-or-shift grammar, so the bypass
eases out, sits level dead over the marker it clears, and eases back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:07:50 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 5ff8eae74b Node detail: fix tab-strip corners, dim inactive tabs, one rule colour
- Swap the active tab's bottom corners: the flat rule turning up into
  the tab's wall is west+north (╯), and turning back down out of it is
  north+east (╰) — had these backwards.
- The left margin belongs to the tab boxes, not the bottom rule: the
  rule is one continuous line, so it now runs the full width including
  through the margin instead of being indented with the tabs.
- Inactive tab outlines move from "muted" to the dimmer "faint" shade.
- The whole bottom rule is now a single colour (accent, the active
  tab's) rather than mixing accent/faint segments — it's one line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:02:52 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 d1c2df61c9 Node detail: flatten unselected tabs into a continuous underline
Only the active tab now breaks the bottom rule (up, across open, back
down); inactive tabs sit flush against it with no corners or seams of
their own, so they read as behind/in-the-dark rather than each having
their own raised box. Top border and its z-order layering (active tab
always wins both corners) are unchanged. Adds a 2-column left margin
when the width has slack for it.

Tab switching moves off the shared Left/Right handler onto Tab/Shift+Tab
only, freeing the arrow keys for other use on this screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 13:53:02 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 76125f7402 Node detail: box every tab, layer corners by z-order
All tabs now render as full boxes at all times (muted by default,
accent when active) instead of only the active one — so every tab's
width is fixed regardless of selection, and labels never shift
column when the active tab changes. Tabs share a single vertical at
each seam; the shared corner "opens" only at the strip's leading
edge and at the active tab's own left edge, "closing" (ceding to the
tab on its left) everywhere else, so the active tab reads as lifted
in front of its neighbours.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 13:33:35 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 a0f91f3b9c Node detail: box the active tab in the tab strip
Since the strip already spent a blank line above and its own line,
box the active tab in accent-coloured rounded corners with its
bottom edge opening into a full-width rule, instead of a plain
── Label ── heading, now that a third line is on the table anyway.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 13:13:14 -04:00
Jean-Pierre MartineauandClaude Fable 5 b7daa6cb0d Path graph: nest detours on their own lane, fold only when rows are tight
The detour fold ran unconditionally, so a route heard as a sibling plus
an inserted relay always dropped that relay onto the sibling's lane —
and the sibling's straight run drew right over a marker it never visits
(the real YUL-Poly fan collapsed to one flat line through three routes,
with label muting as the only cue). Now the whole fan is measured
against the row budget before the shape is committed: while the rows
allow, a detour nests — its relay takes the lane just outside its
sibling's, on the same flank (a depth floor plus a side tie in the
pack/balance passes), so the sibling visibly skips it and the detour
reads as the wider arc it is. Only a viewport too short for the extra
lane folds detours onto the sibling's lane, weakest first, re-measuring
after each — the last resort, never the default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 11:43:09 -04:00
Jean-Pierre MartineauandClaude Fable 5 bf992979ef Revert the share card QR experiments — back to half-blocks
Braille (one dot per module) would not scan, and the quadrant mosaic
reads worse on screen than the plain half-block form. The original
qr_text render scans and stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:53:16 -04:00
Jean-Pierre MartineauandClaude Fable 5 a00fbe5639 Share card: quadrant-mosaic QR — braille would not scan
One braille dot per module drew smallest but starves the scanner of
ink (each dot carries its white surround), and phones would not read
it. Quadrant mosaics keep every dark module a solid quarter-cell —
the same ink geometry that made the half-block form scannable — at
half its width: 27x27 cells for a contact URL instead of 53x27.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:48:10 -04:00
Jean-Pierre MartineauandClaude Fable 5 f6306170ed Share card: render the QR in braille, one dot per module
A braille cell packs a 2x4 block of modules, so the code draws at half
the width and a quarter of the height of the half-block form (27x14 vs
53x27 for a contact URL) — small enough for the 40-column PicoCalc
panel. Simulated-camera decode tests pass with dot fills down to 50%,
since a phone's blur merges the dots into blobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:37:55 -04:00
Jean-Pierre MartineauandClaude Fable 5 25527356a4 Node detail: Share contact card on the Info tab
The advert menu's own-node contact card generalises into
show_contact_card (QR code over the raw meshcore://contact/add link,
in one floating view); the Info tab gains a 📱 Share contact row that
pops it for the page's node. Offered only when the full 32-byte key is
known — a prefix-only heard node has nothing scannable to encode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:28:33 -04:00
Jean-Pierre MartineauandClaude Fable 5 2d6e20ba2e Node detail: fold the vitals into an Info tab
The identity block was eating the stage's vertical budget on every tab.
The pinned chrome shrinks to the one-line identity header + tab strip;
the vitals rows (key, heard, packets, signal, where), the location
preview, and the Open full map / Time machine actions all move into a
new Info tab (replacing the Map tab — the preview now grows into
whatever rows the tab spares). The Routes tab spends the freed lines on
its graph and route list, and the strip costs a single line of air (the
graph's own top padding provides it).

Opening the full map from the page now also seeds the map's find filter
with the node's name, so it opens centred on the node *and* lit among
the rest — exactly as if the user had typed the find.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 09:59:15 -04:00
Jean-Pierre MartineauandClaude Fable 5 8193ac3823 Node detail: pin the stage, window the route list
The page no longer scrolls as one strip — which could carry the route
graph off the screen while the selection below was still driving it.
The identity block, tab strip, and stage are now pinned; the graph's
row ceiling is struck per render against the viewport (compressing its
lanes before anything would overflow), a faint rule closes the stage,
and the route list windows itself into the leftover lines with the
shared ↑/↓ n-more edge markers, whole hanging-wrapped rows at a time.

Enter on a route row now arms a trace on that route directly (the rows
wear the opens-further-prompts …); the dedicated Trace action survives
only as the auto-route stand-in when there is no evidence to list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 03:32:35 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 2f75bb597c Add Calculinux one-time setup script
Sequences the full Lyra/PicoCalc bring-up: opkg python packages, the
rtl8xxxu Wi-Fi boot-scan kick (installed verbatim from the working device),
the deploy user, the read-only SSH clone, the venv + editable install with
TMPDIR off the RAM tmpfs, the login PATH, and the console font. Idempotent;
secrets (deploy key, Wi-Fi PSK) are checked for or read from the environment,
never embedded. README documents both scripts and their run order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:18:13 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 f4abacc3b8 Pin shell scripts to LF endings
The Calculinux setup script runs on the Linux device; a CRLF checkout would
break its shebang and heredoc terminators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:17:12 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b11027ec3d Add Calculinux console-font setup script
Builds, applies, and persists the 512-glyph "meshterm" PSF: Terminus base
+ 256 braille + node/status marks and the list cursor in donor slots +
rounded panel corners aliased onto the square corners. Idempotent; runs
on-device as root against the stock ter-u12n font.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:17:12 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 0e746f2722 Node detail: open the full map centred on the node
The Map tab's inline preview centres on the node's own fix ("… centred here"),
but its "Open full map" action called open_map with only the markers — so the
full map fell back to the persisted global view (wherever the map was last
left) or a fit-to-all-nodes frame, landing somewhere other than the node the
preview had just shown.

open_map gains an optional focus=(lat, lon): given it, the map opens on that
point at the preview's zoom (min(13, max_zoom)) and — mirroring how
LocationPickScreen uses its transient `initial` — deliberately wires no
on_view_change, so a focused peek never overwrites the user's saved global
map_view, even as it is panned. The node detail passes the node's own
coordinates, the same spot the preview is centred on.

Tests drive the real open_map both ways: focused (centres on the node, leaves
the saved view untouched after a pan/zoom) and plain (still restores and
persists the global view).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 23:15:14 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 e4d0b5d964 Bridge: persist the node's contacts across restarts
pymc_core's ContactStore is memory-only, so every bridge restart forgot
every contact and DMs failed with ERR_CODE_NOT_FOUND until the peer was
heard advertising again. Snapshot the store to contacts.json after every
mutation and reload it at startup, alongside the identity the bridge
already persists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 22:18:26 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 1b8c84f2dd Bridge: accept modern MeshCore ACKs with extra bytes after the CRC
Newer MeshCore firmware sends discrete ACK payloads longer than 4 bytes
(4-byte CRC first, then extras). pymc_core's AckHandler requires exactly
4 bytes and silently drops them, so DMs sent through the SPI bridge never
show delivery confirmation. Patch process_discrete_ack at bridge startup
to take the leading 4 bytes as the CRC, matching pymc_core's own
path-bundled ACK parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 22:07:25 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 3c38b366cf Contacts: purge stale contacts by how long since last heard
Add a "🗑 Purge stale contacts…" action at the foot of the Contacts list. It
offers an age ladder (1 week → 1 year, plus a never-heard bucket), each rung
showing a live count of how many contacts it would sweep, so the impact is
visible before choosing; the pick is gated behind a typed "delete" confirm.

A purge deletes each contact from the device table (new Device.remove_contact,
over the meshcore remove_contact command) and forgets it from the cross-session
contact store so a firmware-less bridge doesn't merge it back — but only the
contact is dropped: each node's reception history in MeshTerm is untouched, and
our own pinned node is never a candidate. The contacts cache is invalidated and
the list rebuilt so swept contacts are gone from it.

ContactListScreen gains a `tail` param (symmetric to `lead`) for the action and
Back rows; age bucketing (stale_past / never_heard) is factored out pure and
tested, alongside MockDevice.remove_contact and the screen's tail composition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:22:34 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 94e81a1b82 Contacts: remember heard contacts across sessions
A firmware-less radio bridge holds its contact table only in RAM, so the Contacts
screen and the chat recipient list come up empty (or only as sparse as this
session's adverts) after the bridge restarts, even though you were messaging those
nodes last time. Record every contact MeshTerm reads, keyed by the device's own
public key, and union any the device is no longer reporting back into the list at
the one read chokepoint both lists share (DeviceState.contacts()).

No device write is needed: a direct message addresses a node by a prefix of its
public key, which the remembered contact carries, so a restored contact is
messageable without being written back onto the device. The union is inert where
it isn't needed — a firmware radio reports its whole table, so nothing is added,
no device-type check required — and recording never removes a contact on an empty
read, so a bridge that just restarted doesn't wipe what it knew.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:59:34 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 842a58a515 Settings: remember MeshTerm-changed settings across sessions
A firmware-less radio bridge holds its configuration only in RAM, so the node
name, radio parameters, and tuning set through MeshTerm are back to firmware
defaults when the bridge process restarts. Record every setting changed through
MeshTerm (editor, config CLI, and TOML restore all funnel through _apply_setting),
keyed by the device's own public key.

Unlike channels — silently replayed because filling an empty slot overwrites
nothing — a setting is a single canonical value, so replaying a stale one could
clobber a change made elsewhere. So the store never writes on its own: on connect
it detects drift between what it remembers and what the device reports, and a
startup offer lets the operator restore the saved values, adopt the device's
current ones, or stop remembering the device (the "don't ask again" exit). The
offer is quiet unless a connected device actually drifts, so a firmware radio you
don't manage through MeshTerm is never interrupted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:59:00 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 711621d69e Channels: remember MeshTerm-created channels across sessions
A firmware-less radio bridge holds its channels only in RAM, so every one is
lost when the bridge restarts — the Channels page came up empty each session
even after you added channels. MeshTerm read channels live from the device and
never remembered them.

Add a durable, per-device channel store (<config_dir>/channels.json, keyed by
the device's public key) that records every channel written through MeshTerm.
On connect, reconcile() replays any remembered channel the device isn't
reporting into a free slot — filling gaps, never overwriting an occupied slot.

The gate is provenance, not device type: only channels you wrote through
MeshTerm are recorded, so a firmware radio you never edit through MeshTerm keeps
an empty record and does a single identity probe on connect (untouched). A
bridge, where every channel is necessarily created through MeshTerm, gets its
whole set back. Every mutation (create/join/import/edit/reorder/clear) now flows
through a single write_channel() boundary that keeps the store faithful.

Verified end to end against the live SPI bridge: write a channel, clear it off
the device to mimic a restart, reconnect in a fresh session, and reconcile
restores it with the right key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:20:49 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 c3d712eff5 Channels: offer to add the standard Public channel when absent
An add-action restores MeshCore's built-in fixed-key Public channel on the
next free slot, shown only while no slot already carries the well-known
secret. Leads with the 🌐 globe to distinguish it from the name-derived
# Public channel row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 04:15:58 -04:00