Commit Graph
22 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5.5 19c910bbde Rooms get their own page, and a room that doesn't answer is explained
Joining a room moves out of Chat into a Rooms page beside Channels. It
lists every room server the radio knows, joined ones first, with each
room's access, unread count, last post and last-heard time. Each room
has a page: join, log in again, change password, open the board, forget
it, or delete its stored posts. Chat now lists only joined rooms, the way
it lists only channels on a slot, and only a joined room raises the
unread badge. chat join becomes rooms join, alongside rooms list and
rooms forget.

"No reply" told the reader nothing, because a room is just as silent for
a wrong password as for a login that never reached it. Testing on
hardware showed the commonest cause is neither: a login sent along the
route the radio had learned met silence, and the same login by flood was
accepted in eleven seconds. A silent login is now explained from what is
known: how it went out, when the room was last heard, and whether this
password has worked before. It offers the one next step that evidence
points at:
- Try by flood, after a learned route met silence. The radio forgets
  the route (local, nothing transmitted), then sends the login once.
- Try again…, after a flood met silence. The prompt reopens with the
  password filled in, so Enter resends it and typing replaces it.
- Add it & try again, when the radio doesn't hold the room as a contact.
Every retry is a button press, and none is ever sent on its own. A
password over 15 bytes is refused at the prompt, since MeshCore sends
only the first 15.

On the board, a failed or unfinished login used to leave the compose
line taking posts the room would drop. It now opens only while we are in
and may post, and otherwise says why and what to press. Opening a joined
room that has gone quiet logs in in the background. ^L runs the
explained flow.

Underneath, the login exchange listens for the radio's own confirmation,
matched to the login by its expected ack. It records whether the login
went by flood or along a route, or the radio's reason for not sending it
at all. This also corrects the debug log: "send unacknowledged" read the
library's wait for the *answer* as the send. On hardware the radio
confirms in under 0.1 s. Device.reset_route is new. The simulator learns
routes, can be given a stale one, and refloods.

Refs: #24
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 02:07:10 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b6e7cd5c16 The handhelds are named for their variant: picocalc-lyra and cardputer-zero
M5Stack makes three Cardputers and only the Zero runs Linux; the PicoCalc
takes several cores and only the Luckfox Lyra runs MeshTerm. So the bare
names were ambiguous, and every reference to the two devices now carries
the variant: the --platform values and MESHTERM_PLATFORM, the Platform
constants (PICOCALC_LYRA, CARDPUTER_ZERO), their lane decks and every
screen's picocalc_lyra_lane / cardputer_zero_lane, the font inventories,
the lane chips' theme styles, the scripts/ folders, the two chrome test
files, and the docs and CLAUDE.md that name them.

There is no alias: --platform picocalc is refused with the three real
choices listed, rather than falling back to the desktop layout. The
PicoCalc is auto-detected from its device tree, so it needs nothing.
What names hardware or a user's data keeps its name: the PicoCalc
keyboard's input-device name, its battery's sysfs supply, and the serial
profile the Lyra setup script writes. .gitignore follows the firmware's
folder, and the changelog keeps the words each release used.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 11:52:41 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 c988a2374e Section headings are a dark grey, not the frame's accent
A ── Label ── heading drawn in the border's own colour read as part of
the frame it sits in. Grouped lists' headings and the chat's day
dividers now wear their own theme style, heading: a bold dark grey on
the desktop, slot 8 on the PicoCalc. The frame keeps the accent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 02:52:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 6046b4ff0e A bug report should be one page, and a page nobody has to read first
A report that opens with "it crashed" costs three round trips before
anyone knows which MeshTerm, on which OS, in which terminal, talking to
which radio. The person who hit the bug is the one least able to answer:
half of it is resolved at boot and shown on no screen -- whether icons
draw, whether powerline separators do, which platform flavour resolved and
why. Diagnostics states all of it at once, as a page under This app and as
meshterm diagnostics.

The page is a bare frame, and that is mechanical rather than aesthetic.
The one thing it is for is getting its text out of the terminal and into
an issue, and a terminal is selected by dragging: a border is characters
on the same lines as the content, so every line of a line-wise selection
arrives with a box-drawing glyph welded to each end. A header and a footer
hint arrive as text that looks like part of the report to whoever reads
the paste. So the page draws its rows and nothing besides, the frame the
share screen already uses for the same kind of reason.

Nothing in the block is private, and that has to be a property rather than
a habit, because the whole premise is that it gets pasted in public by
somebody who did not read it. No pairing PIN, no admin password, no
channel secret, no private key, no position, no contact's name or key. The
mesh arrives as aggregate -- a row count per table and the span the
observations cover -- which is the half that explains a bug without naming
anyone the reporter talks to. Two tests hold the line from opposite sides:
one plants real secrets in the stores that hold them and asserts none came
out, and one refuses the key *names* such a field would take, so a value
the suite cannot plant is still caught by what it would arrive as.

The radio is asked and never required. A report about a companion that
will not connect is exactly the report most worth filing, so the failure
becomes connected=no with the radio's own words in error, and every other
fact still arrives.

Table counts come off sqlite_master rather than a list written down here,
so a table added later starts being reported the day it lands; the
interesting count is always the table nobody expected to be full. Only
overridden preferences are listed -- the defaults are in the source and
identical for everyone.

Both faces share one projection of a facts block, so the page cannot drift
from what the command prints: a field added to the report reaches both
without anyone remembering to. Only the layout differs, in one respect --
the scripted face crops a value too wide for its lane, which is right for
a record somebody will split on whitespace and wrong for a page whose
whole job is to be complete, so a long value hangs under its own block
instead. A path also gains the lane constructor it never had; the machine
face had always spelled one correctly and the plain face could not render
one at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:58:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 8076ac1081 Purge contacts is Archive contacts, because it archives them
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:26:43 -04:00
Jean-Pierre MartineauandClaude Opus 5 282334c761 The purge preview shows the evidence in lanes of its own kind
The preview's rows led on a PCTL lane and followed it with WHY IT RANKS LOW, one
header over three lanes that held a contact's n-th weakest reason — so a message
count, an age, a packet tally and a hop count moved between columns from row to
row and nothing in a column meant the same thing twice. The lanes are fixed by
kind now: HEARD and PKTS first, in the Contacts list's own order and drawn in its
own grammar (the column age under recency heat, a tally muted, a faint dash for
none), then the two the sweep adds, MSGS and HOPS. Each is measured across the
victims and headed by a word right-aligned over its own values.

The percentile goes with the phrases. A rank is the score's reading of itself, so
a lane of them restated the order the list was already in; what an audit needs is
the measurements, which is what a reader can check against what they know. The
row pins its name out of the horizontal scroll instead — the evidence is what a
narrow terminal cuts, and the name is what says whose evidence it is. ScoredContact
drops the reasons it was the only consumer of.

A lane also stops trusting a name to be ordinary text. A name is written by whoever
owns the radio, so fit_cells — THE lane fitter, and every column's — now cuts
between glyphs rather than codepoints and folds what no terminal can draw: cutting
an emoji sequence in half stranded a joiner that swallowed the ellipsis appended
after it, leaving the lane a cell short of what was drawn and every column right of
the name starting late, and a newline or an escape in a name ended the row mid-lane
whatever it measured. Both are settled in emoji_width, which already owns what this
terminal really does with these codepoints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 00:05:23 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00
Jean-Pierre MartineauandClaude Opus 5 f27492b2e4 Device config carries the device actions and every setting the firmware has
The Device actions menu entry is gone: clock sync, backup/restore, the identity
key, reboot and factory reset are an Actions section on the Device config page,
which now takes the repeater admin page's shape. It is full-screen, titled with
the node's name and a staged count, and Apply sends in place, one value at a
time; a value the device refuses stays staged with its reason. Read settings
re-reads the device, and unread and blank values read ? and empty.

The settings were checked against the companion firmware (MyMesh.cpp). New:
client repeat, the radio command's trailing byte. Every radio edit now restates
it, because the firmware reads a missing byte as off and a retune silently
stopped a relaying companion. Also new: the auto-add hop limit, read off the raw
frame the library half-parses, and a row for each custom variable the device
reports. The firmware's own bounds now apply: a PIN of 0 or six digits,
path-hash modes 0-2, telemetry deny / by contact / allow all, 150-2500 MHz,
7-500 kHz, and TX power from -9 dBm.

Neither editor page slides its rows sideways any more. The Actions rows pin a
head block, which on its own turned on horizontal scrolling for repeater admin's
whole list and slid its setting labels out of view. SelectScreen(hscroll=False)
now holds against the rows, and both pages use it, so long rows end at the edge
the way Device config's always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 03:23:21 -04:00
Jean-Pierre MartineauandClaude Opus 5 61dc9dc110 Every icon-led list measures its icon column, so no label starts a column early
The terminal draws some icons in one cell (↻ ⌨ 🗑 ✓ ✗ ⚠) and most emoji in two, so
a row written icon-plus-space started its words a column left of its siblings. The
fix kept landing one screen at a time. menus.align_icons now pads every icon in a
list out to the widest one, and menu_rows applies it itself, so a list built there
cannot misalign.

Hand-built lists are brought along: the courier outbox and entry actions, the
watchtower actions and rule popover, the record card's actions, the main menu's
Quit row and the device splash's action rows. The repeater admin's regression
test, dropped by a concurrent rewrite of its file, is restored.

tests/test_icon_alignment.py sweeps every gallery screen on both platforms and
fails, naming the rows and their start cells, when a list's icon-led rows start
their words in different cells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 02:02: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 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 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 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 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 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 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 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 3ccffd13c9 About MeshTerm: a sixth section, for the questions the five doings can't hold
The menu asks "What would you like to do?" and its five sections each
name a doing. "What is this thing, and who made it" is not one, and had
nowhere to go — so it gets the sixth section, the one that names a
subject, sorted last where a reader who has run out of things to do finds
it. Three pages, in the order a stranger asks:

  About MeshTerm        what this is, where it came from, its terms
  About the author      the person behind it, on the mesh and off it
  Support this project  what keeps it going, and the ways to help

Every page is scaffolding: the headings are the settled part, and each
section carries one lowercase muted "placeholder — …" line where its
prose will go. That is the app's own empty-state voice, so an unwritten
page reads as deliberately unwritten rather than as a screen that failed
to load, and filling one in means replacing its placeholder — the screen,
the menu rows and the CLI faces all follow unchanged. What is not a
placeholder is the version, the author and the copyright span: they come
from the package itself, so the About page can never describe a build it
isn't running inside.

The pages act on nothing — no radio, no database, no device — so each
opens straight to its screen with no prompt first, and each keeps a CLI
face (meshterm about / about-author / support) printing the same page to
a shell.

Two new icons, both funnelled for the console: 📖 read/about becomes ¶,
the page-of-prose mark, and 💰 support/donate becomes $.

ScrollScreen earns a derived footer hint on the way through. A read-only
body has only two keys to name, the pager and Esc, so passing "↑↓ PgUp/
PgDn scroll · Esc close" at every call site was both repetition and a lie
on a body that fits — the standing rule is that a footer never advertises
a key that would do nothing. Left unset, it now names the pager only
while the content actually overflows, and takes its Esc verb from the
surface: a floating view closes, a full screen goes back. Every existing
caller passes its hint explicitly and is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:55:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 284e35edb4 Main menu: five sections, each one an answer to the question
The menu asks "What would you like to do?" and then grouped its rows by
subject, which is not an answer to that. Mesh had grown to seven of the
nineteen rows — live watching, passive browsing, and one transmit action
in the same bucket — and Tools said nothing at all, since everything in
the menu is a tool.

So each section now names a doing, and holds what shares it:

  Message      Chat · Channels · Courier · Contacts
  Watch        Dashboard · Live feed · Watchtower · Time machine
  Explore      Map · Mesh walk · Trace target · Trace path · Trophy case
  This node    Device info · Device configuration · Device actions · Send advert
  Other nodes  Repeater admin · TX optimize

Four rows moved for a reason. Contacts is the address book the three
features above it pick a recipient from, not a mesh view. Time machine is
the past tense of the Dashboard, and was stranded in the grab-bag. Send
advert is your own node announcing itself — a device action, filed with
the mesh views only because it was about the mesh. And Repeater admin and
TX optimize are the same job — someone else's radio, over the mesh, with
credentials — which the old Device-management/Tools split hid.

The two walks join the topology views they walk over, with the boards
that score them underneath: see a node, walk to it, get scored. Nothing
is renamed (a menu row still names the screen it opens) and no section is
now larger than five rows or smaller than two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:22:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 0a41303366 Lists: what stays overhead is the section you are in
Two things a grouped list has to keep in view while it scrolls, and neither one
was staying: the lane names that read every row, and the heading that says which
group a row belongs to.

The lanes come first. A column header means the same thing in every section, so
it is not a section landmark at all — it pins for the *whole* list, above any
heading, once it scrolls off (`Separator(pinned=True)`). It is drawn outside the
body slice, so it stays exactly one row: too wide for the terminal it ellipsizes
rather than wrapping and stealing a second reserved row from the content. And it
can now size itself — a Separator title may be a callable taking the render
width — so `column_header`/`Lane` lay each label over its own lane and hand back
the shorter form (DESCRIPTION → DESC) only for the lanes that actually overrun,
from the right, where the trailing lane is the only one with a cell to give.
`lane_header` is that header for `lane_row`'s lanes, shared by the two editors so
they head identical lanes identically.

The headings were the other half, and they were pinning the wrong row. Every
non-pinned separator was a candidate, and the rule takes the last one at or above
the scroll — so what pinned was whatever muted line sat lowest under the heading.
In the Trophy case that meant a discipline's *description* stuck to the top and
the heading itself never did; at the foot of any list, the blank spacer above
Back pinned a blank row. So heading-ness is explicit now (`Separator.heading`,
conferred by `section_heading`, which is why every hand-rolled `── … ──` in the
app — the main menu's categories, the chat picker's Channels/Direct, Trace path's
candidate and ranked lists — now goes through it, and why three lanes headers
that genuinely *are* their block's only landmark say so).

A landmark is also no longer one line but a block: a heading plus the separators
written immediately under it, before the section's first row. Prose written there
is the section's preamble — the Trophy case's "what this discipline scores" — and
it belongs overhead with the heading rather than scrolling away from the rows it
explains. The block hands its rows over one at a time as they leave, so the
heading pins while its description is still the top content row and the two pin
together once both are gone: what is overhead always continues seamlessly into
the body. It never takes more than half the viewport, giving rows up from the
end, so the row that says *which* section this is is the last one dropped.

The frame settles the scroll against however many rows come back rather than
assuming one, and re-asks after sliding the window at the bottom — a heading
among the dropped lines would otherwise vanish instead of pinning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:13:14 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 241be536d8 One exit language: shared menu chrome in ui/menus.py
Every select list now builds its closing furniture through one module,
so the conventions hold by construction instead of by copy-paste:

* Back is THE exit word — bare, no arrow, no icon — with exactly one
  blank separator above it (back_rows). Courier and Watchtower lose
  their "← Back"; the rules popover's "← Close" becomes Back too.
* The staged-changes exit pair (✓ Apply … / ✗ Back — discard) moves to
  exit_rows, shared by the config editor and repeater admin, along with
  changes_phrase and one confirm_discard dialog (apply/send verb
  parametrised).
* Label + description rows share menu_rows (cell-padded lanes): Device
  actions, channel detail, repeater actions, and — converted from the
  inline "label  —  description" style — Send advert, Identity key, and
  the channel manager's Add-a-channel section, whose rows now read as
  two aligned lanes with the trailing … that marks "opens a prompt".
* Grouped-list headings share section_heading (── Label ── in accent):
  Watchtower, Courier, the Time Machine picker, and the admin picker
  (which also drops the stray 🔑 inside its heading).
* Status marks normalize to ✓/✗ (U+2713/17): Watchtower's ✔ and the
  ✖ on Cancel-this-message / Stop-watching become err-tinted ✗ rows.
* fit_cells replaces the four private _fit copies (channels, dashboard,
  time machine, chat picker) and measures display cells, so a wide
  glyph in a name can't skew a lane; empty-state notes drop their
  parentheses ("no channels configured yet", "no matches").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 03:48:48 -04:00