A final pass over the docstrings and comments: "the own X of Y" is now
"Y's own X" (84 places), a navigation entry is a screen on the stack, our
node is our node, a paint is a paint, the quoted UX labels are in code
font, and a list of punctuation glyphs is in code font.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The same rewrite as for the core package, for the screens, the dialogs,
the map, the charts, and the CLI renderers. The glossary words are used
for the screen: the user (not the reader), dialog (not popup), viewport,
list window, highlight, pointer, graph lane, marker, raster, result
screen, and key prefix (never hash for a truncated key).
The rewrite also corrects approximately 70 docstrings and comments that
no longer agreed with the code: rows that no longer exist (Back rows),
counts that changed, renamed constants, wrong cross-references, and
behaviour that changed. Each correction follows the code; no code
changed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pre-release architecture and QA pass, applied as one change because the moves
share files and only the whole set was gate-tested (2480 passed, ruff clean).
Three new modules pull logic down out of layers that should not own it:
core/channel_probe.py (ChannelSlot, read_channel_slots, probe_channel_slots)
so services/device_state.py no longer imports a screen; services/markers.py
(gather_markers, with MapMarker a core/models dataclass) so two screens stop
importing a tool; core/tracing.py (trace_timeout, path_hash_flags) so
core/connection.py no longer imports services. The routing-prefix width is
DeviceState.routing_prefix_bytes(), replacing five private cross-module imports.
One definition per thing: is_path_hash, the NodeResolver/NameKeyResolver
aliases, TILE_RETRY_SECONDS, haversine_km, marks.SELF_MARK, MENTION; nine
private names that crossed module boundaries got public ones and menus.__all__
lists what other modules import. A preference's default is written once
(ALERT_CAP, DONE_CAP, DEFAULT_VIEW_FRACTION read the registry), the stranded
PREFERENCES doc comment sits on its subject, and three docstrings stop naming
questionary, a library dropped long ago.
The command line gains --version (eager, exit 0, documented, tested) and drops
the -p short form on trace, trace-path and tx-optimize: the root lifts -p as
--profile before a leaf ever sees it, so the leaf's could never work, and a
derived test now checks every leaf against every root short option.
tx-optimize with a one-hop --path is a usage error, exit 2, because nothing
transmitted is not a device failure. Log handlers are closed before they are
cleared (197 unclosed-file warnings gone), chat's fire-and-forget tasks keep a
reference, coroutines use get_running_loop(), the never-awaited MapScreen._load
in a test is awaited, and the CLI sweep passes --no-ble to devices so a test
run stops turning on the Bluetooth radio three times (the suite is 40 % faster).
The about page lists every dependency, and the release skill bumps the README's
download names, which still said 0.2.8.
apply_ops stays in tools/: it and its helpers build report blocks and open two
screens, so moving it to core/ would invert core over ui. The widgets CLAUDE.md
names by their private spellings (_format_age, _NODE_GLYPHS) keep them until
CLAUDE.md is edited alongside.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.
Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.
What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.
The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.
Add this commit to `.git-blame-ignore-revs`, which follows immediately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`E501` across 69 lines, wrapped rather than exempted.
The prose ones — module docstrings in `menu`, `node_detail_screen`,
`pathline`, `pathgraph`, `walk_screen`, `trace_runner` and `test_about` — are
reflowed as paragraphs, so the block keeps its shape instead of gaining one
short orphan line. Nothing they say changed.
The code ones mostly gained a line break inside a call. Where a break would
have read worse than a name, the sub-expression got one instead: `reach` in
`topology`'s quality clamp, `plain_cells` in `pathline`, `numbering` in the
trace list, `listed` in `flash.py`'s refusal, and a small `shape`/`is_strip`
helper in two tests that were comparing or matching the same shape twice on one
line. Four docstring summaries lost a word they were not using.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mechanical tail of the ruff pass, all of it behaviour-preserving.
`E741`: 29 uses of the ambiguous `l`, which reads as a `1` in most fonts.
Twenty-four of them held a rendered line and are now `ln`; three held an MVT
`Layer` and are now `layer`; two were a map label sorted by rank, now `lab`.
Renaming only the flagged line and then re-running `F821` was the check — a
half-renamed comprehension leaves a name nothing defines, so the linter finds
what a careful read might not.
`UP031`: 15 `%`-format strings become `.format` calls. `UP007`: the `CellStyle`
and `LabelT` aliases become `X | Y`; both are annotation-only, so the runtime
value never mattered. `E702`: four semicolon-chained `await` lines in
`test_device_state` get a line each.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`B905` over 56 sites, and the answer differs by idiom rather than by file.
21 were the sliding window `zip(xs, xs[1:])` — ragged by exactly one, on
purpose, so `strict=` is the wrong question entirely. Those become
`itertools.pairwise`, which is what they always meant and needs no flag: the
walk's SNR stops, the heat ladder, the route chains in `topology`/`records`,
every polyline in `mapcanvas`, and the graph's edge sets in `pathgraph`.
The other 35 pair sequences that must line up, so they now say `strict=True`
and a mismatch raises instead of silently truncating the longer side. Running
them under that assertion is what sorted the two groups: the first pass put
`strict=True` everywhere and 208 tests failed, all of them a sliding window.
One goes the other way. `mvt._decode_feature` reads tags off a network tile,
where an odd-length list means the tile is malformed — and this decoder answers
that with a shrug everywhere else (`if not geom_ints: return None`, bounds-
checked key and value indices). It keeps `strict=False`, now with a comment
saying why, so a bad basemap tile still can't crash the map.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ruff check --fix` over the whole tree, ahead of putting the linter in CI:
1977 mechanical fixes, overwhelmingly `Optional[X]` → `X | None` (1186 in the
package alone) and the quoted annotations that `from __future__ import
annotations` already made unnecessary, plus import sorting and unused imports.
Nothing here changes behaviour — the suite is 1885 green before and after.
Four findings were real rather than stylistic and are fixed by hand: a dead
`cells` range in `MapCanvas`, a dead slot dict in the heat-ladder test, a
`ProgressScreen` task handle nobody read, and a tab loop binding a `label` it
never used. The two `F821`s were quoted forward references to names imported
inside their function bodies — now declared under `TYPE_CHECKING`, which keeps
`test_performance`'s local import (it guards the startup import graph) while
letting the linter resolve the name.
412 findings remain and are deliberately untouched: they need a decision about
the rule, not an edit to the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fan is one route read against its alternatives, and it only says which route
that is if everything not on it dims. The unused lines already drew grey, but
every marker on them kept its node-type colour and every label its name hue —
so the loudest things in the picture were the nodes the selection had just
ruled out (JP, 2026-09-04).
The widget owns the rule now: where one layer is emphasised over the rest,
`render_path_graph` wraps the caller's marker and label colour callbacks and
draws every node outside the emphasised path in one dark grey. Membership is
tested in the graph's own id space — after the prefix coalesce, the identical-
path collapse and the revisit split — so a route reaching a relay by a short
hash still lights the wide marker its hop folds into. The endpoints are on
every path and never dim; the glyphs keep their shapes, only the ink recedes;
and a picture with nothing emphasised (a lone path, or a fan of equals) fades
nothing at all, so the packet viewer and the Trophy case are untouched.
Node detail had been doing half of this by hand — labels only, its own copy of
the coalesce — and drops that wrapper for the shared rule. Message paths gets
the whole of it for the first time, on both platforms: the grey lands on the
console's dark-grey slot through the same quantizer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A platform's ceiling may change a rule's resolution, never the rule. P3
had picocalc colour node names by *type* instead of by key, so a name's
colour there meant something different than it does on the desktop — the
node's kind, not its identity. Both platforms now run one rule: the key's
first byte picks the hue. The console just spends fewer of them.
node_style gains the binding the heat scale already had. Regular keeps
the full 256-hue wheel; picocalc snaps the same hue to its sixth of it —
the six chromatic bright slots, ~43 first bytes each. It returns the
slot's own hex rather than a color(N), so every downsample on the way out
(Rich's, and the fold's quantizer) lands on that exact slot instead of
guessing a neighbour, and the callers that read an RGB back out of a
style string keep working.
The type marks needed the opposite treatment: their hues are fixed, not
derived, and a naive nearest-RGB match drained the repeater's violet to
grey — a repeater read as an unknown node. They move to the theme's
type.* entries so each platform picks its slot deliberately, and a
braille raster resolves the same entry through theme.mark_rgb, so the
canvas marker and its Rich-drawn twin can't drift.
With names off the type registry, nothing reads it: Platform.name_colour
and core/nodetypes.py go, along with the three reception-layer feeders.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first P3 (a84abf3) ran on the wrong model and its palette never
reached the TUI: MESH_THEME_16 was written in 256-colour cube indices
instead of the 16 VT slots, and the platform theme pick was wired into
the scripted-CLI console while the real rasterizer stayed hardcoded to
truecolor. Replace it end to end:
- theme._VT_SLOTS: the canonical slot->RGB design (conventional hue
families kept; slots 5/6 become the track/faint slates), MESH_THEME_16
rewritten against color(0..15) with the VT's bold-as-brightness and
dim-bank-background rules pinned by tests. /etc/vtrgb + a boot oneshot
install it (console-font script), OSC fallback when kbd is absent.
- platforms.on_platform: set_platform now re-runs registered bindings,
so theme, rasterizer and widgets swap implementations once per switch
and hot paths read module globals - no per-call get_platform().
- The render-boundary fold (theme.fold_text): one str.translate pass
(NFKD accent folds - the Terminus base has no accented Latin - plus
symbol stand-ins and the width-padded emoji map), an embedded-SGR
quantizer for canvas truecolor, and a ?-at-own-width catch-all that
makes the no-wide-glyph short-circuit true by construction. Applied at
the only two places every visible byte passes: render_to_ansi and
MapCanvas.to_ansi_lines.
- ui/fontset.py freezes the installed font's inventory (dumped live);
the gallery now asserts rendered picocalc ANSI carries no truecolor/
256-colour SGR and no character outside it - the parity gate the
first attempt faked by asserting a dataclass field.
- Font build: nine new 6x12 marks (ellipsis, warn, backspace, shift,
gear, refresh, clock, crosshair, padlock) drawn into donor slots, with
donor misses now fatal and a keeper canary (donating pi once erased
Cyrillic pe - shared slot). Table re-dumped on-device: identical to
fontset.py, 519 = 519.
- Type-based name colouring: registry moved to core/nodetypes (services
no longer import ui), fed from monitor observations, heard_nodes and
merge_contacts. Heat gradient quantizes to heat.* steps on picocalc.
- ui/marks.py: the P2-deferred constant relocation - widgets no longer
drags map_render/mapcanvas/pathgraph for tuples and aliases.
- meshterm specimen: the visual language on one card through the real
funnels - the on-device acceptance screen (JP's request).
- tools/chat: delivered/no-ack state uses the standard check/query
marks, not the banned emoji pair.
Verified on the Lyra: font+palette installed, specimen framebuffer
captures in .claude/plans/. 1259 tests green, 13 xfailed (P6 list).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The fan-lane route graph clipped every label to a fixed 12-cell budget, so
node names past twelve characters ellipsized even with room to spare. Cap the
label at the physical canvas width instead: a name is kept whole unless it is
wider than the whole canvas (nowhere for the cells to go), and the placement
pass — which already slides an endpoint's label inward or drops a crowded
relay's — decides where it lands. Message-paths endpoints now read in full.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both trace tools now feed the boards: on a successful trace the session
derives the walk's spec, route, and stats from the reply and offers it to
every discipline, and a walk that sets a record surfaces a ★ note under the
run's aggregates (the walk still records nothing extra — it's the same one
trace per transmission). Scoring is defensive: a hiccup never breaks a
trace.
Trace path can open armed on a given spec, so the trophy case's Trace this
path reopens a record's exact route ready to walk again. The shared route
graph is unchanged; its docstring just drops the pathfinder example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Message paths dialog's braille route graph becomes ui/pathgraph.py —
layered paths with per-layer colour and draw priority, caller-supplied
glyph/label callbacks — so the Pathfinder can draw best-ever/session/attempt
routes through the same widget. Endpoint labels lose their beside-the-marker
restriction: they place above/below like relay labels (with an edge-clamped
anchor so a name at the canvas margin still lands), falling back beside only
as a last resort.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>