diff --git a/docs/survey-navigation.md b/docs/survey-navigation.md new file mode 100644 index 0000000..69ed154 --- /dev/null +++ b/docs/survey-navigation.md @@ -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 | diff --git a/docs/survey-ui-consistency.md b/docs/survey-ui-consistency.md new file mode 100644 index 0000000..8ea6596 --- /dev/null +++ b/docs/survey-ui-consistency.md @@ -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".