Commit Graph
305 Commits
Author SHA1 Message Date
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
Jean-Pierre MartineauandClaude Opus 4.8 22f85e3d51 scripts: add the SPI-radio bridge helper
Expose an SPI-attached MeshCore radio (uConsole AIO, Waveshare HATs) to
MeshTerm over the companion protocol on local TCP. A single stdlib-only
file that is both a plain-text setup menu and the bridge process: it finds
the pymc_core runtime and re-execs the bridge under it, offers run-now /
install-as-a-service / uninstall, and can add a [profiles.bridge] block so
the endpoint shows up in MeshTerm's device picker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 03:32:31 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 4bce531465 Device picker: list configured TCP profiles in the enumerator
A [profiles.*] TCP entry in config.toml was reachable only via
`meshterm -p <alias>` — the interactive picker built its list from the
live scan plus TCP devices remembered in the DeviceStore, never from the
user's own config. So a hand-authored network companion stayed invisible
until after it had been connected to once.

Fold configured TCP profiles into the picker the same way remembered ones
are: _profile_tcp_devices() rebuilds each into a DiscoveredDevice carrying
its alias as the DEVICE-column name, deduped by stable_id so an endpoint
that's also scanned or remembered keeps its richer row. An unconfirmed
profile shows the muted "network companion" tag and turns "MeshCore
device" once its smoke test passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 03:32:31 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 c008fe56d2 Path graph: run the lane-change curve corner-free so it reads thin
The lane change kept a flat platform each side of the curve, where the nodes
sat. But a platform (slope 0) meets the climbing curve at a corner, and a
corner draws a heavy braille knee — a cell filled 3-4 dots — that reads thick,
the more so the shallower the curve's end tangent. Flattening the S last time
(BEND_K 0.5 -> 0.2) thinned the middle but sharpened that corner, trading a
thick centre for a thick knee: the platform model can't make both thin.

Drop the platforms: span the curve across the whole column gap (CURVE_SPAN
0.9 -> 1.0) so it runs continuously node to node with no corner to pile a
knee, and keep the end tangents level (BEND_K 0.2 -> 0.4) so each marker still
seats on a flat point without a platform run. The knees become gentle
rise-and-settle arcs and the stroke stays even and thin end to end. The honest
cost: in a very tight column a full-lane shift is steep right to the node, so
its marker sits on a slight slope rather than dead level — thinness bought with
a hair of level seating, the trade this screen wants.

Verified by rasterising to the dot grid: the curves are already 1-pixel-wide
(a morphological thinning pass removes zero dots from them), so the remaining
weight was never removable fat — it was the corner, now gone. What density is
left sits at endpoint convergence (many routes merging), which is not a lane
change and is left alone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:55:03 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 a6b5405fdc Path graph: flatten the lane-change curve so its stroke reads thin
The bezier lane change pinned both tangents flat against its platforms, which
made the roundest S — and its thickest. A braille cell is 2 dots wide by 4
tall, so a stroke steeper than one dot across per dot down must light two-plus
dots per cell to stay joined and reads heavy; a level-tangent S concentrates
the whole vertical drop into a steep middle, so one shift came out thin at its
eased ends and thick through a bold knee in the centre.

Straighten the S toward a constant-slope diagonal (BEND_K 0.5 -> 0.2) so the
drop spreads evenly and the stroke stays as thin as the geometry allows along
its whole length, and give the curve more horizontal room (CURVE_SPAN 0.8 ->
0.9) so its slope — and thus its stroke — stays shallow. Worst-case cell
density drops from 4 dots to 3 and the heavy knees become long gentle sweeps,
while the ends still ease off the platforms with no hard corner. Geometry
(node columns and lanes) is untouched — only the edge paths change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:22:39 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 2561313a16 Node detail: contract a bidirectional repeater knot to one super-node
Three or more repeaters that relay each other in every order are a
strongly-connected component the left-to-right flow graph can't order. The
bidir-pair merge collapsed all of them onto one column, where their markers and
labels piled up illegibly (labels rammed into the trunk line, the ordering
lost). A two-node pair draws fine as a vertical; three-plus does not.

Contract each such knot to a single super-node — the standard SCC-condensation
move, the one honest way to seat a cycle in a DAG layout. pathgraph gains
`bidir_clusters`, which finds the 3+ mutually-linked groups; node detail
rewrites every route's draw sequence so a knot's members become one synthetic
cluster stop, drawn as a single marker labelled by count and shared type
("3 repeaters", or "n nodes" when mixed). Only the graph geometry contracts —
each route row and its trace spec keep every member named in order, so the list
under the graph still carries the exact ordering the marker folds away, and a
trace still arms on the real path. The tidy two-node pair is left exactly as it
was.

Also completes _collapse's emphasis handling: routes made identical by the
contraction now fold to one line that lights when either of its rows is
selected, keeping the strongest priority for geometry and the emphasised copy's
colour for the highlight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:54:36 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 348de1c0af Path graph: hold the layout still when the highlight moves
Selecting a different route on the Node detail Routes tab relaid the whole fan:
priority drove both the geometry (which route is the spine, who owns a shared
relay, the lane packing) and the highlight, and the caller raised the selected
route's priority — so changing the pick seized the centre lane and shuffled
every marker.

Split the two. PathLayer gains an `emphasis` field that governs only the draw
colour and z-order (the highlighted route drawn on top, winning any shared
cell); `priority` alone fixes the geometry. Node detail now pins priority to a
fixed evidence order (route 0, the best-evidence route, is always the spine)
and varies only emphasis with the selection, so the picture stays put and just
repaints — the selected route white, the rest grey, off-route labels muted.
Emphasis wins a shared edge over a higher-priority spine, so a highlighted
alternative paints its whole run rather than dropping out where it overlaps.

The Message paths caller is untouched (emphasis defaults to 0, so its colour
still falls to priority exactly as before).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:38:13 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 a770f559cd Node detail: make the Back row leave, like Esc
Committing the Back action resolved a bare "back" token, but the opener's
action loop only breaks on CANCEL (or None) and has no "back" branch — so it
fell through and simply re-showed the page. Back appeared inert while Esc
worked. Resolve CANCEL for the Back row so it leaves the screen on the same
path Esc does, back to the contact list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:34:15 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8d8193b6b1 Node detail: a tabbed stage with a switchable route list
The page stacked the location preview and the route graph down one long
scroll, and the graph was inert — no way to pick which route it lit. Give
it a tabbed stage instead: a one-line strip (new widgets.tab_strip) pages
Map and Routes one full-height view at a time, switched with ←→ (Tab /
Shift+Tab backup; BackTab now dispatches as "shift_tab"). Tabs are
adaptive — only views with content show, a lone tab collapses to a lit
heading, and our own fix-less node falls back to a bare action list.

The Routes tab folds the old route/suggest info rows into a selectable
route list (best marked ★ best, the firmware's device route, then the
good observed alternatives), each spelled contact→relays→us through the
path widget with a hanging wrap. ↑↓ over the rows drives the graph: the
picked route lights white while the rest go grey and every node off it
fades its label to grey (a per-render label_rgb_of closure — the pathgraph
widget stays generic). A dedicated 🎯 Trace this route row opens the trace
flow armed on whichever route is selected (nothing transmits here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:01:10 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 57d413ba42 Topology: find routes with Yen's, not a flooding frontier
Opening the Node detail page for a well-connected contact could hang for
tens of seconds. The cost was topology._best_routes: a best-first walk over
partial paths to the target. On a densely-heard graph every wandering prefix
through the core is cheaper than the one weak link a distant node sits behind,
so the frontier floods the core — millions of dead-end prefixes — before it
ever reaches the target. Measured against the live DB, a single node took 28 s
(4.4M heap pushes), and scenarios() runs twice per page open.

Replace it with Yen's k-shortest-loopless-paths: find the shortest route once
by Dijkstra, then derive each next-shortest by rerouting around one edge of the
last. The work is polynomial in the graph size however dense the core. Verified
against the old search over the real DB: identical k-cheapest costs on all 148
nodes it could finish, and the worst node drops from 28 s to 22 ms (~1250x),
with the full 5 alternatives still found. Tie-ordering among equal-cost paths
may differ, which is moot — scenarios() re-scores every candidate by weakest
link anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 02:17:08 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b928ae19a3 Path graph: balance alternative flanks so fans draw tighter
Column-packing already collapsed a per-path lane down to the rows a
column actually needs, but it left each node on the *side* of the spine
its jog-order lane fell on. The jog order seats relay-sharing routes
adjacent, which piles column-sharing routes onto the same flank — so a
five-route fan through at most two nodes per column still drew five lanes
whenever one column stacked two above the spine and another stacked two
below, the band being as tall as the deeper flank plus the other.

Balance the flanks instead: 2-colour the alternatives (above/below) to
flatten the deeper flank, so a same-column pair splits across the spine
and the band shrinks to spine-plus-one-each-side. A hard ceiling keeps
balancing from ever drawing a taller band than the jog order would — a
column that genuinely stacks three nodes forces one flank two deep, and
padding the opposite flank to match it (prettier, but a row taller) is
refused. A graph the jog order already draws flat keeps its airy layout
rather than lopsiding a row shorter, since its one-sided alternative has
the same deepest flank and loses the balance tie-break.

Across the live topology this tightens 23 graphs (one by two lanes, e.g.
7bc505 5 -> 3) and leaves 78 unchanged, none taller. The best spine stays
straight where the flanks come out even and leans only gently otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 01:04:16 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 5c6052884a Path graph: pack lanes by column so sparse fans draw tight
A lane per route stacked the band as tall as the graph had routes, even
where the routes barely overlapped: TSFCT SUTTON-680M's five observed
routes drew five lanes deep though at no column did more than two nodes
coexist. A lane is a whole-width row, but two routes need distinct rows
only in the columns where they both carry a node — endpoint to endpoint
every route already shares the origin and us.

So after _assign_lanes seats the routes (best centred, sharing routes
adjacent), _compress_lanes squeezes them: each node keeps its side of the
best spine but slides to the innermost free row in its own column. A
column with one off-spine node claims one flanking row however many routes
fan past it; only a column where routes genuinely stack pays for deeper
rows. SUTTON now draws three lanes / ten rows instead of five / sixteen.

The best path's nodes stay on the spine, so it still runs straight through
the centred endpoints; the even/odd endpoint-centring is untouched (its
single-column shapes can't compress). Adds pack/stack tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 00:14:32 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8f6fa6b6ae Path graph: centre the endpoints on a row-quantized lane layout
The origin and destination rode the best path's discrete lane, which for
an even lane count is the upper of the two central lanes — so they, and
the spine, sat half a lane above centre. Anchor them at the band's
geometric centre (max_lane / 2.0) instead: identical to the best lane
for odd counts (spine stays dead straight), the midpoint of the two
central lanes for even counts.

Back that with a row-quantized vertical layout so the centring is exact.
Lanes now sit on integer text rows a fixed pitch apart (_LANE_PITCH_ROWS
= 3, two blank padding rows per gap) rather than a continuous dot step;
on an even count the two central lanes are opened one extra row apart, so
the endpoints land on the exact middle row between them instead of a
fractional row that snapped off centre. The band's top is anchored to a
cell-mid row so integer lane rows land dead-on with no per-lane snap
drift.

The public lane_step (dots) parameter becomes lane_pitch (rows); the
node-detail override is dropped so that page shares the 2-row pitch,
keeping only its larger max_rows for busy nodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:47:33 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 25922efb0b Path graph: keep a both-ways pair from jamming the x-balance
A pair walked in both directions is a 2-cycle in the edge set; the
longest-path relaxation loops through it and inflates every downstream
depth toward the node-count cap, dragging other relays hard against the
ends (a one-hop relay landed at x=0.09 instead of its natural 0.33).
Rank balanced_x over the graph with each such pair merged to one
representative — the acyclic flow the picture really is; the pair, drawn
as a single vertical, rightly shares an x anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:14:42 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8b64b39513 Path graph: keep edge-pinned relay labels from vanishing
A relay a balanced rank pins near a canvas edge centred its long label
off-canvas, placed nothing, and — unlike an endpoint — had no inward-slide
or side-placement fallback, so its marker went silently unlabelled. Give
every node the endpoint's inward-anchor clamp and marker_label fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:59:02 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b561ff2d27 Path graph: size the lane-change curve as a share of the column gap
Replace the slope-keyed _SHIFT_RATIO (dots of run per dot of drop) with
_CURVE_SPAN, the fraction of a lane change's column gap given to the eased
curve; the rest splits into the two flat platforms the nodes sit on. The
platform-to-curve proportion now holds constant however far a node drops or
however wide the columns fall, instead of the curve widening with the drop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:48:08 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 891487e928 Path graph: multilane-highway flow, bezier lane changes, no doubled edges
Every node now sits on a level platform: an edge leaves level, shifts
once, arrives level, so an off-lane relay reads as a flat-topped
trapezium instead of a pointed peak or valley. The shift itself is a
cubic-bezier S-curve with horizontal tangents at both ends, easing out
of one platform and settling into the next with no corner anywhere.

Every edge is keyed by its unordered node pair and drawn exactly once,
killing the doubled/offset lines that came from a pair walked in both
directions. That bidirectional pair is the sole edge drawn as a bare
straight segment (near-vertical when the layout stacks the markers) —
the one place an up-and-down line is the honest picture.

A direction arrow embeds in the trunk just before us, so the whole
graph reads as a directed run node → us.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:06:26 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 9cf5a9cc78 Node detail: draw routes inbound (node → us), keep only good ones
The route graph on the node page is built from observation data — every
route it knows was received, not sent — so it now reads left to right in
that inbound direction: the contact on the left, our star on the right.
Renames the section "Routes heard" and the caption "node → you, as heard",
dropping the "path to node" framing that over-claimed a direction observed
paths may not hold in reverse.

Only good alternatives are drawn now, not every chain ever heard: an
observed route earns a grey lane only when its evidence is fresh (its
stalest hop heard within ~4 evidence half-lives) and not a far-weaker
outlier (score within a quarter of the strongest observed). The
best-evidence route still rides white regardless, and a bare direct
neighbour keeps its zero-hop line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:22:28 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 7b5b581b4b Packet viewer: move snr immediately below heard
Group the two reception facts — when heard and signal quality — together
at the top of the card, above the identity block, rather than tucking snr
below from/type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:30:14 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 56f4346ad8 Contacts test: Enter now resolves the highlighted node
Track the Node detail wiring: Enter is no longer inert, it hands the
whole Contact record on (open_contacts opens its detail page), and each
row carries the Contact itself. Esc still leaves with CANCEL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:06:18 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 598b272483 Emoji width: carve wide-VS16 exceptions back out of the narrowing
A width-1 terminal isn't uniform: the ☀️ probe paints in one cell but
🛩️ (U+1F6E9 VS16) paints in two, so blanket-narrowing VS16 sequences
pulls the airplane's chat-row border a column short and smears the line.
Add a curated wide set (_DEFAULT_WIDE_VS16, MESHTERM_WIDE_EMOJI) — the
mirror of the lone-narrow allowlist — that forces those base codepoints
back to two in both width authorities, selector still skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 01:29:41 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 3ac43b7c35 Node graph: a layered redrawing, one marker per node, names throughout
The contact page's route graph fanned every path as a free lane from the two
endpoints and averaged shared relays together, so a well-connected node drew as
an acute tangle of near-overlapping lines. Worse, the same physical node could
appear twice: a 1-byte trace hop (27) standing beside its own wide contact id
(27d4396a2967) whenever the short hash was too ambiguous to widen — YUL-Poly
drew YUL-UpperSalaberry as two separate markers.

Redraw the route graph as a layered left-to-right graph (pathgraph.py):

* longest-path ranks put every node in the column of its hop distance, so each
  edge steps strictly forward and a relay shared by several routes is drawn once;
* an edge that skips a column routes through invisible waypoints, bending around
  the nodes between its ends instead of slicing through them;
* nodes are ordered to cut crossings (barycentre sweeps) then spread down the
  full height with their ys pulled toward their neighbours' (PAVA packing), which
  straightens each route into a lane and opens the branch angles up;
* first-appearance ordering keeps the layout identical on every repaint.

Kill the double-drawn node at two levels. build_topology now coalesces an
under-specified id into the one longer node it can only be (27 -> 27d4396a2967),
folding their links — fixing the atlas and the scenario ranker too, not just this
graph — while leaving a genuinely ambiguous stub (c5 -> two nodes) alone. The
widget then closes the same gap locally across its own layers, catching a hash
that is ambiguous against the whole contact list but unmistakable among these
paths (be -> be1d1c1dbc4b).

On the node detail page specifically: name every node, not just the two ends
(a relay reads by its contact name, unknown ones keep the honest hash byte), and
draw the target with its own map glyph (repeater, room, sensor) to match the
header. us stays on the left, the target on the right.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:47:23 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 62a2d76e8c Node detail: a full-screen page for each contact, reached by Enter
Pressing Enter on a contact now opens its whole story on one page instead of
a bare row: identity (name in its hue, key with the routing hash lit, type,
first/last heard, packet tally, reception SNR/RSSI), an inline location
preview centred on the node (a small static basemap via the new MiniMap), the
observed routes to it, and an action list (trace armed with the suggested best
path, open full map, time machine). Contacts owns the show/open/reshow loop;
topology.suggested answers "the best evidence-backed way to reach this node",
wired ahead of the firmware route in the trace arming precedence. Timemachine
and Trace grew the entry points the page opens.

Four fixes fell out of driving it on real hardware:

Slow open. Each located-node page built a fresh BasemapSource and resolved its
TileJSON -- a blocking HTTPS round-trip (~100ms, up to the 12s timeout on a bad
link) -- on every open. Hold one source for the session (ctx.basemap_source),
shared by the map, the picker, and the preview, and warm it behind the menu at
login, so the resolve is paid once, off the navigation path.

Black map. A companion advertising nonsense coordinates (Homestead reports
lat -97, lon -1042) flung the viewport off the world, leaving the map a screen
of empty/water fill that reads as solid black -- both from "open full map" and
from filtering to it and framing. The old guard only caught the 0/0 no-GPS
null island; the new geo.usable_fix() rejects any out-of-range fix too, applied
to the map markers and the location preview alike. Verified on-device: of 265
located contacts, only Homestead drops.

Path to node. The route/suggest rows render through THE path widget with a
1-byte hash in parentheses (a "name (3d)" tag), so they cross-read hop for hop
against the route graph's byte labels.

Route graph. Draw us on the left and the target on the right (us to target,
"how we reach it", left to right) via a local endpoint swap that leaves the
shared route_graph_style untouched for its other callers, and give the fan a
generous lane step and row budget so a well-connected node's candidate paths
spread into separate, legible tracks instead of a cramped tangle -- the height
stays adaptive, so a one- or two-route node still draws compact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:41:52 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 198111095a Atlas graph: flatten the fan arc so the rim leaves fill the width
The neighbour fan drew on a true circle, so only the due-east leaf reached
the far side; the top/bottom leaves curled back toward the focus (~0.31 of
the reach), wasting the right corners. Decouple a leaf's east reach from its
vertical spread — the reach now traces a flatter arc (_FAN_X_FLATTEN of the
fan angle) — so rim leaves land well east and the fan spreads across the
width. The setback still scales with fan size, so small near-due-east fans
don't ease back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:45:09 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 41c2d3168e Atlas graph: key-hue every marker, air the fan out west, always breadcrumb
Colour each node's marker (and its body-text glyph) by its own key-derived
hue instead of the node type — the glyph *shape* still carries the type — so
the graph reads by identity. No marker is whited out any more, not even the
selection or our own repeaters/nodes: the selection is shown by its white
label and the lit route. Our own node keeps the white `you` mark; the legend
becomes an honest shape key (role glyphs muted) now that colour means identity.

Anchor the focus at the far west with its name to the right and let the fan
spread across the whole width east of it — the graph aired out rather than
squeezed into the middle. The focus and the came-from node keep their icon on
the left with the name to its right, but every edge now attaches at the *right
end of the name* (not the icon) so a connecting line never crosses a label; the
came-from ducks in just west of the focus and lower.

Render the breadcrumb trail always, even at the root where it is just our own
node, so the header keeps a steady height rather than shifting up a row on the
first walk step.

The fan's promote/collapse ranking already folds SNR, sample count, and recency
together via Link.strength() — documented, not changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:17:34 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8054d064e8 Atlas: document that "N hops out" is the ring, not the walked trail length
The focus line's hop distance is the shortest observed path from us to the
node (a BFS over the evidence graph), which legitimately reads shorter than the
breadcrumb trail when the walk wandered or doubled back. Record why the two can
differ so it isn't mistaken for a miscount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:49:29 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 f113e6a784 Atlas graph: light the whole route to the selected link
Selecting a link only brightened that one hop's edge; the path that reaches it
read no differently from the rest of the fan. Draw the route to the selection —
its own edge plus the approach the walk took into the focus (came_from → focus)
— at top priority and full strength, shedding the evidence-age fade the other
edges keep, so it reads as one lit thread running west through the focus to the
selected neighbour east. The age stays legible in the row's age column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:46:51 -04:00