Skip to content

Finish the nav convention, and confirm with a tick - #14

Merged
oroderico merged 8 commits into
mainfrom
finish-nav-convention
Aug 16, 2026
Merged

Finish the nav convention, and confirm with a tick#14
oroderico merged 8 commits into
mainfrom
finish-nav-convention

Conversation

@oroderico

Copy link
Copy Markdown
Owner

Eight commits. The first four are the branch as it stood when #12 was parked overnight; the last four are today's, in the order the decisions happened.

Already reviewed in #12

  1. fd81835 Finish the nav convention — the Wallet and Origo menus were still on the old list, so the two most-visited screens looked unlike the rest.
  2. 5bc9ccb Frame the menus like Jade — rules between rows, a bordered back button.
  3. 6a904df Confirm with a tick — reject on the left of the title bar, accept on the right, nothing along the bottom.
  4. 21936e4 Take the tick to the lists and the paged screens too.

New here

  1. Draw the entropy verdicts where the reader is already looking. Poor entropy! and Pattern detected! were titles. A title names the screen you are on; these are findings about the run just made, and a finding that the seed may be weak should not sit in the strip a reader learns to skim. They move to the body; the header keeps the arrow and the tick.

  2. Give the screens that are only read a single way out. A tick is the answer to a question, and a screen whose result every caller discards asks nothing — the argument this repo already makes where menus are drawn. The notices carried the opposite pair, a tick and no arrow, so their single exit wore the glyph for assent. The Safety text and the backup menu's word/number lists ended on a confirm the caller threw away; each gains a read-only sibling (page_read, read_numbered_list) because both callers are real.

  3. Close the header with the rule its boxes are drawn to expect. The arrow and tick boxes are drawn with no bottom edge on purpose — on a list, the first cell's rule was that edge. Every other screen wearing the chrome had no cells to supply it, so the boxes hung open. The header draws it now.

  4. Take the nav convention to the Stackbit carousel. The last screens drawing their own title and no chrome. The words go on the ring the paged screens already walk, so the arrow is reachable rather than decorative.

Behaviour change worth calling out

The Stackbit carousel no longer wraps forever. Leaving now means walking to the arrow, which on 24 words is up to twelve steps. That is the cost of putting the arrow on the ring rather than drawing one that cannot be taken.

Two bugs this would have shipped, found by reading it back

nav_screen toggled the cursor between arrow and tick whenever there was an arrow, so a screen with no tick would park the cursor on an undrawn control and leave nothing lit. And the opening position was still the caller's to get right — a screen with no label but start_on_back false opened on that same undrawn tick. Both are closed, the second by deriving the position from the label rather than leaving it an unwritten rule.

The self-test's notice block asserted only that the tick was drawn. It now asserts the arrow is there and the tick is not: a check that only knows how to see presence cannot see excess — the same lesson #12's tick already taught once.

Verification

Every one of the eight commits was built and self-tested individually, not just the tip. On the final tree: 52 Python tests, the QR smoke test, the self-test under ASan and UBSan, a firmware build the ELF audit passes at 303952 bytes (58% of the 512 KiB budget, +16 bytes against 21936e4), and a 3-minute libFuzzer run over the parsers at 1,207,037 executions with no findings.

Each changed screen was rendered off the framebuffer and read, rather than trusting the geometry. Flashed to a TTGO T-Display; the screens were not systematically walked through on the device, so panel-specific colour remains unverified by this PR.

Four gaps from reviewing the chrome after it landed, all the same shape: a
convention that holds almost everywhere teaches the reader it does not hold.

The Wallet and Origo menus were still on the old list, so the two most-visited
screens in the firmware were the two that looked unlike the rest. They wear the
chrome now, without an arrow: choose_nav grew a `back` flag, the way nav_screen
already had one. Neither screen has a level above it - Origo is the top, and a
loaded wallet's only exit erases the session and reboots. Putting that on the
arrow would give a control that is cheap and reversible on every other screen a
destructive meaning here, one press from any row, which is the same class of
inconsistency the chrome was built to remove. Both keep their exit as a row the
reader travels to. A screen with somewhere to go back to gets an arrow; one
without does not pretend.

The ring arithmetic learned about the missing arrow rather than assuming it:
nav_ring_size, nav_position and nav_selection take `back` and count its slot
only when it is there.

QR export and Descriptor export opened on the confirm bar while every other
warning screen opened on the arrow - including Compact SeedQR, which makes the
same kind of claim. All three say a photograph of what follows gives away more
than the reader may intend; the way forward should not be preselected on any of
them. A test now pins that, matched on the warning text a screen shows rather
than on a list of call sites, so a new screen making one of those claims is
covered the day it is written. Proved by putting one of the two back.

nav_bar_label_fits claimed to hold every screen on the chrome and did not hold
the coin-word reflip warning, which arrived after the table was written.

The old path went with the menus: choose, choose_at and choose_kept had no
callers left, and -Werror=unused-function said so. seedtool_render_list stays
for the self-test's render smoke checks - the linker already drops it from the
image, which is why deleting it would cost diff and save nothing.

Left alone deliberately: the footer hint on the dice, value and brightness
screens. Those have no chrome and up/down there change a value rather than
moving a cursor, so the hint is the only thing telling the reader what the
buttons do. Its comment did describe a string that no longer exists ("L/R
move"), which is fixed.

The image is 320 bytes smaller.

Verified with the host self-test, 52 Python tests, and a firmware build the
audit passes at 303664 bytes.
Rules between the rows and a bordered back button, taken from Jade's Settings
screen rather than approximated: ui/dashboard.c:362 builds it, ui/dialogs.c
draws it.

Three pieces, and the third is why the first two are not enough on their own:

- A rule above every row, so rows read as cells rather than as stacked lines
  of text. Jade borders each menu item and the top one twice
  (dialogs.c:290). It sits in the gap between cells, where the selection bar
  never paints over it.

- The back arrow gets a box, drawn whether or not it is selected, dim when
  idle. A bare arrow in the corner reads as decoration on a screen with no
  other chrome up there; a box reads as something that can be taken.

- The box has no bottom edge, and the rule above the first row is what closes
  it. That is Jade's own arrangement, with its reason in its comment: "bottom
  edge will be covered by upper line above top menu item" (dialogs.c:268).
  Without it the two are a button hovering over an unrelated list; with it
  they are one frame. The box grew a pixel, 18 to 19, so its sides reach the
  rule instead of stopping short of it.

Both list renderers get the rules, not just the nav one: the plain list still
draws the Wallet and Origo menus, and a screen that gains the chrome should
not also change how its rows are drawn.

nav_chrome_bands_do_not_collide had to be told the difference between a rule
and a collision. It asserted the gap under the title bar was entirely dark,
and the new rule lands there - but its own comment says what it is for, which
is catching text that wrapped into the band. So it now permits exactly the
rule's colour and still fails on anything brighter, which is what a glyph is.
Verified by pushing the title 5px down and watching it fail.

Verified with the host self-test, 51 Python tests, and a firmware build the
audit passes at 304064 bytes. Walked on hardware.
A screen the reader takes or leaves has two controls and nothing else, so they
sit where Jade puts them: reject on the left of the title bar, accept on the
right, nothing along the bottom (ui/confirm_address.c builds exactly that, and
passes no footer buttons at all).

The slot was already there. The title has been centred between two margins the
width of the arrow's box since the chrome arrived, so the right margin has been
empty and exactly the right size all along - the tick moves in without anything
else moving over.

Drawn rather than set, for the same reason the back arrow is: the 16px face has
no glyph for a tick and Jade's symbols font is a component the audit rejects by
name. Two strokes of 2px squares from a common low point, so the diagonals read
as solid rather than as a dotted line.

What this gives up is the label. The bars said what would happen - Show QR,
Open wallet, Proceed anyway - and a tick says only yes. That is why it goes on
the screens whose title already names the act, and why the label stays in the
struct: the self-test still measures it, and a caller that needs the words back
can still draw the bar. The lists keep theirs, where the confirm is one stop of
a ring among the items rather than one of two answers.

Two self-test checks needed rethinking rather than adjusting, and finding out
which took instrumenting all seventeen of the function's exits to say which had
fired:

- nav_bar_label_fits measured a bar these screens no longer draw. It would have
  failed loudly, but only because it treats an absent label as a failure - so
  the screens now assert their slot is drawn instead. On a notice, which has no
  arrow, the tick is the only control: if it failed to draw, the screen would
  be one the reader cannot leave, and nothing else here would notice.

- nav_title_stays_in_its_column asserts nothing lights right of x=218, to catch
  a title overflowing its column. The tick's box starts at exactly 218, so the
  check could no longer tell an overlong title from the control. The title's
  placement depends only on whether there is an arrow, never on the tick, so it
  is measured on a tick-free rendering of the same screen. Confirmed still
  honest by giving one screen an absurd title and watching it fail.

Verified with the host self-test, 52 Python tests, and a firmware build the
audit passes at 303920 bytes. Walked on hardware.
The confirm was a tick on the screens that are only a question, and still a bar
everywhere else - which is the same convention-in-half the branch below this one
exists to fix. It is a tick everywhere now: Review words, the numbered lists,
the transcript and hash pages. No screen draws a bar, and none of the labels
that went with them said anything a tick does not: they were all "Continue".

The ring objection turns out to be weaker than it looks. Reading it as a walk
down the screen breaks at exactly one point either way - with a bar, moving
down off it wrapped up to the arrow; with the tick, the last row wraps to the
corner. A ring has one discontinuity wherever it is put, and putting it here
sets the two ways out of a screen side by side rather than at opposite ends.

Two things this got wrong first, both caught rather than reasoned out:

The tick ignored confirm_enabled, so the checksum screen with a word still
wrong drew a live-looking control that could not be taken. It has the bar's
three states now - filled, outlined, dim.

And it drew on menus, which pass no label because their rows are their actions.
The bar guarded against that with `if (!nav->confirm) return;` and the tick did
not inherit it, so the Origo and Wallet menus each grew a tick offering an
answer to a question they never ask. Found on the device, not here, and the
reason nothing here found it is worth keeping: every check written for the tick
asserted it was *present*. A test that only knows how to see presence cannot
see excess. The menu case now asserts both places a confirm could appear stay
empty, and was proved by putting the bug back.

The self-test's five chrome blocks all built their nav structs by hand without
the tick, so they were measuring a layout the firmware had stopped shipping.
Each also had to measure its title on a tick-free rendering: the title's column
ends exactly where the tick's box begins, so an overlong title landing past it
reads as the control.

nav_bar_label_fits went with the bars - the compiler pointed out it had no
callers left, which is also how it is known no screen draws one.

Verified with the host self-test, 52 Python tests, and a firmware build the
audit passes at 303936 bytes. Walked on hardware.
"Poor entropy!" and "Pattern detected!" were passed as titles, so they
were drawn in the title bar between the arrow and the tick. A title
names the screen you are on; these are not names but findings about the
run just made, and a finding that the seed may be weak should not sit in
the strip a reader learns to skim.

They go in the body now, on the two centred lines the acknowledge
screens already use, with the bits line under the verdict so the number
the accept/reject decision was made on stays visible. The header keeps
the arrow and the tick, so the way out and the way on do not move.

The self-test's chrome table holds every screen on this chrome with the
widest text each can build; its two rows for these screens now carry a
NULL title, which is the table saying the empty bar is deliberate rather
than an entry someone left unfilled.

Verified with the host self-test, and by rendering both screens off the
framebuffer and reading the result rather than trusting the geometry.
A tick is the answer to a question. The firmware says so itself where a
menu is drawn, which passes no label because "a tick there offers an
answer to a question the screen never asked" - and a screen whose result
every caller discards asks nothing either.

Three kinds of screen were in that position and none was drawn that way:

The notices - "Error", "Too long for a QR", "Invalid checksum" and the
rest - carried the opposite pair, a tick and no arrow, so the single way
out of them wore the glyph for assent. They have the arrow now and no
tick. Their label went with the tick: no bar has drawn it since the
chrome arrived, so it only ever decided whether the tick appeared.

The Safety text and the backup menu's word and number lists were paged
screens ending on a confirm whose answer the caller threw away. Each
gains a read-only sibling next to the one that keeps it - page_read
beside page_text, read_numbered_list beside show_numbered_list - because
both callers are real: the same list is a step in the backup sequence,
where the tick means "written down", and a thing to browse from a menu,
where it means nothing. The difference is one argument, not a second
copy of the loop.

Two things this got wrong on the way, both found by reading it back:

nav_screen toggled the cursor between arrow and tick whenever there was
an arrow, so on a screen with no tick it would park the cursor on a
control that is not drawn and leave nothing lit. The ring only turns
when both controls exist now.

And the opening position was still the caller's to get right: a screen
with no label but start_on_back false would open on that same undrawn
tick. It is derived from the label instead, so the rule cannot be broken
by a future caller that does not know it exists.

The self-test's notice block asserted only that the tick was drawn. It
now asserts the arrow is there and the tick is not - a check that only
knows how to see presence cannot see excess.

Verified with the host self-test and the 52 Python tests, and by
rendering a notice and the Safety page off the framebuffer.
The back arrow's box, and the tick's, are drawn with sides and a top and
no bottom. That is deliberate, and Jade's own reason is quoted where it
happens: the line underneath would be the fourth edge. On a list that
line was the rule above the first cell, so the frame closed and the
chrome read as one piece.

Every other screen wearing this chrome has no cells to supply it, so the
two boxes hung open and nothing crossed the bar. The rule is drawn by
the header itself now - a screen gets it for wearing the header rather
than for happening to be a list.

It is COLOR_DIM, which is the colour the self-test's band checks already
permit as chrome, so the bands that must stay clear of text still do.
The list's own first rule now repaints the same pixels in the same
colour and is left alone: a cell wants a top edge whether or not the
header drew one. Its comment no longer claims to be what closes the box.

Verified with the host self-test, and by rendering a notice, an
acknowledge screen, a paged screen and a list off the framebuffer to
confirm the rule crosses on all four and the list neither doubled it nor
moved its cells.
The two Stackbit screens were the last ones drawing their own title and
no chrome. They had one control - the chord, which left - and it was the
one control they never drew, so the way out was something the reader had
to already know.

They wear the header now, arrow included. There was room without moving
anything: the simple grid starts at y=26 and the physical one at y=28,
both below the header's rule.

The arrow is reachable rather than decorative, which is the part that
needed deciding. The words go on the ring the paged screens already
walk - position 0 is the arrow, 1..N are the words - so stepping off
either end lands on the way out instead of wrapping past it, and the
chord reads on from a word exactly as it does from a page. page_step,
page_shown and page_nav gained their confirmable argument in the commit
below; this reuses it rather than growing a fourth carousel.

The carousel no longer wraps forever, which is a real change for anyone
who used to spin it: leaving now means walking to the arrow.

The self-test's two grid proofs count highlighted pixels to measure the
lit punch cells, and a selected arrow fills its box with exactly that
colour - the unit area discovered from "0001" would then be wrong for
every number after it. Their nav puts the cursor on a word, and says why,
so it is not moved onto the arrow by someone tidying later.

README described the old controls and now describes these.

Verified with the host self-test, which still proves all 2048 punch
grids in both layouts with the chrome drawn, the 52 Python tests, the
self-test under ASan and UBSan, and a firmware build the ELF audit
passes at 303952 bytes. Both layouts were rendered off the framebuffer
and read. Flashed to a TTGO T-Display; the screens were not walked
through on the device as part of this commit.
@oroderico
oroderico merged commit a80a049 into main Aug 16, 2026
3 checks passed
@oroderico
oroderico deleted the finish-nav-convention branch August 16, 2026 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant