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>