Files
n_signer/plans/feather_signer_ui.md

26 KiB
Raw Permalink Blame History

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/display.c, firmware/feather_s3_tft/main/display.h, firmware/feather_s3_tft/main/mnemonic.c, firmware/feather_s3_tft/main/mnemonic_wordlist.h, firmware/feather_s3_tft/main/key_derivation.c. 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 (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):

  • 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):
    • 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

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() 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() is called.
  • On success: store the resulting mnemonic in s_mnemonic (already exists in main.c), 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 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(), 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() and show_approval_prompt_with_caller() 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.c.

Public API:

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 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.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:

/* 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() (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:

#define RGB565_DIM_GRAY 0x4208   /* ~25% white */

5.4 main.c refactor

Sketch of the new app_main():

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 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:

/* 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 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:

/* 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 — 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 using volatile writes, modeled on the existing src/secure_mem.c. 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 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 — 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/. 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

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