Files
n_signer/plans/feather_signer_ui.md

435 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 = (24096)/2 = 72.
- 1 row × 16px → y = (13516)/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 800836 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 8521004) 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
```