Docs: two surveys — navigation exceptions, and UI consistency

Both are catalogues, not changes. Nothing in either has been acted on;
each finding says where it is, what it does, and what makes it a question,
so the decisions can be taken one at a time instead of discovered one at
a time. Every code reference in both was machine-checked (144 of them).

The navigation survey lists every place the app does something other than
"Esc pops and you carry on". The headline is that three screens rebuild
themselves each round and lose your place — node detail loses the open tab
too, so working through the Routes list means re-opening it after every
trace — while eight others carefully restore it and four sidestep the
problem entirely by keeping one screen for the whole visit. Also: Esc
peels a live filter on three screens and abandons the whole screen on two,
which is the same keys giving opposite results on the app's most-used
list; six different spellings of the value a Back row resolves with, so
callers test three at once; and a pop-then-re-push workaround repeated
five times where the session API has a gap.

The consistency survey is mostly good news — footer hints, titles, the
lexicon, status marks and the query line all came back clean under
mechanical sweeps, which is what makes the nine remaining items worth
trusting as close to the whole list. The ones that show: four screens
whose empty state shouts in sentence case when the empty state is the only
thing on screen, a dialog with no Cancel whose default lands on an action
and whose rightmost button is the destructive one, and two full screens —
Trace among them — that never had a PicoCalc lane pass and so advertise
pager chips that do nothing.
This commit is contained in:
Jean-Pierre Martineau committed 2026-08-22 14:22:45 -04:00
1 parent e86d6a705a
commit bfd396da69
2 files changed
+563

No files matched your search

+350
View File
@@ -0,0 +1,350 @@
# Navigation survey — every exception to "Esc pops the stack"
Written 2026-08-22 against `main` @ `4b29248`. A catalogue, not a change: nothing here has
been fixed. Each finding says where it is, what it does, and what makes it a question.
The point of the survey is that MeshTerm's navigation is *almost* uniform. The exceptions
are few enough to list exhaustively, which means they can be decided one at a time rather
than discovered one at a time.
---
## 1. The machinery, in one screenful
Three primitives, all in [`ui/tui/`](../meshterm/ui/tui/):
| Call | What it does |
|---|---|
| `session.push(screen)` | screen becomes the top of the stack and is drawn |
| `session.pop(screen)` | that screen leaves the stack |
| `screen.resolve(value)` | the screen's future completes; the awaiting caller decides what happens next |
`session.run_screen(screen)` is push + await + pop as one call — the normal way to show a
screen. `Screen.handle("escape")` resolves with the `CANCEL` sentinel
([`screen.py:243`](../meshterm/ui/tui/screen.py#L243)); every screen inherits that unless it
overrides `handle`.
So the app has no "navigator". A screen never decides where the user goes — it resolves a
value and its *caller* decides. Every exception below is a caller deciding something other
than "pop and carry on".
---
## 2. What "normal" looks like
The reference flow is the main menu ([`menu.py:466-559`](../meshterm/ui/menu.py#L466)):
1. build the row list
2. `push` the menu
3. await a choice
4. `pop` the menu in a `finally`
5. run whatever was chosen
6. loop — rebuilding the menu, **re-highlighting the row just used** (`default=last_selection`)
Point 6 is the part worth naming: *returning from a thing lands you on the thing you
returned from.* It is the behaviour every list should have, and most do.
---
## 3. Exception A — cursor position on re-entry
**The single largest inconsistency in the app.** Three groups, three behaviours:
### A1. Restores the cursor — the intended behaviour (7 sites)
| Screen | Where | How |
|---|---|---|
| Main menu | [`menu.py:502`](../meshterm/ui/menu.py#L502) | `default=last_selection` |
| Channels (manager) | [`channels.py:214`](../meshterm/ui/channels.py#L214) | `default=highlight` |
| Channels (one channel) | [`channels.py:728`](../meshterm/ui/channels.py#L728) | `default=cursor` |
| Device config editor | [`config_editor.py:161`](../meshterm/ui/config_editor.py#L161) | `default=cursor` |
| Device actions | [`config_editor.py:688`](../meshterm/ui/config_editor.py#L688) | `default=cursor` |
| Repeater admin | [`repeater_admin.py:172`](../meshterm/ui/repeater_admin.py#L172) | `default=cursor` |
| Courier outbox | [`courier_screen.py:120`](../meshterm/ui/courier_screen.py#L120) | `default=cursor` |
| Watchtower | [`watchtower_screen.py:137`](../meshterm/ui/watchtower_screen.py#L137) | `cursor = choice` |
Watchtower has the most careful version of it — it *drops* the restore when the chosen row
is about to vanish ([`watchtower_screen.py:145-146`](../meshterm/ui/watchtower_screen.py#L145)):
```python
elif choice == _CLEAR:
store.clear_acked()
cursor = None # the row itself disappears
```
That refinement exists in exactly one place. Anywhere else a row can disappear, the restore
either silently fails to match (harmless — falls back to the top) or lands on whatever row
slid into the vacated position (not harmless).
### A2. Reuses the screen object, so it keeps everything (4 sites)
Contacts states the principle outright
([`contacts_screen.py:200-201`](../meshterm/ui/contacts_screen.py#L200)):
> One screen for the whole visit: re-running it keeps the highlight (and any sort or
> filter) on the row the user just opened a detail for, rather than snapping to the top.
Also chat, live feed, dashboard. Strictly better than A1 — it preserves sort and filter too,
not just the cursor — and it costs less code.
Contacts also shows the honest exception: after a purge changes the data, it deliberately
*does* rebuild ([`contacts_screen.py:217-219`](../meshterm/ui/contacts_screen.py#L217)), and
says why.
### A3. Rebuilds and loses your place (3 sites) — **the actual finding**
| Screen | Where | What is lost |
|---|---|---|
| Trophy case | [`records_screen.py:851`](../meshterm/ui/records_screen.py#L851) | cursor — a fresh `SelectScreen` per round, no `default=` |
| Time Machine picker | [`timemachine_screen.py:1323`](../meshterm/ui/timemachine_screen.py#L1323) | cursor (sort *is* carried) |
| Node detail | [`node_detail_screen.py:1360`](../meshterm/ui/node_detail_screen.py#L1360) | cursor **and the open tab** |
Node detail is the sharpest case. `NodeDetailScreen.__init__` sets `self._tab_index = 0`
([`node_detail_screen.py:446`](../meshterm/ui/node_detail_screen.py#L446)) and the opener
constructs a new one every iteration. So: open a contact → Tab to **Routes** → pick a route
→ Enter to trace it → come back → **you are on Info, at the top**. The route you were
working through is two keypresses away again, every time.
All three are a one-line fix in the A2 direction (hoist the construction out of the loop).
> **Question for JP:** is A2 the rule? If so these three are bugs. If a *deliberate* reset
> is wanted anywhere, Contacts' purge branch is the model for how to say so.
---
## 4. Exception B — hand-offs that flatten the stack instead of nesting
Two places let one screen hand off to a *peer* screen, and both deliberately unwind first so
the user does not end up deep in a stack they have to climb.
**B1. Trace → Trophy case.** A trace that sets a record raises a dialog offering the trophy
case. Choosing it resolves the sentinel `OPEN_TROPHY_CASE`
([`trace_screen.py:128`](../meshterm/ui/trace_screen.py#L128)), the trace screen comes down,
and only then does the caller open records
([`trace_screen.py:2193-2199`](../meshterm/ui/trace_screen.py#L2193)):
> the trace screen (and every prompt over it) is already down, so the trophy case opens
> over a clean stack and Esc from it unwinds straight to the main menu — never back into
> this session.
**B2. Trophy case → Trace.** The mirror image, and the comment is even more explicit
([`records_screen.py:905-909`](../meshterm/ui/records_screen.py#L905)):
> the browser does not reopen behind it: when the trace screen closes, this whole flow
> returns, landing the user on the main menu instead of a trophy-case → trace →
> trophy-case stack that takes many Escs to climb out of.
These two are the same rule discovered twice: **a hand-off between peers flattens; only a
sub-view nests.** It is not written down anywhere.
**B3. Node detail → Trace / Map / Time Machine / Share** ([`node_detail_screen.py:1372-1397`](../meshterm/ui/node_detail_screen.py#L1372))
follows the same rule *without saying so*, and that is where §3's A3 comes from. Because
`run_screen` pops on resolve, node detail is already down when the peer opens — so the peer
gets a clean stack, exactly as B1 and B2 arrange by hand. But unlike B1/B2, node detail then
**comes back**, by constructing a fresh screen at the top of the loop.
So the three sites agree on flattening and differ on what happens afterwards:
| | flattens before the peer | returns afterwards | keeps its state |
|---|---|---|---|
| B1 Trace → Trophy case | yes, deliberately | no — lands on the menu | n/a |
| B2 Trophy case → Trace | yes, deliberately | no — lands on the menu | n/a |
| B3 Node detail → peers | yes, as a side effect of `run_screen` | **yes** | **no** |
B3 is the only one that re-enters, and re-entering through a constructor is what costs the
tab and the cursor. That makes A3-on-node-detail a *consequence* of this shape, not an
independent bug: fixing it means either hoisting the screen out of the loop (keep the object,
keep the state) or nesting properly (keep it pushed while the peer runs).
> **Question for JP:** "flatten before a peer opens" looks like the settled rule — three for
> three. What is unsettled is whether a hub screen should *nest* instead, so it never has to
> be rebuilt. Node detail is the only hub, so this is really one decision about one screen.
---
## 5. Exception C — the backdrop re-push
`run_screen` pops the screen when it resolves. But a dialog raised straight afterwards needs
something behind it, or it draws over a blank frame. So five sites pop, then immediately
re-push the same screen as a static backdrop:
| Site | Screen re-pushed as backdrop |
|---|---|
| [`records_screen.py:867`](../meshterm/ui/records_screen.py#L867) | trophy case, behind the discipline picker / delete confirms |
| [`contacts_screen.py:210`](../meshterm/ui/contacts_screen.py#L210) | contacts, behind the purge picker |
| [`repeater_admin.py:108`](../meshterm/ui/repeater_admin.py#L108) | node picker, behind the login prompt |
| [`menu.py:910-911`](../meshterm/ui/menu.py#L910) | base screen, behind the disconnect dialog |
| [`tx_screen.py:885`](../meshterm/ui/tx_screen.py#L885) | flight screen |
Records has the fullest explanation ([`records_screen.py:862-866`](../meshterm/ui/records_screen.py#L862)).
This is a **workaround for an API shape**, repeated five times. The screens that avoid it
(channels, config editor, repeater admin's own menu, courier, watchtower, main menu) all
drive `push`/`await future`/`pop` by hand instead of calling `run_screen`, precisely so the
screen stays up while sub-prompts float.
> **Question for JP:** would a `session.run_screen(screen, keep_pushed=True)` — or a
> `session.backdrop(screen)` context manager — retire both the five re-pushes and the six
> hand-rolled push/await/pop loops? They are the same need spelled two ways.
---
## 6. Exception D — what Esc peels before it leaves
Four screens carry a live find-as-you-type filter and draw it through the one
`render.query_line`. **Esc means two different things across them:**
| Screen | Esc with a filter active | ⌫ with a filter active |
|---|---|---|
| Map | clears the filter, stays ([`map_screen.py:920-925`](../meshterm/ui/map_screen.py#L920)) | — |
| Mesh walk | clears the filter, stays ([`walk_screen.py:430-436`](../meshterm/ui/walk_screen.py#L430)) | deletes a char, then pops the trail ([`walk_screen.py:449-456`](../meshterm/ui/walk_screen.py#L449)) |
| **Select list** | **leaves the screen, filter and all** ([`select.py:773-774`](../meshterm/ui/tui/select.py#L773)) | deletes a char |
| **Path composer** | **leaves the screen, filter and all** ([`path_composer.py:717-718`](../meshterm/ui/path_composer.py#L717)) | deletes a char, then pops a hop |
So on the map, typing `yul` then Esc shows you the map again. On a contact list, typing
`yul` then Esc drops you back to the menu. Same affordance, same glyphs, opposite outcome —
and the select list is by far the most-used of the four.
Chat is a fifth member of the family with a different peelable thing
([`chat.py:624-629`](../meshterm/ui/chat.py#L624)):
```python
if self._selected is not None:
self._clear_selection() # first Esc unpicks; next leaves the chat
```
so chat peels, and its comment reads as though peeling were the house rule.
**Score: 3 screens peel, 2 do not.** Worth noting the peel is *not* free — on a screen where
Esc peels, leaving a filtered list takes two Escs, which is the cost JP is weighing on exit
rows elsewhere.
> **Question for JP:** peel everywhere, or nowhere? The current split is not defensible as
> a distinction between spatial and list screens, because the path composer is a list and
> does not peel, while chat is not a filter and does.
---
## 7. Exception E — six spellings of "the user pressed Back"
`back_rows(value)` takes the value its Back row resolves with. Call sites, verbatim:
| Value passed | Sites |
|---|---|
| *(nothing — `None`)* | [`admin_picker.py:49`](../meshterm/ui/admin_picker.py#L49), [`:77`](../meshterm/ui/admin_picker.py#L77), [`config_editor.py:617`](../meshterm/ui/config_editor.py#L617), [`:790`](../meshterm/ui/config_editor.py#L790), [`:1025`](../meshterm/ui/config_editor.py#L1025), [`courier_screen.py:242`](../meshterm/ui/courier_screen.py#L242), [`watchtower_screen.py:225`](../meshterm/ui/watchtower_screen.py#L225), [`:362`](../meshterm/ui/watchtower_screen.py#L362) |
| explicit `None` | [`tx_optimize.py:439`](../meshterm/tools/tx_optimize.py#L439), [`contacts_screen.py:263`](../meshterm/ui/contacts_screen.py#L263) |
| `"__back__"` | [`tools/chat.py:187`](../meshterm/tools/chat.py#L187), [`records_screen.py:790`](../meshterm/ui/records_screen.py#L790) |
| `_BACK` | [`channels.py:619`](../meshterm/ui/channels.py#L619), [`:683`](../meshterm/ui/channels.py#L683), [`contacts_screen.py:152`](../meshterm/ui/contacts_screen.py#L152) |
| `_CANCEL` | [`config_editor.py:736`](../meshterm/ui/config_editor.py#L736) |
| a tuple shaped like the screen's other values | [`records_screen.py:850`](../meshterm/ui/records_screen.py#L850) `("back", None, 0, None)`, [`trace_screen.py:2012`](../meshterm/ui/trace_screen.py#L2012) `("back", None)` |
The cost lands on the callers, which have to test every way at once:
```python
# contacts_screen.py:204
if chosen is CANCEL or chosen is None or chosen == _BACK:
# records_screen.py:858
if picked is CANCEL or picked is None or picked[0] == "back":
```
Note `config_editor` names its Back sentinel `_CANCEL` — the one word CLAUDE.md reserves for
dialogs, used here for a row that says "Back".
> **Suggestion:** `back_rows()` could default its value to `CANCEL` itself, so a Back row and
> Esc resolve *identically* and every caller's three-way test collapses to `is CANCEL`. The
> tuple-shaped ones exist only because their screens type their values as tuples; those could
> take `CANCEL` too.
This ties directly into the exit-row question — see §10.
---
## 8. Exception F — the stack reset
One site clears the whole stack: [`menu.py:824`](../meshterm/ui/menu.py#L824), when the device
link drops mid-session. The watcher cancels the menu worker, `session.reset()` drops whatever
screens it left behind, and the reconnect dialog opens over a clean frame.
This is the only place the stack is cleared rather than unwound. It relies on every screen
being pop-safe in a `finally`, which is documented at
[`session.py:388-395`](../meshterm/ui/tui/session.py#L388). Correct as written — noted here
because it is the one code path where a screen can vanish without its caller resolving it,
and anything holding state across a `run_screen` boundary would silently lose it.
---
## 9. Exception G — one tool floats, twenty-two replace
`Tool.popup` ([`tools/base.py:67`](../meshterm/tools/base.py#L67)) decides whether the menu
stays pushed while the tool runs. **Exactly one tool sets it:** `advert`
([`tools/advert.py:33`](../meshterm/tools/advert.py#L33)).
Every other tool pops the menu, runs full-screen, and the menu is rebuilt afterwards. Which
is right for a screen — but several tools are *also* dialog-sized in practice, and the result
presenter already makes exactly this call at the other end
([`surface.py:603-610`](../meshterm/ui/surface.py#L603)): a short text-only result floats as
a dialog, anything bigger opens a window.
So the app decides "float or replace" twice, by two different mechanisms, one hand-declared
per tool and one measured from the content.
> **Question for JP:** should `popup` be inferred the way the result presentation is, or is
> one declared flag on one tool the whole of the need?
---
## 10. Where the exit-row question actually lands
JP's note — *"remove the cancel and close actions at the bottom of pages; Esc is quicker and
available on all platforms; keep it where there is a confirm or cancel situation"* — was
acted on for the one unambiguous case (the path composer's `Cancel` row, which literally ran
`super().handle("escape")`; removed in `4b29248`). The rest of the territory:
**Already clean.** The three About pages have no exit row at all — prose, Esc, done. Titles
carry no emoji. Footer hints are 100% compliant on width, Esc-last, and verb choice (56
literal hints audited).
**Rows that are exactly Esc, still standing:**
- `back_rows()` — the app-wide **Back** row, 19 sites. Two of them carry the comment
`# a visible exit beside Esc` ([`courier_screen.py:242`](../meshterm/ui/courier_screen.py#L242),
[`watchtower_screen.py:225`](../meshterm/ui/watchtower_screen.py#L225)) — i.e. the row exists
*because* it duplicates Esc, which is precisely the premise now in question.
- `ReorderScreen`'s Back row ([`select.py:931`](../meshterm/ui/tui/select.py#L931)) —
`super().handle("escape") # Back resolves CANCEL, same as Esc`.
- Live feed's Back row, reached by scrolling past the oldest packet
([`livefeed_screen.py:41-44`](../meshterm/ui/livefeed_screen.py#L41)).
Removing **Back** app-wide is a bigger decision than the path composer's Cancel, because:
- it is written into CLAUDE.md as a binding standard ("the only exit word on rows");
- `exit_rows()` builds on it — an editor with staged changes shows `✓ Apply n staged changes`
over `✗ Back — discard staged changes`, where Back is *not* redundant: it names a
consequence Esc does not;
- on PicoCalc, Esc is a real key, so the platform argument does not add anything there.
**A middle position worth considering:** keep Back exactly where it says something Esc does
not (the `exit_rows` discard case), drop it where it says nothing (the plain `back_rows`
case). That is roughly 3 sites kept, 16 dropped — and §7's suggestion (Back resolves `CANCEL`)
would make the drop mechanical rather than a per-caller edit.
**Buttons that are exactly Esc:** only one — the `Close` button on the new-record dialog
([`trace_screen.py:639`](../meshterm/ui/trace_screen.py#L639)). Left alone deliberately: it
is the *default*, so Enter dismisses safely, and this dialog appears unbidden after a trace.
Dropping it would leave `Trophy case` as the default and make a stray Enter navigate away.
Every other `Cancel` in the app is one half of a real two-way choice.
---
## 11. Summary — the shortlist
| # | Finding | Sites | Weight |
|---|---|---|---|
| A3 | Screen rebuilt per round, losing cursor (and node detail's tab) | 3 | **high** — felt on every visit |
| D | Esc peels the filter on 3 screens, leaves on 2 | 5 | **high** — same keys, opposite result |
| E | Six spellings of the Back value; callers test three at once | 19 | medium — invisible to users, costly in code |
| C | Pop-then-re-push backdrop workaround | 5 | medium — an API gap, repeated |
| B | Peer hand-offs all flatten, but the rule is unwritten and node detail pays for it | 3 | medium |
| G | `popup` declared once by hand while the result presenter infers it | 1 | low |
| A1 | Cursor restore doesn't handle a vanishing row (except watchtower) | 7 | low |
| F | `session.reset()` on disconnect — correct, noted for completeness | 1 | none |
+213
View File
@@ -0,0 +1,213 @@
# UI consistency survey — what is off, and what turned out to be fine
Written 2026-08-22 against `main` @ `4b29248`. Companion to
[`survey-navigation.md`](survey-navigation.md), which covers navigation exceptions; this one
covers everything else in the CLAUDE.md UX standard. A catalogue, not a change.
**The short version: the standard is very well kept.** Six sweeps found nothing at all;
what remains is nine small, specific things. That is the useful result — it means the list
below is close to complete rather than a sample.
---
## Part 1 — the clean sweeps
Each of these was checked mechanically across the whole package, not spot-checked. The scripts
are one-offs and were not kept; each is a dozen lines of `ast.walk` and is trivial to redo.
| Rule (CLAUDE.md) | How it was checked | Result |
|---|---|---|
| Footer hint ≤72 cells | `wcswidth` over 56 literal hints | **0 over** |
| `Esc` last in the hint sentence | atom split on `·` | **0 misplaced** |
| No `Enter pick`/`choose`/`commit` | regex over all hints | **0** |
| Filter atom is always `type to filter` | regex over all hints | **0 deviations** |
| No emoji in a screen or dialog title | 86 title literals vs an emoji range | **0** (one hit was a `Choice` row title, where icons belong) |
| Sentence case in titles | capitalised non-leading word, minus proper nouns | **0** (5 hits were markup-wrapped panel headings) |
| Lexicon: "heard" not "seen"; "contacts" not "nodes list" | regex over all non-docstring literals | **0** |
| Status marks: never `✔ ✖ ✅ ❌` | scan of every source line | **0** (the 3 hits are the fold table that *normalizes* them, and a docstring warning against them) |
| `…` on rows that open a prompt, none on rows that act | 40 row labels via the builders | **1 arguable** — see §2.7 |
| Empty state never parenthesised | scan of 53 empty-state-shaped strings | **0** |
| `render.query_line` is the one query line | import graph | **exactly** the four screens CLAUDE.md names: select list, path composer, map, mesh walk |
Two more worth calling out because they are the kind of rule that usually rots:
- **`Del` on a row is gated correctly.** The hint atom only appears when the highlighted row
is actually deletable ([`select.py:448`](../meshterm/ui/tui/select.py#L448)) — a real
instance of "never advertise a key that would do nothing", implemented rather than assumed.
- **The PicoCalc lane's "a chord earns a chip" rule holds.** The one bare-letter shortcut in
the app — Time Machine's `w` — has its F3 chip, with the reasoning written out in full at
[`timemachine_screen.py:227-242`](../meshterm/ui/timemachine_screen.py#L227).
---
## Part 2 — the findings
Ordered by how visible each is to someone using the app.
### 2.1 Empty-state voice: four sites shout (rule: lowercase, muted, `— explanation`, no full stop)
The house voice is `no contacts yet — receive an advert first`. These four break it, and they
are the empty states of four whole screens, so each is the *only* thing on screen when it shows:
| Site | Current | House voice |
|---|---|---|
| [`chat.py:234`](../meshterm/ui/chat.py#L234) | `No messages yet — say hello!` | `no messages yet — say hello` |
| [`message_paths_screen.py:199-202`](../meshterm/ui/message_paths_screen.py#L199) | `Nothing overheard — the radio only logs frames it hears while MeshTerm is listening.` / `No direct-message frames logged in the window.` | lowercase, no full stop |
| [`timemachine_screen.py:335-336`](../meshterm/ui/timemachine_screen.py#L335) | `Nothing recorded in this window.` + `Press w to widen it.` | lowercase, no full stop |
| [`timemachine_screen.py:453-454`](../meshterm/ui/timemachine_screen.py#L453) | `Nothing sent in this window.` + `Press w to widen it.` | ditto |
The two `Press w to widen it.` lines are a second question: they are *hints*, sitting in the
body, on a screen whose F-key lane and footer already advertise `w`. Either the empty state
absorbs it (`nothing recorded in this window — press w to widen it`) or it goes.
Deliberately **not** on this list: `Never heard` ([`contacts_screen.py:258`](../meshterm/ui/contacts_screen.py#L258))
is a purge-ladder *row label*, and `No revisits` ([`records.py:237`](../meshterm/services/records.py#L237))
is a discipline *title*. Both correctly capitalised.
### 2.2 One dialog inverts the button convention
The rule: *safe way out on the left, committing verb on the right and default*.
[`config_editor.py:538-541`](../meshterm/ui/config_editor.py#L538) — the Location dialog:
```python
[("Pick on map", "map"), ("Type coordinates", "type"), ("Clear", "clear")],
title="Location",
```
No `Cancel`, no `default=`. So the leftmost slot — reserved for the safe way out — holds an
action, the rightmost — reserved for the default commit — holds the *destructive* option
(`Clear`), and the default falls to index 0. Esc still cancels, so nothing is lost; but it is
the only dialog in the app shaped this way, out of 49 call sites.
### 2.3 One dialog defaults to the middle button
[`config_editor.py:988-992`](../meshterm/ui/config_editor.py#L988) — `[Cancel, Preview, Apply]`
with `default=1`, so Enter lands on **Preview**, not the rightmost `Apply`.
This is very likely deliberate — previewing before overwriting a whole device config is the
cautious path — but the standard as written says the rightmost verb is the default, and this
is the only three-button dialog with a real Cancel. Either the rule grows a clause ("with
three buttons, the *cautious* commit defaults") or this changes.
### 2.4 Two dialog *shapes* the standard doesn't describe
Beyond confirms, the app has two other dialog shapes, both used consistently but neither
written down:
- **The toggle picker** — `[Off, On]` with the default tracking the current value
([`config_editor.py:441`](../meshterm/ui/config_editor.py#L441),
[`repeater_admin.py:313`](../meshterm/ui/repeater_admin.py#L313)). No Cancel; `Esc keep`
carries it, which is exactly the footer verb CLAUDE.md reserves for a value picker. Correct
in practice, undescribed in the standard.
- **The notification with an onward door** — `[Trophy case, Close]`, `default=1`
([`trace_screen.py:637-639`](../meshterm/ui/trace_screen.py#L637)). Appears unbidden after a
record-setting trace. `Close` is the default so a stray Enter dismisses rather than
navigating — the inverse of the usual rule, and right for a dialog the user did not ask for.
Both are good designs. The gap is that CLAUDE.md describes only the confirm, so a future
dialog of either shape has nothing to copy.
### 2.5 Two full screens have no F-key lane of their own — one of them is Trace
Twelve classes declare `floating = False` (they are full screens, not dialogs). Ten override
`fkey_lane`. Two do not, and so inherit `Screen.fkey_lane` → the raw `DEFAULT_LANE`
([`fkeys.py:116`](../meshterm/ui/tui/fkeys.py#L116)):
| Screen | |
|---|---|
| [`TraceScreen`](../meshterm/ui/trace_screen.py#L345) (`trace_screen.py:345`) | the app's busiest screen |
| [`TxSweepScreen`](../meshterm/ui/tx_screen.py#L93) (`tx_screen.py:93`) | |
`DEFAULT_LANE` is the *always-enabled* pager. The ten others build from
`default_lane(nav=self.content_overflows)` ([`fkeys.py:131`](../meshterm/ui/tui/fkeys.py#L131)),
which dims the chips when the body already fits. So on PicoCalc both screens advertise live
`Page ↑` / `Page ↓` chips whether or not there is anything to page — the one thing the lane's
standing rule forbids.
Trace is the one worth a proper look rather than a one-liner. Its footer
([`trace_screen.py:498-505`](../meshterm/ui/trace_screen.py#L498)) reads
`↑↓ actions · Enter run · PgUp/PgDn scroll · Esc back` — every key physical on the PicoCalc, so
nothing is *undiscoverable*. But it is the screen where F1–F3 would earn their keep most
(re-run, flip the path, open the composer are all one cursor move away today), and it is
currently the only central screen that hasn't been through the lane pass. Worth deciding what
its three free slots should say, not just gating the pager.
`TxSweepScreen` genuinely is one line: `return default_lane(nav=self.content_overflows)`.
(The About page reads as an exception in a grep but isn't: it passes `floating=False` as a
constructor argument rather than a class attribute, and it does define its own lane at
[`about.py:115`](../meshterm/ui/about.py#L115).)
### 2.6 Dialogs that scroll but declare no lane
CLAUDE.md says `EMPTY_LANE` is for "a screen with none at all (**every dialog**)". Five
surfaces set it explicitly — the confirms, the text prompt, the busy spinner, the reorder
list, one trace dialog. But two floating dialogs that genuinely *do* page inherit
`DEFAULT_LANE` instead:
- `RecordDialog` ([`records_screen.py:146`](../meshterm/ui/records_screen.py#L146)) — a record's
story, which scrolls
- `PathComposerScreen` ([`path_composer.py`](../meshterm/ui/path_composer.py)) — a windowed
list of hop suggestions
Both arguably *want* the pager, which would make the doc's "every dialog" too strong. The
question is whether the rule is "dialogs get no lane" (then these two need `EMPTY_LANE` and
lose paging chips) or "a surface gets the pager iff it pages" (then the doc's parenthetical
should read *every dialog that doesn't scroll*).
### 2.7 One row label that may want an ellipsis
[`config_editor.py:787`](../meshterm/ui/config_editor.py#L787) — `Share QR / URI`. The rule is
"a row that opens further prompts ends with `…`". This one opens a *view* (the QR popup), not
a prompt — so it turns on whether "further prompts" means "any further surface". Every other
row in the app is unambiguous; this is the only edge.
Also worth a glance while there: `Share QR / URI` is the only row label in the app using ` / `
as a separator.
### 2.8 `★` carries two meanings
CLAUDE.md itself lists it twice: `★ you (yellow)` under node types, and `★ best/winner` under
concept icons. In practice both are live — the map and every path line draw `★` for our own
node ([`pathline.SELF_GLYPH`](../meshterm/ui/pathline.py)), while the trophy case and the
new-record dialog draw `★` in accent for a record
([`trace_screen.py:635-636`](../meshterm/ui/trace_screen.py#L635)).
They are never on the same surface, and the colours differ (yellow vs accent), so nothing is
actually ambiguous today. Flagged only because it is the single glyph in the whole marks table
with two entries, and the "one glyph per concept" heading promises otherwise. If a record ever
needs to appear next to a path line, this is where it bites — `🏆` is already reserved for the
trophy case and would carry it.
### 2.9 `node.unknown` vs `muted`, unverified
CLAUDE.md is emphatic that an unidentified node takes `node.unknown` and that this is
*deliberately not* `muted` ("muted is chrome and may sit a step darker, while an unidentified
node is content you can still act on"). `node.unknown` appears at 11 sites. There are also 26
`style="muted"` uses inside [`pathline.py`](../meshterm/ui/pathline.py) and
[`widgets.py`](../meshterm/ui/widgets.py) — the two modules that draw nodes.
Most of those 26 are certainly chrome (separators, labels, the un-lit part of a key, which the
standard explicitly says stays muted). But this is the one rule in the standard that a
mechanical sweep cannot settle: it needs a human to look at each and say "that's chrome" or
"that's an unnamed node". Listed here as the one unaudited corner, not as a finding.
---
## Part 3 — shortlist
| # | Finding | Sites | Effort | Weight |
|---|---|---|---|---|
| 2.1 | Empty states shout on four screens | 4 (6 strings) | trivial | **high** — it is the whole screen |
| 2.2 | Location dialog has no Cancel and defaults to an action | 1 | small | medium |
| 2.5 | Trace and TX advertise a dead pager on PicoCalc; Trace has never had a lane pass | 2 | one line + one design pass | **medium-high** |
| 2.3 | Restore dialog defaults to the middle button | 1 | decision, not code | low |
| 2.6 | Two scrolling dialogs inherit the pager the doc says they shouldn't have | 2 | decision | low |
| 2.4 | Two dialog shapes used consistently but undocumented | — | doc only | low |
| 2.7 | `Share QR / URI` — ellipsis? separator? | 1 | trivial | low |
| 2.8 | `★` has two entries in a "one glyph per concept" table | — | doc only | low |
| 2.9 | `node.unknown` vs `muted` in the two node-drawing modules | ~26 to eyeball | manual | unknown |
Items 2.1, 2.2, 2.5 and 2.7 are code. The rest are decisions about the standard itself, and
2.3, 2.4 and 2.8 may well resolve as "the standard should say what the code already does".