# Plan: Feather S3 signer interactive UI (splash, main menu, generate, view, enter, working) Status: ready to implement. Related firmware: [`firmware/feather_s3_tft/main/main.c`](../firmware/feather_s3_tft/main/main.c:1), [`firmware/feather_s3_tft/main/display.c`](../firmware/feather_s3_tft/main/display.c:1), [`firmware/feather_s3_tft/main/display.h`](../firmware/feather_s3_tft/main/display.h:1), [`firmware/feather_s3_tft/main/mnemonic.c`](../firmware/feather_s3_tft/main/mnemonic.c:1), [`firmware/feather_s3_tft/main/mnemonic_wordlist.h`](../firmware/feather_s3_tft/main/mnemonic_wordlist.h:1), [`firmware/feather_s3_tft/main/key_derivation.c`](../firmware/feather_s3_tft/main/key_derivation.c:1). Companion: persistence will be added in a follow-up plan (option 3 from design discussion: PIN-derived encryption of NVS-stored seed). ## 0. Color palette (global) Strict 4-color palette for ALL screens (no other colors used): | Color | Constant | Use | |---|---|---| | Black | `RGB565_BLACK` | Background everywhere | | White | `RGB565_WHITE` | Default text / active labels | | Red | `RGB565_RED` | Selection / focus / important call-outs (e.g., `N_SIGNER` title) | | Dim gray | `RGB565_DIM_GRAY` (`0x4208`) | Disabled / unselectable items, hints, footers | No yellow, cyan, green, magenta, blue. Existing screens (`show_idle_screen`, `show_approval_prompt_with_caller`) get retro-fitted to this palette in §5.4. The `RGB565_*` constants for unused colors stay defined in [`display.h`](../firmware/feather_s3_tft/main/display.h:14) (harmless) but the new code never references them. Selection convention: - **Selected** menu item: red text on black background (no inverse-video fill needed in the 4-color scheme; red is bright enough as the focus color). A leading `> ` prefix is also drawn for redundancy. - **Non-selected** items: white on black. - **Disabled** items (e.g., `View` when no mnemonic is loaded): dim gray on black. - **Footers / hint text**: dim gray on black. ## 1. Goal Replace the current "generate-random-mnemonic-on-boot, immediately go to working screen" proof of concept with a navigable on-device UI that lets the operator: 1. See a splash screen at power-on. 2. Choose at the main menu between **Generate** a fresh mnemonic, **Enter** an existing mnemonic, or **View** the currently loaded mnemonic. 3. After Generate or successful Enter, transition into the existing **Working Page** (sign / approve flow that already exists today). 4. Persistence is **out of scope** for this plan — every boot starts at the splash, mnemonic lives only in RAM. A follow-up plan will add PIN-protected NVS storage. ## 2. Hardware / display constraints From the existing firmware ([`display.c`](../firmware/feather_s3_tft/main/display.c:38)): - Panel: 240×135, ST7789, landscape. - Font cell at `CHAR_SCALE = 2`: `CHAR_W = 12`, `CHAR_H = 16`. - Usable text grid: **20 columns × 8 rows** (with ~0px margin) or **19 columns × 6 rows** with the comfortable insets the user noted. Plan assumes 19×6 so we have ≥4px breathing room on every edge. - 3 buttons (current physical mapping in [`main.c`](../firmware/feather_s3_tft/main/main.c:31)): - `D0` = GPIO0, active **LOW** (boot button, internal pull-up) - `D1` = GPIO1, active **HIGH** (external pull-down) - `D2` = GPIO2, active **HIGH** (external pull-down) ### 2.1 New menu-button semantics | Button | Role in new UI | Existing role (working page) | |---|---|---| | `D2` | **Up** (scroll up) | "always allow" approval | | `D1` | **Down** (scroll down) | "approve once" | | `D0` | **Enter / select** | "deny" | The working-page button semantics intentionally **stay the same** as today (D0=deny, D1=approve once, D2=always). Only menu screens reinterpret them as Up/Down/Enter. The button-handling code is split into two layers (see §5.2) so the working-page approval flow does not have to change behavior. ## 3. Screen flow ```mermaid flowchart TD A[Power on] --> B[Splash: N_SIGNER] B -- D0 or D1 or D2 --> C[Main Menu] C -- Generate selected --> D[Generate page: random 12 words] D -- auto --> E[View page] C -- Enter selected --> F[Enter page: pick letter] F -- letter chosen --> G[Pick word starting with letter] G -- word chosen --> H[Phrase preview: continue or delete] H -- continue and len 12 --> I[Save prompt] H -- continue and len lt 12 --> F H -- delete --> H2[Drop last word] H2 --> H I -- save --> W[Working page] I -- back --> C C -- View selected --> V[View page read-only] V -- any button --> W E -- any button --> W W -. menu hotkey not in scope .-> C ``` Notes: - From the **Working page** there is currently no "back to menu" hotkey. That is **out of scope** for this plan; once entered, the working page is the run-mode terminus until reboot. Adding a long-press-to-menu is left as a tracked TODO at the end of this doc. - The View page is reachable from two places: post-Generate (auto), and from the main menu (read-only). Both behave the same — "any button returns to caller". Caller is tracked so that View-from-Generate goes to Working, and View-from-Menu returns to Menu. ## 4. Per-screen specification All screens use `display_clear(RGB565_BLACK)` as the base, then draw text with the existing 12×16 cell. Coordinates below are pixel offsets from top-left. ### 4.1 Splash screen - Centered horizontally and vertically: the literal string `N_SIGNER`, color `RGB565_RED`, on black background. - 8 chars × 12px = 96px wide → x = (240−96)/2 = 72. - 1 row × 16px → y = (135−16)/2 ≈ 60. - Below the title (y = 60 + 24 = 84), a hint line `"press any button"` in `RGB565_DIM_GRAY`, centered (small font). - Wait for **any** of the three buttons (debounced, see §5.1). The "drain held buttons first, then read fresh edge" pattern from [`wait_for_approval()`](../firmware/feather_s3_tft/main/main.c:122) is reused. - **No timeout** — strictly button-only advance, per user direction. ### 4.2 Main menu - Title row at y=4: `"MAIN MENU"` in `RGB565_WHITE` (centered). - Three menu items at y=28, y=52, y=76: - `Generate` - `Enter` - `View` - The selected item is rendered as red text on black with a leading `> ` prefix. Non-selected items are white on black with no prefix (a 2-char gap so positions don't shift). Disabled items are dim gray. - Footer at y=120: `"D2 up D1 dn D0 ok"` in `RGB565_DIM_GRAY` (small font). - Behavior: - `D2` (Up): selection moves up, wrapping from Generate → View at the top edge. - `D1` (Down): selection moves down, wrapping from View → Generate at the bottom edge. - `D0` (Enter): commit the selected item. - **View** is drawn in `RGB565_DIM_GRAY` and **not selectable** when no mnemonic is loaded. The cursor skips it on Up/Down navigation in that state. ### 4.3 Generate page - Renders the title `"GENERATING..."` centered in `RGB565_WHITE` while [`generate_mnemonic_12()`](../firmware/feather_s3_tft/main/mnemonic.h:6) is called. - On success: store the resulting mnemonic in `s_mnemonic` (already exists in [`main.c`](../firmware/feather_s3_tft/main/main.c:63)), then transition to the View page with `caller=GENERATE`. - On failure: show `"GEN FAILED"` in `RGB565_RED` for ~2 seconds, then return to main menu. - This page has no button input loop of its own — it's a transient pass-through. ### 4.4 View page - Title row at y=2: `"YOUR MNEMONIC"` in `RGB565_WHITE` (small font), centered. Only shown if there is enough vertical room — see layout below. - 12 words laid out as **3 columns × 4 rows**. - Column widths: each column gets ⌊240/3⌋ = 80px. - Each cell shows `"NN word"` where `NN` is 1-based index zero-padded to 2 digits and `word` is the BIP-39 word. - Longest BIP-39 word is 8 chars, plus `"NN "` prefix = 11 chars total. 11 × 12 = 132px — that does NOT fit in an 80px column. - **Resolution:** drop the index prefix from the cell and rely on row-major order (top-left = #1, top-right = #3, bottom-left = #10, bottom-right = #12). Give a tiny index legend in the footer ("L→R, top→bottom") instead. 8 chars × 12 = 96px still > 80px column. - **Real resolution:** drop `CHAR_SCALE` to 1 (6×8 cells) for the View page only. That gives 40 cols × 16 rows. 12 words in 3×4 grid then becomes trivial: each cell is up to 13 chars (`"NN. word "`) at 6px = 78px, fits in the 80px column with margin. We add a `display_draw_text_small()` variant (see §6.1) that uses `CHAR_SCALE=1`. - Final layout (small font): - Title at y=2, height 8. - Grid starts at y=14. Row pitch = 24px (8px cell + 16px gap for readability). Last row at y=14+3·24 = 86. - Footer at y=110: `"any key = back"` in `RGB565_DIM_GRAY`. - Cell text format: `"%2d. %s"` (e.g., `" 1. abandon"`), color `RGB565_WHITE` on black. - Behavior: any button (debounced) returns to the caller (`GENERATE` → working page; `MENU` → main menu). - The working-page button semantics are *not* active here. ### 4.5 Enter page (multi-stage) This page is the most complex. It has three sub-states implemented in a sub-state machine: #### 4.5.1 Stage A — Letter picker - Top line: `"WORD %d/12"` (e.g., `"WORD 3/12"`) in `RGB565_WHITE`. - Second line: `"PICK FIRST LETTER"` in `RGB565_DIM_GRAY` (small font). - A 26-letter grid of the alphabet: - With small font (6×8): a row of 26 chars = 156px, fits in one row. But we want a visible cursor on the selected letter, so use 2 rows of 13 letters each (`A..M` / `N..Z`) at large font (12×16 with extra spacing) — 13 × 14px = 182px wide, fits. - At y=40 row 1 (`A B C D E F G H I J K L M`). - At y=64 row 2 (`N O P Q R S T U V W X Y Z`). - Non-selected letters: `RGB565_WHITE` on black. Selected letter: `RGB565_RED` on black (no fill — pure color cue). - Footer at y=120: `"D2 up D1 dn D0 ok"` in `RGB565_DIM_GRAY` (small font). - Behavior: - `D2`: move cursor up — A↔N, B↔O, …, M↔Z (wrap top-to-bottom within column). - `D1`: move cursor right (and wrap onto next row); from `Z` wrap to `A`. - **Note:** with only 3 buttons we cannot have separate Left/Right/Up/Down. The Up/Down semantics here mean "move within the alphabet" — `D2` = previous letter, `D1` = next letter, with wrap. The visual 2-row layout is purely cosmetic (so the operator can see the whole alphabet at a glance); navigation is **linear**, not 2D. The selected letter is highlighted wherever it lands in the 2-row display. - `D0`: commit selected letter, move to Stage B. - A **back** mechanic is needed too. Convention: holding `D0` for >1 second cancels Stage A and returns to the main menu (only meaningful if no words have been entered yet); otherwise it returns to Stage C of the previous word. See §4.5.4. #### 4.5.2 Stage B — Word picker - Top line: `"WORD %d/12 [%c]"` showing the chosen letter (e.g., `"WORD 3/12 [P]"`) in `RGB565_WHITE`. - Below: the list of all BIP-39 words starting with that letter, scrollable. Word counts per letter range from 1 (e.g., `x`) up to ~150 (most common letters). Use small font (6×8). - Layout with small font: 16 visible word rows at row pitch 8+1=9px starting at y=14, last row at y=14+15·9=149 — overflows. Cap visible rows at **12**, row pitch 9px, y=14..14+11·9=113. - 12 rows × 1 column is fine; longest word ≤ 8 chars × 6px = 48px, leaves plenty of room. We can also do **2 columns of 12** if needed for letters like `s` (~250 words won't happen — BIP-39 max per letter is around 91 for `s`). 1 column × 12 visible with scroll keeps it simple. - Cursor: highlighted row drawn in `RGB565_RED` on black (with a leading `> ` prefix for redundancy). Non-cursor rows in `RGB565_WHITE` on black. - Footer at y=120: `"D2 up D1 dn D0 ok"` in `RGB565_DIM_GRAY` (small font). - Behavior: - `D2`: prev word (with scroll up at top of viewport, wrap from first to last word in that letter group). - `D1`: next word (with scroll down at bottom of viewport, wrap last → first). - `D0`: commit selected word, append to the in-progress mnemonic buffer, move to Stage C. The full BIP-39 wordlist from [`mnemonic_wordlist.h`](../firmware/feather_s3_tft/main/mnemonic_wordlist.h:1) is already linked in. We add a small helper that produces `(start_index, count)` pairs for each letter on first use and caches them in a static array of 26 `(uint16_t start, uint16_t count)`. See §6.2. #### 4.5.3 Stage C — Phrase preview / continue / delete - Top line: `"WORDS SO FAR (%d/12)"` in `RGB565_WHITE`. - Body: the entered words rendered in a 3-column grid using small font, same as the View page (§4.4). Empty slots are blank. The most recently added word is drawn in `RGB565_RED` (visual confirmation of the just-committed entry); previously confirmed words in `RGB565_WHITE`. - Footer (two lines) at y=104, y=120, small font: - y=104: `"D2 continue D1 delete"` in `RGB565_DIM_GRAY`. - y=120: `"D0 save"` in `RGB565_WHITE` when `count == 12`, in `RGB565_DIM_GRAY` otherwise. - Behavior: - `D2` (continue): if `count < 12`, go back to Stage A for word `count+1`. If `count == 12`, no-op. - `D1` (delete): drop the last word from the buffer, redraw. If `count` was 1, drop to 0. If `count` becomes 0, this stays on Stage C with an empty grid; pressing continue again restarts at word 1. - `D0` (save): only valid if `count == 12`. Otherwise no-op. On save, validate the full mnemonic via the BIP-39 checksum check ([`mnemonic_validate()`](../firmware/feather_s3_tft/main/mnemonic.h:1), see §6.3). On valid: copy buffer into `s_mnemonic`, transition to working page. On invalid: show `"BAD CHECKSUM"` in `RGB565_RED` for 2s and return to Stage C with all 12 words still present (so user can delete and try again). #### 4.5.4 Cancel / back from Enter - Long-press `D0` (>1 second) at any stage of Enter cancels and returns to **main menu** without saving; the in-progress buffer is wiped via `secure_memzero`-equivalent (we will add `secure_memzero` for the firmware — see §6.4). - Short-press `D0` is the normal "select / save" semantic. ### 4.6 Working page (existing, retro-fitted to 4-color palette) - This is the existing approval-loop screen. Behavior change in this plan: we **arrive** here from the menu/save flow rather than from boot directly. Button semantics remain unchanged (D0=deny, D1=once, D2=always). - Existing [`show_idle_screen()`](../firmware/feather_s3_tft/main/main.c:201) and [`show_approval_prompt_with_caller()`](../firmware/feather_s3_tft/main/main.c:163) currently use yellow/cyan/green/magenta. They are **retro-fitted** to the 4-color palette: - All informational labels → `RGB565_WHITE`. - The "APPROVE sign_event?" header → `RGB565_RED` (it's a focus/attention call-out). - All hints/footers ("D1=allow once" etc.) → `RGB565_DIM_GRAY`. - Status counters at the bottom of the running screen → `RGB565_DIM_GRAY`. - The existing key derivation (mnemonic → seed → privkey → pubkey → npub) is moved out of `app_main()` into a function `apply_mnemonic_and_enter_working()` that is called from both `Generate→View→Working` and `Enter→Save→Working` paths. See §5.4. ## 5. Code structure changes ### 5.1 Button input layer Add a small input module: [`firmware/feather_s3_tft/main/buttons.h`](../firmware/feather_s3_tft/main/buttons.h:1), [`firmware/feather_s3_tft/main/buttons.c`](../firmware/feather_s3_tft/main/buttons.c:1). Public API: ```c typedef enum { BTN_NONE = 0, BTN_D0, /* aka ENTER in menu mode, DENY in working mode */ BTN_D1, /* aka DOWN in menu mode, APPROVE_ONCE in working mode */ BTN_D2, /* aka UP in menu mode, APPROVE_ALWAYS in working mode */ } btn_id_t; typedef enum { BTN_EVT_NONE = 0, BTN_EVT_PRESS, /* short press (release before LONG_MS) */ BTN_EVT_LONG_PRESS, /* held for >= LONG_MS, fires on release */ } btn_event_kind_t; typedef struct { btn_id_t id; btn_event_kind_t kind; } btn_event_t; void buttons_init(void); /* Block until a press/long-press event arrives, or timeout_ms expires. * Returns BTN_EVT_NONE on timeout. timeout_ms == 0xFFFFFFFFu = wait forever. */ btn_event_t buttons_wait(uint32_t timeout_ms); /* Non-blocking: returns BTN_EVT_NONE if no event ready. */ btn_event_t buttons_poll(void); ``` The existing `wait_for_approval()` in [`main.c`](../firmware/feather_s3_tft/main/main.c:122) is rewritten as a thin wrapper over `buttons_wait()` so the existing approval semantics still work without code duplication. Constants: - `BTN_DEBOUNCE_MS = 30` (existing) - `BTN_LONG_MS = 1000` - `BTN_POLL_MS = 10` ### 5.2 UI state machine Add [`firmware/feather_s3_tft/main/ui.h`](../firmware/feather_s3_tft/main/ui.h:1), [`firmware/feather_s3_tft/main/ui.c`](../firmware/feather_s3_tft/main/ui.c:1). ```c typedef enum { UI_SPLASH, UI_MAIN_MENU, UI_GENERATE, UI_VIEW, UI_ENTER_LETTER, UI_ENTER_WORD, UI_ENTER_PREVIEW, UI_WORKING, /* delegates to existing main loop */ } ui_screen_t; typedef enum { VIEW_FROM_GENERATE, VIEW_FROM_MENU } view_caller_t; typedef struct { char words[12][12]; /* room for 8-char word + nul, padded for safety */ uint8_t count; /* 0..12 */ int8_t letter_idx; /* 0..25, current cursor in Stage A */ uint16_t word_cursor; /* cursor index inside current letter group */ uint16_t word_scroll; /* top of viewport in word list */ view_caller_t view_caller; } ui_state_t; /* Run the UI until a mnemonic is loaded and the user requests transition to working. */ void ui_run_until_working(char out_mnemonic[256]); ``` `ui_run_until_working()` is called from `app_main()` after display init and before the main USB/sign loop. It returns when the user has either: - generated a mnemonic and dismissed the View screen, or - entered a valid 12-word mnemonic and pressed Save. The mnemonic is written into `out_mnemonic`. Caller is responsible for zeroing the local UI buffer (this is done inside `ui.c` before return, but main.c also keeps its own copy ephemeral). Each screen is a function `ui_screen_splash()`, `ui_screen_main_menu()`, etc. Each one returns the next `ui_screen_t` enum, plus mutates `ui_state_t` as needed. The dispatcher is a simple `while (current != UI_WORKING) { current = handlers[current](&state); }`. ### 5.3 Display additions Add to [`display.h`](../firmware/feather_s3_tft/main/display.h:1): ```c /* CHAR_SCALE=1 variant (6x8 cells, 40 cols x 16 rows). */ esp_err_t display_draw_text_small(int x, int y, const char *text, uint16_t fg, uint16_t bg); /* Convenience: centered text using the regular (12x16) font. */ esp_err_t display_draw_text_centered(int y, const char *text, uint16_t fg, uint16_t bg); esp_err_t display_draw_text_small_centered(int y, const char *text, uint16_t fg, uint16_t bg); /* Inverse-video helper used by selected menu items. * Draws a filled rect of the bg color sized to the text, then text on top. */ esp_err_t display_draw_text_inverse(int x, int y, const char *text, uint16_t bg, uint16_t fg); ``` Implementation notes: - The existing [`display_draw_text()`](../firmware/feather_s3_tft/main/display.c:204) (line approximate, locate via search) hardcodes `CHAR_SCALE = 2`. Refactor it so the scale is parameterized in an internal helper, then `display_draw_text` and `display_draw_text_small` are thin wrappers. - Centered helpers compute `x = (DISPLAY_WIDTH - char_w * strlen) / 2` and call the underlying drawer. Add a dim color constant for grayed-out menu items: ```c #define RGB565_DIM_GRAY 0x4208 /* ~25% white */ ``` ### 5.4 main.c refactor Sketch of the new `app_main()`: ```c void app_main(void) { display_init(); buttons_init(); /* draw splash, main menu, etc. */ ui_run_until_working(s_mnemonic); /* now s_mnemonic holds 12 words, derive keys */ if (apply_mnemonic_and_enter_working(s_mnemonic) != 0) { /* show error, halt; future plan adds "back to menu" */ } /* zero s_mnemonic immediately after key derivation, same as today */ secure_memzero(s_mnemonic, sizeof(s_mnemonic)); transport_init(); /* existing USB/sign loop */ run_signing_loop(); } ``` Where `apply_mnemonic_and_enter_working()` does what lines 800–836 of [`main.c`](../firmware/feather_s3_tft/main/main.c:800) currently do (mnemonic→seed→privkey→pubkey→npub→idle screen draw), refactored into one function. The big `while (1)` USB loop body (currently lines 852–1004) is moved into `run_signing_loop()` for readability. No behavior change. The existing global statics (`s_pubkey_hex`, `s_privkey`, `s_npub`, `s_npub_short`, `s_mnemonic_short`, `s_frame_buf`, `s_response_buf`, `s_signed_event_buf`) keep their current scope. ## 6. Helpers / library additions ### 6.1 Small font drawing Already covered in §5.3. The existing `font5x7.h` is 5×7 inside a 6×8 cell, so `display_draw_text_small()` literally just drops the `CHAR_SCALE` multiplier. The existing scale-2 path is preserved. ### 6.2 BIP-39 letter index Add to [`mnemonic.h`](../firmware/feather_s3_tft/main/mnemonic.h:1): ```c /* Returns the number of BIP-39 words starting with `letter` (a-z, case insensitive), * and writes the start index in the global wordlist into *out_start. */ size_t mnemonic_words_for_letter(char letter, size_t *out_start); /* Returns pointer to the i-th BIP-39 word (0..2047) in the global list. */ const char *mnemonic_word_at(size_t index); ``` Implementation: iterate the wordlist once on first call, cache 26 `(start, count)` pairs in a `static` array. ### 6.3 Mnemonic checksum validation The current [`mnemonic.c`](../firmware/feather_s3_tft/main/mnemonic.c:1) provides `generate_mnemonic_12` and `mnemonic_to_seed`. For Enter mode we need to validate user-typed input independently of seed derivation (so we can show a checksum error before committing keys). Add to [`mnemonic.h`](../firmware/feather_s3_tft/main/mnemonic.h:1): ```c /* Validate a space-separated 12-word mnemonic against BIP-39 checksum. * Returns 0 on valid, negative on word-not-in-list / wrong-count / bad-checksum. */ int mnemonic_validate(const char *mnemonic); ``` If `mnemonic_to_seed()` already returns nonzero on bad checksum, `mnemonic_validate()` can be a thin wrapper that runs the checksum half but skips PBKDF2. Confirm this during implementation by reading [`mnemonic.c`](../firmware/feather_s3_tft/main/mnemonic.c:1) — if the existing function only does PBKDF2 and trusts the input, we add the proper BIP-39 checksum check (entropy bytes from word indices + last 4 bits = SHA256(entropy)[0] >> 4). ### 6.4 Secure memzero Add a tiny `secure_memzero()` to a new [`firmware/feather_s3_tft/main/secure_mem.h`](../firmware/feather_s3_tft/main/secure_mem.h:1) using `volatile` writes, modeled on the existing [`src/secure_mem.c`](../src/secure_mem.c:1). Not strictly new functionality (we already do `memset(buf,0,sizeof)` in many places) but the in-progress mnemonic buffer in `ui_state_t` and word stage buffers should use it for clarity. ## 7. CMakeLists updates [`firmware/feather_s3_tft/main/CMakeLists.txt`](../firmware/feather_s3_tft/main/CMakeLists.txt:1) adds the new sources: - `buttons.c` - `ui.c` - `secure_mem.c` `SRCS` list grows by these three files. No new component dependencies. ## 8. Out of scope (tracked TODOs) These are intentionally deferred and listed here so they don't get lost: 1. **Persistence (PIN-protected NVS storage of the seed).** Will be a separate plan, [`plans/feather_signer_persistence.md`](feather_signer_persistence.md:1) — to be authored after this UI plan lands. It will reuse the same letter/digit scroll UI for PIN entry. 2. **"Back to menu" hotkey from the working page.** Today, once you arrive at working, you're stuck until reboot. A long-press gesture (e.g., D0+D2 simultaneously, or D0 long-press during idle screen only) could re-enter the menu and lock the loaded mnemonic. 3. **Lock screen / re-auth.** Without PIN, the device is fully unlocked once a mnemonic is entered. The persistence plan will add a "lock" hotkey that wipes RAM keys and returns to the splash/PIN-entry screen. 4. **Display blanking / screensaver.** ST7789 doesn't burn-in dramatically but reducing the backlight after idle would be polite. 5. **Test plan additions.** The current firmware has no automated tests on-device; we rely on host-side tests in [`tests/`](../tests/). UI logic in `ui.c` should be written so that the screen-transition function `ui_next_screen(state, event)` is **pure** (no display/button calls inside) and can be unit-tested with a host-side harness. This is a soft target — recommend doing it but not blocking on it. ## 9. Acceptance criteria - [ ] Power-on shows the red `N_SIGNER` splash; pressing any of D0/D1/D2 advances to the main menu. - [ ] Main menu lists `Generate`, `Enter`, `View`. `View` is dimmed and unselectable when no mnemonic is loaded. - [ ] In the main menu, D2 moves selection up with wrap, D1 moves down with wrap, D0 commits. Selected row is shown in inverse video. - [ ] Selecting `Generate` produces a fresh random 12-word BIP-39 mnemonic, displays it on the View page in a 3×4 grid, and any button press transitions into the working page (key derivation + USB/sign loop). - [ ] Selecting `View` (after a mnemonic is loaded) shows the same View page, and any button press returns to main menu. - [ ] Selecting `Enter`: - Stage A presents the alphabet with a movable highlight; D2/D1 navigate, D0 picks. - Stage B presents all BIP-39 words for the chosen letter, scrollable; D2/D1 navigate, D0 picks. - Stage C shows the running phrase preview as a 3×4 grid; D2 continues to next word, D1 deletes last word, D0 saves (only when 12 words are present). - Long-press D0 cancels and returns to main menu, wiping the partial buffer. - Save with valid checksum transitions to working page; save with invalid checksum shows `"BAD CHECKSUM"` for 2s and stays on Stage C. - [ ] Working-page button semantics (D0=deny, D1=once, D2=always) remain identical to today. - [ ] No mnemonic persistence: a reboot starts at the splash with no loaded mnemonic. - [ ] All in-progress buffers (`ui_state_t.words[][]`, the working `s_mnemonic`) are wiped via `secure_memzero` on transition out of the screens that use them. ## 10. Mermaid: button semantics by screen ```mermaid flowchart LR subgraph Splash Sp[D0 D1 D2 advance] end subgraph MainMenu M2[D2 up] --- M1[D1 down] --- M0[D0 enter] end subgraph EnterLetter EL2[D2 prev letter] --- EL1[D1 next letter] --- EL0[D0 select / long=cancel] end subgraph EnterWord EW2[D2 prev word] --- EW1[D1 next word] --- EW0[D0 select / long=cancel] end subgraph EnterPreview EP2[D2 continue] --- EP1[D1 delete last] --- EP0[D0 save / long=cancel] end subgraph View V[any button = back] end subgraph Working W2[D2 always allow] --- W1[D1 approve once] --- W0[D0 deny] end ```