Commit Graph
100 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 9b2c5527f8 MeshTerm 0.2.2
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The first build that contains the whole app. 0.2.1's binaries start and then
offer you nothing, so this replaces them rather than adding to them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:54:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 141698678e The downloadable builds actually have the app in them
The Windows build launched, printed help, drew the specimen card, and showed a
menu containing one item: Quit. Not a permissions problem and nothing to do with
Bluetooth — the tools were simply not in the bundle.

`meshterm/tools/__init__.py` finds every tool with `pkgutil.iter_modules` and
imports it so its `@register` decorator fires. Nothing static ever names a tool
module, so PyInstaller had no reason to include one, and the registry came up
empty: no menu entries, no CLI subcommands, an app that starts and does nothing.
`collect_submodules("meshterm.tools")` fixes it, which is the same treatment
`bleak.backends` already had for the same reason — the spec knew about other
people's dynamic imports and not about ours.

The worse half is that CI said this was fine. The smoke test ran `--help` and
`specimen`, and a build with no tools at all passes both. It now runs a
tool-provided subcommand, so an empty registry fails the pipeline instead of
reaching a release page. Green tests that cannot see the defect are worse than no
tests, because they are believed.

`MESHTERM_HOME` comes out of the same session. Every copy of MeshTerm reads and
writes `~/.meshterm`, so trying a downloaded build meant pointing it at the same
34MB history database as the checkout it was built from, with no way to keep them
apart. The variable moves the whole directory. An empty or whitespace value falls
back rather than resolving to the current directory, because a shell exporting it
blank is common and scattering someone's history into their downloads folder is a
poor way to find that out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:49:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 8cc1cae213 Say, in the changelog, what happens to it at 0.9.0
A note to whoever cuts the first public release, left where they will be looking
rather than in a plan they may not have open: at 0.9.0 everything above the fold
collapses into one entry saying "first public release" with the headline features
under it.

The reasoning, so it does not read as vandalism later: every version below 0.9.0
was released nowhere. Their entries are notes to ourselves about getting ready — a
formatter pin, a runner label, a workflow that fired on the wrong branches. None
of it is news to somebody arriving on launch day, and a changelog that opens with
a fortnight of housekeeping buries the thing they came for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:34:00 -04:00
Jean-Pierre MartineauandClaude Opus 5 2ea8f9fb83 MeshTerm 0.2.1
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
Closes out the changelog's Unreleased section under a date, which is the whole
ceremony: everything since 0.2.0 was written down as it landed, so cutting a
release is renaming a heading rather than reading a week of commits back.

0.2.0's link points at its tag rather than a release page, because 0.2.0 was
never released anywhere — it was tagged locally before there was anything to
build or anywhere to put it. 0.2.1 is the first version that exists outside this
machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:30:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 eb61c55f22 The Intel Mac leg asks for a runner that still exists
`macos-13` is retired. GitHub's image list starts at macOS 14 now, and the
current x86_64 labels are `macos-15-intel` and `macos-26-intel`.

The failure mode is worth writing down, because it is not the one you would
expect: a job asking for a retired label does not fail. It queues, forever,
and holds the entire run open behind it — so the four legs that had already
built sat there under a run that never finished, and anything waiting on that
workflow would have waited with them. That includes the release workflow, which
calls it, so a tag pushed today would have produced no release and no error to
explain why.

`macos-15-intel` rather than `-26-intel`, for the same reason the Python in this
matrix is 3.10: built against an older system, runs on more machines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:30:20 -04:00
Jean-Pierre MartineauandClaude Opus 5 0c12bd4fcf Somewhere to download a build from
Pushing a version tag now builds the binaries and puts them on the Releases page,
with a combined SHA256SUMS and notes lifted out of the changelog section for that
version. It stays inside GitHub — while the repository is private the release is
private too — and it is emphatically not the PyPI publish, which is `release.yml`
and still needs a person to run it on purpose.

`installers.yml` gained a `workflow_call` trigger so the release calls it instead
of keeping a second copy of the matrix. Two matrices would agree for about a month
and then quietly stop, and the files people download should be built exactly the
way the ones CI checks on every push are.

The README now leads its install section with the download rather than with pip,
because most people who want MeshTerm have a radio and a terminal and not a Python
toolchain. It also says plainly that the first launch will be blocked: these builds
are unsigned, macOS will refuse them until the quarantine attribute is cleared, and
Windows will show its SmartScreen warning. Signing costs real money on both
platforms, so the honest move is to say so and give people the checksum file rather
than let them think something is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:25:03 -04:00
Jean-Pierre MartineauandClaude Opus 5 ebb3c80f15 Pin the formatter, and stop it editing the record
The first CI run went red on `ruff format --check`, on a tree that was clean on
this machine. Three separate problems, all found by pushing.

`ruff>=0.4` is not a pin. CI installed 0.16.6 where the laptop had 0.15.18, and
0.16 formats Python code blocks inside Markdown while 0.15 leaves them alone — so
the two disagreed about whether the repository was formatted, which is the one
thing a formatter exists to settle. Pinned to the 0.16 line. Dependabot will
offer the next one, and CI will say whether it is safe.

The file it tripped over is `docs/survey-ui-consistency.md`, which quotes four
lines of a dialog call as it looked on 22 August. That excerpt is a fragment —
two arguments, no call around them — so the formatter parsed it as two statements
and rewrote them into a tuple and an assignment that never existed anywhere. The
surveys are dated records of a past state, so `docs/` is now excluded: a
formatter editing a quotation is not tidying, it is falsifying.

And the installer workflow was firing for branches it had no business building.
Dependabot opened three action bumps within a minute of the push; each one edits
`.github/workflows/installers.yml`, each matched its own path filter, and each
started a five-platform build. `branches: [main]` stops it.

Everything else was green on the first try, including the part that was actually
uncertain: all seven test legs passed, macOS among them, and the installers built
on Windows, macOS on Apple silicon, and Linux on both x64 and arm64 — so the
uConsole build works and the ARM runner was available after all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:01:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 4f60f44512 A build you can download and run without Python
Most people who want MeshTerm have a radio and a terminal, not a Python
toolchain. This is the install for them: one file, download it, run it.

`packaging/meshterm.spec` freezes the app with PyInstaller in console mode —
which is not a detail, since a windowed build of a terminal program starts with
nowhere to draw and exits. The assets land at `meshterm/assets` inside the bundle
so that `Path(__file__).parent.parent / "assets"` keeps resolving exactly as it
does in a checkout, and bleak's per-platform backends are named as hidden imports
because nothing static ever sees the one that actually gets used. UPX is off: a
few megabytes are not worth being flagged by antivirus on a first release.

The build was run here rather than only written. The Windows executable comes out
at 14 MB, runs from a directory with no source tree in sight, and draws the
specimen card — which is the real test, because the specimen reads the asset
files.

`installers.yml` does the same on five machines, because frozen builds cannot be
cross-compiled. Windows, macOS on both Apple silicon and Intel (plenty of people
still have one, and an arm64 build will not run for them), and Linux on x64 and
ARM64 — the last for the uConsole. Each leg runs the binary from a temporary
directory before uploading it, so a build that only works beside its own source
fails on the runner instead of on somebody's laptop, and each one gets a checksum
written beside it. It publishes nothing; attaching these to a release stays a
separate decision.

Also here: `meshterm --help` now opens with the same sentence as the package
summary, rather than a fourth description of the project written in its own way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 05:00:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 ab8ef2aa23 Say how the project is run, and ask better bug questions
The bug form now asks what JP's Discord post asks: how often it happens, whether
there's a workaround, what hardware you're on, and which companion device — with
the note that "a Xiao" doesn't narrow anything down. Three things his post didn't
cover got added: the MeshTerm version, whether it still happens under `--mock`
(which splits an app bug from a radio bug before anyone spends an evening on it),
and whether you're on the regular or PicoCalc look, since those draw differently
enough that a bug in one often doesn't exist in the other.

The README gains the part that was missing: what to expect from a maintainer
doing this on evenings and weekends. Issues welcome, ask before opening a pull
request, no promised timelines, and a nudge on a quiet issue is fine. Report on
GitHub or on Discord, whichever you have — the real ones get moved across either
way, and being told beats filing it tidily.

Install is rewritten for someone who wants to run MeshTerm rather than work on
it. It said `pip install -e ".[dev]"`, which is a contributor's command and puts
pytest and ruff on the machine of somebody who just wants to talk to a radio. Now
it's one pip line, a pipx alternative, `--mock` for people without a radio yet,
and an honest "coming" list for installers and PyPI.

Badges now include CI, the licence and Discord, so they say something checkable
rather than only decorating. The two survey documents get a line at the top
saying they're a snapshot of a date, not a to-do list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 04:56:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 628b665420 The project can be joined, reported to, and argued with
The rest of the community files, which are the half of open-sourcing that is not
code: how someone tells you about a hole, what happens if a thread turns nasty,
and what you will be asked when they file a bug.

SECURITY.md points at GitHub's private vulnerability reporting rather than an
address that will be scraped, and promises only what one person in his spare time
can keep — an acknowledgement inside a week, which is not a fix inside a week, and
a decision inside ninety days. Its scope section is the MeshTerm-specific part: a
channel key or an admin password reaching a log, an export or the screen is a
vulnerability here, a crafted packet corrupting the observation database is one,
and a hole in the MeshCore firmware or in `bleak` is somebody else's to fix. It
also says plainly that the database is not encrypted, because a backup of it
carries a mesh's whole history.

The code of conduct is Contributor Covenant 2.1, taken verbatim from source rather
than paraphrased, with a real address in the enforcement section.

The bug form asks the seven things that otherwise get asked in every thread:
version, flavour, transport, OS and Python, terminal and its size, companion
firmware. Flavour is there because the PicoCalc draws itself in 53 columns and
sixteen colours, so a layout bug in one flavour frequently does not exist in the
other. It asks for the version from the splash screen rather than from
`meshterm --version`, which does not exist yet.

Dependabot is weekly and capped low, with minor and patch grouped into one pull
request: a solo maintainer facing five dependency PRs every Monday stops reading
any of them. Funding points at the three destinations the app's own support page
already offers, so the two cannot drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 04:31:32 -04:00
Jean-Pierre MartineauandClaude Opus 5 117b958edf Everything PyPI needs, and no way to reach it by accident
The distribution is named `mesh-term`, because `meshterm` is on PyPI's prohibited
list and a request to free it is 319 deep in a queue. The name after
`pip install` is the only thing that changes: the import package is still
`meshterm`, the console script is still `meshterm`, and nothing in the code reads
the distribution name, so this is one line rather than a rename. Proven rather
than assumed — the wheel was built, installed into a clean interpreter, and the
command run from outside the source tree.

The rest is what a stranger sees on a package page and what the app never shows
them: project URLs, trove classifiers for the console, the three operating
systems and the four Python versions, and the licence moved to its PEP 639
expression form. No `License ::` classifier, which that form forbids, and no
`Typing :: Typed` either — there is no `py.typed` marker, and a classifier that
claims something untrue is worse than an absent one.

`release.yml` publishes to PyPI and is built to be hard to fire. It has no
`push: tags` trigger, and that absence is the design: a workflow that published
on a `v*` tag would make tagging v0.9.0 on launch day claim a name on a public
index, permanently, as a side effect of an unrelated action — and PyPI has no
rename and no real delete. So a human runs it from the Actions tab and types the
version, the job compares that against the version the tree actually built and
refuses on a mismatch, and a `pypi` environment can hold it for a reviewer.
Trusted Publishing means there is no API token in the repository to leak. It
publishes whatever version is committed; there is no version argument, so it
cannot publish something other than what is there.

CI gains a packaging job that does the same build and clean-install check on every
push, publishing nothing. Packaging breaks quietly, and the alternative is finding
out on the one day that is worst for finding out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 04:00:56 -04:00
Jean-Pierre MartineauandClaude Opus 5 365df236b9 The promises in CONTRIBUTING are checked by a machine
`.github/` starts here: the three commands the contributing guide asks a
contributor to run, run for them on every push and pull request.

Two jobs rather than one. Style is a property of the source, so `lint` runs once
on Linux and a failure means one thing. Behaviour is a property of the machine,
so `test` runs across the systems the description now promises — Windows, macOS
and Linux — which until this file existed had only ever been checked on one of
them, whatever the description said.

The matrix is shaped for a private repository, where minutes are metered and
billed by a multiplier: Linux 1x, Windows 2x, macOS 10x. Linux therefore carries
the whole 3.10 to 3.13 range, and the expensive runners cover its ends, since a
version-dependent break shows up at an end rather than in the middle. Seven legs,
about fifty billed minutes a run. The comment says to widen it to the full
product once the repo is public and standard runners stop costing anything.

Two small decisions worth their lines. `fail-fast` is off: the point of a run is
the whole picture of what broke, and a first red leg cancelling its siblings
hides the rest until the next push. And `concurrency` cancels a superseded run,
because a second push makes the first run's answer obsolete and those are metered
minutes spent on a stale commit.

One line of `.gitignore` came with it. The catch-all `.*/` that hides `.venv`
and the caches was hiding `.github/` too — the same rule `.claude/` already needed
an exception to. It fails silently in the worst way: a workflow that is never
committed looks exactly like a workflow that never runs.

The version risk this was expected to surface is already gone: the suite was run
locally on 3.13 as well as the usual 3.10, and both report 1888 passing. What
remains genuinely unchecked is macOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 03:49:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 660540a84e The version is written once, and there is somewhere to record a change
Two pieces of release machinery that have to exist before a first bump, plus
the bump: 0.1.0 to 0.2.0.

The version was written twice — `pyproject.toml` and `meshterm/__init__.py` —
and kept in step by hand. That is a drift waiting to happen, and an unusually
visible one here: `__version__` is read by the splash wordmark, the menu mark,
the About page, a trace export and the tile-server user agent, so the number a
user reads on screen is the package attribute, while the number in a built
wheel would have been the `pyproject` literal. `[project] version` is now
`dynamic` and hatchling reads the attribute, so there is one place to edit and
the two cannot disagree. Verified against hatchling rather than assumed: it
resolves `0.2.0`.

`CHANGELOG.md` starts in Keep a Changelog form, with the `[Unreleased]` heading
that makes release notes a rename rather than an excavation of the log. Its
first entry is 0.2.0 and it says plainly that the history before it is not
reconstructed — 596 commits of pre-history would be a worse document, not a
more complete one.

0.2.0 rather than something nearer the announced 0.9.0: the numbers between here
and the release are for real feature sets, and there is no reason to spend them
early. 0.9.0 is a number chosen for the launch, not one to be arrived at by
counting — landing on 0.7.0 on the day and bumping straight to 0.9.0 is fine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 03:34:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 b7c629b50f The whole project spells macOS and Bluetooth one way
Two words the project was saying two ways, now settled to one each.

`macOS` is how Apple has styled it since 2016, and the repo was already split:
`connection.py`, `flash.py` and `test_disconnect.py` had it right, while the
About page and the new Summary said `MacOS`. Three files change, all of them
prose. Same five characters either way, so nothing about a rendered width moves.

`BLE` becomes `Bluetooth` in the Summary and the package docstring. Those two
lines are read by people deciding whether this runs on their machine, and
`Bluetooth` is the word they know and the word the About page already uses. The
identifier stays `BLE` everywhere it is one — the transport name, the connection
code, the config keys, the CLI. That is the boundary: the acronym is what the
thing is called in the code, the plain word is what it is called when introducing
the project to someone who has not met it yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 02:15:03 -04:00
Jean-Pierre MartineauandClaude Opus 5 37e46bfda5 The project says what it is in one line
The Summary is the only description most people will ever read: it is the line
under the name in PyPI search results, the one a package manager echoes, and the
sentence that decides whether anyone opens the README. It said "A modern
command-line toolkit for tuning and exploring a MeshCore mesh", which named a
category rather than the thing, and left out every fact a reader is actually
deciding on.

The new one is JP's own wording and answers those instead: what it is (a
full-featured TUI-based MeshCore client for your terminal), how it reaches a
radio (USB, BLE, TCP), and whether it runs on the reader's machine (Windows,
MacOS, Linux). "USB" and "MacOS" are the spellings the About page already uses,
so the front door and the metadata now say it the same way.

The package docstring carries the same sentence, split at the summary line as
`D205` wants, so `help(meshterm)` and the PyPI page agree.

The README's opening is untouched. It is doing a different job — a paragraph that
gets to breathe, with the links and the longitudinal-record pitch in it — and a
one-line summary is not a replacement for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 02:09:25 -04:00
Jean-Pierre MartineauandClaude Opus 5 60e2cd1e2d Blame skips the sweep and answers with the author
The commit before this one rewrote a hundred and fifty-three files without
changing what any of them does. Left alone, that is what `git blame` would name
for half the lines in the repo — the formatter, standing in front of whoever
actually wrote the line and whatever they were thinking at the time.

`.git-blame-ignore-revs` is the file that fixes it. GitHub reads it with no setup
at all, so the blame view on the web is already right; locally it needs
`git config blame.ignoreRevsFile .git-blame-ignore-revs` once, which is now step
two of the dev install in CONTRIBUTING alongside the editable install, since a
contributor who never runs it gets the wrong answer silently rather than an
error.

The file's header states the bar for being in it: whole-tree, provably
behaviour-free. That is a deliberately narrow door — an entry here makes a commit
invisible to the tool people use to ask why a line looks the way it does, and a
commit that changed anything real must never be able to hide behind it.

Verified rather than assumed: `git blame` on a line the sweep rewrote now reports
`f07f8c1b` from July, which is the commit that actually wrote it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:08:05 -04:00
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`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>
2026-09-07 01:07:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 5db0c6adb9 The tables that are a contract opt out of the formatter
Groundwork for the whole-repo `ruff format` sweep, kept separate so that sweep
can be a commit with nothing in it but layout.

An audit of `ruff format --diff` found the sweep is mechanical where it counts —
152 of 153 files come out AST-identical, the suite passes on the formatted tree,
and `meshterm specimen` is byte-identical on both platforms — but it found three
things worth handling first.

One is a defect. `test_a_contact_the_device_never_held_is_still_removed_here`
opens its docstring on a quote character, so the formatter breaks up the run of
four quotes by inserting a space, and that trips `D210`. A straight sweep would
therefore have taken `ruff check .` red — the one commit whose entire point is
that both gates pass. The quoted phrase becomes curly quotes, which is already
how the rest of the tests spell a quotation, and the collision goes away.

One is readability. `sparkline` ends on a subscript of a call that no longer
fits, and the formatter's answer is to hang `[` newline `0` newline `]` off the
end. Naming the intermediate says the same thing in two plain lines.

The rest are fences. Six tables in this codebase are hand-packed on purpose: the
glyph map and the fold table read as two columns because that is how you check a
substitution; `FONT_RANGES` is a device-verified inventory that a reader scans in
rows; `_RAW_ROW_SKIP` groups its fields by which part of a packet they came from;
`_NODE_SLOT_HEXES` runs the wheel in 60-degree steps with the angle in the lane.
The formatter's layout is all-or-nothing — one line, or one item per line — so it
would turn `FONT_RANGES` into two hundred lines and flatten every grouping into
an undifferentiated list. `# fmt: off` says these are data laid out to be read,
not code that happens to be wide.

`test_theme16`'s heat ladder is fenced for a stronger reason: it pairs two steps
per row under one comment naming the band, so exploding it would leave
"under 5 minutes — white" attached to the second half of its own pair and the
first half unlabelled. That is not alignment loss, it is a comment that starts
lying.

Not fenced: the `heat.*` ladder inside `MESH_THEME`. It wants the same treatment
and cannot have it — ruff honours `# fmt: off` for a statement, and those rows
are elements inside a dict inside a call, where the marker is simply ignored. It
loses its aligned comment column in the sweep, along with about ninety other
lanes across the repo. Each comment stays on its own entry, so nothing there
starts lying; if the column is wanted back it needs the whole `Theme(...)` call
fenced, which is a bigger decision than this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:04:57 -04:00
Jean-Pierre MartineauandClaude Opus 5 8d8c8b0f2b The basemap user agent is one an operator can actually answer
`MeshTerm/0.1 (+https://github.com/; mesh node map)` went out on every
OpenFreeMap tile request and was wrong twice over: the URL is a bare host
nobody can visit, and `0.1` was a hand-written number that had already
drifted from the package's `0.1.0` and would drift again on the next bump.
OpenFreeMap issues no API key, so this header is the entire handle a tile
operator has on a client — the difference between being emailed about
traffic and being blocked for it.

The URL now points at the real repository, and the version is the package's
own `__version__`, which is what every other surface that shows a version
already reads (the About page, the splash wordmark, the menu mark, a trace
export). That is the single source of truth here, so reusing it is what
keeps the string honest.

Deliberately *not* `importlib.metadata.version("meshterm")`, despite that
being the usual advice: MeshTerm is run straight from a checkout at least as
often as from a wheel — this repo's own venv has no `meshterm` distribution
installed, nor does the PicoCalc deploy — so the metadata lookup would raise
`PackageNotFoundError` in exactly the setups it is developed and tested in,
and a fallback around it would be a second version literal to keep in step
with the first. Reading the package attribute has no failure mode at all,
which is also why the value stays a module constant rather than becoming a
function: it never changes, computing it cannot raise, and the tile fetch
path is no place to discover either.

Three tests in the basemap section of `test_map.py`: the string is exactly
the repo, the live version and the descriptive tail; it rides the header of
the request that actually leaves; and the module still imports with a real
version in it when the metadata lookup is made to fail outright, which is
the regression guard against someone reaching for `importlib.metadata` later
without a fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 00:48:41 -04:00
Jean-Pierre MartineauandClaude Opus 5 40ecf5151d The tests' docstrings lead with a sentence too
The last 122 `D205` sites, which takes `ruff check .` to zero.

Same shape as the package pass and just as additive: these were a paragraph
with no summary, so each one gains a plain sentence saying what is being
asserted, and every word that was there moves down into the description. The
dense ones benefit most — `test_pathline`'s 23, where "A seam is one cell, not
two" now stands alone and the chevron mechanics explain it underneath, and
`test_tui`'s wide-glyph repaint test, whose seven lines on prompt_toolkit's
relative cursor now sit under the one line that says what the test pins.

Four module docstrings (`test_advert`, `test_gallery`, `test_platforms`,
`test_repeater_admin`) opened with "X tests: a, b, and c" and now name the
subject on the first line with the list below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:37:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 466fd33d34 The package's docstrings lead with a sentence
`D205` across the 20 sites in `meshterm/` and `scripts/`, which leaves the
package and the scripts fully ruff-clean.

None of these were a summary that happened to wrap. They were a paragraph with
no summary at all, so the fix is additive: a plain sentence saying what the
thing *is* goes on the first line, and the prose that was there becomes the
description under it. "The transport of the current/selected connection:
``"serial"``, ``"ble"``, ``"tcp"``, or ``None``" now opens with "Which
transport the current or selected connection uses" and lists the four values
below; `_emit` leads with what it returns before the four paragraphs on why a
wide glyph forces a whole-row repaint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:30:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 4545173c65 Every public callable says what it is for
The 46 missing docstrings, written rather than generated.

Most of the test ones already carried the explanation — as a comment sitting on
the first line of the body. That prose is the docstring; it was only in the
wrong place, so the config editor's PIN and lane tests keep their own words and
lose the duplicate comment. The rest are new: the two `repo` fixtures, the
counting `FakeDevice` in `test_device_state` (each method now says what it
answers *and* that it counts the call), the PicoCalc chrome and 16-slot theme
tests, and three one-offs.

In the package: `PathLine` and `FastRenderer`'s constructors point at the class
docstring that already documents their arguments rather than repeating it,
`SelectItem.__post_init__` and `courier_screen`'s opener say what they cache and
why, and `emoji_width.create_content` explains the memo it wraps the line lookup
in. `flash.py` and `uart1-mux.py` get theirs too, and `tests/__init__.py`
finally says what the package is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:28:01 -04:00
Jean-Pierre MartineauandClaude Opus 5 b5006cc415 Every line fits the hundred columns it was configured for
`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>
2026-09-06 20:22:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 ca0023f5b7 The import blocks hold only imports
`E402` read as seventeen deliberate late imports and was in fact two accidents.
A module constant had settled into the middle of `packet_viewer`'s import block
(`_BODY_CACHE_MAX`, now below it), and ten test modules put their `_FIXTURE`
path or a small helper above the last import — the `tests.conftest` screen
reader, which now joins the block it belonged to.

`B008` is the opposite case: all six are Typer declaring a CLI parameter by
calling `Argument`/`Option` in the default, which is the framework's API and
cannot be written another way in this style. The objects it returns are inert
sentinels no command mutates — exactly the thing `extend-immutable-calls` is
for, so the rule now knows about them instead of being silenced per line.

`.claude` leaves the lint's reach: those are helper scripts for the skills, not
package code, and CI has no reason to gate on them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:17:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 e9a4f644d8 A line variable is called a line, and a format string is one string
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>
2026-09-06 20:14:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 61b6387269 Every zip says whether its sides are the same length
`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>
2026-09-06 20:12:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 71b16ec7b7 The annotations, imports, and dead locals answer to ruff
`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>
2026-09-06 17:26:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 dda0a38ed0 The cooldown countdown centers its clock, reason, and Cancel chip
The wait dialog drew its three lines flush left while every other dialog in
the app centers its body — so the Cancel chip sat against the border, reading
as an afterthought beside the button rows it is a sibling of. It now centers
line by line the way ButtonDialog and ProgressDialog do, and takes the same
+12 breathing room in its content-sized width (49 cells at its widest, still
inside the PicoCalc's 53).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 12:10:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 8b724d270b The shared Claude settings stop pinning one account's home
.claude/settings.json carried an absolute Windows path with the account name in
additionalDirectories -- 568 of 582 commits deep. It cannot become a $DEV_
reference, since additionalDirectories takes literal paths, but it does not
belong in the shared file either: settings.local.json is the per-machine,
already-gitignored half of that pair, and this is what it is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 03:12:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 8a03cadc4e The deploy scripts default to a user anyone can be
DEPLOY_USER and MT_USER were overridable from the environment since the commits
that introduced them -- that part was always right, and is why they never moved
to .dev.env: they run on the target device, where a gitignored file on someone
else's workstation does not exist.

Only the fallback was wrong: it named one person, so a stranger who runs the
setup script unmodified gets a Linux account named after the maintainer. It is
`meshterm` now, and the comments describing it say the knob rather than the
value.

Nothing changes for the Lyra provisioned under the old name: these are one-time
setup scripts, and re-running one takes DEPLOY_USER set to the old name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 02:45:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 caac83c64a The developer environment hides where dotfiles hide
dev.env and dev.env.example become .dev.env and .dev.env.example, and the five
skills that name them follow. The .gitignore rule takes the dot too; it matches
the exact name, so the example stays tracked while the real one stays out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 02:09:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 bcf387c620 The test fixtures stop being a picture of one person's mesh
Every node in the suite was named after hardware on JP's desk: YUL-Cartierville
and YUL-Poly are repeaters he runs, two more were the BLE
companions, another his own handle, and a long tail of YUL-Far / YUL-Me / bare
YUL besides. A stranger reading the tests learned his callsign, his city and his
gear before learning what the tests check.

They join the family the suite had already invented for exactly this — Alice,
Lounge, Yagi-Repeater, hub-1: Hilltop-Repeater, Lakeside, Waymarker, Homestead,
and Hub for the 3d-keyed node. Hub is not a new name; test_device_config already
called that contact Hub, so the suite was carrying two names for one fixture.

Replacements are the same length as what they replace, so every width budget,
truncation assertion and gallery line is unchanged. The two map tests that type
a literal query picked `yu`/`yul` to match the old name; they type `hi`/`hil`
now, same length, same single match.

Left alone on purpose: the About page and the donate link, where the handle and
the repeaters are the point, the hand-drawn splash art, and the XIAO README
reporting what JP's own radio advertises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 02:01:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 892aefa11b The machine you develop on stops being written into the repo
Every device address, login, key path, password and local venv path in the
.claude/ skills moves to dev.env at the repo root: gitignored, with a tracked
dev.env.example showing the shape. The skills now name $DEV_* keys and say to
read dev.env first, so they still run -- against whoever's hardware.

Two of the values were not secrets so much as a wrong assumption. The device
paths under that home directory are now $HOME-relative in run_instrumented.sh,
instrument.py and drive_console.py, and the deploy docs point at $DEPLOY_USER
and $KEY_PATH, which those scripts already parameterised. Hardcoding a home
directory was only ever correct for one machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:33:38 -04:00
Jean-Pierre MartineauandClaude Opus 5 4d8ba0fc5e The profiling skill stops pointing at a file that is gone
.claude/plans/ is deleted from the working tree along with the notes it held.
The profile-picocalc skill closed on "where the numbers live", naming
responsiveness.md; it now says to put the baseline, the breakdown and the tour
in the commit message of the change they justify, where a number stays attached
to the diff that earned it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:18:10 -04:00
Jean-Pierre MartineauandClaude Opus 5 5a2449ebe3 The working notes stay on this machine
.claude/plans/ and housekeeping.md were internal planning — phase worklists,
hardware addresses, unshipped ideas — tracked alongside the code they planned.
They stop being tracked here, and .gitignore keeps them from coming back.

The three source comments that cited .claude/plans/picocalc-platform.md now say
what they mean on their own: a pointer into a file the reader cannot open is
worse than no pointer.

housekeeping.md is deleted outright; it will be replaced with something better.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:15:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 95276dd1b1 The map opens without being told how much of the mesh to frame
`meshterm map` with no flags crashed on its own bounds check: --fraction
defaults to None so the preference can answer for it, and the guard compared
that None against 0.0. The one invocation nobody qualifies was the only one
that could not run.

The guard now only speaks when a fraction was actually passed. It still earns
its place next to Typer's own min/max, which admit exactly 0.0 -- a fraction
that frames nothing.

The command had no test at all, so there is a seam now: the CLI is registered
onto a throwaway Typer app with run_tool_command stubbed, and the params the
command builds are read back. Bare, zero, and both ends of the range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:10:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 d3639e0ceb The main menu pads its icon column like every other list
Preferences' title sat a column left of the twenty-three rows above it. Its icon
is the reason: the terminal draws the gear in one cell where it draws every other
menu mark in two, and the menu was writing `icon + " "` -- the exact bug icon_lane
and icon_mark were built for on the node page in September, on a list that had
since been the only one taught the lesson.

The menu is the list most likely to drift, too, because it is the one that never
declares its icons: it takes whatever the tool registry hands it, so the next tool
to arrive with a narrow mark would have re-opened this on its own. The column is
now measured across every visible tool and each mark padded out to it, which on a
platform with no icon lane measures zero and leaves the titles flush, as before.

The label builder lifts out of the menu loop to _menu_labels so the alignment has
a seam to be tested through. The test asserts the shared start column rather than
the gear, and first asserts the menu still mixes both widths -- a menu that lost
its narrow icon would otherwise keep passing while proving nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 00:27:23 -04:00
Jean-Pierre MartineauandClaude Opus 5 34092613f1 The tests read the About pages as written, not as scaffolded
The four pages were scaffolds when their test was written: three headings apiece
standing in for a shape not yet settled, and under each one a muted placeholder
line in the app's empty-state voice. All four have been written since, and six
assertions were still describing the scaffold -- a suite that fails on the work
being finished is a suite nobody can read a real regression out of.

The headings are now one list, PAGE_HEADINGS, because two tests ask two things
of the same fact -- that they render in order, and that each is a stop the sticky
heading can pin -- and a page that grows a section should not be able to satisfy
one while drifting from the other. That list is the pages as they stand: the
author page's four, and the platform and future sections About MeshTerm gained.

The placeholder test loses its subject entirely, so it goes. Its neighbour, the
one holding prose to its indent block, was *passing* on the same absence -- it
looked for placeholder lines that had escaped to column 0 and found no
placeholders at all. It now asks the question directly: on the support page,
which carries neither of the two blocks that are page frame rather than body,
nothing but a heading may sit flush.

The licence check still wanted MIT. It reads Apache 2.0 now, and also holds the
page to the trademark sentence -- the part that says what the licence does not
cover, which is the half a fork most needs to have been told.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 00:10:31 -04:00
Jean-Pierre MartineauandClaude Opus 5 1e4fad4d2d A wait you can see is a wait you can leave
Every feature paced its own bursts privately, so nothing knew what anything else
had just put on the air, and a wait was a frozen screen -- which on a radio app
reads as a dead link rather than as courtesy.

There is a shared clock now. Every real transmission marks it, in the five send
methods that are the only places the radio is actually spoken to (the simulator
marks the same points, so the whole thing is walkable with no hardware). Anything
about to transmit can ask what is still owed.

Two clocks, because two kinds of transmission cost the mesh very differently. The
general one is the transmit cooldown, whose minimum is now 0.1s -- a floor, not an
off switch; there is no zero for duty-cycle courtesy. Flood adverts get their own,
floored at 5s and defaulting to a minute: every repeater in range rebroadcasts one,
and it waits out both clocks.

Under two seconds the wait just happens -- a dialog that flashes up is itself the
interruption. Over it, a countdown floats over whatever asked: the seconds left,
why it is waiting, and a Cancel chip. Enter and Esc both take it, sharing one hint
atom because they do the one thing on offer, and cancelling abandons the action
rather than merely the wait. It resolves itself at zero.

The suite resets the clock between tests. It is process-wide, which is right for a
session and wrong for a run: one test sending something left a cooldown standing
for the next, whose scripted flow then stopped on a countdown nobody wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:19:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 9de428f6ca A cursor stops at the end of its list
Down off the last row wrapped to the first on a good third of the app's
lists, and the select screen shipped that as its default -- twenty-one
call sites had already opted out one at a time, each writing wrap=False
for its own reason. The reasons were all the same one, so the flag is
gone and clamping is simply what a cursor does.

The window follows the highlight, so a roll doesn't move a pointer: it
hauls the whole list back to the other end and flips its edge markers,
which reads as the screen changing under you rather than as one step.
Held-down arrows now settle at an end instead of looping past what you
were trying to reach.

The hand-rolled cursors came along: the trophy case's, the trace and TX
optimize action rows, the reorder list -- where the ungrabbed cursor
wrapped while the grabbed one clamped, so the same two keys meant
opposite things in the two modes -- a prompt's completion list, and a
dialog's buttons.

Tab is the exception, and it stays a ring: it has no reverse of its own,
so on the last chip it comes round or it dead-ends. Same for the three
picocalc F-lane chips that are single-key advancers -- Sort, the node
page's tab strip, the Time Machine's window -- where the lane offers no
way back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:04:17 -04:00
Jean-Pierre MartineauandClaude Opus 5 407b893819 A row is named after the feature it belongs to
The section holding the sweep bounds was called Transmit power, which is also
the name of a device setting one screen over -- so it read as a second place to
set the radio's power rather than as the search that finds one. It is TX
optimize now, after the feature it configures, and its rows are Range min and
Range max: the two ends of what that feature sweeps.

Their descriptions lost "the power search" along the way, which had been a third
name for the same thing. The heading says which feature; the rows only have to
say which end.

Pause between sends goes back to Transmit cooldown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:58:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 03f8e1496f Every row says what it does, not what it is
The descriptions were written for someone who already knew the answer. A user
reading "Airtime budget: duty cycle = 100 / (factor + 1) %" or "Per-hop
path-hash size; larger resists collisions, adds overhead" has to ask, which is
the opposite of what a description is for.

So both registries now answer the reader's actual question -- what does this do
to me -- in as few words as it takes. "Wider is faster, narrower reaches
further" instead of naming the unit twice. "Caps how much of the air we use;
0 = no cap" instead of the formula. "How loud this radio transmits."

Labels went with them where the name was ours to choose: Sweep floor and Sweep
ceiling are Lowest power and Highest power, Opening frame is Map fit, Silence
alarm is Alert after, Basemap source is Map tiles. The radio's own names stay --
someone matching Spreading factor against their phone app should find the words
they came for. History kept is just History.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:51:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 fe4780e070 The page keeps what MeshTerm alone decides
Background adverts came off it. The cadence is the *companion's*, remembered per
public key and set on Device config, so a preference could only ever be the value
a radio starts on before you give it one -- a default behind a default, and a row
that says "newly seen device" while meaning "your own radio, the first time".
adverts.json answers for it again; the store is back to the constants it had.

Transmit cooldown now defaults to 5s rather than 1s. It paces every burst we are
allowed to make, and on a shared mesh the polite number is the larger one.

The last-column choices answer their own row: it asks whether to use the column a
terminal hides, so the values are auto, yes, no instead of naming the mechanism a
second time. YAML 1.1 reads a bare yes/no as a boolean, so a hand-edited file that
writes the obvious thing is understood rather than silently dropped -- we still
quote ours on the way out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:46:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 cef7a764af How MeshTerm behaves gets a page of its own
Three kinds of "setting" were tangled into two files and one of them had no
screen at all. The radio's settings live on the radio and are edited on Device
config. Machine setup -- where the database is, which companion to talk to --
stays in config.toml, a text editor's job. Everything in between was app
behaviour: six keys sitting in config.toml with nothing to open them, and a
dozen more that were only module constants a reader could never reach.

Those are preferences now, and they have a page. core/preferences.py is a
PrefSpec registry in the shape of the device-config one -- group, type, bounds,
help, and a default -- and the page is built from the same parts as the device
editor, deliberately: one grouped lane-aligned list, current -> new staged in
warn, Apply/Back appearing only once something is staged, the shared discard
confirm on the way out. Two editors that stage values should not be two screens
to learn. What it adds is a Defaults section holding one row that stages every
changed preference back to its built-in value; it is drawn only while a reset
would do something, and it stages rather than writes, so it is as reviewable and
as Esc-discardable as any other change and the file is still only ever written
by the one action at the bottom. Its rows scroll sideways where the device
editor's don't: the explanation is the widest thing on the row and the only part
that runs past the edge, so <-> slide it under a pinned setting and value.

The file is preferences.yaml and holds only the values you disagree with the
code about -- grouped and commented, each entry above its help and its default.
That is what lets a later change to a default reach an install that never asked
to be exempt; delete a line and the code takes over again. A hand edit gone
wrong costs that line, never the session.

Every preference is wired to something that reads it. ctx.preferences is the
explicit path; preferences.current() is for the render layer, which has no
context to reach through, the same reason get_platform() exists.

The About pages come along into a This app section, the third scope after this
node and other nodes, with Preferences leading it -- the one row there that
changes MeshTerm rather than describing it. A section of one would have broken
the menu's own no-bucket-of-one rule, and the app's scope is the honest home for
both halves anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:36:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 c6892f9b6c The editor covers the PIN too, on one chord for both
Device info stopped printing the pairing PIN; the Device config editor one menu
over still listed it in plain digits, which meant the concealment held only until
the reader walked one screen sideways. It masks there now on the same terms —
and a *staged* PIN masks as well: one typed a moment ago is still a PIN, and a
row that uncovered itself the instant it was edited would leave the secret up for
the rest of the staging session.

The reveal moves to `^S`, on both pages. It was a bare `p` on the info page,
which a read-only body can afford; the editor cannot, its letters being
find-as-you-type, and one value that comes out from under a different key
depending on which screen you are standing on is a second thing to learn for
nothing. The chord also has to be meant — no PIN appears because a hand brushed a
letter. `^S` is XOFF's key and safe for the same reason `^Q` is not XON here: raw
mode clears IXON/IXOFF, which the chord table already relied on. Both pages say
`^S show PIN` / `^S hide PIN` in the footer and carry the same F3 `Reveal`/`Hide`
chip — F3 being the slot a select list spends on its own verb, free here because
this list hands out no deletable rows.

The editor's list becomes `_ConfigMenu`, which owns the toggle and the one row
builder both it and the staging loop call: a toggle is a refresh like any other
(`replace_items` follows the highlighted row by value and keeps the typed
filter), so uncovering the PIN while three letters deep in a filter leaves the
reader exactly where they were. `_menu_items` grew the flag and hands it to the
value lane; nothing else in the row shape moved.

Both pages join the gallery, each in both states — the config editor had no
specimen at all before this, and the revealed state is the widest its PIN row
gets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:28:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 42937ce411 The pairing PIN waits to be asked for
Device info is the page you open to read your radio out to somebody, the one that
gets screenshotted into a forum thread and left up while a colleague leans over
your shoulder. Every value on it is yours to give away except one: the BLE
pairing PIN is what lets another phone onto the radio, and it was sitting in the
Identity section in plain digits alongside the node name and the coordinates.

It now draws as the field rather than the value — six mask bullets, the same
glyph a password prompt types in, promoted to marks.MASK_MARK so the two can
never drift apart. Six is the width of the *field* (the setting's own bound is
999999), not of what is in it: masking per character is right while someone is
typing and counting their own digits, and wrong for a stored secret, where it
would say how many to guess. A device that reports no PIN keeps its "?" — an
absence is not a secret, and a "?" behind bullets would promise something to
uncover.

`p` uncovers it, and `p` puts it back. A bare letter is a read-only page's idiom
for its own verb (the Time Machine's `w`) and this page has no find-as-you-type
to compete for it; the footer names what the press would do *now* — `p show PIN`,
then `p hide PIN` — and on the console the same toggle is F3, `Reveal` then
`Hide`, since the lane is the only place that platform can learn the key exists.
A device with no PIN advertises neither: the atom drops and the slot stays empty,
because the action isn't a thing on that page at all.

The page is therefore its own screen now (ui/device_info_screen.py) rather than a
generic result window, and ScrollScreen grows `replace_content` — replace_items
for a read-only body — so the toggle swaps the page under a reader who keeps
their scroll position; nothing reflows, six cells become six. The CLI has no key
to press, so `meshterm info` and `config show` print it masked and
`config get device_pin` is the way to name it deliberately: one rule, which is
that the whole-device dump never carries the PIN. The editor one menu over still
lists it plainly — you go there to change it, a row at a time, and its letters
already belong to find-as-you-type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:13:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 1d951052bf The widest lane gets the widest column
The device config table let its two narrow lanes size themselves to their
content first: SETTING stretched to the longest label it happened to hold and
CURRENT to the longest value, and DESCRIPTION — the one lane whose content is
systematically the widest — lived on what was left. That was 19 cells of 72, so
nearly every explanation wrapped two or three lines deep and the reader scrolled
a 67-line table to read twenty settings.

The narrow lanes are capped instead, at 24 cells for the setting and 15 for the
value, and their cells are pre-folded to fit so `no_wrap` still holds each lane
to exactly that width. DESCRIPTION keeps the whole remainder: 27 cells, and the
table falls to 52 lines. Four rows fold — three telemetry labels and the
path-hash value — and every one of them sat on a wrapping description already,
so the fold costs nothing the reallocation didn't already buy back. A folded
label hangs two further in, so a continuation can never be read as a section
heading at column 0, and a value folds before its parenthesised label rather
than through it: `1` over `(2-byte hashes)`, not a torn `1 (2-byte`. A long node
name — the field is 31 bytes — now folds too, where before it dragged the whole
lane across the screen.

The cap only exists to buy the prose room, so where that lane isn't drawn it
lifts. Two places don't draw it: the PicoCalc, whose 53 columns are spent by the
setting and its value, and the staged view, whose third narrow lane leaves
nothing for prose. Rich was already dropping the column outright at both widths
— now the table says so itself, and shreds nothing on the way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:52:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 c4a718c28a Both ends of the walk are cut the same way
The head was cropped and cracked; the tail still dropped whole hops behind a ⋯,
because that mark was left over from when the fit itself was hop-based. So one
line carried two different claims about what was out of view, and the reader had
to know which end they were reading to know which one they were being told.

The trail is now one line read through a window, the shape every other path row
that outgrows its lane already has (the node page's routes, the Message paths
lanes, a trophy row under ←→): composed once at natural length, cropped at
whichever edge the walk continues past, each cut wearing cut_mark — the chip
sheared on its own half block, the faint … in arrow mode. Nothing is elided, so
nothing says it is.

←→ slide by the app's own 8-cell step rather than a hop: hops were the old fit's
unit, and a cropped line has no hop boundaries to land on. cut_from goes with
them — the window covers the unscrolled case in the same six lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:14:43 -04:00
Jean-Pierre MartineauandClaude Opus 5 23e89633fb The walk's start is cropped, not given up whole
The breadcrumb fitted itself with ellipsized(ELIDE_HEAD): the head hop went
behind a leading ⋯ and whatever fitted after it snapped right. That mark cost
three cells and, worse, the whole hop it was standing in for — a 46-cell lane
opened on "⋯ › Repeater-06" when it had room for the tail of Repeater-05 too.

The trophy case has always cut its rows the other way: composed at natural
length, handed over whole, cracked at the lane's edge in the sheared chip's own
fill. Same crop here, mirrored to the end the trail is anchored on — cut_from,
cut_to's head-side counterpart, added to the widget so no surface hand-rolls it.
The trail draws whole while whole fits and crops its beginning when it doesn't;
a crop lands exactly on the width, so the right snap comes free.

Each end now carries the mark that is true of it: the head opens on the crack,
which says this segment continues, and the scrolled tail keeps the ⋯, which
says whole hops are missing — because off that end they really are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:02:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 5ca5ea7d56 The viewer's top is two stops as well
Enter on the live feed names *this* packet, so the dialog rightly opens holding
one — but there was then no way back to the stream from inside it. The reader
who walked up to the newest packet hit a clamp, and following the feed again
meant Esc, ↑ on the list, and re-opening whatever had landed since.

The pin is now the viewer's stop too, on the same key it is on the feed: ↑ off
the newest packet, and Home from anywhere, holds the top *position* rather than
a packet, so every arrival becomes the card being read. The title says
`following` where it would say `n/total` — what changes under the pin is the
card, not the number — and ↓ steps back off onto whichever packet is showing at
that moment, keeping the scroll the reader had in it, because that card is the
one they were already reading.

Both ends move together: the viewer tells the feed when it pins (`on_pin`), the
way it already tells it when it lands on a packet, so a dialog closed while
following leaves the list following, and one closed on a packet still lands on
that packet. Only a live list has the stop at all — over a snapshot there is
nothing to follow, so ↑ on the newest packet clamps as it always did.

The footer follows the moves rather than the count: `↓ hold packet` on the pin,
`↑ follow · ↓ older` one stop below it (two keys doing different things, so an
atom each), and the plain pair everywhere else. On the console the `Newest`
chip reaches the pin, exactly as the feed's `Top` chip does, which is also why
the jump chips light over a live list holding a single packet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 12:36:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 24e4da9176 The walk hangs off the edge its start isn't on
The breadcrumb snapped flush right whenever the line was fitted, which was
right at rest but wrong the moment ← had scrolled the walk's head back into
view: a line showing its own rounded start chip sat pushed against the far
edge with a lane of blank page in front of it.

Alignment now follows one thing, whether the start is on the line. It fits ⇒
flush left, where a path that begins at its beginning belongs; it doesn't ⇒
the right snap, which is what a cut head looks like, holding the ⋯ against the
edge so it reads as "the line continues off this side" rather than as a gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 12:33:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 6924ff7f45 The presets are MeshCore's, not ours
Every one of the region presets named a modulation no mesh runs any more, and
two of them named a frequency nobody was ever on.

MeshTerm's list was three regions on 250 kHz / SF11 — the shape MeshCore
shipped with — plus two trade-off rows built on the EU frequency. Through 2025
most regions left that modulation for a narrow 62.5 kHz one, so the EU row is
what MeshCore now calls "EU/UK (Deprecated)", the US row had the right
frequency with the wrong modem (910.525 is SF7/BW62.5/CR5), and "AU / NZ 915"
at 915.525 was not a preset at all: Australia is 915.800 wide, 916.575 narrow,
923.125 in SA/WA and QLD, and New Zealand is 917.375. Four parameters have to
match for two nodes to hear each other, so a stale table is a radio staged onto
an empty channel.

The list is now MeshCore's own suggested settings, mirrored verbatim — names,
order and values — from what its apps, the web flasher and config.meshcore.dev
all read at api.meshcore.nz/api/v1/config. Twenty-three regions, including the
two MeshCore still lists under its own "(Deprecated)" names, because nodes that
never re-tuned are on them and picking one is how you go and listen. The names
are spelled as MeshCore spells them so the row can be read against the phone
app.

Four regions also name a path-hash size, which the preset now stages alongside
the radio fields as the firmware's mode (size - 1), exactly as MeshCore's own
config GUI converts it. The rows moved to the two-lane menu shape — name, then
the parameters in MeshCore's print order — which fits 53 columns where the old
one-line title ran to 85, and the list opens on the preset the radio is already
tuned to, staged values included, so picking a neighbour is a comparison rather
than a guess.

The trade-off pair is gone with them. They were ours rather than MeshCore's,
they pinned a deprecated EU frequency onto whoever picked them, and the wide /
mid / narrow choice they were reaching for is one the region list now makes
properly per region.

The simulator boots on the firmware's build defaults too (869.618, BW 62.5,
SF8, CR5 per MeshCore's platformio.ini), rather than on the settings the EU
mesh left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:17:57 -04:00
Jean-Pierre MartineauandClaude Opus 5 0b719a4736 The client nobody closed, closed
A Bluetooth session that lost its companion could not get it back, and the
reason was one call that four separate layers each declined to make (JP,
2026-09-04).

When the peripheral is what goes away, bleak fires `BLEConnection`'s
dropped-link callback, which restores the connection's fields to what the
caller originally passed — setting `client` back to None and letting go of the
live object. `BLEConnection.disconnect` then guards on `self.client and
self.client.is_connected` and has nothing to close; `ConnectionManager
.disconnect` guards on a flag its own drop handler already cleared;
`_force_close_transport` reaches past both, but only as far as that same
`BLEConnection`, whose disconnect is the no-op above. Every guard is locally
right and the sum of them is that `BleakClient.disconnect()` is never called
on a link that dropped by itself.

On Windows the WinRT `BluetoothLEDevice` and its GATT session then stay open
for the life of the process, and every later connect to that address is served
a broken service table: the link comes up and the UART subscribe fails with
"Characteristic 6E400003-… was not found!". The device advertises at -61 dBm,
answers a hand-written APP_START over raw GATT with a valid SELF_INFO, and is
unreachable to this app until it restarts — while a fresh process connects to
it in 0.6s. That asymmetry is the whole tell, and it is what turned every
reconnect attempt into a fast, inexplicable failure.

So the client is ours now: taken at connect while the library still has one to
give, and closed unconditionally at teardown — never guarded on `is_connected`,
because the case that matters is exactly the one where it is already False.
Bounded and silent, since a teardown may not raise or hang, and a no-op on the
transports that have no client to hold.

Measured on the real companion: the session drops, is heard again 7.2s after
the device comes back, and is rebuilt 0.97s later — 8.3s from drop to live,
where it previously never recovered at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 03:49:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 c090db829c A returning companion is heard, not guessed at
Bluetooth notices a drop cleanly — the client's connection flag flips the
moment the peripheral goes — but the way back was two guesses stacked on each
other, and neither held (JP, 2026-09-04).

The reconnect flow gave serial a real question to ask ("has the port
re-enumerated?") and gave Bluetooth nothing: it simply attempted the connect
every couple of seconds. Each of those blind attempts drove bleak's own
internal address lookup into a device that wasn't advertising, which is a slow
failure, so the attempts sprawled across and past the window in which the
companion actually came back. And when one finally did land, it landed holding
the `BLEDevice` the startup scan produced — a handle that names a peripheral
the OS no longer has, because a device that power-cycles comes back as a new
one. The session opened over BLE and then, having dropped, could not find its
way back to a radio sitting a foot away with its light on.

BLE now gets the same shape serial has, asked of the only registry Bluetooth
keeps: `find_ble_device` watches for that one address's advertisement and
returns the instant it is heard. The answer arrives with a live handle, so the
listening and the fixing are one step — the reconnect opens the peripheral
that is on the air now, and `reconnect(ble_device=...)` prefers it over
anything the picker scanned. A miss costs a scan window rather than a failed
connect, and nothing is attempted against a device that isn't there.

The ordering is load-bearing, so the teardown splits out as `release_link` and
runs before the first scan: a companion we are still holding a link to does
not advertise, and the old flow's first act was a connect attempt that dropped
the dead client on its way past. Waiting first would have waited out a device
that was powered on and silent purely because we hadn't let go of it. The
resume intent is captured there and held across retries exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 03:11:16 -04:00
Jean-Pierre MartineauandClaude Opus 5 99bfdade64 The feed opens on its columns, not on a status line
The live feed's heading said three things and none of them earned a row:
the order is what the TIME lane reads down, Enter is already named by the
footer hint and the F-key lane, and the live-light reported the hub's
subscription rather than any traffic — green over a silent mesh, and the
one screen that never starts the hub itself. Dropping the line gives the
feed a content row back, which is a row in thirteen on the PicoCalc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 03:03:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 12d42a8804 Off the highlighted route, a node recedes
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>
2026-09-04 02:38:38 -04:00
Jean-Pierre MartineauandClaude Opus 5 a5fd4357f1 A windowed list stops at its ends
Four screens hand their rows to a ListWindow and let the highlight drive it:
the mesh walk's links, the path composer's suggestions, the node detail's
routes, and Message paths' arrivals. All four wrapped — ↓ off the last row
landed on the first — and in a windowed list that is not one step. The window
follows the highlight, so the wrap hauls the whole list from the tail back to
the head and flips both edge markers on the way: it reads as the screen
changing under the reader rather than as a move they made (JP, 2026-09-04).

All four clamp now, the way the live feed's cursor already did and the way a
grouped select list does with `wrap=False`. Held-down arrows settle at an end.
The rule is written down where the pattern lives, on `ListWindow` itself —
each screen owns its own cursor, so the class can state it but not enforce it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 02:20:20 -04:00
Jean-Pierre MartineauandClaude Opus 5 0eb9845cd9 The graph holds still while the arrivals scroll past it
Message paths laid its whole body out as one strip — quote, fan, captions,
then every arrival — and let the frame scroll it under the selection. Walk
far enough down a busy message and the picture the routes are being compared
against slides off the top of the dialog, which is the one thing the reader
was looking at (JP, 2026-09-04).

The head is pinned now and only the list moves, the app-wide windowed-list
pattern the node detail's Routes tab already runs: the quote and the fan take
their lines, the arrivals window into what is left with faint `↑ n more` /
`↓ n more` edge markers, and the fan compresses its own lane pitch before the
list would lose its window — floored at two arrivals and a marker, because
comparing one arrival against another is what this dialog is for. PgUp/PgDn
now page the selection by a windowful of arrivals rather than by body lines,
and both they and the F-lane's chips say so only while there is more than one
arrival to move between.

The variable-height fit both screens need moves to `ListWindow.fit_blocks`,
beside the single-line `fit` it is the sibling of; node detail drops its own
copy and its hand-kept top/page state for the shared window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 02:03:25 -04:00
Jean-Pierre MartineauandClaude Opus 5 6fc4e7afc9 The community has a page, and the page is its invite
A fourth About page, `Join Discord`, sitting between the author and the ask:
what is this, who made it, where is everyone, how do I help. Like its three
siblings it is written markdown under assets/pages, reads nothing and
transmits nothing, and keeps a CLI face — `meshterm discord`.

It is short on purpose. The invite link, and the code for it in a `qr` fence,
because on a PicoCalc there is no clipboard and the code is how a link leaves
the screen at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 08:38:44 -04:00
Jean-Pierre MartineauandClaude Opus 5 155c0b5b21 A QR is drawn from its link, not pasted in as art
The three codes on the Support page were half-block art pasted into
support.md, and markdown had been quietly shearing them. A module row whose
first module is light opens on a *space*, and every CommonMark parser eats
leading whitespace on a continuation line — so four of the fifteen rows in the
Buy-me-a-coffee code, and rows in the other two, were rendered one module to
the left of where they belong. A sheared row is not a QR; a phone gets
nothing.

Markdown grows one construct instead: a fence tagged `qr`, whose body is a URL
and whose rendering is the code for it, through the same `qr.qr_text` widget
the channel and contact share popups draw. So the link is written once, the
code can never drift from it, and a QR looks the same everywhere in MeshTerm
— black on white, which is what makes it read under any terminal palette
rather than inverted on a dark one.

The three fences replace 45 lines of art.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 08:38:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 211f38255b A path field means two different things, and now says which
No, it isn't the ack — ACK is its own payload class and was never in the match.
Both symptoms come from the same place: a packet header's route type, which we
never recorded, decides what its `path` field *is*.

A FLOOD packet accumulates: every relay appends its hash, so the path is the
route it travelled to reach us. A DIRECT packet is the opposite — the path is a
routing instruction the sender wrote and the relays consume — so it says where
the packet is going, and an empty one means the route was used up, not that the
packet arrived in a single leap. Reading every path the first way is what drew a
message from five hops out as having appeared from nowhere.

The evidence is in your own log. The four-hop route on those sends is identical
across four consecutive messages, where a flood-accumulated path varies every
time; it ends on 40ef0f, which is the first hop of everything hairu sends *us*;
and a PATH frame carrying exactly those four hops crosses the log seconds before
the first message that uses it. That is a learned route to hairu being announced
and then used — not a route anything took to us.

So route_typename is stored (schema v15, a `route` column beside the crypto and
addressing ones added for the same reason), and an Arrival carries whether it
was routed. A routed frame with no path has route_known False: it is kept as a
copy that reached us — the count and the time are real — but it is drawn as
"routed · route not carried" instead of a confident zero hops, and it puts no
lane in the fan, because an empty lane there is a claim of adjacency the frame
never made. Frames from before the column read None and fall back to the old
behaviour rather than to a guess.

The round trip is a second bug and needed no new data. The path line closed on
our own ★ unconditionally, and for an outgoing message its head is our ★ too —
so every send drew ★ ▶ … ▶ ★. The graph did the same, DST_NODE having been
hardcoded to us. Both now take a destination: the far end for a message we sent,
ourselves for one we received. It is the rule the line's head already followed —
a path runs between the nodes it went between — applied at last to its tail. A
channel broadcast keeps us at both ends, because there the copies we log really
are our own message coming back.

collapse now keys on the route type as well as the hops, so a route travelled
and a route intended never fold into one row even when the hashes match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 00:16:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 c8c7eab128 A direct message's frames are its own, by the MAC over its ciphertext
The paths on a DM listed a message's own retransmissions, the other end's
traffic, and both of those for every other message sent in the same minute and
a half. On one real bob_caribou message that was 30 rows over 8 paths, of
which 23 were the same two one-hop routes repeated. Those one-hops were the
tell (JP): they were our *own* sends, overheard coming back off the two
repeaters in earshot, correctly showing one hop because one hop is all they had
travelled — but shown under the message as if they were its route.

The channel view has always matched by decrypted content, and asked for here.
A direct frame cannot be read, but it does not need to be: its body is
[dest 1B][src 1B][MAC 2B][ciphertext], and that MAC is a tag over this
message's plaintext. Copies of one message share it; the next message's differ.
frame_addressing already knew where those two bytes were and threw them away,
and the observations table already had a cipher_mac column its channel sibling
used — so a direct message now groups exactly as a channel one does, with no
schema change and nothing decrypted.

Three filters, in order. The frame ran between the conversation's two ends; it
travelled the direction this message did; it carries its group's MAC. The
middle one is doing more work than it looks: both ends' hashes sit on every
frame either way, so matching on the pair alone was what put our sends under a
received message.

The window is clamped by the conversation's own messages rather than a flat
±90 s — nine messages fell inside one such window. The clamp reads only
messages going the *same* way, which the first cut of it got wrong: an inbound
stamp is the sender's clock, so ordering it against our own sends compares two
clocks, and a received message came back with an empty window. The directions
are also bounded differently, because we know different things about them. A
send is stamped by our clock as it leaves, so no frame of it predates it and
all the room goes forward to the next send, where the retries are. A received
message keeps a centred window, halfway to the received messages either side.

Rows are paths now, not frames: identical routes fold into one line carrying
its copy count, its first sighting, and the best SNR that path managed. Twelve
identical lines said "twelve arrivals" where the truth was "two paths, six
times each".

Measured on the conversation that prompted this, whose history predates the
MAC and so exercises only the direction and window fixes:

  outbound "Allo! Ça marche JF"   30 rows / 8 paths -> 2 rows (x3 each)
  inbound  "J'ai pas de ack..."   30 rows / 8 paths -> 2 rows, 6 and 7 hops

which is exactly what the packet log says by hand. Old history keeps the
address-and-time fallback and is billed as such; frames recorded from now on
match exactly, and say so in the same words the channel view uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 23:50:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 2baa8e1bfc The live feed's depth is a packet count, not a clock
Restores the WIP parked on 2026-08-31 before the PicoCalc sync.

The feed seeded itself from `utcnow() - OBSERVATION_WINDOW`, borrowing the
two-hour window the dashboard's rolling stats are bounded by. Two bounds for
one list, and the wrong one usually won: on a quiet mesh the screen opened
nearly empty, not because little had ever been heard but because little had
been heard *lately* — which is the one thing the reader can already see from
the ages in the rows.

So the cap is the only bound, and it goes from 100 to 500. A busy hour runs to
some hundreds of packets, so a hundred rows forgot the last ten minutes at
exactly the moment the feed was worth watching. Depth is close to free here:
the paint is windowed, so the cap costs a pointer per entry and a few
milliseconds at open, never anything per frame. Widening the seed costs
nothing either — `observed_at` is indexed, so the query walks the index
backwards and stops after _FEED_CAP rows whatever the floor.

Measured on a 700-packet history spread over a year: the unbounded seed fills
all 500 rows and reaches 249 days back, where the two-hour window seeded one.

OBSERVATION_WINDOW keeps its name and its meaning, now solely the dashboard's:
it is what the RF health card means by "reception over 2 h", and its docstring
says so rather than leaving the next reader to find two screens sharing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 23:11:30 -04:00
Jean-Pierre MartineauandClaude Opus 5 026e78078e The icon column is measured, not assumed to be two cells
🗑 is drawn in one cell where 💾, 🌍, ⏳ and 📱 are drawn in two, and a command
row that wrote `icon + " "` started its label wherever its own icon ended. So
Delete contact… sat a column left of Archive contact above it (JP). Ten of the
app's lexicon icons are like this — 🗑 ✎ ⚙ ▶ ★ ↻ ↕ ⇄ ⌨ # — so any list mixing
them with the other twenty-seven was out of line; the Contacts tail's 🗑 Purge
against 📂 View archived was the same fault one screen over.

Not an emoji-width entry. The classifier is clear that both authorities and the
terminal agree 🗑 is one cell: it sits outside Emoji_Presentation and carries no
VS16, so the font draws a one-cell text glyph and everyone is right. Forcing it
into the wide set would reserve a cell nothing draws and pull the border in —
the mistake 🛣 already cost the Trophy case. The measurement was never wrong;
the layout assumed it away.

The trace and TX sweep screens had solved this properly and privately, each
with its own copy: declare the marks a list can draw, measure the widest, pad
every mark out to it. That is now menus.icon_lane/icon_mark, THE icon column,
and those two delegate rather than duplicate. marked_label takes the lane so a
mixed list can pass it, defaulting to measuring the single icon — which stays
right for a list whose rows all lead with the same mark, and is precisely what
misaligns one that doesn't.

The node page measures its column once over every action it can offer, so the
group ends aligned however many of archive/restore/delete a given contact
earns. PicoCalc is unaffected and stays that way by construction: it draws no
icon lane at all, so the column measures zero, the separator goes with it, and
the tint lands on the label as before.

The tests assert in display cells rather than characters. That distinction is
the bug — 🗑 plus two spaces and 📂 plus one are three cells and different
string lengths, and an index-based assertion would have called the fix broken
and the break fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:19:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 424c33ac93 The purge is an edit, and archiving is not a deletion
JP's pass over the sweep, and one cut corner it found: the gallery specimen
hand-stamped its percentiles, so three contacts rendered as the 2nd, 7th and
11th of a field of three. The number cannot be written down and still be true
— it is a rank against the others in the same list — so the specimen now
scores through rank_contacts like everything else and reads 10/30/50. The
computation was never wrong; the picture of it was.

The ladder answers in the number the reader is choosing: how many contacts
you *keep*, the one that has to fit the device, with what goes as the muted
remainder. It also stops rounding with round(), whose banker's habit sent 4.5
and 3.75 to the same 4 and made "keep 90%" and "keep 75%" identical rungs on
a five-contact table; ceil is both distinct and the safe direction on a keep.

The preview is editable. Delete lifts one contact out of the sweep and the
list refreshes through replace_items, so sparing the one node you recognised
no longer means backing out to a shallower rung that also spares forty you
didn't care about. Rows h-scroll with the percentile lane pinned out of the
shift — it is your place in a list ordered by it. Neither the scroll atom nor
the keep atom is written into the base hint; the select list splices each in
where its key would act, which is also what keeps all three inside 72 cells.
It is a hub now, held by session.stay: re-running run_screen on a screen that
had already resolved spun forever the first time a row was spared.

Opening a candidate's page from in there offered to delete it, which was two
answers to one question. open_node_detail takes manage=False: the rule is
that management verbs belong to the list a contact lives in, never to a page
opened out of a list about to act on it wholesale.

The confirm is amber and button-only. Two caution tiers exist for two costs,
and a red typed gate here spent the app's loudest warning on its most
reversible bulk action. Red is left to the deletion, which now says what it
is — the key forgotten by both sides, no restore, only being heard again —
and points at the archive as the reversible way to free the same slot. The
page carries all three verbs: archive a live contact, restore an archived
one, delete either for good.

Archived contacts get their own screen rather than a section nailed to the
foot of the Contacts list, reached by a row under the purge carrying the
tally. That needed the shared contact list to stop believing every list has
the same columns: the middle lanes are now a declared tuple of ContactLane
(NAME and KEY stay fixtures), read by the header, the row builder, the sort
metric and the width arithmetic alike, replacing a show_traced flag that had
already made three of those four say "and if it's the trace picker…". The
archived list declares NAME · ARCHIVED · KEY and sorts on exactly those.
ARCHIVED draws as HEARD does — a relative age under recency heat — because
it is the same question about a different event.

One simplification falls out: TRACED sorted on a raw timestamp and opened
descending to mean newest-first, alone among the age columns. Every age lane
now sorts by age with never at +inf, so traced opens ascending like heard,
and reads the same either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:03:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 99cd616ae5 The purge keeps the contacts worth a slot, and archives the rest
A device's contact table is finite, and a busy mesh fills it with whatever
adverts arrived — mostly nodes heard once, in passing, four hops out — until
there is no room left to discover anyone new. The age ladder could only ask
how long ago; it had nothing to say about which of two equally quiet contacts
was the one you would miss.

So contacts are now rated. Six weighted terms, each normalised and summed:
direct correspondence heaviest at 30, then how lately heard (25), how often
(15), channel posting (12), hops (10) and distance (8). Additive rather than
multiplicative, because a product lets one zero annihilate a contact that is
strong everywhere else — a node advertising no position would have scored
nothing however much you talk to it.

The rule the whole thing rests on is that an unknown scores *neutral*. Half a
real mesh advertises no location, plenty of nodes are never overheard as a
relayed frame so carry no hop count, and a channel poster whose name two
contacts share can be attributed to neither — the wire gives a channel frame
no sender key at all. A term that cannot be computed takes the population
median of the contacts that could, so the decision falls to the axes that are
known. Scoring those as zero would have swept by how much metadata a node
happens to broadcast. It is also why the two least reliable terms carry the
two smallest weights, and why distance calibrates against this mesh's own
median spread rather than a constant: 50 km is far downtown and unremarkable
in a valley.

A newcomer gets 25 points decaying to nothing over a fortnight, because every
other term rewards accumulated evidence and a node first heard this morning is
otherwise arithmetically identical to one quiet for a year. Four things are
never candidates at all: a watched node, anyone you have sent a message to, a
repeater whose admin login is stored, and a contact MeshTerm has never heard —
that last one predates this history, and sweeping it would be purging by how
long the app has been running. They still rank, since a percentile that
dropped them would not be a rank against your contacts; they just consume no
kept slot, so starring a node never deepens the next sweep.

Only the percentile is ever displayed. A raw 47.3 means nothing without the
distribution it came from and would need a legend the moment the weights were
retuned.

The victims are archived, not deleted: off the device, which is the scarce
resource, and kept here with a stamp. Everything else was already independent
of the contact row — reception history, map position, transcripts — and the
store keeps the full key, so a restore is one write, which the chat screen
already performs on demand when a send is rejected for an unknown recipient.
merge_contacts learns to withhold them, or the union would put them straight
back and the sweep would look like it did nothing.

The old age ladder survives as the second section of the one picker, because
"everything I haven't heard in a year" is a plain predictable operation a
ranking cannot express. Both routes converge on the same preview — weakest
first, with each row's percentile and the two or three facts that put it
there — so an age rung reaching into contacts that rank perfectly well says so
before the typed confirm rather than after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 00:32:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 8ac0be0928 The battery gauge is a block, filled in eighths
One braille cell was four coarse levels lit in one colour. It is now a
filled block whose two channels answer two different questions.

Colour answers "roughly how much" from across the room: a lit
foreground over its own darker ground of the same hue — light green on
green over half, yellow on brown over a quarter, light red on red
below. The braille fill inside the block answers "exactly how much" on
a second look: each dot row lights left before right, so one cell
carries eight rungs, one per 12.5% of charge. The band holds still
while the dots climb through it, which is what keeps the two readable
as two.

The charging sweep keeps its five whole-row frames and does not colour
itself — a frame of an animation is not a reading, so the band goes on
answering for the real charge underneath it, exactly as the number
beside it does.

That leaves the alarm nothing to say in hue, so it says it in ground:
under 6.25% — half the last dot's worth of charge — the cell alternates
black dots on red with the light red on the bare page. Where every
other cell is filled, a beat with no fill at all is the loudest thing
the palette has. It is drawn a frame at a time because a terminal's own
blink only ever toggles the foreground, and the PicoCalc's framebuffer
console ignores SGR 5 outright — device-checked, along with its want of
bright backgrounds, which is why the grounds are all dim-bank slots.

Neither animation sits behind Platform.effects any more. The header is
rebuilt on the idle tick regardless, so both cost a colour swap in a
frame already being painted, and the one platform the alarm is *for*
was the one platform never drawing it. effects keeps the braille
spinner, which is what it was really gating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:14:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 82b93cf155 The oldest message owns the head of the transcript
A chat pick anchored on its own body, so ^Home landed the frame two lines into
the transcript: the day divider survived only as a pin, the first sender's chip
was gone, and the PicoCalc title bar lit its ↑ over a conversation with nothing
above it. The first message has nothing before its divider, so its pick anchors
at line 0 — the head is drawn, not pinned, and the arrow goes dim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:48:44 -04:00
Jean-Pierre MartineauandClaude Opus 5 8fc24d4901 The clip arrows lead the bar, and say it in colour
They rode the PicoCalc title bar's tail, appearing and disappearing with the
scroll — and a half-shown pair put a blank cell where the missing arrow would
be, so reading it meant comparing the row against a memory of itself. Both
arrows now sit at the left edge, where the eye starts, and never move: an arrow
whose direction has more takes the border's own accent, one with nothing that
way drops to muted. That is the live/dim language the F-key lane already draws
one row below, and the bar keeps its width whatever the body is doing.

The Esc atom keeps the tail it was given, now on its own — a hair more room for
a long title, since the two arrow cells left that end of the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:20:48 -04:00
Jean-Pierre MartineauandClaude Opus 5 d28240f658 An arrow atom names the arrows and nothing else
Surveying every popup dialog's hint turned up three that said the arrows were
something they aren't. The yes/no confirm's "←→/Y/N choose" put a key inside the
arrow atom that does not choose at all — Y/N answers outright — and the
autocomplete's "↑↓ Tab complete" did the same with Tab, which completes rather
than moves. Several keys may share an atom, but only when they do the same
thing: "↑↓ PgUp/PgDn scroll" and "Home/End ends" are the shape that is fine.

The composer's "←→ cursor" named the wrong thing rather than too much. The
cursor in this app is the row ❯ points at, which is what ↑↓ moves there; the
arrows move the insertion slot along the path, and "slot" is the word the rest
of the app already uses for it.

The confirm takes the ButtonDialog hint verbatim rather than a longer one of its
own — a yes/no prompt is a button row like any other, and its y/n accelerators
are already hinted by the Yes/No labels. It reads the same everywhere now, and
the Esc verb stops falling off the end of the box on a 53-column console.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:20:34 -04:00
Jean-Pierre MartineauandClaude Opus 5 7407358ede A button row's Enter belongs to whichever chip is lit
The quit confirm promised "Enter quit · Esc cancel", which held only while Quit
was the highlighted button — and on a bonded device there is an "Unpair & quit"
between Cancel and Quit, so Enter has three things it might do. The hint also
left out ←→, the one key that changes which. Both dialogs (the TX winner's
"Enter apply" is the same shape) drop back to the dialog default, which says the
mechanism instead of guessing the outcome: ←→ choose · Enter select · Esc cancel.

A row-picking list keeps its licence to name a committing verb, because every
row commits the same way. A lone-button acknowledgement keeps "Enter OK" too,
having nothing to choose between — that is the exception the new standards test
walks the package for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:02:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 2a7d01108b ^W is a stack verb, not a call-chain one
Pressing it in the packet viewer closed the viewer and stopped there, then fired
at whatever the reader pressed next. A screen opened from a key handler cannot
be awaited by its opener — a handler is sync, so the live feed floats the viewer
with a detached task — and the sentinel went only to the top screen, so the
unwind it raised had nowhere to propagate and died in that task. The live feed
below was meanwhile parked on a visit.result() that no press would resolve, and
the flag stayed armed to unwind at the next navigation boundary the reader
happened to cross. The chat's delivery paths and the trace screen's path flows
open the same way and had the same fault.

The unwind now arms every frame on the stack, top first, down to but never
including the root, stopping at anything modal for the reason the top-of-stack
refusal already gives. Every frame is on the stack whether or not something
awaits it, so the stack is the reliable path down; resolving a frame nothing is
waiting on is inert.

The detached launches go through session.run_detached, which absorbs the
PopToMenu its task cannot carry — a duplicate of an unwind already travelling
out through the screen that opened it — so a press no longer leaves an
unretrieved task error behind. Flows keep their own finally blocks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:19:56 -04:00
Jean-Pierre MartineauandClaude Opus 5 332f81583a A hint is drawn once per frame
The footer line already carries the top screen's hint, so a floating dialog was
saying the same sentence twice on one frame: once in its own border, once at the
bottom of the terminal. On the desktop the border now says nothing and the
footer is the single place hints live; its clip arrows fall back to the base
frame's own "more" wording, so a silent border still says the one thing every
border here says.

The PicoCalc keeps the border hint, because it has no hint line to defer to —
the lane stands where the footer would be and speaks only for its five chips.
But what the lane does say it says already, one row below, so the border drops
every atom whose keys are all chips: the packet viewer's "PgUp/PgDn scroll" and
"Home/End ends" go, its arrows and Esc stay. An atom survives unless *every* key
it names is covered, so a half-covered pair never tells half a truth, and one
riding in the verb half (the map's Home/^U) keeps the atom whole.

Resolved per paint rather than folded into a screen's hint, since both halves
move: the viewer rewrites its hint as the list grows past one entry, and every
screen reads its lane fresh each frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 01:38:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 69cdd8f07e Purge stale contacts: a ten-day rung
Sits between the week and the fortnight, where the ladder is now fine enough to
separate "quiet for a bit" from "gone".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 00:55:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 6904898827 Purge stale contacts: a two-week rung
The ladder jumped straight from a week to a month, which is the widest gap in
it and lands either side of the span a contact is most often judged on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 00:54:39 -04:00
Jean-Pierre MartineauandClaude Opus 5 551299e076 Survey ledger: D closed, and two exceptions it had missed
D was the last of the original findings still open, and re-running the survey
against the tools (rather than the screens) turned up two shapes the first pass
never looked for: a picker gathered in prompt_params, which pops before the
screen it feeds, and an entry chain of prompts, which abandoned instead of
stepping back. Both are written up as H and I with their sites, and the
shortlist gains them.

Only G is left, and it is not navigation-visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:12:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 71dee82fd3 An entry chain is a stack: Esc steps back, not out
Five flows asked two or more things in a row as a straight run of awaits, so Esc
anywhere in one abandoned the lot. Mistype the second half of a 32-hex channel
key and the name you typed first went with it; back out of the courier's
schedule and the message you had just written was gone.

menus.run_steps runs a chain as the stack it is. Each step is awaited with the
answers gathered so far — the same list every time, so a step can label itself
with an earlier answer and offer its own previous answer as its default when it
is come back to — and returns None to step back. None from the first step ends
the flow; there is nothing behind it.

Converted: joining a channel by key and editing one (name then key), the custom
firmware variable (name then value), and the courier's queue flow (recipient,
message, schedule). The courier's recipient list now stays pushed for the whole
flow, so stepping back from the message lands on the row it was being written
to, and the schedule's "At a time…" prompt steps back to its own rungs rather
than out of the queueing.

A step signals "back" with None, which is what _pick_schedule returns for the
real answer "when it's next heard" — so _schedule_step swaps the two, carrying
that answer as its own WHEN_HEARD sentinel.

A trailing Cancel/verb confirm is left alone: its left button says Cancel and
means "don't do this", which is a decision rather than a step. That line is now
in CLAUDE.md with the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:12:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 deea9ebdd2 Esc peels a typed filter before it leaves, everywhere
Four screens carry a find-as-you-type filter and Esc meant two things across
them: the map and the mesh walk cleared the query and stayed, while the select
list and the path composer left the screen, filter and all. Typing "yul" on the
map then pressing Esc showed you the map; typing "yul" on a contact list then
pressing Esc dropped you at the main menu. Same affordance, same glyphs,
opposite outcome — and the select list is by far the most-used of the four.

They peel now, all of them: the typed query is the most recent thing the reader
entered, so the first Esc takes it back and the second leaves. The footer says
which, swapping its trailing Esc verb for "Esc clear" while a filter stands —
the word the map and the walk have always used, via a _esc_verb helper beside
the two atom-splicers that already bend that line. The composer's Esc now
layers exactly as its own backspace does: the entry first, then the screen.

This was finding D in docs/survey-navigation.md, open since the survey was
written and the last thing left telling a reader what Esc does. The rule is now
stated in CLAUDE.md under Navigation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:02:21 -04:00
Jean-Pierre MartineauandClaude Opus 5 72e11e17e6 One channel page for the whole visit, and the last hand-rolled round goes
The channel detail was the app's last screen rebuilt per round: a fresh
SelectScreen each time, with the cursor restored by default=cursor and anything
typed to filter dropped. It stays pushed now, and its rows and vital-signs line
are swapped in place after each action — both are live (the mute row is a
toggle, the summary counts unread and ages the last message), so both are
re-read, and the highlight follows the row that was just used.

replace_items gains a prompt= so a summary line above the rows can be refreshed
the same way the title already could; without it, keeping the screen would have
frozen the vital signs at whatever they said on the way in.

That leaves _menu_round with no session path at all — both callers now reach it
only when there is no screen stack — so it shrinks to what it always was
underneath: select once, dispatch, be called again. The push/await/pop loop and
the asyncio import go with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 17:56:38 -04:00
Jean-Pierre MartineauandClaude Opus 5 23865e6c9a Chat keeps one conversation list for the whole visit
Backing out of a thread already landed on the picker rather than the menu, but
on a brand-new one: the tool re-ran the whole picker each round and put the
cursor back with a default= restore, which recovers the cursor alone and only
while the row it names still exists. Anything typed to narrow the list was gone
every time, and the screen was rebuilt around two devstate reads.

The picker moves into the tool's own loop and stays pushed, so a chat nests
above it and Esc is one pop onto the row it was opened from — filter, scroll and
all. The row builder splits out as _picker_items, and the rows are swapped in
place after every chat and every delete: an exchange moves its thread up the
recency order and a deleted history hollows its dot and demotes the row, so the
list has to be re-read, and replace_items follows the highlighted thread there.

The picker tests now read _picker_items directly rather than intercepting a
select() call, and the two Del tests drive _delete_history and assert the
rebuilt row demotes — the same coverage, one layer down from the loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 17:53:08 -04:00
Jean-Pierre MartineauandClaude Opus 5 8496e42118 TX optimize walks back one list at a time
The same defect Trace had, doubled: both entry pickers were gathered in
prompt_params, so both had popped before the sweep screen opened. Esc from the
sweep landed on the main menu, and Esc from the "measure at" list did too —
never on the node list one step behind it. Tuning a second target meant
reopening the tool and re-picking the node.

Both pickers stay pushed now, one inside the other: admin_node_visit keeps the
node list (the stay form repeater admin already uses), _measure_visit keeps the
target list, and the sweep nests on top. Esc walks the flow back the way it was
entered, each list still holding its cursor, filter and scroll, and sweeping the
same tuned node against a second target is one pick.

_pick_target becomes _target_items, a pure row builder, so the rows and the
screen that shows them are no longer welded together; _sweep resolves a picked
name (or typed hex prefix) and opens the screen. Drops an unused ctx.device()
call — the contacts read ahead of it already needs the link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 17:47:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 4c83d69309 Trace keeps its target list: Esc from a walk lands back on the picker
The trace-target picker was gathered in prompt_params, which is a one-shot: it
resolved and popped before the live screen opened, so the trace screen sat
directly on the main menu. Esc out of a walk therefore skipped the list it was
launched from entirely, and trying the next node meant reopening the tool from
the menu — the picker's whole recency sort, the typed filter and the row you
were on, thrown away on the way in.

So the picker moves into the tool's own loop and stays pushed for the visit,
the hub idiom the contacts list and the Time Machine already use. The live
screen nests above it: one Esc pops back onto the row that launched it, the
second leaves for the menu. prompt_params now only marks the interactive path.

Coming back re-reads the lanes, because a returning trace ages the very column
the list is sorted by: ContactListScreen.update_rows swaps the rows in place
(the replace_items counterpart for a list whose lanes are data), so the node
just traced rises to the top of the TRACED sort with the highlight riding it
and the filter untouched. Both ages and counts come from stored history, so a
hand-back costs no device round-trip.

The free-text way in — no contacts at all, or no full-screen session — keeps
its one-shot shape, since there is no list underneath to come back to.

test_navigation gains the two guarantees: the picker is still on the stack
while the live screen runs, and update_rows re-sorts without moving the reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:49:44 -04:00
Jean-Pierre MartineauandClaude Opus 5 7ca0069247 Say why the quit chords skip the confirmation
^Q and ^C exit straight out, and the next reader of that dispatch block would
reasonably wonder whether the missing confirm was an oversight. It isn't: the
quit dialog is there to catch one Esc too many at the main menu, where a single
reflexive keystroke would otherwise end the session (JP, 2026-08-30). A hidden
two-key chord cannot be pressed by accident, so there is nothing for a confirm
to catch — and a confirm is itself a screen that has to paint and take a key,
which is precisely what an escape hatch cannot depend on.

Comment only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:29:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 c31ada0c8b Write the stack down: CLAUDE.md, the About page, and the survey's ledger
The rules the last three commits established, stated where they bind: a
Navigation section in CLAUDE.md covering the strict stack, `stay`/`Visit` for a
hub, `replace_items` for a list whose content is data, and the modal-vs-floating
distinction ^W turns on.

The two global chords get their one statement on the About page, since neither
has a screen to be advertised on. It also says what happens when the host takes
Ctrl+W first (VS Code's integrated terminal reads it as close editor) — the key
is a convenience, and nothing depends on it.

docs/survey-navigation.md gets its ledger updated: A, B and C are closed, with
what was actually done to each. D and G stay open, and D is now recorded as the
reason Esc Esc was rejected as the pop-all key — on the map, the mesh walk and
chat, Esc Esc is already an ordinary peel-then-leave.

Also drops an asyncio import the watchtower no longer needs, and an escape
sequence in a new docstring that made ast.parse warn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:45:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 f2a49c8e2d Editors keep their place too: rows change, the reader doesn't move
Six lists rebuilt themselves as a whole new screen every round, because their
content is data: an editor's rows carry a staged `current → new` value, its
title counts what is staged, the outbox loses the message it just sent, the
watchtower's log rewrites the row you acknowledged. Each approximated the cost
with `default=cursor`, which recovers only the cursor, and only while the row it
names still exists — the typed filter went every time.

SelectScreen.replace_items swaps the rows (and optionally the title) in place
instead. The place is kept by value, so the highlight lands back on its row
wherever that row moved to, and falls back to the same position in the list when
the row is gone entirely — which is what you want after deleting the row you
were sitting on. The filter rides along.

So the config editor, device actions, the channel manager, the courier outbox,
the watchtower and the repeater-admin editor all hold one screen for the whole
visit now, refreshed after each action. The courier's ticker is started once for
the visit rather than per round, and folds a flow's effect in immediately rather
than waiting up to a second for the next tick.

The admin node picker gains a `stay` form. Repeater admin used to draw a second,
identical SelectScreen as a static backdrop so the password prompt had something
behind it, then drop the list before the session; the real list stays up through
both now — so cancelling the password lands back on the node list rather than
bowing out of the flow, and leaving a node's admin session lands there too.
Administering two repeaters in a row is an Esc and a pick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:38:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 a409fd4e9b Hubs keep their place, and peers nest instead of flattening
Node detail, the contacts list and the trophy case were each rebuilt every time
something they opened closed. Node detail was the sharpest: its constructor sets
the tab to Info, so Tab to Routes, pick a route, trace it, come back — and you
were on Info at the top again, two keypresses from the route you were working
through, every single time.

All three now stay pushed for the whole visit. The screen object is what carries
the tab, the cursor, the sort and the filter, so returning is a pop rather than
a rebuild. It also retires the pop-then-re-push-as-a-backdrop dance in three
places: a screen that never left the stack is already the backdrop its dialogs
draw over. Contacts still rebuilds after a purge or a removal, and the trophy
case after the record count moves — the rows they were holding a place in are
gone, which is the one thing worth losing the cursor over.

The two peer hand-offs stop flattening. Trace → trophy case used to resolve the
whole trace screen with a sentinel so it unwound before the trophy case opened;
trophy case → trace ran the trace only after the browser came down and then
returned outright. Both landed the reader on the main menu, both said in their
comments that this was to avoid a stack the user would have to Esc through.
That was a hand-built escape from a stack with no other way out, and ^W is the
real one, so both nest now: Esc goes back one, to the screen that opened it.

The trace screen takes an `open_trophy_case` callback rather than resolving a
sentinel the opener has to interpret, which is also what lets the two screens
open each other — a cycle a strict stack allows on purpose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:27:53 -04:00
Jean-Pierre MartineauandClaude Opus 5 ea68b9f890 Navigation: the stack is the model, and ^W leaves it all at once
Navigation was almost a stack already, and the survey in docs/survey-navigation.md
catalogues where it wasn't. This is the machinery for the strict version, plus
the one shortcut past it.

A screen that owns a loop can now keep itself pushed for the whole visit:

    async with session.stay(screen) as visit:
        while True:
            chosen = await visit.result()

One push, one pop, and the *object* survives its sub-screens — so the cursor,
the sort, the scroll offset and any live filter are simply still there on the
way back, rather than being approximated by a `default=` restore that can only
recover the cursor and only when its row still exists. It also retires the
pop-then-re-push-as-a-backdrop workaround: a screen that never left the stack
already is one.

^W unwinds every frame at once, back to the main menu. It resolves the top
screen with POP_ALL, which each navigation boundary turns into PopToMenu, and
every frame pops itself in a finally on the way out — the same single-pop
discipline applied N times. PopToMenu derives from BaseException so it crosses
the `except Exception` guards that stop a broken tool from taking the menu down;
those must not report an unwind as a tool failure. The flag it sets matters
independently of the future: pressed during a device read there is nothing
armed to carry the sentinel, so the next screen the flow tries to open refuses
to open instead.

It declines to fire over anything modal — an unanswered dialog, a progress bar,
a busy splash — which is what `modal` is for. `floating` is only about being
drawn as a box; a select list floats and is not modal, the busy splash is modal
and does not float.

^Q quits from anywhere. Both chords are deliberately unadvertised: a global verb
has no screen to belong to, so a footer atom or an F-key chip would ride on
every screen, and the lane only has three free slots to begin with. A test
walks every string literal in the package to keep them out of the UI.

Quitting mid-screen used to abandon the coroutine driving the app: the chord
exits the application straight from the key handler, so `run_async` returned
while main() was still parked on a screen, and its finally — which closes the
history and chat runs, stops the services and arms the exit watchdog — never
ran. It is cancelled and awaited now.

The menu is the root: built once, kept for the session, and the one frame that
catches the unwind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:20:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 b6f3218af9 Mesh walk: just the name
The title is the screen's name now, with no status atoms at all. Everything it
used to carry is already on the screen below it: the breadcrumb trail ends on
the focus, the line under that names the node in colour with its hash and its
ring, and how big the neighbourhood is, is what the canvas and the link list are
for. Set once at construction rather than composed per render, so the top row
holds still while the walk moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 02:59:01 -04:00
Jean-Pierre MartineauandClaude Opus 5 2252f2622e Mesh walk: the title says how big the mesh is, not where you are
"Mesh walk — YUL-Cartierville · 42 nodes · 61 links · 3 matches" ran 62 cells,
which a 53-column title bar clips rather than wraps — the link count went, and
so did the bar's clip arrows and its Esc hint. The focus was the longest atom,
the one that rewrote the top row at every step of the walk, and the only
redundant one: the breadcrumb trail ends on it and the line under that names it
in colour, with its hash, its ring and when it was last heard.

So the title reads like the map's — the other spatial screen, whose centre is
likewise the body's to name — status atoms only, and the match count swaps for
the node count instead of joining it, the same way the map's does:

  Mesh walk · 42 nodes · 61 links
  Mesh walk · 3 of 42 match · 61 links

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 02:53:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 7c36f809fd PicoCalc: the title bar says how to leave the screen
This platform has no footer hint line — the F-key lane stands where it would be,
and the lane only advertises what its five slots do — so Esc, the key every
screen answers to and the reason no screen spends a row on a Back item, was
nowhere on the screen.

It now rides the title bar's tail, last, after the clip arrows and in the same
"↑↓ · hint" shape a floating dialog's subtitle already uses. The verb is the
screen's own: the atom is lifted off its footer hint (the grammar puts Esc last,
so the tail of the hint is the clause), which is how a value picker says keep and
the main menu says quit, and a screen naming no way out advertises none. It gives
way before it crowds the title — the verb first, then the key, then the cells
back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 02:20:44 -04:00
Jean-Pierre MartineauandClaude Opus 5 c1db8e5a31 Cursor row: a route's hues are content, so the highlight spares them
A picked row folds its node hues to the cursor white so the row reads as one
thing rather than as a name arguing with its own selection. On a path line that
is wrong: there the hue is not decoration on a name, it is what tells one hop
from the next — and what a route graph above the row is cross-referenced by,
its labels being a marker and one byte of hash. Chips were never folded (their
fills are outside the fold's vocabulary), so the bug was the arrow form: every
path line on the PicoCalc, and on any terminal without the powerline glyphs.

PathLine now stamps every shape it mints with PATH_INK, a style that draws
nothing; Text.append_text carries the stamp into the row as a span over the
hops, and the fold skips identity spans lying inside one. So the Message paths
row keeps each hop in the colour its graph label wears, and so do the TX
screen's "Route — via", the trophy board's walk and the feed's sender → recipient
lane. The node detail's Routes tab, which bought this by hand (no cursor base at
all), is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:42:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 8e8a73fc36 Busy card: the chip already says "working", so the LED bar goes
The skeleton card floated between screens carried a Knight-Rider scanning bar
under its caption: eighteen braille lamps, a red head and a persistence-of-vision
trail sweeping back and forth. It drew the eye harder than the words did, on a
card the reader is only ever passing through, so it and its colour ramp are gone
— the one-cell spinner beside the caption is the whole animation, and the card is
two lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:36:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 0fa8290add Contacts: a contact the device never held is still ours to remove
The list a screen removes from is the union of the device's contact table and
the contacts MeshTerm remembers for it, so an entry can exist only on our side
— or the firmware can drop one since we last read. Removing such a contact hit
ERR_CODE_NOT_FOUND and reported "couldn't remove", leaving a row the reader had
no way to get rid of.

The device layer now raises ContactNotOnDeviceError for that code, the same
class (and the same table lookup) the send path already raises. The single
removal reports it as a warning naming what happened, forgets our half and ends
the visit; the purge sweep counts it as purged rather than failed. The
simulator refuses the same way, so the path is walkable under --mock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:24:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 972c0b6f03 Screens: Esc is the way out, so no row spends two lines saying so
The app-wide Back row existed because it duplicated Esc — two of its call
sites said as much in a comment. On both platforms Esc is a labelled key
and every footer hint names it, so the row bought nothing for the blank
separator and line it took out of every screen; on the PicoCalc's 26 rows
that is a row in thirteen.

back_rows() is gone, and with it the four hand-drawn copies: ReorderScreen's
clean-state row, the live feed's pinned foot stop (the cursor now clamps on
the oldest packet), node detail's tail action, and the action-list rows on
the trace and TX-sweep screens.

Kept, because there leaving is a choice rather than an exit — the same rule
that keeps a dialog's Cancel button: exit_rows()' staged pair, where Apply
has no key of its own and ✗ Back names what the other way out costs;
ReorderScreen's dirty-state pair; the two Quit rows, which initiate the
app's terminal action behind a confirm; and the new-record dialog's Close,
which is its default, so dropping it would let a reflex Enter navigate.

With no Back row there is no Back value: the six spellings callers used to
test three at a time (_BACK, "__back__", _CANCEL, two tuple shapes, bare
None) collapse to `is CANCEL` / `is None`. The gallery now asserts the
absence of an exit row on every screen, on both platforms.

Resolves findings E and §10 of docs/survey-navigation.md. Finding D — Esc
peeling a filter on three screens and leaving on two — still stands, and is
now the only thing left telling a user what Esc does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:15:53 -04:00
Jean-Pierre MartineauandClaude Opus 5 9d524a27a2 Emoji: a joined sequence is one glyph, so it costs one glyph's cells
A ZWJ sequence — 🤷‍♂️, 👨‍👩‍👧, 🏳️‍🌈 — is a single grapheme the terminal draws
in the width of its base, and Rich's stock measurement already knows that.
prompt_toolkit's does not: it adds the codepoints up, reserving three cells for
the shrug and six for the family, and it is prompt_toolkit that places the right
border. Every joined emoji has been pulling a row's border in by the excess.

Correct it in both authorities the same way, by dropping what the joiner folds
in before measuring, so a sequence inherits whatever its base is worth and the
two curated sets still reach it. Rich needed this too: the selector-skipping
replacement was losing the stock loop's own ZWJ handling and counting the male
sign as a cell.

Measuring is only half of it — prompt_toolkit lays out one codepoint at a time,
so the sequence has to arrive as one fragment or it is still laid out in pieces.
ClusterTextControl merges the run on the finished lines (the last point they are
still ours: split_lines returns plain str and drops the marking), and the window
makes one two-cell Char of it. Every codepoint still reaches the terminal, in
order, so the glyph composes exactly as before.

The rule holds at either emoji width, so the width-2 path installs it alone —
no narrowing, no flag category, Rich untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:27:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 e9f07dee0e Chat: a direct thread picks a message too, whatever the comments said
Two comments claimed the pick was channels-only, one of them flatly stating that
direct threads never select. Neither is true: _move_selection and _select_section
carry no is_channel gate, and footer_hint has offered a picked-message hint for
both kinds all along. What actually differs is what Enter does with the pick — a
reply @mention in a channel, the delivery paths in a direct thread.

Both also called it the "reply target", which names only the channel half of
that; it is the picked message, and only one kind replies to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 03:40:49 -04:00
Jean-Pierre Martineau 955ee2549f Updated splash screen (ANSI art, CP437) 2026-08-27 00:53:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 9ec72dbf48 Skill: blank the NULs in ANSI art without touching its SAUCE record
An unpainted cell exports as 0x00, which no terminal draws as nothing — it
comes out as tofu, as a stray glyph, or swallowed with the rest of the row
shifted left. One byte wide to fix, and this makes that one substitution and
no other: same length out, every escape sequence where it was.

The trap it exists to avoid is the obvious version of the job. An ANSI file's
last 128 bytes are a SAUCE record whose NULs are structure — binary fields and
the padding on the font name — so a tr over the whole file corrupts the
metadata and fixes no art at all. It cuts at the 0x1A that opens the record and
counts what it left alone. Both splashes are in exactly that state today: 24
NULs each, all of them SAUCE.

A space is only black if the span it lands in left the background black, so
the ones that would come out coloured are named by row and column rather than
quietly repainted — that is an editing call, not a substitution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:59:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 80a2a9bd0b Contacts: a contact is dropped one at a time, from its own page
The list could only sweep contacts by age — a purge or nothing. Removing one
named contact now lives where the reader is already looking at it: the last
action on its Node detail page, behind a red Cancel/Remove confirm, and the
list it returns to rebuilds without the row.

The deletion lands in both halves of what the list shows — the device's table
and the cross-session store — because forgetting only the radio's half merges
the contact straight back on the next read. A refusal is put in a dialog and
nothing is forgotten: a contact the radio still holds must not vanish from the
list. Only the contact goes; its reception history and chat messages stay, and
a later send offers to write it back.

An action row's tint now survives the icon lane being dropped — it lands on the
label, as marked_label already does for menu rows — so the delete still reads
as a delete on the PicoCalc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:39:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 c66e2c86b6 Frames: the border is one colour again, so drop the corner light
Every framed box was post-processed on the way to the screen: the top-left
corner blended toward white and the highlight decayed along the top and left
edges, so a box read as lit from that corner. It reads as a gradient laid over
the chrome rather than as the box being a box. The borders draw flat now.

The pass goes with it — ui/tui/glow.py, and the three composers that ran it
(base, startup, dialog), each of which also carried the platform gate that kept
it off the 16-slot console where it could only ever hand back the same lines.
Nothing else called it, and its memo went with the module. Text colour is
untouched: the pass only ever recoloured box-drawing glyphs, and titles and
subtitles were already skipped.

The four glow tests become one that pins the outcome instead of the mechanism —
every box glyph in a composed frame, nested panels and dialogs included, carries
the border's own colour and no other. Verified against the old pass: with it
back, that set holds sixty-five.

Platform.effects keeps its other two claims (the header blink, the braille
spinner); it just no longer mentions a pass that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:18:29 -04:00