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:
1 parent
e86d6a705a
commit
bfd396da69
2 files changed
+563
No files matched your search
@@ -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 |
|
||||
@@ -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".
|
||||
Reference in new issue
Block a user