Scrolled down the purge preview and walked back up, the cursor rode off
the top of the box. Two things let it, and either was enough.
A select list under a prompt reported its highlighted row a prompt's
height too far down. The row's line is read off the body as it is laid
out, which already counts the prompt, and the prompt's height was then
added on top. The frame kept that lower line in view, so walking up left
the real row above the window's top edge for as long as the walk lasted.
And at the bottom of any list with a pinned column header or section
heading, the window slides down under the pins so they cost stale lines
at its top rather than the last ones. Walking up, the highlight reaches
the window's top line before the scroll has to move, and the slide
dropped exactly that line. The slide now stops at the highlighted row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The curated narrow and wide sets are gone, with the probe that decided when
to apply them and the add-narrow-emoji skill that fed them. They lined up
the rows somebody had already looked at and none of the rows nobody had,
and a contact's name arrives off the air carrying whatever its owner typed.
In their place, one structural rule: a glyph built from several codepoints
(a selector sequence, a ZWJ sequence, a keycap, a flag) or a lone
text-default emoji or Regional Indicator is reserved two cells, the most a
single glyph draws. Pinned into two cells, a glyph the font draws in two
fills them and one it draws in one leaves a blank beside it, so the text
after any emoji starts in the same column on every row. Pinning alone could
only hold the lanes after it.
Two exceptions keep their one cell: Rich's single-cell ranges, which its
fast path measures without asking, and the nine text-default emoji the app
draws itself as marks. That list is closed by a test over the package's
source, so a new mark cannot slip into the reserve unnoticed.
Rich measures in three places and prompt_toolkit in a fourth, and all four
are patched from the same rule at once: Rich's segment splitter walks a
string until a whole-string width meets its cut, and a character measured
differently from the string around it is a cut that walk never meets. Every
multi-codepoint glyph is also merged into one fragment before layout, so a
mark followed by a selector is not folded into a one-cell slot.
Installed only by the full-screen app on a platform that draws emoji. The
command line keeps the stock tables, because nothing pins its output.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pin jumps over whatever part of a glyph's reservation the font did not
cover, and nothing wrote that cell. prompt_toolkit never writes the cell
after a wide glyph either, since it counts it as the glyph's own, so a
repaint that put an emoji where two letters had been left the second letter
standing beside it on a terminal that draws the emoji in one cell. On the
fast path the row is erased first, but only in the default colours, so the
same cell was a hole in a chip's fill.
Both pins now erase the reservation in the current colours before drawing
the glyph, with ECH, which blanks cells without moving the cursor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rows the fast renderer writes were already pinned. Anything
prompt_toolkit lays out itself — a floating dialog, and the backdrop it
repaints — still went out through its differential renderer, which steps a
cursor of its own by each glyph's measured width and moves relatively from
there, so one glyph drawn at another width threw off every move after it.
That renderer never knows a column absolutely, so the pin is spelled
relative to the glyph instead: save the cursor, draw the glyph, restore,
step forward exactly the width the renderer is about to add. The terminal's
cursor is back in step after every glyph, and nothing is tracked that could
drift from the renderer's own bookkeeping. PinnedOutput wraps the real
terminal; the width reclaim stays outermost.
A flag's two indicators are now merged into one cell before layout, like a
ZWJ sequence, so the pair is written as one glyph rather than with a cursor
move between its halves.
Nothing is pinned on a platform that draws no emoji: the PicoCalc only ever
draws glyphs its own font has verified.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Alignment stopped depending on the width tables being right about a glyph.
After anything whose drawn width is not certain, a row now carries an
absolute column address naming the column the app measured that glyph
into, so a mismeasured emoji costs one cell inside its own lane instead
of every lane after it and the frame's right border.
The curated narrow/wide sets keep their job — they are what makes the
reservation right, so a listed glyph gets neither a gap nor an overwrite
— but they are no longer what holds a row together. A name off the air,
which nobody can put on a list in advance, now frames flush too.
Pinning only where it is needed: the ranges the app draws in bulk and both
authorities agree are one cell (box drawing, braille, blocks, the node
marks, the chevrons) are listed, and a row holding nothing else is handed
back untouched — one scan, no walk. That list is a performance claim, not
what correctness rests on; getting an entry wrong costs what today costs.
Proved against a terminal that disagrees on purpose rather than against a
measurement, since the disagreement is the thing that cannot be probed.
Still limited to the rows the fast renderer writes itself: a frame
prompt_toolkit lays out — a dialog, and the backdrop it repaints — tracks
the cursor in its own arithmetic and is uncorrected. MESHTERM_COLUMN_SNAP=0
turns it off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map is the only networked part of MeshTerm, and every failure on that path
is logged at DEBUG while log_level defaults to WARNING: a reader whose tiles
never arrive gets a blank ground and no explanation anywhere. On macOS that is
routinely a Python with no certificate store at all, which no amount of looking
at the app can reveal.
scripts/basemap-doctor.py is the explanation. It reports the interpreter, the
trust store, the tile source and the cached-tile count, then fetches the
TileJSON and one real tile the way BasemapSource does, and turns the failure
into one verdict with its fix.
The verdict is an A/B rather than a reading: curl verifies against the OS trust
store and Python against its own, so curl succeeding where Python fails is the
app's store and nothing else, while both refusing the same certificate is TLS
interception or a genuinely bad server — one exception, opposite causes,
opposite fixes. Collapsing curl's exit codes into "failed" had it blaming the
store and the network in the same breath.
It also answers the other way a map looks wrong, since it costs nothing in the
same round trip: an unset COLORTERM is 256 colours, where the basemap's greens
and blues collide.
Started under the wrong Python it finds the installed meshterm command, reads
its shebang and hands itself over, so nobody has to know which of their three
Pythons the app went into. By subprocess, not execve, which on Windows is not a
real exec and segfaults under a redirect — and stdout is flushed first, or the
banner loses the report it introduces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tighter (0.035) left wedges a reader had to hunt for, looser (0.10) blurred pairs
whose point still read plainly. Set by eye on the same ladder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Set by eye on the same ladder: at 0.10 the solid point is found at one cell without
looking for it, and the safe side of the line is the blur, since a wrong blur still
draws a legible seam and a wrong interlock draws none. The shade stays at 0.10.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SEAM_BLUR drops from 0.10 to 0.035: on a ladder of the spectrum's own pairs drawn
both ways, the solid point stays findable at one cell down to a little under two
noticeable differences, so the interlock is the ordinary seam and the thin chevron
is for fills that are the same or as good as (a mirrored leg, a keyless run, the
greens' nearest neighbours). SEAM_SHADE drops from 0.15 to 0.10: a lightness step
is what a thin line is seen by, so it needs nothing like the blur distance to read,
and 0.10 is a clear line that is still plainly the chip's own colour.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The library arms one five-second future for the whole table and never re-arms
it, so a node holding four hundred contacts fails every read with "no event
received" however healthy the radio is. What means "the companion stopped
answering" is a gap between records, so that is what is timed now: each record
restarts the clock and the dump may take as long as it takes. An uncorrelated
ERROR mid-stream is somebody else's command and no longer kills the read.
A tool that failed is a caution rather than a loss, so it takes the amber tone
and the popup wraps it instead of handing a long sentence to the full-frame
result window.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The thin chevron between two chips the reader can't tell apart is now drawn in the
previous chip's own fill stepped SEAM_SHADE darker (lighter for a dark fill) on the
fill ahead, so nothing on the ribbon is a colour that isn't a chip's. The step is
measured from whichever fill is further along, so the mark clears the fill it sits
on by the full shade however close the two are.
Whether two fills blur is now a perceptual distance, not a hue gap: ui/oklab.py is
the reference OKLab transform (from_hex, to_hex, distance, shaded), and the seam
interlocks only where the fills sit SEAM_BLUR (0.10) or more apart. The hue rule
mis-sized the wheel badly: sixteen first-byte steps measure 0.026 across the greens
and 0.165 across the cyans, so it blurred too little on one side and too much on the
other, and an sRGB distance would only have restated the hue gap, a fixed byte step
being a near-constant 55-60 of 8-bit RGB all the way round. 0.10 is five noticeable
differences, the typical 0x10 pair with a little to spare, erring toward the thin
chevron because a wrong blur still draws a legible seam and a wrong interlock draws
none. The greys need no exemption any more: the keyless grey and the faded slate sit
0.18 apart and keep their interlock on the metric alone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The preview's rows led on a PCTL lane and followed it with WHY IT RANKS LOW, one
header over three lanes that held a contact's n-th weakest reason — so a message
count, an age, a packet tally and a hop count moved between columns from row to
row and nothing in a column meant the same thing twice. The lanes are fixed by
kind now: HEARD and PKTS first, in the Contacts list's own order and drawn in its
own grammar (the column age under recency heat, a tally muted, a faint dash for
none), then the two the sweep adds, MSGS and HOPS. Each is measured across the
victims and headed by a word right-aligned over its own values.
The percentile goes with the phrases. A rank is the score's reading of itself, so
a lane of them restated the order the list was already in; what an audit needs is
the measurements, which is what a reader can check against what they know. The
row pins its name out of the horizontal scroll instead — the evidence is what a
narrow terminal cuts, and the name is what says whose evidence it is. ScoredContact
drops the reasons it was the only consumer of.
A lane also stops trusting a name to be ordinary text. A name is written by whoever
owns the radio, so fit_cells — THE lane fitter, and every column's — now cuts
between glyphs rather than codepoints and folds what no terminal can draw: cutting
an emoji sequence in half stranded a joiner that swallowed the ellipsis appended
after it, leaving the lane a cell short of what was drawn and every column right of
the name starting late, and a newline or an escape in a name ended the row mid-lane
whatever it measured. Both are settled in emoji_width, which already owns what this
terminal really does with these codepoints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Where two consecutive chips share a fill or sit under SEAM_HUE_GAP apart, the seam
was the solid point drawn bare, so the page cut a wedge out of the ribbon: a run of
near hues read as dashes, and on a light terminal the wedge was a hole. It is now
the thin chevron (U+E0B1, core powerline set) in the chip's ink on the fill ahead,
powerline's own mark for a join inside one colour. The ribbon runs on unbroken, the
join is a line drawn on it, and it wears no fill of its own so it can't be taken
for a chip; on a faded leg it takes the dim ink and recedes with what it joins.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two consecutive chips with near-identical fills — first key bytes under 0x10 apart
on the hue wheel, measured circularly — used to interlock on a chevron the eye
couldn't find, one shade of green laid on the next. They now take the same exception
an exact match does: the point drops its background and the page cuts the wedge.
The wheel is recovered from the fill itself, so an override colour and a console
slot are placed on it too; the greys (keyless, dim, our own neutral) blend only with
their exact selves, since two distinct greys still read as two chips.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Calculinux bring-up, both console-font builds, the GPL-2.0 text that belongs
beside the 6x8 one, and the xiao-radio firmware kit move under scripts/picocalc/;
the SPI bridge is scripts/uconsole/meshterm-spi-bridge. The folder's own README is
gone: its per-file index is now a section of docs/hardware.md, next to the table
that says which manual is yours. Every path in the manuals, NOTICE, CLAUDE.md, the
tests, the package docstrings and the scripts' own headers follows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A new "Set clock on connect" preference (Device group, off by default) has the
interactive session write the host time to the radio once per connection — at
launch, on a deferred connect, and after a reconnect, since a link drop is exactly
when a board without a real-time clock may have rebooted and lost the time. The
set runs in a background task off the connect path and reports only in the log,
with the drift it corrected; a refused write costs a warning line, never the
session. Scripted CLI runs never start the service, so a one-shot command that
only reads the radio never writes its clock.
The device-side step is the one the Device config screen's Sync clock action
already ran inline in apply_ops; it is lifted into services/clock_sync.set_clock
and the action and the service both call it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/hardware.md shrinks to a router: which guide for which device, and the
two platform flavours. Everything the guides were still reaching into it for
moved home — the bridge's environment knobs into uconsole.md, and the
verified/unverified lists into each manual's closing section, split by
device (the PicoCalc's gains the MX1.25 pin order and the 5 V path, both
inferred rather than measured on a Lyra).
The PicoCalc manual gets the Wi-Fi kick back — it works and stays; only the
join script is going. The uConsole manual opens on the same kind of
disclaimer as the PicoCalc's, minus the hardware-handling parts: a LoRa
transmitter, a Linux shell, the guide may be wrong or stale, your call.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scripts/calculinux-console-font.sh is now calculinux-console-font-6x12.sh.
Every reference follows: the setup script's hand-off, the 6x8 script's
SHARED default and comments, the theme16 test that parses it, the module
docstrings, the two manuals, the scripts index and CLAUDE.md. The font files
it builds keep their names.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A "Three ways to break it" chapter before Phase 1: the bare glass screen that
cracks when the mainboard flexes (do the Pico swap with the board out of the
case, on a padded surface, and tape the panel's edges with Kapton), the brass
screw posts that snap out of the shell when a screw is forced, and the charge
control that powering the core board's own USB-C bypasses (forum members
measured cells at 4.46 V and 4.74 V after powering the Pico's port). Steps 1
and 2 and the day-to-day section now follow it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/picocalc.md starts from a stock PicoCalc with its Pico and ends with a
Linux handheld running MeshTerm with a LoRa radio soldered inside: a priced
shopping list, the Lyra swap, writing Calculinux, the serial console, Wi-Fi,
the setup script phase by phase, then PlatformIO, the firmware build with the
patch explained hunk by hunk and a by-hand route for when it stops applying,
flashing, the four wires, the Lyra setup, day-to-day use and troubleshooting.
The README gains a Documentation table and points the handheld paragraph at
the two manuals; the script indexes and the changelog follow. hardware.md
stays as the reference behind both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/uconsole.md walks a uConsole with the AIO LoRa board from requirements
to a running bridge and a connected MeshTerm: the system prep, the radio
runtime (and the bookworm/trixie split), the preflight, a foreground run,
the user service, the profile, updating, troubleshooting, and what has been
tested. Plain language, one step at a time; docs/hardware.md stays as the
reference behind it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Support page's text and QR code and the repository's funding file all pointed at a
tinyurl redirect, which a reader could not audit; they now carry the hosted-button address
itself. The page still renders on both platforms.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two real contact names pulled the purge preview's reason lanes out of line, and the
Contacts list and chat picker would do the same, since all three pad names through one
measurement.
"Tech-Info 👨🏻💻" carries a skin tone. Rich measures the modifier zero, as the
terminal draws it; wcwidth gives it two, so prompt_toolkit reserved four cells for a
two-cell glyph. The cache now measures a modifier zero at either calibration, which lets
prompt_toolkit fold it into the cell before it the way it folds a variation selector,
and the cluster merge carries it inside a joined run.
"That's So Fetch 🏴" is a pirate flag cut at the name's byte limit, just past its
joiner. Both authorities folded whatever followed a joiner into the glyph, and here
that is the lane's padding space, so the name measured a cell short. A joiner now joins
only a pictograph. On the width-2 path Rich's own loop is wrapped for this one case; its
table is still left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The target ladder described every rung in a phrase, "keeps 45 · archives 12", so
comparing two rungs meant finding the numbers inside two sentences. The counts are
columns now, right-aligned under a pinned TARGET / KEEPS / ARCHIVES header like the
editors' lane header, one rung lane measured across both sections so the numbers run
straight down the whole ladder. Keeps still leads. A rung that archives nothing shows
a muted 0.
The preview joined its reasons with " · ", so the second and third began wherever the
first happened to end. They sit in three lanes measured against the victim list, and
remeasured when a spared row leaves, under the one WHY IT RANKS LOW header: a lane holds
a contact's n-th weakest reason, whose kind varies from row to row, so a word per lane
would claim a kind the column does not keep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rich draws a panel's corners rounded, and square only where it judges the console
legacy Windows, which a captured pytest run on Windows is. The credit tests looked for
the square corner, so they passed here and failed on every Linux and macOS runner.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dashboard, map, trace and the Message paths dialog two by two, each with an alt text that
says what it shows, and the PicoCalc photo alone beneath them, downscaled to 1200 px wide so
the README does not load a 7 MB camera frame. The placeholder comment is gone with its job.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
github-release.yml gains a website job: once the release is published it rebuilds the meshterm.net site from the tagged tree and pushes it back with a deploy key scoped to that one repository. The release skill says what to do when only that job fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The macOS installers of 0.3.3 never built: the notices step refused pyobjc-core and
pyobjc-framework-libdispatch, whose wheels ship no licence text. Their MIT text is vendored
now, and a test checks every vendored path on every platform.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The three rules the licensing pass settled, pinned where the other binding standards live so
a new file, a new map surface or a new NOTICE paragraph is held to them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The five installers bundled Cascadia's font licence and nothing else --
not MeshTerm's own LICENSE and NOTICE, not the MIT/BSD/PSF notices
Apache-2.0 and those licences all want kept with a redistributed copy.
packaging/notices.py walks the mesh-term distribution's actual runtime
dependency closure through importlib.metadata (markers evaluated for
this platform, the dev extra excluded), gathers each package's licence
text, and fails the build loudly if one is missing rather than shipping
a silent gap -- which is exactly what it found: pyserial and the nine
winrt-* packages bleak's Windows backend pulls in all declare a licence
but ship no licence file in their wheel at all. Their real text, pulled
from each project's own repository, is vendored under
packaging/vendored-licenses/ for those two documented, verified gaps;
anything else missing still stops the build.
The spec generates THIRD-PARTY-NOTICES.txt fresh into the build
directory on every run and bundles it at the archive root alongside
LICENSE and NOTICE. installers.yml uploads the same three files once,
from the linux-x64 leg, so they land beside the downloads on the
release page too, without extracting an installer just to read them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A silent skip would let an unmarked file through, which is the one thing the test exists to
catch. The two new changelog lines now read in the file's own voice, and a docstring in the
logging tests is wrapped under the line limit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The relicensing grant binds by conduct only if the contributor had a fair chance to see it, and the template mentioned CONTRIBUTING only inside an HTML comment the rendered PR never shows. One visible checkbox at the top of the checklist.
Co-Authored-By: Claude Fable 5.1 <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>
The GPL-2.0-only 6x8 script executes the generator block out of the Apache-2.0 6x12 script, and GPLv2 and Apache-2.0 do not mix inside one program. Both files are the author's, so the block between the BEGIN/END markers is additionally available under GPL-2.0-only for that use; both headers and the hardware manual say so. No code changes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
meshcore-uart1.patch quotes upstream lines and build-firmware.sh produces a MeshCore binary, so the licence those copies travel under now sits beside them as scripts/xiao-radio/LICENSE.MeshCore, verbatim from upstream, and is named from the bench card and the hardware manual.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
preferences.yaml becomes preferences.toml: config.toml and the device-config
backups were already TOML, and tomli/tomli-w already dependencies, so PyYAML
existed for this one file and is dropped. The file keeps its shape - flat
keys, only the overrides, grouped under comment headings with each entry's
help and default - and one name, PREFERENCES_FILENAME, for both call sites.
TOML refuses a whole document over one bad line, so a refused file is read
again a line at a time: a valid line keeps its typed value, a refused one
hands its right-hand side to parse_value as text. One broken hand edit still
costs only its line, and a word left unquoted (log_level = DEBUG) still reads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pymc_core was renamed openhop_core in 2026 and the current meshcore-uconsole
package depends on the new name, so a bridge that only knew the old one failed
its own preflight on a fresh install. It now finds either runtime, newest first
(an interpreter with openhop_core beats an earlier candidate with pymc_core;
MESHTERM_PYMC_PYTHON still overrides), imports through the name it found, and
lets two of its three compat shims stand down under openhop_core, which accepts
long ACK payloads and pushes completed trace replies itself; pushing a trace
twice was the alternative. The raw RX-log push is still the bridge's.
What the port found on the device shaped the rest. The 1.12.0 .deb is built for
trixie against Python 3.13 and cannot run on a bookworm uConsole, so the bridge
looks first in a venv of its own (~/.local/share/meshterm-spi-bridge/venv, made
with openhop-core[hardware]) and, without the GUI's helpers there, configures
the radio itself with the uConsole AIO preset rather than the runtime's
Waveshare defaults (TX-enable on pin 6, no DIO2 switch, no TCXO -- deaf on the
AIO), every MESHCORE_* knob the GUI reads honoured and passed only where the
constructor accepts it. It also loads the GUI's identity key first, so a bridge
in its own venv is still the same node. Verified on the uConsole: openhop-core
1.1.3, radio up, 159 contacts restored, info and contacts answered over TCP,
and the boot service now runs from that venv. The manual and the index say all
of this instead of the rename warning.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
--mock promises a session with no real hardware, and a scan was the one thing
the devices tool did regardless: it walked the serial ports and switched the
Bluetooth radio on, which broke the promise on the first command a stranger
without a radio would try, and which the test sweep had to work around with
--no-ble. Now the simulator is the inventory under --mock: one row with the
flag itself as its target, since that is how this device is selected, and a
stable id of its own. The sweep's workaround is gone and a contract test
replaces both discovery entry points with ones that fail if reached.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A leading underscore says "internal to this file, don't import me", and the
column-age formatter and the node glyph table wore one while seventeen modules
imported them and CLAUDE.md named them as the one place each concept lives.
format_age sits beside format_ago now (prose age, column age), NODE_GLYPHS and
DEFAULT_GLYPH beside them, age_seconds with the ages. A mechanical rename, 140
references across 24 files, CLAUDE.md's three lines with them so the guide and
the code move as one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four image links under docs/screenshots/ (dashboard, map, trace, picocalc)
with a comment above them saying what to capture, so taking the pictures is
the only step left. They render as soon as the files exist.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Preferences page loads the font the moment its value is picked, rather than
after Apply: a VT has one font for the whole screen, so the only honest way to
show what the 6x8 build looks like is to load it, and the page is the preview,
repainting at the new row count on the kernel's SIGWINCH. The page tracks what
the console is drawn in right now; staging the saved value again reloads it at
once, Apply keeps whatever is showing (the tool's re-apply after the write then
finds nothing to change), and leaving with a discard reloads the saved font.
Off the handheld the service says no once at page open and is asked nothing
more. Four tests drive the page through each path with the service stubbed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A Linux VT loads one font for the whole console, so asking for the 6x8 build
(53x40, fourteen rows more) changes what the shell is drawn in too. The new
console_font preference (6x12 default, 6x8 the alternative, offered on the
PicoCalc only, round-tripping through preferences.yaml on every platform)
therefore comes with services/consolefont.py: remember the font the shell had
with `setfont -O`, apply the one the preference names, and restore it on every
way out -- the menu's finally and an atexit hook, since ^Q and an unhandled
error leave by other doors. Verified on the device that setfont -O captures
the current font with its unicode table and that feeding it back reproduces it
exactly, with no sudo, because the VT is the process's own controlling
terminal. Only the interactive menu switches; a scripted run never touches the
console it was invited into. Changing the preference on its page applies live.
Shipping both fonts needed the licence untangled: the 6x8 base is the Linux
kernel's font_6x8 bitmap, GPL-2.0, so its build moves into
calculinux-console-font-6x8.sh under that licence with the GPLv2 text beside it
in scripts/LICENSE.GPL-2.0. The shared generator stays in the 6x12 script
between BEGIN/END markers and the 6x8 script executes that block, so the marks,
donors and aliases the font contract pins exist in one place. The produced PSF
is a data file the kernel console reads; MeshTerm never links or bundles it, and
NOTICE and the hardware manual say so. PrefSpec gains a platforms gate for the
page; the labels use an ASCII x because U+00D7 is not in the console font.
The 6x8 base has no Cyrillic; the manual says so and the owner accepts it.
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>
Hatchling's default sdist is everything git does not ignore, which packed the
Claude skills, the GitHub configuration, the PyInstaller spec, the device-side
scripts and the developer environment template into an archive whose only job
is to build and test the wheel. An exclude list keeps the package, the tests
and their fixture, the manual and the files a build reads. The build backend
floor is hatchling 1.26, the first to write the PEP 639 licence fields into the
metadata; an older one built a wheel that said nothing about the licence.
Workflow comments that reasoned from a private repo's metered minutes are
reworded for the public one, a skill's usage line stops naming one machine's
venv, and the scheduler's lock file is ignored in the tree rather than only in
one clone's info/exclude.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/hardware.md is the single place a reader goes to put MeshTerm on a
PicoCalc or a uConsole, written in the shape of the command-line manual: what
the three targets are and which exclude each other, why the console font is
built on the device, every phase of the Calculinux bring-up and its knobs, the
XIAO UART radio from parts to a verified link, the SPI bridge with an honest
note that it was written against pymc_core 1.0.x while today's uConsole stack
depends on the renamed openhop-core, and a closing list of what has only ever
been proven on one bench. scripts/README.md and xiao-radio/README.md become
short indexes pointing at it; the old ones were stale in four places (trace
through the bridge works, the firmware env is _serial, two scripts were
unlisted). README links the manual, gains the two dependency rows it lacked
(markdown-it-py, PyYAML) and a fixed fonts anchor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A read-through of scripts/ ahead of the public release, applying only what could
be verified from the code. The setup script clones over public HTTPS when no
deploy key sits at KEY_PATH and keeps the SSH path when one does, and its
timezone is a TIMEZONE knob rather than a hardcoded city. The flasher recognises
the plain XIAO nRF52840's bootloader (PID 0x0044) as well as the Sense's
(0x0045), both from Adafruit's board.h. The mux unit was After= the very target
that WantedBy= pulls it into, an ordering cycle; it now orders on basic.target,
which still wants a check on the device. The console-font header stopped
claiming a default run installs /etc/vtrgb (it removes the custom remap), and
states that the font it builds is an OFL-1.1 Terminus derivative that must not
be called Terminus and that the optional 6x8 candidate embeds a GPL-2.0 kernel
bitmap. The radio-bridge header describes the four phases the script actually
runs instead of a pin-routing phase it never contained. Personal remarks in
comments are reworded impersonally.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"🛣 Longest distance — 5th place: 112.0 km": the discipline written the way its board
heading writes it — discipline_label, the mark padded to the measured lane so a
one-cell 🛣 and a two-cell 🎯 start their titles in the same cell — then the standing
and the metric. The bare mark-and-number it replaces was dry, and it had to lean on the
title bar for the discipline; now the header names the record and the bar names the
kind of page.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The node page's identity header, for a record: one line above the strip carrying the
discipline's own mark (the one its board heading wears) and the score in the
discipline's unit, which is the one thing the record is — the title bar already names
the discipline and the standing. The score lane that repeated it on Info is gone, and
the metric's spelling is one function, record_score, shared with the board's score
lane so a partial Longest-distance walk reads ≥ in both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Info is the record's stats with the page's actions at its foot, the cursor opening on
Trace; Route is the walk two ways — THE route graph, its key, the route line — with
Trace this path as its one row, so Enter there walks the route on show, the node page's
rule for its Routes tab; Area is the ground drawing, unchanged. Each tab's stage is
composed once per width and cached, and the footer names only what the tab answers: a
lone action row earns no ↑↓ atom. The gallery gains the Route tab as a specimen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The node page's shape (widgets.tab_strip, Tab/Shift+Tab, the next tab on F3): Stats is
the record read — its stats, identity first, then the route two ways — with the action
rows at its foot; Area is the ground drawing across the whole stage the viewport leaves
under the strip, so the shape gets every row and column the page has rather than the
block a stacked layout could spare it. The Area tab is offered only for a walk with a
drawable shape; a record without one keeps a lone-tab heading and nothing to switch. A
page too narrow to draw on says so instead of squeezing.
widgets.tab_air is the strip's platform-bound air, hoisted from the node page so both
tabbed screens spend it the same way. The gallery gains the Area tab as a specimen on
both platforms.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Rich's centre justify strips each row's trailing spaces before it pads it, so a
code row whose right edge was light modules lost cells and landed a column off
its neighbours; the bottom-left finder square came out one cell skewed, and a
camera could not lock on. The About pages' codes were never centred, which is
why only the full-screen share codes broke.
A code is now moved across a line one way only: the same indent in front of
every row, computed once from the fitted code's width. The test pins that all
rows share a width and every finder starts in the same column.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three body headings — Stats, Area walked, Route — each with the note that used to be a
faint caption under its block, so a page this tall can be skimmed by its landmarks. The
record's identity leads its stats: the spec walked and when it was set, then what the
walk measured. The area drawing takes the page's whole width and rows in step with it,
a quarter of the columns (half as tall as wide in braille dots), so a wider page is a
bigger shape rather than a padded one; rows a flat shape leaves blank are still
trimmed, so a tall box costs it nothing.
widgets.body_heading is THE form for a heading inside a page's body — accent title,
muted note — hoisted from the Time Machine's private copy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A camera reads contrast, and a light-on-dark code is what a scanner expects
of a screen, so every code MeshTerm draws is now pure white ink on a pure
black field (the theme's `qr` style, both ends named on both themes), never
black on white.
The share views — a channel's share link, a contact card, the CLI's share
commands run in the menu — are no longer a titled popup with an instruction
over the code. They are `QrScreen`, a **bare frame**: `Screen.bare` and
`frame.compose_bare` draw the body alone on blank rows, with no header, no
footer, no title bar, no box, no hint and no F-key lane, and nothing on it
but the code and the URL it encodes, sat a little above centre. Esc leaves.
The code fits itself to the frame every paint, width and rows both: the
standard fit first, then a lighter error level, then a narrower quiet zone,
before it would ever be cut — a contact card is 57 cells and 29 rows at the
standard fit, the PicoCalc is 53 across and a regular terminal 24 tall. When
code and URL can't share the frame the code is whole at the top and the URL
a page down. The frame states its height before it asks for the body, which
is what lets the screen size to it.
A `qr` fence in a written page is the one exception and keeps its place in
the prose — the About pages' codes flip to white on black like the rest.
Found on the way: the PicoCalc's SGR quantizer matched a colour only when it
was a whole sequence, and Rich writes a style's foreground and background as
one sequence (`ESC[38;2;…;48;2;…m`), so a white-on-black module reached the
console in truecolor. It now walks every sequence's parameter list.
The standard is written into CLAUDE.md under Screens and lists; the gallery
carries the share screen as a specimen on both platforms.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The area drawing rode beside the stats in a third of the width, which squeezed the
shape and left no cell for a label on any pin. It is a block of its own now, between
the stat lanes and the route graph — page-wide up to a cap, a fixed eight rows, north
up, the shape against the left edge under the left-aligned stats — and every hop's pin
carries its first hash byte, placed the way the map places a name (beside the pin,
clear of the drawn lines where it can, dropped rather than overprinting), so a pin and
the graph node under it match by eye. The star stays bare: it already says who that is.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The reader is in a record — reading its stats, scrolling the route, arming a trace from
it — so it fills the frame like the browser under it and Esc reads back. It used to
float as a 62-column card, which read as a question over the list rather than as the
page it is. RecordDialog is RecordScreen, full-frame, and the reading width it asked for
as a dialog goes with the name.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The third lead-in picker kept pushed as a hub, and the one the ledger said would stay:
a contact list under the trace screen, so Esc out of a trace landed on the list — two
places where the trace is the whole visit. It is a question, and asks like the other
two: the contact list floats over the menu as a one-shot (run_dialog, public now; the
list stays a full-screen place everywhere else), the trace screen opens over the menu,
and Esc from it lands there. The typed fallback floats the same way instead of filling
the frame.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A bare SelectScreen floats by default, and the browser was full-frame from the menu
only because nothing sat under it; opened from a trace, over the still-pushed trace
screen, the same page came up as a content-sized box. It is a place, not a question,
and declares so like the contact list does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The menu is popped while a tool runs, so a lead-in question — the TX optimize link, the
node to manage, the password that follows it — had a blank base pushed under it to draw
as a box at all. A dialog is smaller than the frame, and what shows around it should be
the page it was reached from: the root the menu already declares for ^W is now the base
_floated pushes on an empty stack, and the reconnect dialog draws over it too, so the
menu loop stops forgetting its root on the way out. A session with no root declared
keeps the blank fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two flows were rebuilt on it today and nothing had it written down — the stack is for
places, a popup informs, confirms or asks and is then gone, and a question draws as a
box even as the only frame on the stack. The entry-chain rule gains its list form,
run_wizard, one popup turning its page.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The same shape TX optimize just lost, with one popup instead of two: the node list was
kept pushed as a hub under the password prompt and the admin page, so Esc from the page
landed on the list it was picked from — two places where there is one. A popup asks and
is gone: the pick is now a floating one-shot (session.select gains text's floating
flag, so a lead-in question draws as a box even as the only frame on the stack), the
admin page opens over the main menu, and a refused login re-asks the question with the
same node highlighted. admin_node_visit is deleted; nothing keeps a popup pushed now.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The menu is popped while a tool runs, so a floating screen that is the only frame on the
stack was painted as the full-frame background — the TX optimize link dialog opened as
a page and only its sub-prompts looked like popups. The blank base _run_dialog_screen
puts under a one-shot dialog is now one context manager, and stay(dialog=True) puts the
same base under a popup's whole visit, which is what run_wizard asks for.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two pickers stacked one over the other, each kept pushed under the sweep as a hub, read
as two places to be — and a popup is not a place: it asks, confirms or informs, then it
is gone. The node to tune and the node to measure at are now one floating list that turns
its page ("… · step 1 of 2"): Esc on the second step turns back to the first with the
picked node still highlighted, Esc on the first leaves, and the box is popped before the
sweep screen opens, so Esc from the sweep lands on the main menu.
menus.run_wizard is the dialog form of run_steps — the same stack semantics drawn in one
box — and SelectScreen.turn_page is what it turns on: the opposite claim from
replace_items, since a page turned is a new question and neither the typed filter nor the
highlight rides along. A step with nothing to list (the typed hex prefix when the tuned
node is the only contact) runs as an awaitable floated over the box's last page.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TX delay, direct TX delay and RX delay show, stage and send at one place instead
of two. Device config's RX delay had no rounding at all, so SettingSpec gains
the same decimals field the remote catalog carries, applied on parse and display.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The log misleads read raw: a count hides its span (204 identical records that
were three seconds), a failed CLI run is logged twice, the suite's runs from
before 59f10f9 sit in the real file, and the runs table keeps UTC while the
log keeps local time. logscan.py groups tracebacks, warnings and the DEBUG
failures that lost their stack, each with first and last; folds the CLI's
reports onto the run they repeat; hides fixture and other-database runs and
says how many; and joins the runs table by run id. SKILL.md is the method:
pin the question, fetch the log (this machine, the PicoCalc, an attachment),
summary then around/run/show, the reading traps, and checking HEAD before
calling a finding live.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A packet's via field names the repeaters that forwarded it and neither the
node that sent it nor the one that heard it, yet its ribbon opened and closed
square, the mark that says the route began and ended there. PathLine takes
from_origin / to_destination: a cleared end opens on the notch or closes on
the point, and arrow mode spells it with a bare leading or trailing arrow.
The packet viewer's via chain and the TX sweep's composed relays draw both
ends open. Under the chain, the "reception describes..." note gives way to
the hop count atom the node page and trace scenarios already carry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pick location on map gathered its surrounding nodes before opening, and that
meant reading the contact list from the radio whenever the session hadn't
cached it. A companion refusing that read (ERR_CODE_BAD_STATE, which the log
shows all session) held the picker closed through every retry, twenty-odd
seconds, only to open with no contacts anyway. Our own position waited the
same way after Device config dropped its cached self-info.
The picker now opens on what is already in hand: cached contacts and
self-info (DeviceState.peek_contacts / peek_self_info) and our own history,
via gather_markers(wait=False). Whatever the cache lacked is fetched behind
the open map and joins it through MapScreen.set_markers when the radio
answers. The read is never cancelled, so a fetch another screen shares
survives the picker closing. The Map tool and the node page keep waiting,
unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v1.15 matches get keys by prefix, so a key it lacks falls into a shorter
sibling's branch: get radio.fem.rxgain answers with the get radio line, and
gps on a board without its receiver answers "Can't find GPS". Neither is an
error string and neither parses, and an unparseable reply left the cache
alone, so those rows stayed on ?, which says never asked. Any reply that
can't be the setting's value now marks it n/a; only no reply keeps ?.
The fold itself rode along in 564ac0e; this adds the regression test, the
catalog's note on prefix-shadowed replies, and reflows a docstring line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Device config folded latitude and longitude into one Location row behind a
Pick / Type / Clear dialog. It now lists them as two rows, as repeater admin
always did. Above them, on both pages, a "Pick location on map…" row opens
the map picker and stages both coordinates at once. The map opens on the
position as staged, or as the device (or node) last reported it. It opens on
the mesh where there is no fix (0, 0 or unread), via map_screen.coords_or_none.
On repeater admin a picked coordinate is stored the way a CLI read would spell
it, so picking the spot the node already holds unstages rather than queueing a
no-op set. Typing a pair and the Clear button went with the dialog. Each
coordinate is typed on its own row now, and 0 in both still means no fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Device actions menu entry is gone: clock sync, backup/restore, the identity
key, reboot and factory reset are an Actions section on the Device config page,
which now takes the repeater admin page's shape. It is full-screen, titled with
the node's name and a staged count, and Apply sends in place, one value at a
time; a value the device refuses stays staged with its reason. Read settings
re-reads the device, and unread and blank values read ? and empty.
The settings were checked against the companion firmware (MyMesh.cpp). New:
client repeat, the radio command's trailing byte. Every radio edit now restates
it, because the firmware reads a missing byte as off and a retune silently
stopped a relaying companion. Also new: the auto-add hop limit, read off the raw
frame the library half-parses, and a row for each custom variable the device
reports. The firmware's own bounds now apply: a PIN of 0 or six digits,
path-hash modes 0-2, telemetry deny / by contact / allow all, 150-2500 MHz,
7-500 kHz, and TX power from -9 dBm.
Neither editor page slides its rows sideways any more. The Actions rows pin a
head block, which on its own turned on horizontal scrolling for repeater admin's
whole list and slid its setting labels out of view. SelectScreen(hscroll=False)
now holds against the rows, and both pages use it, so long rows end at the edge
the way Device config's always did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The editor floated as a content-sized box over the picker it was opened from,
spending the frame's width on a backdrop while the reader worked through a
whole node's catalog. It now fills the frame like the local Device config
editor it mirrors; the picker, value prompts, confirms and progress dialogs
still float. The gallery gains a repeater_admin case at both platforms.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The terminal draws some icons in one cell (↻ ⌨ 🗑 ✓ ✗ ⚠) and most emoji in two, so
a row written icon-plus-space started its words a column left of its siblings. The
fix kept landing one screen at a time. menus.align_icons now pads every icon in a
list out to the widest one, and menu_rows applies it itself, so a list built there
cannot misalign.
Hand-built lists are brought along: the courier outbox and entry actions, the
watchtower actions and rule popover, the record card's actions, the main menu's
Quit row and the device splash's action rows. The repeater admin's regression
test, dropped by a concurrent rewrite of its file, is restored.
tests/test_icon_alignment.py sweeps every gallery screen on both platforms and
fails, naming the rows and their start cells, when a list's icon-led rows start
their words in different cells.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catalog was written from convention, and the firmware disagreed: the delays
are floats (0.5 showed as 0 and typing it back was refused), bw/sf/cr are not
keys at all (the radio is one `get radio` / `set radio f,bw,sf,cr`), the guest
password is readable, and path.hash.mode and twenty-odd others were missing.
It is now transcribed from CommonCLI.cpp, with a test pinning the key list.
Read settings asks each question once and leaves every row answered: a value,
`empty`, or `n/a` where the node's firmware lacks the setting; reads that got
no reply are listed. ^R (F3 Read) re-reads the highlighted row alone. The value
lane drops its age stamp. Delays round to two places.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>