Bring in the 65 upstream commits since 0.11.1, with ^K lock and the self chip.
Both sides kept their features. React gives way to Lock in two places. On the
PicoCalc lane, Lock keeps the Shift half of F2 and React is ^E only. In the
hint, ^E react is the first atom to go when ^K lock has no room.
The long-message code now refills the compose line with MentionEditor, which
replaced LineEditor upstream.
Select a message in a channel or a DM and press ^E (Shift+F2 on the handhelds) to
react with an emoji. A reaction is a usual message on the wire: MeshTerm writes the
format of MeshCore One and reads it and the two formats of meshcore-open. The
transcript folds each reaction under the message that it names, with a count, or
the names of the nodes while that message is selected. A reaction does not raise
the unread badge, and the Messages list previews it as "reacted 👍".
A reaction names its target by the sender's timestamp. Thus MeshTerm now gives the
device the timestamp of each message it sends and stores exactly that second. A
retry of a DM keeps the timestamp and counts the try number, as the protocol
specifies. The emoji that ^E offers are the reaction_emoji preference. Rooms have
no reactions.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
So they pop a bit more: #f5e5a8, pale and low in chroma, which keeps it
well clear of warn's amber (0.12 apart in OKLab) and title.warn's (0.085).
The PicoCalc draws its nearest slot, the bright yellow that warn uses.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A section heading, in a grouped list or as a chat's day divider, was the
dark grey of faint. It now wears title.border, the colour of the title
in the frame around it: bold #d4d4d4, and white on the PicoCalc. It is
still never the border's own grey.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#7c7c7c went too far. The border now sits halfway in lightness between
that and the old #a2a2a2, and on the PicoCalc it is back on the light
grey slot, the nearest one.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every node-type mark is white now: ● ▲ ■ ◉ were pink, violet, white,
and orange, and the shape already says the type. Each keeps its own
type.* style, so one can take a colour back in one place; the unknown ○
keeps its grey. On the map a crowd in one cell used to glow toward
white, which a white marker cannot do, so that signal is gone.
The border grey goes down to #7c7c7c, which JP picked on a ladder. That
sits almost on faint, so the title bar's live clip arrow is now lit like
the title instead of taking the border's grey. On the PicoCalc the
border is the dark grey slot.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A plain border, the one with no caution tier, was the indigo accent: one
more colour on every screen, and it meant nothing. It is now its own
theme style, border, in the grey of muted, with title.border and
hint.border beside it; the amber and red tiers keep their colours. That
covers the base frame, every dialog, the handhelds' title bar, the tab
strip, and the CLI's panels. The title bar's clip arrow for a direction
with nothing more now dims to faint, since the border and muted are the
same grey. On the PicoCalc the border takes the light grey slot.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Tailwind slate greys were cold. Each one is now r=g=b at its own
OKLab lightness, so every contrast stays where it was: the theme's
chrome, the path chips, the map's roads, labels and buildings, the heat
tail, and the emulator window. test_neutral_greys fails any #rrggbb
literal that reads as a grey and has a tint.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The star of our node had the hue of our key, set from the self-info at run
time. It is now the white of "you" on each surface: the map, the routes,
the lists, the legends, and the chat. The run-time colour, the device-state
hook, and the style generation of the render cache are removed. A faded
star keeps its grey.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The star of our node was the yellow of the map. SELF_MARK now names a
theme style, you.mark, and the theme writes our key's hue into it when the
device-state cache reads the self-info. The context makes that connection,
so the services still do not import the UI. The map, the routes, the lists,
the legends, and the chat all draw it, and the render cache keys on a style
generation so no old bytes stay. A faded star keeps its grey.
In a chat, a sender or a mention with no key had the light blue keyless
chip. It is now quiet: the grey of plain text on that grey darkened by the
chip rule. Routes keep the keyless chip, arrow mode keeps node.unknown, and
the PicoCalc keeps the keyless pair.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The star at the left of the time becomes two cells in the fill of our
chip: the notch, then the point, with no label between them. Thus the mark
has the colour of the mention chip in the body. Without chips, it is ">>"
in our hue. The Messages list puts the same mark at the left of the age.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Our sender chip is our name, turned over, with the star on the page to its
right. A mention of us has the turned-over chip and no star: the message
that names us gets the star at the left of its time, so every time moves
two cells right, and the Messages list puts it at the left of the row's
age. A mention chip drops its @ where chips are drawn.
The compose line draws each complete @[Name] as its chip and treats it as
one atom: the arrows step over it, and Backspace or Delete removes all of
it. A reply inserts its mention at the cursor, so one message can name
several nodes. The prompt and the typed text are white.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A final pass over the docstrings and comments: "the own X of Y" is now
"Y's own X" (84 places), a navigation entry is a screen on the stack, our
node is our node, a paint is a paint, the quoted UX labels are in code
font, and a list of punctuation glyphs is in code font.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pathline.py, theme.py, termfont.py, fontset.py, widgets.py, and oklab.py.
These were done last, after the path chip work in them was committed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The console font gains the two powerline chevrons, drawn into the next
two donor slots (U+2559 and U+255B, which nothing draws), in both the
6x12 and the 6x8 builds. With them in its inventory, the PicoCalc's
verdict is core, so its routes are ribbons of chips like the
Cardputer's.
A chip there can only be filled from the eight background slots, since
under a 512-glyph font the VT has no bright backgrounds. So a node's
chip is the dim twin of its hue (theme.DIM_TWIN), and its label is the
hue itself: the colour the name has in every list and in arrow mode. A
hash label and an annotation are that colour throughout. A keyless chip
is white on light grey, the same pair one bank down. Our star, a faded
hop and the composer's slot have no dark grey to stand on, so they
stand on the page, the slot as a white + in a break of the ribbon. Two
chips of one colour join on a thin chevron in that colour.
Bold is brightness on the console, and Rich merges a selected row's
bold into every span on it, which turned the dim fills bright. Every
chip style there says "not bold"; the tests read the rendered row back
through the emulator's VT to hold that.
The new font takes effect once the font script runs again on the Lyra
(as root). Until then the PicoCalc's installed font lacks the chevrons.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A room server is a message board on a radio: it keeps its members' last
posts and sends each member the ones they missed. MeshTerm could see rooms
and run your own, but not join one. Posts that reached the radio were filed
as direct messages from the room, with their authors thrown away.
Chat now has a Rooms section between Channels and Direct. Opening a room
shows its board at once, each post under the member who wrote it. A post
carries the first four bytes of its author's key, so it is named by any
contact or overheard advert with that key and coloured by the key itself.
An author nothing names stands as the hash, in the unknown-node grey. The
first visit asks for the room's password over the board. After that,
opening a room logs in again only once it has been quiet for
room_relogin_minutes (a new preference, 30 by default), and ^L (F6 on the
handhelds) logs in on demand. The title says what the room allowed:
member, admin or read-only. A read-only member gets a line saying the room
drops their posts, in place of a compose box. A post goes out once, never
soft-retried, since a duplicate would reach every member.
Underneath: messages carry an author (schema 18), a room's board leaves
out its command replies, and a re-sent post is recorded once. Room posts
raise the unread badge and a room's command replies don't. The device
gains room_login, sharing admin_login's exchange and reading the access
the room granted. Room passwords are remembered in rooms.json; an admin
login is kept in admin.json, shared with Repeater admin. The simulator
has a room, Lakeside BBS, to join with "hello".
Two fixes the research turned up. An admin command took the first direct
message from anyone as its reply, so a room post or a companion's message
landing mid-command was read as the node's answer. It now listens before
sending, and only for a reply from that node that is not a post. And
Repeater admin names a room server's guest password "Room password", the
password members join with.
Refs: #24
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"Host" already meant two things here: a TCP companion's network host,
and battery="host" for this machine itself. And with a PicoCalc preview
coming, the package is less a console host than an emulator of a
handheld's display: its panel, grid, font, colours and keys. So it is
meshterm.emulator, Platform.console_host is own_display, its desktop
window is window.py (EmulatorWindow) and its device front end
framebuffer.py, since "sim" and "device" now mean the radio simulator and
the device being emulated. MESHTERM_HOST_FONTS is MESHTERM_EMULATOR_FONTS.
The package's own description opens on what it is not: nothing runs the
handheld's processor or operating system; it mimics the display's
constraints so the window obeys the device's.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
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>
The stock image gives an app no text console, so MeshTerm draws its own: the TUI
runs in-process with prompt_toolkit's output feeding a small VT parser, the cells
are drawn in Terminus 6x12 with MeshTerm's marks over it, and keys go in through a
pipe in xterm's spelling. The front end is the only part that differs: framebuffer
and evdev on the device (written ahead of the hardware), a Tk window on the desktop.
Each handheld's glyph inventory is now named by Platform.font, which replaces
ascii_fold; the render-boundary fold derives its accents, emoji, and fallback from
that inventory and quantizes colour only where truecolor is off.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Same hue as the fn key's print, darkened in OKLab at constant chroma: white
on #e34b0f holds 4.0:1 where the print's #ff6633 held 2.9:1.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A lane's keyboard-bound half is now a LaneDeck named by Platform.lane_deck: which
keycodes drive the slots, where the chips sit, how they are drawn, and which of a
screen's lane definitions it reads. Screens define picocalc_lane; cardputer_lane
follows it until a screen overrides it, so the two can part ways one screen at a
time. The Cardputer deck is Fn+4..8, Shift with them as F16-F20, eight-cell chips
centred over their keys in the fn key's orange-red and the Shift blue.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The groundwork for region scopes. core/regions.py holds the firmware's
maths (key = SHA-256("#"+name)[:16] over UTF-8, code = HMAC over type byte
+ payload with the reserved values stepped off) and the Scope reading of a
received frame. core/region_store.py keeps the regions known by name,
which repeaters carry them, and each channel's send scope, and memoizes
resolution. Observations gain transport_code and scope_body (schema v16)
so a scoped frame resolves against a name learned after it was heard.
Device gains set_flood_scope (the session override, * forcing unscoped)
and request_regions (the anonymous REGIONS request). The default scope
is now framed by MeshTerm: the library stored it with a # the firmware
and apps leave off, and padded by characters rather than bytes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A LoRa chip on the host's own SPI bus (the uConsole AIO) is now a
transport like serial, BLE, and TCP: --spi, an SPI profile, or the
picker's row for /dev/spidev1.0. MeshTerm starts a software node as a
child process under whichever Python has openhop-core, talks to it over
a loopback port, and ends it on disconnect; the node also exits with its
parent, so the pins are free whenever MeshTerm is not running.
The node keeps identity, prefs, channels, and contacts under
~/.meshterm/radio/<spidev>/, and on first use copies the bridge's
identity, contacts, and radio settings so it stays the same node.
Board wiring lives in a profile's spi table; radio settings are the
node's own, edited on Device config. Closes#21.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
The Calculinux bring-up, both console-font builds, the GPL-2.0 text that belongs
beside the 6x8 one, and the xiao-radio firmware kit move under scripts/picocalc/;
the SPI bridge is scripts/uconsole/meshterm-spi-bridge. The folder's own README is
gone: its per-file index is now a section of docs/hardware.md, next to the table
that says which manual is yours. Every path in the manuals, NOTICE, CLAUDE.md, the
tests, the package docstrings and the scripts' own headers follows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scripts/calculinux-console-font.sh is now calculinux-console-font-6x12.sh.
Every reference follows: the setup script's hand-off, the 6x8 script's
SHARED default and comments, the theme16 test that parses it, the module
docstrings, the two manuals, the scripts index and CLAUDE.md. The font files
it builds keep their names.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A leading underscore says "internal to this file, don't import me", and the
column-age formatter and the node glyph table wore one while seventeen modules
imported them and CLAUDE.md named them as the one place each concept lives.
format_age sits beside format_ago now (prose age, column age), NODE_GLYPHS and
DEFAULT_GLYPH beside them, age_seconds with the ages. A mechanical rename, 140
references across 24 files, CLAUDE.md's three lines with them so the guide and
the code move as one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A camera reads contrast, and a light-on-dark code is what a scanner expects
of a screen, so every code MeshTerm draws is now pure white ink on a pure
black field (the theme's `qr` style, both ends named on both themes), never
black on white.
The share views — a channel's share link, a contact card, the CLI's share
commands run in the menu — are no longer a titled popup with an instruction
over the code. They are `QrScreen`, a **bare frame**: `Screen.bare` and
`frame.compose_bare` draw the body alone on blank rows, with no header, no
footer, no title bar, no box, no hint and no F-key lane, and nothing on it
but the code and the URL it encodes, sat a little above centre. Esc leaves.
The code fits itself to the frame every paint, width and rows both: the
standard fit first, then a lighter error level, then a narrower quiet zone,
before it would ever be cut — a contact card is 57 cells and 29 rows at the
standard fit, the PicoCalc is 53 across and a regular terminal 24 tall. When
code and URL can't share the frame the code is whole at the top and the URL
a page down. The frame states its height before it asks for the body, which
is what lets the screen size to it.
A `qr` fence in a written page is the one exception and keeps its place in
the prose — the About pages' codes flip to white on black like the rest.
Found on the way: the PicoCalc's SGR quantizer matched a colour only when it
was a whole sequence, and Rich writes a style's foreground and background as
one sequence (`ESC[38;2;…;48;2;…m`), so a white-on-black module reached the
console in truecolor. It now walks every sequence's parameter list.
The standard is written into CLAUDE.md under Screens and lists; the gallery
carries the share screen as a specimen on both platforms.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 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>
`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>
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>
`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>
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>
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>
Teal was still answering three questions it had no business answering. The wordmark's
colour is the app's identity, and it is also a node hue — so a cyan-keyed node could
vanish into the very highlight pointing at it.
- A node's key-derived hue folds to the cursor white on the cursor row. A row is a
column of identities right up until it is the row you are on, and then it is
answering a different question; a hue on it argues with its own selection. Folded
once at the render boundary for any Text whose *base* style is `cursor` (which is how
every list and every screen drawing its own rows already marks it), so no screen has
to remember the rule. `theme.is_identity_style` is node_style read backwards — every
hue a key can mint, on both platforms — and only those fold: the heat of an age, an
SNR reading, a red badge, a path line's chip fills all keep what they were given. So
does the grey of a node no key could place: the highlight knows where you are, not who.
- The reverse-video chip — a dialog's committing button, a running action's Abort, the
arrow-mode composer slot, the editor cursor — is grey. A chip marks where a press
lands. It is chrome, and chrome does not get a hue. Light grey slot 7 on the VT: the
one grey a reverse can put behind text there, and the fill the F-key lane already uses.
- The active sort column's heading and triangle take the same white as the cursor row,
being the same claim one axis over. They were a bare `#22d3ee`, which put a selection
cue back on the node spectrum and, on the console, straight onto the brand's own slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The row `❯` points at borrowed `brand` — so the app's identity ink and
"the row you're on" were the same hue, and worse, teal/cyan is itself a
node hue: a cyan-keyed node's name vanished into its own highlight.
Give the highlight its own name. `cursor` is white on both platforms
(slot 15 on the VT, the top of the bright bank, so the row's bold cannot
promote it further and every dim-slot `not bold` pin inside a highlighted
row goes on meaning what it meant). White sits outside the per-node
spectrum, so it can never collide with an identity; it does share ink
with `you`, which is the accepted cost — spans that set their own colour
keep it over the base, so only unstyled text goes white, and only on the
row already under the reader's eye.
Every surface that draws its own cursor row follows: the select list and
its reorder mode, the feed, chat, routes and actions on the node page,
the composer, records, trace, tx, the walk, the paths screen, the prompt's
suggestions. The composer's insertion-slot chip takes the same white — its
docstring already tied it to "the colour the list cursor below it wears",
and the chip ink was already picked to stay readable on white.
Reverse-video chips keep `selected`: on the VT a reversed white lands on
the light grey the F-key lane already owns, so a white default button
would stop reading as the committing verb.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A name standing alone above its messages was a coloured word with nothing
to say it was a label — at a glance it read like something someone typed.
It now sits in a chip, and not a chip of its own invention: ui/widgets.
name_chip builds a one-hop PathLine and asks it for its line, so the fill,
the ink, the padding, the rounded cap and the closing point are whatever a
route's segments are wearing today and cannot drift from them. A sender
wears exactly what that node wears as a hop — hue in the fill under dark
ink, the keyless grey where no key places them, and our own messages
headed by the ★ on its neutral grey rather than by the word "you".
Only the group header. An @mention inside a body is part of what was said,
and framing it would put a chip in the middle of a sentence.
Degrading comes free with the borrowing: where the terminal can't draw
separators — the PicoCalc console among them — a path line is arrow text
and one hop is just the name in its hue, which is what a sender label was
before it was framed. The specimen card grows a chips row, which doubles
as a one-hop sample of the route language.
And the unknown sender takes node.unknown rather than muted: one hex apart
here, a whole slot apart on the console, and an unidentified sender is
content you can still act on, not chrome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A name standing alone above its messages was a coloured word with nothing
to say it was a label — at a glance it read like something someone typed.
It now sits on a field: the node's own hue as ink on the neutral dark grey
pathline already fills our ★ chip with, closed at both ends by the rounded
caps a path line closes on, or by half blocks where the font has none.
Only the group header. An @mention inside a body is part of what was said,
and framing it would put a chip in the middle of a sentence.
The widget is ui/widgets.name_chip — the ink arrives as whatever style the
caller would have drawn the name in and is *added* to the fill as a Style,
because Rich silently drops "node.unknown on #3f3f46" and renders the name
bare. The console has no field to offer (its one dark grey is slot 8 and
backgrounds stop at 7), so the name draws unframed there, bound on
truecolor like the heat gradient. The specimen card grows a chips row so
both dresses are visible on the acceptance screen.
And the unknown sender takes node.unknown rather than muted: one hex apart
here, a whole slot apart on the console, and an unidentified sender is
content you can still act on, not chrome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three About pages were scaffolding built out of Text() calls, which
made writing them a Python edit and capped what they could ever say. They
are markdown now — meshterm/assets/pages/*.md, drawn by ui/markdown.py
into the app's own language rather than markdown's: # the page's name in
brand, ## a section in the body accent (with the muted · aside), ###
indented with its prose, paragraphs and lists hanging as blocks, quotes
and fences behind a rail that survives wrapping, tables through Rich, and
links that say where they go — nothing is clickable on a framebuffer
console. Two paragraphs are page frame rather than body and sit flush and
muted: the standfirst under the title, the colophon under the closing
rule, which is where the copyright lands.
The pages stay scaffolded on purpose — the placeholders are unchanged —
but filling one in is now editing its .md, and the live package facts
arrive as {version}/{author}/{copyright} so a page still can't drift from
the build it describes.
Being a document earns the screen two things a block never had: its ##
headings are landmarks, so a heading pins while its own prose scrolls
under it and ^PgUp/^PgDn step by them, and the console lane claims
Sect ↑/Sect ↓ on the free F1/F2 pair once a page has more than one
section — dimmed while the whole page fits, since the lane never
advertises a dead key.
Parsing is markdown-it-py (already under Rich, now declared): a real
CommonMark parser, so an author writes ordinary markdown. The md.* styles
are defined in both themes; the new test renders the whole vocabulary at
53 cells and asserts it stays inside the console font and the 16 slots.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The console's compact glyphs for REQ and RESPONSE read them as inbound
and outbound mail — ↓ for the ask, ↑ for the answer. That is the wrong
axis: what a reader is tracking is the exchange, not the mailbox, and in
an exchange the question goes out and the answer comes back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six disciplines measured the whole walk — how far it travelled, how far
out it reached, how many nodes it touched. None of them measured a single
link, and that is its own game: a node reached over a chain of short hops
scores far, not long, while one huge jump between two relays scores
nothing today.
Longest leg (🏹) takes the greatest distance the walk crossed in one hop,
both ends positioned. The measurement rides the loop that already sums
the circuit, so it costs nothing extra, and it carries the ids of the two
ends it spanned — our own end left null — so the record card can name the
link on THE path widget, ★ at home like every other route we draw. A
record set before the discipline existed simply has no leg lane.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 53-column mark's fourth row is the only row in either wordmark whose
colour lands on the dim bank (185;28;28 -> slot 1), and on the console it
came out part red, part light red: the M's right leg, E, S, H and the R's
right leg promoted, the M's left leg, T, E and the R's left leg not.
The split follows the bevels. The mark alternates letter blocks with slate
bevel spans, the bevel is slot 8 -- spelled 90, which on the VT sets the
intensity bit -- and the quantizer handed back a bare 31 for the blocks
after it. Bare, it keeps the bit and renders 91. Every leg that happened to
follow a bevel went light red; every leg that followed one of the row's few
resets stayed red. The .ansi art is one colour throughout; the promotion is
entirely on the way out.
So the fold's quantizer now states its intent the way the theme's dim-slot
styles already must: a dim slot leaves as 22;3N, never a bare 3N. Adjacent
art spans reset nothing between them, so this is the only place that can
know. It fixes the same latent promotion in a map raster, where two
neighbouring cells are exactly this pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Water, its watercourses, parks and highways were literal hex, so the
PicoCalc fold picked their slots by nearest neighbour and lost the point
of each one: the dark blue landed on grey, the dark green on black, the
motorway amber on bright red. Same failure the node-type marks already
dodge by naming a style, so these do too — a map.* block in both themes,
resolved through mark_rgb like every other mark.
On the console water is now blue, every watercourse the bright blue one
rung up, parks green and highways brown. Truecolour is unchanged: the
named styles carry the same hexes they replaced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JP's spec for the heard-age scale, replacing the four-step ladder: white
under 5 minutes, then yellow at 5m, light red at 1h, brown at 1d, red at
1w, light grey at 1mo, and one cold grey for over a year — which is also
"never heard", since past a year the distinction stops earning a colour.
The `heat.*` styles are renamed for what they describe: a node heard
eight minutes ago is `heat.minutes`, one heard eight days ago is
`heat.days`. The old hot/warm/cool/cold read as temperature but had to
carry an age, and at seven rungs that stopped being sayable. The steps
themselves move into a table, so the ladder is one thing to read.
Three of the seven land in the VT's dim bank, so they say `not bold` — a
selected row would otherwise jump them a rung (brown to yellow, red to
light red), which is the one way a heat colour can lie about an age.
The splash wordmark's vertical ramp becomes white / yellow / light red /
red / light black, each picked to quantize onto exactly one palette slot
(15, 11, 9, 1, 8) while staying a warm fade in truecolour. Its rows are
art rather than renderables, so they had never passed through the fold
and the console was downsampling them on its own — they do now, which is
what makes the slot choice hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>