Skip to content

One back arrow and one confirm bar, instead of a footer per screen - #11

Merged
oroderico merged 14 commits into
oroderico:mainfrom
sandman21vs:nav-back-confirm
Aug 16, 2026
Merged

One back arrow and one confirm bar, instead of a footer per screen#11
oroderico merged 14 commits into
oroderico:mainfrom
sandman21vs:nav-back-confirm

Conversation

@sandman21vs

Copy link
Copy Markdown
Collaborator

Two buttons, and a meaning that changed with the screen. Up and down moved a
cursor on a menu, paged forward on the transcript, went back on a
confirmation, and were heads and tails on a coin flip. The chord meant select,
or continue, or undo. Every screen taught its own convention in a footer, and
the reader had to read the footer to know which one they were on.

This gives the firmware one convention, taken from Blockstream Jade
(main/ui/dialogs.c, which puts a back button in every title bar rather than
spending a list row on it):

 ┌──────────────────────────────┐
 │ ◀   Review words             │   back arrow, top left
 │  01. harvest                 │
 │  02. regret                  │   up/down only ever move the cursor
 │  03. come                    │
 │        Continue              │   confirm bar, bottom
 └──────────────────────────────┘

Up and down move the cursor. The chord takes whatever is highlighted. The
arrow is always top-left and the bar is always along the bottom, so neither
control moves as the screen changes, and no footer has to explain them.

The bar opens selected by default. A screen whose whole purpose is to warn
says otherwise: Compact SeedQR, Poor entropy! and Pattern
detected!
open on the arrow instead, because the way forward should not be
preselected on a screen that exists to say "this may be a bad idea".

Labels say what happens, not "Continue"

The reader is agreeing to the next screen, not declaring themselves finished.
So the bar reads Use this word, Start quiz, Open wallet, Show
QR
, Show descriptor, Generate seed, Proceed anyway, Try
again
, Fix a word — each naming its own consequence.

Two screens lost a body line to this, because the bar now asks what they used
to ask: Poor entropy! and Pattern detected! dropped "Proceed anyway?", and
the entropy verdict dropped the question from "Looks good - generate?".

Paging became the cursor

The transcript, the SHA256 and the word list were paged with up and down, and
paging back off the first page left the screen entirely. On a one-page
transcript that meant a single press dropped out of show_generated and
landed back at the menu, with nothing on screen having said that press was a
way out — the footer read 1/1.

The ring is now arrow → page 1 → page 2 … → page N → bar. Moving down reads
forward, the first page's neighbour above is the arrow, and nothing leaves the
screen except taking one of the two controls. These open on the first page
rather than the bar: reading is what they are for, so the cursor starts where
the reading does.

Back rows became the arrow

Nineteen menus ended with a Back row, which put the way out at row 3 on Word
entry, row 5 on Backup and row 12 on Addresses, and gave each menu one row it
could not act on.

The conversion is mechanical by construction: the arrow returns count
the index the Back row occupied — so "Back" comes out of the array, the
count drops by one, and every check against its old index reads the same
answer. Not one dispatch changed. That is what made nineteen call sites a
rename rather than nineteen chances to get an index wrong.

[delete] in letter entry went the same way. Deleting the last letter of a
stem is a step back, and on an empty stem it always was one — it left the
word.

Two menus keep the plain list. The Origo menu has no Back to lift — it
is where back goes. The wallet menu's last row is Erase and restart,
which is an action, not a way out.

Notices got a bar and no arrow

Eighteen screens called acknowledge() and discarded the answer — the errors,
Too long for a QR, Invalid checksum, Master fingerprint, Backup confirmed. Their footer read BOTH continue Up/Down back over a screen
where back and continue did the same thing, so it was describing a way out
that did not exist.

They now draw one bar and no arrow, and up/down do nothing rather than
pretending to. Giving them an arrow would have restated the same false promise
as a control.

Six navigation bugs this surfaced

Making the exits explicit is what made these visible. All six were found by
hand in the simulator; none was reachable before, because these screens were
only ever left by paging forward.

1. Backing off the SHA256 skipped the transcript. show_generated chained
its screens with && and a while, and a short-circuit chain has exactly one
way out of it: the whole function. The word list and the backup intro did the
same. It is a stage machine now — each back is the stage before it, and only
the first one leaves.

2. Backing off the canonical transcript discarded a whole D20 run.
show_generated returned nothing, so collect_entropy returned "went
forward", so create_seed returned to the menu. Backing out and finishing
were the same value. show_generated now says which happened, and
collect_entropy answers by showing the verdict again with every roll still
in hand.

3. Backing off the completed mnemonic discarded eleven words and seven
flips.
The flips and the result they produce are one loop now, so that back
is the last flip again.

4. Stepping back from the first coin flip erased the words.
complete_checksum answered it by calling enter_mnemonic again — which
declares its own empty word array and reopens the method menu. Eleven words,
gone to one press, with no warning that the press meant that. The words live
in complete_checksum now and a step back hands them to a review list: the
same list restore already uses, so any of them can be fixed and the flips
resumed.

5. Backing off the reviewed words skipped the entry screen. It dropped
straight to the length menu. revisit_prefix chains the two, so the review's
arrow reopens the entry screen and only its arrow reaches the menu.

6. Backing off Checksum valid abandoned a restore that had already
succeeded.
Its words are held in restore_seed now, the way
complete_checksum already holds its own, and restore_mnemonic gained a
resume flag that reopens the review without asking for the words again.

A seventh existed on the passphrase prompt and this branch fixed it, but the
fix is not here: main moved that prompt onto the Derivation screen while
this was in progress, so there is no longer a passphrase screen on the way
into the wallet to back off. The merge took your structure and dropped mine.

While in complete_checksum: a checksum that failed to complete fell through
in silence, handing back the menu with no reason given. It says so now.

The shape underneath

Adding a nav screen used to mean a renderer, a header declaration, and two
identical backend wrappers — and each renderer carried the chrome as four
loose parameters, so every new kind of screen grew its own copy of on_back,
confirm, confirm_enabled and counter.

seedtool_nav_t carries them once. Six renderers became four:

shape what it is
seedtool_render_nav_text two or three centred lines — three when line3 is given
seedtool_render_nav_rows four left-aligned rows, a numbered word list
seedtool_render_nav_list a scrolling list
seedtool_render_nav_dice nav_text plus the entropy quality bar

A notice is nav_text with .back = false. nav_begin and nav_end hold
the order — clear, chrome, body, counter, bar — so a screen type added later
says only what goes between them.

The app had three near-identical two-control loops, one for text, one for the
dice screens, one for the notices, differing only in which renderer they
called and which controls existed. They are one loop with those as arguments;
nav_acknowledge, nav_dice_confirm and notice are three lines each on top
of it.

One trap worth knowing. The struct's zero value dims a bar and removes an
arrow, neither of which is what a screen usually wants. So it is built in the
four app helpers and never at a call site — the places that can get it wrong
are few, and are the places that document it.

confirm(), acknowledge(), dice_confirm(), enter_mnemonic(),
choose(), screen_text3(), screen_text4(), nav_hint() and ACK_FOOTER
all go with their last callers.

Geometry, and how it is checked

The chrome packs a title bar, three list rows and a confirm bar into 135
pixels with single-digit gaps, and draw_centered_box wraps rather than
clips — so a title or a label one word too long lands in the band below
instead of running off the glass.

nav_chrome_bands_do_not_collide in host/origo_simulator.c renders all 22
screens in each of their states and measures the framebuffer: the gaps
between bands, the title against its column's right edge, the bar's label
against both edges. It is written the way dice_screen_hints_clear_the_bar
already was, with the strings copied from seedtool_app.c.

It earned its place twice. It caught the scroll track overhanging into the
confirm bar's row — 2px, invisible by eye. And it caught that "A photo reveals every address" is two lines at this face: not new, the plain screen
has always wrapped it, but the bar now sits where a third line would land, so
the check allows one wrap and fails on the second rather than pretending the
first is not there.

What is deliberately not converted

Four screens keep a footer, because on them up and down are values, not
navigation, and the chrome has nothing to attach to:

  • the coin flip (up is heads, down is tails)
  • the dice and card entry carousel
  • the scrolled digit field
  • the brightness setting

Converting these would mean inventing a meaning for the buttons, not adopting
one. They are the honest exception rather than an oversight, and are worth a
separate conversation about what a two-button value entry should look like
under this convention.

Testing

./tools/run-simulator.sh --self-test     # passes
python3 -m unittest discover -s tests    # 51 passed

Be aware of what that does not cover. The self-test reaches core maths and
pixel geometry. It reaches no navigation at all — and navigation is the whole
of this change. Every one of the six bugs above was found by hand in the
simulator, several of them after a version had already been written and
believed correct. The rings, the stage machines and the back chains have been
verified by reading, not by test.

The firmware build and its reproducibility check have not been run here; only
the host build and the Python suite.

Notes on the merge

Branched from the old main and merged with current main — 22 conflicts,
18 of them in seedtool_app.c. Where they overlapped, main's structure won
and the chrome was reapplied on top:

  • draw_quality_bar and draw_progress_bar were the same extraction made on
    both sides; main's name stayed, with this branch's NULL guard moved inside
    it so the three callers that pass an optional bar do not each need one.
  • show_generated was split into review_backup_and_show_wallet upstream;
    both are stage machines now.
  • enter_word and enter_word_number gained the allowed wordset from Only offer a last word the checksum can accept #4;
    review_prefix passes NULL, since no word in a prefix is the one the
    checksum narrows.
  • The five screens main added that this branch never saw — Type, Extended
    public key, Addresses, Derivation, Coin method — had their Back rows
    lifted the same way as the rest.

One layout change worth flagging: the flipped-word confirmation from #3 showed
bits, number and word on three lines. The chrome draws two, so the number and
the word now share one (0820 grid). Nothing is lost, but it is a visible
change to a screen that just landed, and nav_text's third line is there if
you would rather have it back.

Up and down mean "go back" on some screens here and "go forward" on
others, so the two buttons carry a meaning that changes underneath the
reader. Jade does not have this problem: a back button lives in the
title bar of every screen (main/ui/dialogs.c), the action sits at the
bottom, and the navigation buttons only ever move a cursor.

This adds that chrome and puts one screen on it - the review the reader
reaches after typing a mnemonic back in. Continue and Back were rows of
the word list there, which put the way out at row 13 of a 12-word
restore and row 25 of a 24-word one; now the list holds words and
nothing else, the arrow is always top-left, and the bar is always along
the bottom. The bar opens selected, since a reader who has just typed
twelve words is more often done than not.

Continue used to appear only once the checksum was valid, so the
control the reader was heading for came and went as words were fixed.
It is now always drawn, dimmed and unselectable until the checksum
passes; the title still carries the verdict.

choose_nav and seedtool_render_nav_list are written to take the other
screens as they are tested, not just this one - the chrome is a
parameter of the screen, and which control it opens on is too.

The three bands fit 135 pixels with single-digit gaps, so the geometry
gets a self-test that renders the screen in each of its states and
measures them: the scroll track had to be trimmed by 2px, which nothing
short of measuring would have caught.
Typing a word by number ends on a screen showing the word and "Number N
of 2048" over "BOTH continue   Up/Down back" - the footer this branch
exists to remove, and the clearest case of it: up and down here mean
"go back", while two screens later they mean "move the cursor".

The screen now carries the arrow and the bar instead. The bar reads
"Use this word" rather than "Continue", since what the reader is
agreeing to is that this is the word they meant, not that they are
finished.

The chrome moved into draw_nav_header and draw_nav_bar so the list and
the text screen share it rather than each drawing their own - a third
screen should inherit the geometry, not restate it. Body lines stay at
the heights seedtool_render_screen already uses, so gaining the chrome
does not also move a screen's own text.

The geometry self-test grows to cover it, with the clear band starting
one line-height below line 2: draw_centered wraps rather than clips, so
a body line one word too long lands at a fixed y that measuring is the
only way to see.
Nine more screens, chosen by whether backing out of them does anything:
these are the sites that act on acknowledge()'s answer. The eighteen
that write (void)acknowledge(...) keep the old widget for now - their
footer already promises "Up/Down back" on a screen where back and
continue do the same thing, and giving them an arrow would restate that
false promise as a control.

Each bar is labelled with what taking it does rather than a uniform
"Continue" - Start quiz, Open wallet, Show QR, Show descriptor, Enter
again, Try again - since the reader is agreeing to the next screen, not
to being finished.

Three of them open on the arrow instead of the bar, the opt-out the
chrome was built with: Compact SeedQR, which encodes the entire seed
into one photograph, and the two entropy warnings. A screen whose whole
purpose is to say "this may be a bad idea" should not have the way
forward preselected.

Poor entropy! and Pattern detected! lose their "Proceed anyway?" body
line, which the bar now says.

The geometry self-test takes all ten screens with their widest text.
It found that "A photo reveals every address" is two lines at this
face - not new, the plain screen has always wrapped it, but the bar
sits where a third line would land, so the check allows one wrap and
fails on the second rather than pretending the first is not there.
The screens that bracket a dice or coin run - "128 flips needed" before
it, "Looks good" after - were the last two confirmations with a real
back still on the old footer.

They keep their quality bar. It sits at 90..103 and the confirm bar
starts at 118, so neither had to move: what the chrome takes here is
the footer's own row, which is what it replaces. draw_quality_bar is
split out of seedtool_render_dice_screen so the plain screen and the
nav one draw the same bar rather than each holding a copy.

Their labels say what happens next - Start, Generate seed - and the
"Looks good - generate?" line drops its question, which the bar now
asks. The verdict stays spelled out rather than left to the bar's
border colour, for the reason the original comment gives: not every
reader tells a green outline from a dim one at a glance.

dice_confirm is gone, both its callers having moved. ACK_FOOTER is now
down to acknowledge() and its own definition - the eighteen screens
that discard the answer, which still need a bar-only variant rather
than an arrow that leads nowhere.
On the transcript, the SHA256 and the word list, up on the first page
left the screen entirely. On a one-page transcript that meant a single
press dropped out of show_generated and back to the menu, with nothing
having said that press was a way out - the footer read "1/1".

Paging is now the cursor moving. The ring runs back arrow, page 1, page
2 ... page N, confirm bar, so moving down reads forward and the first
page's neighbour above is the arrow rather than the exit. Nothing leaves
the screen except taking one of the two controls, which is what the
chrome promises everywhere else.

These screens open on the first page rather than on the bar - the
opt-out again, and the plainest case for it: reading is what they are
for, so the cursor starts where the reading does.

The page counter moves into the gap between the last body line and the
bar, at the small face, and loses the chord hint it used to carry: the
controls are drawn now, so there is nothing left to teach in a footer.

The chord on a page reads on one step rather than doing nothing, the
same direction it always went - it just no longer leaves the screen
from the middle of one.

screen_text3, screen_text4 and nav_hint go with their last callers.
Completing a checksum types eleven or twenty-three words and then flips
coins for the rest. The chord is the only way back out of a flip, since
up and down are the coin's two faces - and on the first flip that back
called enter_mnemonic again, which declares its own empty word array
and reopens the method menu. Eleven words, gone to one press, with no
warning that the press meant that.

The words now live in complete_checksum itself and a step back hands
them to review_prefix: the same list restore already uses, with the
same arrow and confirm bar, so any of them can be fixed and the flips
resumed. It is review_and_confirm without the checksum gate, which a
prefix missing its final word could never pass anyway.

Up and down stay heads and tails. What changed is where back goes, not
what the buttons mean.

enter_mnemonic goes with its last caller; restore_seed reaches the same
place through restore_mnemonic.
Fourteen menus ended with a Back row, which put the way out at a
different place on each of them - row 3 on Word entry, row 5 on Backup,
row 12 on Addresses - and gave a menu one row it could not act on.
The arrow already sits in the title bar on every screen this branch has
touched; these had no reason to keep saying it in a list instead.

Taking the arrow returns the index the Back row used to occupy, so not
one dispatch changed: "Back" comes out of the array, the count drops by
one, and every check against its old index reads the same answer. That
is what makes fourteen call sites a mechanical change rather than
fourteen chances to get an index wrong.

Menus draw no confirm bar - their rows are their actions, and there is
nothing left to confirm once one is picked - so the chrome here is the
arrow alone, and the self-test covers that the bar's band stays dark.

browse_addresses loses its own cursor write: choose_menu_at persists it
now, and the old assignment would have parked the cursor on the arrow's
return value, one past the end of the list.

The Origo menu keeps its plain list. It has no Back to lift - it is
where back would go.
show_generated chained its screens with && and a while, and a
short-circuit chain has one way out of it: the whole function. Backing
out of the SHA256 landed wherever the transcript had been entered from
rather than on the transcript; the word list and the backup intro did
the same. It is a stage machine now - each back is the stage before it,
and only the first one leaves. Backing out of the quiz lands on the
intro rather than jumping two stages to the word list; two presses
reach the same place.

complete_checksum had the same shape one level down. Backing out of the
reviewed words dropped to the length menu, skipping the entry screen
they were typed on. revisit_prefix chains the two, so the list's arrow
reopens the entry screen and only its arrow reaches the menu.

The [delete] row in letter entry becomes the arrow. Deleting the last
letter of a stem is a step back, and on an empty stem it always was one
- it left the word. The arrow returns the index that row held, so the
branch reading it is unchanged.

The eighteen notices get a bar and no arrow. Their answer was always
discarded, so "BOTH continue   Up/Down back" was describing a way out
that did not exist; now there is one control, and up/down do nothing
rather than pretending. confirm() and ACK_FOOTER go with them.
Backing off the canonical transcript ended collect_entropy, which
returned "went forward" to create_seed, which returned to the menu -
so one press on the first screen after generating discarded every roll
of a D20 run. show_generated now says whether it left from that first
screen, and collect_entropy answers by showing the verdict again with
the rolls still in hand.

Backing off the completed mnemonic did the same to a checksum
completion: eleven words and seven flips, gone to the New Seed menu.
The flips and the result they produce are one loop now, so that back is
the last flip again.

While there: a checksum that failed to complete fell through in
silence, handing back the menu with no reason given. It says so now.

Neither back was reachable before this branch - both screens used to be
left by paging forward off the end, and paging back out of the first
page fell through the same way. What changed is that the arrow makes
the exit explicit, which is also what made these visible.
The wallet opens on "Optional passphrase", and get_session_passphrase
answered it with a bool: "No passphrase" was true, and backing out was
false - the same answer a derivation failure gave. show_wallet_data
read that as "nothing to show" and returned, and its three callers read
*that* as "the visit is over". So one press on the first screen of the
wallet threw away a completed checksum, a restore, or a generated seed.

It is three-way now - decided, stepped back, timed out - and each
caller shows what came before the wallet again: the completed mnemonic,
the checksum verdict, the backup intro. A mismatched passphrase returns
to the prompt too, which is what its own "Try again" says will happen.

restore_seed had the same shape one level up: backing off "Checksum
valid" left a restore that had already succeeded. Its words are held in
restore_seed now, the way complete_checksum already holds its own, and
restore_mnemonic gained the resume flag that reopens the review without
asking for the words again - chained the way revisit_prefix chains its
two, so the review's arrow reopens the entry screen rather than
dropping past it to the length menu.
Adding a nav screen meant a renderer, a declaration, and two identical
backend wrappers - and each renderer carried the chrome as four loose
parameters, so every new kind of screen grew its own copy of on_back,
confirm, confirm_enabled and counter.

seedtool_nav_t carries them once. Six renderers become four: notice is
nav_text with no arrow, screen3 is nav_text with a third line, screen4
is nav_rows, and the dice screen is nav_text plus its bar. nav_begin
and nav_end hold the order - clear, chrome, body, counter, bar - so a
screen type added later says only what goes between them.

The app had three near-identical two-control loops, one for text, one
for the dice screens, one for the notices, differing in which renderer
they called and which controls existed. They are one loop with those as
arguments; nav_acknowledge, nav_dice_confirm and notice are three lines
each on top of it.

The struct is built in the four app helpers, never at a call site: its
zero value dims a bar and removes an arrow, which is not what a screen
usually wants, so the places that can get it wrong are few and are the
places that document it.

page_nav does the same for the paged screens, which were both spelling
out the same four fields. SEEDTOOL_NAV_BODY replaces passing a page
index as the selection - the cursor is on what the screen shows, and a
text screen has no row for an index to mean.

No screen changes. The self-test renders all 22 and measures them.
# Conflicts:
#	main/seedtool_app.c
#	main/seedtool_display.h
#	main/seedtool_render.c
Upstream added five screens this branch never saw - Type, Extended
public key, Addresses, Derivation, Coin method - and each ended with a
Back row, which is what the arrow replaced everywhere else. Converted
the same way as the rest: the row comes out, the count drops by one,
and the arrow returns the index it held, so no dispatch changed.

Two menus keep the plain list. The Origo menu has no Back to lift, and
the wallet menu's last row is "Erase and restart" - a row that does
something, not a way out - so choose_at and choose_kept stay for them.

choose() itself goes: every caller now either keeps a cursor or wears
the chrome.
The firmware build treats -Wformat-truncation as an error where the
host build does not, and it caught the word list's footer: two %u into
sixteen bytes.

The values are small - pages tops out at six for twenty-four words -
but `page` now comes from page_shown() rather than from the loop it
used to be, so the compiler no longer has a range for it and assumes
ten digits apiece. Twenty-four bytes is that worst case, sized the way
the backup intro's own buffer already is and for the same reason.

page_text's footer was always wide enough to escape this; only the
numbered list's was cut to the real bound rather than the assumed one.
@oroderico
oroderico merged commit c536f3e into oroderico:main Aug 16, 2026
5 of 6 checks passed
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.

2 participants