One back arrow and one confirm bar, instead of a footer per screen - #11
Merged
Conversation
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.
This was referenced Aug 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 thanspending a list row on it):
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!andPattern detected!dropped "Proceed anyway?", andthe 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_generatedandlanded 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 readsforward, 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
Backrow, which put the way out at row 3 on Wordentry, 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
Backrow occupied — so"Back"comes out of the array, thecount 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 astem 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
Backto lift — itis 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 readBOTH continue Up/Down backover a screenwhere 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_generatedchainedits screens with
&&and awhile, and a short-circuit chain has exactly oneway 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_generatedreturned nothing, socollect_entropyreturned "wentforward", so
create_seedreturned to the menu. Backing out and finishingwere the same value.
show_generatednow says which happened, andcollect_entropyanswers by showing the verdict again with every roll stillin 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_checksumanswered it by callingenter_mnemonicagain — whichdeclares 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_checksumnow and a step back hands them to a review list: thesame 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_prefixchains the two, so the review'sarrow reopens the entry screen and only its arrow reaches the menu.
6. Backing off
Checksum validabandoned a restore that had alreadysucceeded. Its words are held in
restore_seednow, the waycomplete_checksumalready holds its own, andrestore_mnemonicgained aresume 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:
mainmoved that prompt onto the Derivation screen whilethis 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 throughin 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_enabledandcounter.seedtool_nav_tcarries them once. Six renderers became four:seedtool_render_nav_textline3is givenseedtool_render_nav_rowsseedtool_render_nav_listseedtool_render_nav_dicenav_textplus the entropy quality barA notice is
nav_textwith.back = false.nav_beginandnav_endholdthe 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_confirmandnoticeare three lines each on topof 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()andACK_FOOTERall 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_boxwraps rather thanclips — 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_collideinhost/origo_simulator.crenders all 22screens 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_baralready 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 screenhas 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:
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
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
mainand merged with currentmain— 22 conflicts,18 of them in
seedtool_app.c. Where they overlapped,main's structure wonand the chrome was reapplied on top:
draw_quality_baranddraw_progress_barwere the same extraction made onboth sides;
main's name stayed, with this branch's NULL guard moved insideit so the three callers that pass an optional bar do not each need one.
show_generatedwas split intoreview_backup_and_show_walletupstream;both are stage machines now.
enter_wordandenter_word_numbergained theallowedwordset from Only offer a last word the checksum can accept #4;review_prefixpasses NULL, since no word in a prefix is the one thechecksum narrows.
mainadded that this branch never saw — Type, Extendedpublic key, Addresses, Derivation, Coin method — had their
Backrowslifted 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 visiblechange to a screen that just landed, and
nav_text's third line is there ifyou would rather have it back.