# Modal State Architecture

**Version:** 1.0
**Last updated:** 2026-04-16
**Phase:** 20.5 (Modal State Architectural Audit)
**Status:** Authoritative — update in the same commit as any modal state machine change

> **DANGER:** The modal layer is the single most fragile component in this codebase.
> 71% of recent gap-closure fixes touched modal-related code (RETRO-AGENT-FAILURE-PATTERNS.md M-4).
> Read this document before touching ANY overlay component.

---

## Agent Rules

### RULE-M-01: State reset on both sides before hide (bac9584 lesson)

When setting `set_*_visible(false)` from Rust, reset ALL in-out forwarding properties on
DashboardWindow FIRST. Slint `in-out` props stay dirty until explicitly overwritten — closing
the modal does NOT clear them. For LookupModal, `reset_lookup_modal_state()` at main.rs:4191
does this. Any new modal with forwarded in-out props MUST have an equivalent reset function.

**Violation symptom:** Second modal open shows data from the previous session.

### RULE-M-02: All dismiss paths are parallel — grep before adding logic

Each modal has 2-4 dismiss paths (Esc, backdrop click, X button, Cancel button). These paths
run in parallel — not chained. Before adding any side-effect to a dismiss handler, grep for all
dismiss paths for that modal and apply the change to every one.

**Reference:** Pitfall 4 (F1 parallel-path-drift pattern).

### RULE-M-03: New settings paths MUST call invoke_sync_cards_updated (INV-2)

Every handler in settings-modal that writes to config or tokens must end with
`invoke_sync_cards_updated()`. This rule was violated in commit `dc622640` (cards disappeared
after Shopify token clear). See CALLBACK_PIPELINE.md INV-2 for the full callsite inventory.

### RULE-M-04: Esc in TextInputs requires explicit key-pressed handler

`FocusScope` Esc does NOT fire when a TextInput has keyboard focus — Slint routes events to
the focused element. Any modal with a TextInput that should support Esc close MUST have:
```
key-pressed(event) => { if event.text == Key.Escape { root.close-requested(); return accept; } return reject; }
```
on EVERY TextInput in the modal.

**Reference:** Pitfall 1, C-3, D-4.

---

## Complete Overlay Inventory

| # | Component | File | Visibility Control | Architecture |
|---|-----------|------|--------------------|-------------|
| 1 | LookupModal | `crates/app/ui/lookup-modal.slint` | `lookup-modal-open: bool` (in prop on DashboardWindow) | Separate component, full overlay with backdrop |
| 2 | SettingsModal | `crates/app/ui/settings-modal.slint` | `modal-open: bool` (in-out on component itself) | Separate component, self-gating via internal `if root.modal-open` |
| 3 | StateTransitionModal | `crates/app/ui/state-transition-modal.slint` | `state-modal-visible: bool` (in-out on DashboardWindow) | Separate component, rendered by `if` in dashboard |
| 4 | AddProductForm | `crates/app/ui/add-product-form.slint` | `add-product-visible: bool` (in on DashboardWindow) | Separate component, rendered by `if` in dashboard with backdrop |
| 5 | RecipientDetailPanel | `crates/app/ui/recipient-detail.slint` | `recipient-detail-visible: bool` (in on DashboardWindow) | Mounted sidebar (not overlay), no click-outside path |
| 6 | ProductDetailPanel | `crates/app/ui/product-detail.slint` | `product-detail-visible: bool` (in on DashboardWindow) | Mounted sidecar (not overlay), no click-outside path |
| 7 | Delete-item confirmation | `dashboard.slint` (inline, lines ~1141–1258) | `delete-item-modal-visible: bool` (in-out on DashboardWindow) | Inline in dashboard, no separate component |
| 8 | Start-return confirmation | `dashboard.slint` (inline, lines ~1260–1360) | `start-return-modal-visible: bool` (in-out on DashboardWindow) | Inline in dashboard, no separate component |
| 9 | RecipientPickerModal | `crates/app/ui/recipient-picker.slint` | `show-recipient-picker: bool` (in-out on DashboardWindow) | Separate component, full overlay with backdrop |

---

## 1. LookupModal

**File:** `crates/app/ui/lookup-modal.slint`
**Visibility:** `root.lookup-modal-open` (in prop on DashboardWindow, set by Rust via `set_lookup_modal_open()`)
**Architecture:** Separate component, full overlay with backdrop rectangle.

```mermaid
stateDiagram-v2
    [*] --> Closed
    Closed --> SearchView: set_lookup_modal_open(true)

    state SearchView {
        [*] --> Searching
        Searching --> ProductExpanded: toggle-product-expanded(pid)
        ProductExpanded --> Searching: toggle-product-expanded(same pid)
        ProductExpanded --> UnitCreating: creating-unit-product-id set
        UnitCreating --> ProductExpanded: SN entry cancelled (Esc in SN TextInput)
        UnitCreating --> ProductExpanded: create-unit-confirmed(pid, sn)
        Searching --> ReassignPrompt: unit already assigned
        ReassignPrompt --> Searching: prompt dismissed
    }

    state CreateProductView {
        [*] --> EnteringDetails
        EnteringDetails --> [*]: create-confirmed(name, url)
    }

    SearchView --> CreateProductView: lookup-show-create-form = true
    CreateProductView --> SearchView: back / Esc / X button

    SearchView --> Closed: close-requested()
    CreateProductView --> Closed: close-requested()
    Closed --> [*]
```

### State Properties

| Property | Direction | Type | Owner | Purpose |
|----------|-----------|------|-------|---------|
| `lookup-modal-open` | in | bool | Rust (DashboardWindow) | Render gate — `visible:` binding on lookup-modal-inst |
| `lookup-target-card-name` | in | string | Rust | Modal title subtitle |
| `lookup-target-card-id` | in | string | Rust | Passed through to unit-selected callback |
| `lookup-target-card-index` | in | int | Rust | Used to route item-selected to correct card |
| `lookup-products` | in | [PickerProductData] | Rust | Product tree data |
| `lookup-tree-viewport-height` | in | length | Rust | Flickable height for product tree |
| `lookup-search-text` | in-out | string | Slint | Search input two-way bind |
| `lookup-show-create-form` | in-out | bool | Slint | Switches between search view and create-product view |
| `shopify-fetch-in-progress` | in | bool | Rust | Shows "Fetching image..." in create form |
| `shopify-fetched-image-url` | in | string | Rust | Shows fetched image preview |
| `lookup-expanded-product-id` | in-out | string | Slint | Which product is expanded |
| `lookup-creating-unit-product-id` | in-out | string | Slint | Which product has inline SN entry open |
| `lookup-new-unit-serial-id` | in-out | string | Slint | In-progress SN text |
| `lookup-reassign-prompt-visible` | in-out | bool | Slint | Shows reassignment confirmation prompt |

### Callback Chains

| Callback Name | Direction | Source | Handler | Side Effects |
|---------------|-----------|--------|---------|-------------|
| `close-requested()` | out | lookup-modal.slint (backdrop click, X button, Esc in FocusScope or TextInput) | `dashboard.slint` → `lookup-close()` → `main.rs:on_lookup_close` (line 4671) | `reset_lookup_modal_state()` (main.rs:4191) then `set_lookup_modal_open(false)` |
| `search-changed(query)` | out | search TextInput `edited` | `dashboard.slint` → `lookup-search-changed(query)` → `main.rs` | Rebuilds `lookup-products` in-memory, sets `lookup-tree-viewport-height` |
| `item-selected(pid, name, hint)` | out | product row [+] button | `dashboard.slint` → `lookup-item-selected(card-index, pid, name, hint)` → `main.rs` | Adds product-level item to card, writes to SQLite, closes modal |
| `unit-selected(sid, pid, cid)` | out | available unit [+] button | `dashboard.slint` → `lookup-unit-selected(sid, pid, cid)` → `main.rs` | Assigns serial unit to card, SQLite write, closes modal; calls `global-keys.focus()` |
| `unit-force-reassign(sid, pid, cid)` | out | reassignment prompt "Move it" | `dashboard.slint` → `lookup-unit-force-reassign` → `main.rs` | Same as unit-selected but skips assignment conflict check |
| `create-unit-confirmed(pid, sn)` | out | SN input Enter or OK button | `dashboard.slint` → `lookup-create-unit-confirmed(pid, sn)` → `main.rs` | Creates new serial instance in SQLite, refreshes product list |
| `toggle-product-expanded(pid)` | out | product row click | Handled INLINE in `dashboard.slint` — NOT forwarded to Rust | Toggles `lookup-modal-inst.expanded-product-id` local state |
| `create-confirmed(name, url)` | out | Create button in create form | `dashboard.slint` → `lookup-create-confirmed(card-index, name, url)` → `main.rs` | Creates new product in SQLite/GH Issue, closes create form |

### Esc Handling

Three overlapping Esc paths:

1. **FocusScope** (modal-focus) in backdrop Rectangle — fires `close-requested()` [lookup-modal.slint:87–95]
2. **TextInput key-pressed** in search input — fires `close-requested()` directly [lookup-modal.slint:198–205]
3. **TextInput key-pressed** in create-name-input — fires `close-requested()` [lookup-modal.slint:806–810]
4. **TextInput key-pressed** in SN entry — clears `creating-unit-product-id`, does NOT close modal [lookup-modal.slint:637–643]

> **FRAGILE (C-3, D-4):** The modal-focus FocusScope only gets Esc when no TextInput has focus.
> When search-input is focused (normal state), only the TextInput key-pressed fires. These paths
> produce the same outcome today but any change to one without the other breaks Esc for some users.

### Dismiss Paths

| Path | Source | Handler | Cleanup |
|------|--------|---------|---------|
| X button | lookup-modal.slint | `close-requested()` → `on_lookup_close` (main.rs:4671) | `reset_lookup_modal_state()` + `set_lookup_modal_open(false)` |
| Backdrop click | lookup-modal.slint | `close-requested()` → `on_lookup_close` (main.rs:4671) | same |
| Esc (FocusScope) | lookup-modal.slint:87–95 | `close-requested()` → `on_lookup_close` | same |
| Esc (search TextInput) | lookup-modal.slint:198–205 | `close-requested()` → `on_lookup_close` | same |
| Esc (create-name TextInput) | lookup-modal.slint:806–810 | `close-requested()` → `on_lookup_close` | same |
| item-selected / unit-selected | main.rs handler | closes modal after action | `set_lookup_modal_open(false)` |

> **FRAGILE (bac9584 lesson — RULE-M-01):** State reset must happen in Rust (`reset_lookup_modal_state()`
> at main.rs:4191) before `set_lookup_modal_open(false)`. If only one side is cleared, stale state
> appears on next open. This was the root cause of the bac9584 cascade bug.

---

## 2. SettingsModal

**File:** `crates/app/ui/settings-modal.slint`
**Visibility:** `settings-modal-open` (in-out on component itself and mirrored as `settings-modal-open` in-out on DashboardWindow)
**Architecture:** Self-gating — SettingsModal controls its own visibility via an `if root.modal-open` guard inside the component. DIFFERENT from all other modals, which are gated from the parent dashboard.

```mermaid
stateDiagram-v2
    [*] --> Closed
    Closed --> Open: set_settings_modal_open(true)

    state Open {
        [*] --> ViewingSettings
        ViewingSettings --> ShopifyTokenChanging: "Change Token" clicked
        ShopifyTokenChanging --> ViewingSettings: cancel or backdrop
        ViewingSettings --> ShopifyClearConfirming: "Clear" clicked
        ShopifyClearConfirming --> ViewingSettings: timer expires (3s) or cancel
        ShopifyClearConfirming --> Closed: "Confirm Clear" clicked
        ViewingSettings --> DiscordTokenChanging: "Add/Change Token" clicked
        DiscordTokenChanging --> ViewingSettings: cancel or backdrop
        ViewingSettings --> DiscordClearConfirming: Discord "Clear" clicked
        DiscordClearConfirming --> ViewingSettings: timer expires (3s) or cancel
        DiscordClearConfirming --> Closed: Discord "Confirm Clear" clicked
        ViewingSettings --> Saving: "Save Settings" clicked
        Saving --> Closed: save completes
    }

    Open --> Closed: cancel-clicked() (Cancel btn, backdrop, Esc)
```

### State Properties

| Property | Direction | Type | Owner | Purpose |
|----------|-----------|------|-------|---------|
| `modal-open` | in-out | bool | Shared | Self-gating render |
| `store-slug-text` | in-out | string | Shared | Shopify store slug field |
| `github-project-url-text` | in-out | string | Shared | GH Project URL field |
| `error-message` | in-out | string | Shared | Error display |
| `shopify-token-configured` | in-out | bool | Rust | Switches between "Configured" status and text input |
| `shopify-token-text` | in-out | string | Slint | New token entry (password) |
| `shopify-token-changing` | in-out | bool | Slint | "Change Token" was clicked — shows text input |
| `saving-in-progress` | in-out | bool | Rust | Disables inputs; shows "Saving..." |
| `clear-confirming` | in-out | bool | Slint | Two-step clear confirm for Shopify token |
| `discord-token-configured` | in-out | bool | Rust | Discord token state |
| `discord-token-text` | in-out | string | Slint | New Discord token (password) |
| `discord-token-changing` | in-out | bool | Slint | "Add/Change Token" clicked |
| `discord-clear-confirming` | in-out | bool | Slint | Two-step clear confirm for Discord token |

**Internal timers:** `clear-reset-timer` auto-resets `clear-confirming` after 3 seconds. `discord-clear-timer` auto-resets `discord-clear-confirming` after 3 seconds. [settings-modal.slint:79–94]

### Callback Chains

| Callback Name | Direction | Source | Handler | Side Effects |
|---------------|-----------|--------|---------|-------------|
| `cancel-clicked()` | out | Cancel button OR backdrop click OR Esc FocusScope OR TextInput key-pressed | `dashboard.slint` → `settings-cancel-clicked()` → `main.rs:on_settings_cancel_clicked` (line 2948) | Closes modal, clears token fields |
| `save-clicked()` | out | Save Settings button | `dashboard.slint` → `settings-save-clicked()` → `main.rs:on_settings_save_clicked` (line 3012) | Writes config to WCM, triggers `invoke_sync_cards_updated()` (INV-2 compliance) |
| `shopify-clear-clicked()` | out | Clear button (after confirm) | `dashboard.slint` → `settings-shopify-clear-clicked()` → `main.rs:on_settings_shopify_clear_clicked` (line 2961) | Clears Shopify token from WCM, triggers `invoke_sync_cards_updated()` (INV-2 compliance at main.rs:2997) |
| `shopify-change-clicked()` | out | Change Token button | Handled INLINE — sets `shopify-token-changing = true` | No Rust handler; Slint-local state only |
| `discord-save-token-clicked()` | out | Save Token button | `dashboard.slint` → `discord-save-token()` → `main.rs` | Stores Discord token in WCM |
| `discord-clear-clicked()` | out | Discord Clear (after confirm) | `dashboard.slint` → `discord-clear-token()` → `main.rs` | Clears Discord token from WCM |
| `discord-change-clicked()` | out | Discord Change Token button | Handled INLINE — sets `discord-token-changing = true` | No Rust handler; Slint-local state only |

### Esc Handling

`FocusScope` (modal-focus) with `init => { self.focus(); }` — grabs focus on modal open to override the search bar. [settings-modal.slint:52–67] However, each TextInput also has `key-pressed(Escape) => root.cancel-clicked()`. These are redundant-but-consistent.

> **FRAGILE (RULE-M-04):** When a TextInput is focused, the FocusScope Esc does NOT fire.
> Both paths call `cancel-clicked()` today so outcome is identical, but this is a latent C-3 risk.

### Dismiss Paths

| Path | Source | Handler | Cleanup |
|------|--------|---------|---------|
| Cancel button | settings-modal.slint:648–657 | resets token fields inline, then `cancel-clicked()` → `on_settings_cancel_clicked` (main.rs:2948) | Clears token fields, `modal-open = false` |
| Backdrop click | settings-modal.slint:37–48 | resets token fields inline, then `cancel-clicked()` → `on_settings_cancel_clicked` | Same as Cancel |
| Esc (FocusScope) | settings-modal.slint:52–67 | `cancel-clicked()` → `on_settings_cancel_clicked` | Closes modal (token fields NOT reset from Esc path — minor gap) |
| Esc (TextInput key-pressed) | each TextInput | `cancel-clicked()` → `on_settings_cancel_clicked` | Same as Esc FocusScope |

> **FRAGILE (F1, RULE-M-02):** Backdrop and Cancel button BOTH manually reset token fields before calling
> `cancel-clicked()`. The FocusScope Esc path does NOT reset them — it calls `cancel-clicked()` directly.
> If a new token field is added, it must be added to backdrop reset AND Cancel button reset.
> See settings-modal.slint:37–48 and 648–657. Parallel paths. (dc622640 history for INV-2 violation)

---

## 3. StateTransitionModal

**File:** `crates/app/ui/state-transition-modal.slint`
**Visibility:** `state-modal-visible: bool` (in-out on DashboardWindow), `if root.state-modal-visible` in dashboard.slint
**Architecture:** Separate component, rendered by `if` in dashboard.

**Two entry points:**
- Card context: `on_card_item_square_clicked` (main.rs:5369) — sets `show-unassign-option = true`
- Product detail context: `on_product_change_unit_state` (main.rs:5248) — sets `show-unassign-option = false`

```mermaid
stateDiagram-v2
    [*] --> Closed
    Closed --> Open_CardContext: on_card_item_square_clicked (main.rs:5369)
    Closed --> Open_ProductContext: on_product_change_unit_state (main.rs:5248)

    state Open_CardContext {
        [*] --> ShowingStates
        note right of ShowingStates: show-unassign-option = true\nunassign-checked may be stale (known gap)
    }

    state Open_ProductContext {
        [*] --> ShowingStates2
        note right of ShowingStates2: show-unassign-option = false
    }

    Open_CardContext --> Closed: state-selected(sid, state, unassign) — inline sets visible=false
    Open_CardContext --> Closed: close-requested() / backdrop — inline sets visible=false
    Open_ProductContext --> Closed: state-selected(sid, state, unassign) — inline sets visible=false
    Open_ProductContext --> Closed: close-requested() / backdrop — inline sets visible=false
```

### State Properties

| Property | Direction | Type | Owner | Purpose |
|----------|-----------|------|-------|---------|
| `state-modal-visible` | in-out | bool | Shared | Render gate; cleared by Slint inline on state-selected/close-requested |
| `state-modal-serial-id` | in-out | string | Rust | Target serial |
| `state-modal-current-state` | in-out | string | Rust | Display only (current state label) |
| `state-modal-product-name` | in-out | string | Rust | Display only |
| `state-modal-is-assigned` | in-out | bool | Rust | Controls whether unassign checkbox renders |
| `state-modal-show-unassign` | in-out | bool | Rust | true from card context, false from product detail |
| `unassign-checked` | in-out | bool | Slint (component) | Whether "Also unassign from card" is checked |

### Callback Chains

| Callback Name | Direction | Source | Handler | Side Effects |
|---------------|-----------|--------|---------|-------------|
| `state-selected(sid, new-state, unassign)` | out | Any state button click | `dashboard.slint` (inline): sets `state-modal-visible = false`, then → `state-modal-confirmed(sid, new-state, unassign)` → `main.rs:on_state_modal_confirmed` (line 5266) | SQLite write for new state, optional unassign, `apply_filters` |
| `close-requested()` | out | X button, Cancel button | `dashboard.slint` (inline): sets `state-modal-visible = false` | No Rust handler — pure Slint state clear |
| Backdrop dismiss (inline) | N/A | Backdrop TouchArea click | `dashboard.slint` (inline): sets `state-modal-visible = false` | No Rust handler |

### Esc Handling

**NONE.** StateTransitionModal has no FocusScope and no TextInput key-pressed handler. Esc does nothing while this modal is open. [Verified: state-transition-modal.slint — no FocusScope, no key-pressed] This is a documented gap (see Esc Priority Chain).

### Dismiss Paths

| Path | Source | Handler | Cleanup |
|------|--------|---------|---------|
| State button click | state-transition-modal.slint | inline `state-modal-visible=false` then `state-modal-confirmed` → main.rs:5266 | SQLite write, apply_filters |
| X button / Cancel | state-transition-modal.slint | inline `state-modal-visible=false` | No Rust cleanup |
| Backdrop click | dashboard.slint inline | `state-modal-visible=false` | No Rust cleanup |
| Esc | NOT HANDLED | — | — |

> **FRAGILE (known gap):** `unassign-checked` is NOT reset between opens. If a user checks
> "Also unassign from card" then cancels, the next open will still have it checked. See Pitfall 3.
>
> **FRAGILE (Pitfall 5):** `state-modal-visible=false` is set BEFORE `state-modal-confirmed` fires.
> The dismiss is optimistic — if Rust handler fails, modal is already gone with no retry path.

---

## 4. AddProductForm

**File:** `crates/app/ui/add-product-form.slint`
**Visibility:** `add-product-visible: bool` (in on DashboardWindow), `if root.add-product-visible` with backdrop in dashboard.slint
**Architecture:** Separate component, rendered by `if` in dashboard with a backdrop overlay.

```mermaid
stateDiagram-v2
    [*] --> Closed
    Closed --> Open: set_add_product_visible(true)

    state Open {
        [*] --> Filling
        Filling --> ShowingSuggestions: name-changed triggers suggestions
        ShowingSuggestions --> Filling: suggestion-selected
        Filling --> Validating: submit-clicked
        Validating --> Filling: validation error (add-product-validation-error set)
        Validating --> Closed: validation passes, product created
    }

    Open --> Closed: discard-clicked() (X btn, Discard btn, backdrop)
```

### State Properties

| Property | Direction | Type | Owner | Purpose |
|----------|-----------|------|-------|---------|
| `add-product-visible` | in | bool | Rust | Render gate (Rust-owned) |
| `add-product-name` | in-out | string | Slint | Name field two-way bind |
| `add-product-shopify-url` | in-out | string | Slint | URL field two-way bind |
| `add-product-suggestions` | in | [ShopifySuggestion] | Rust | Auto-suggest dropdown data |
| `add-product-show-suggestions` | in | bool | Rust | Whether dropdown is visible |
| `add-product-has-image` | in | bool | Rust | Image preview visibility |
| `add-product-image-preview` | in | image | Rust | Preview image |
| `add-product-validation-error` | in | string | Rust | Error text (empty = no error) |

### Callback Chains

| Callback Name | Direction | Source | Handler | Side Effects |
|---------------|-----------|--------|---------|-------------|
| `submit-clicked()` | out | "Add Product" button | `dashboard.slint` → `on-add-product-submit()` → `main.rs` | Validates, creates product in SQLite, `set_add_product_visible(false)` (via Rust) |
| `discard-clicked()` | out | X button, Discard button, backdrop click | `dashboard.slint` → `on-add-product-discard()` → `main.rs` | `set_add_product_visible(false)`, clears form fields |
| `name-changed(val)` | out | name TextInput edited | `dashboard.slint` → `on-add-product-name-changed(val)` → `main.rs` | Updates suggestions, clears validation error |
| `shopify-url-changed(val)` | out | URL TextInput edited | `dashboard.slint` → `on-add-product-shopify-url-changed(val)` → `main.rs` | Triggers Shopify URL lookup |
| `suggestion-selected(url)` | out | Dropdown suggestion click | `dashboard.slint` → `on-add-product-suggestion-selected(url)` → `main.rs` | Sets URL field, hides dropdown |
| `set-image-clicked()` | out | (legacy — currently unused) | `dashboard.slint` → `on-add-product-set-image()` → `main.rs` | No-op or deferred |

### Esc Handling

**NONE.** AddProductForm has no FocusScope and no TextInput key-pressed handler. Esc does nothing while open. [Verified: add-product-form.slint — no FocusScope, no key-pressed in TextInputs] Documented gap.

### Dismiss Paths

| Path | Source | Handler | Cleanup |
|------|--------|---------|---------|
| X button | add-product-form.slint | `discard-clicked()` → `on-add-product-discard()` → main.rs | `set_add_product_visible(false)`, clears form |
| Discard button | add-product-form.slint | `discard-clicked()` → `on-add-product-discard()` → main.rs | same |
| Backdrop click | dashboard.slint (on backdrop TouchArea) | `on-add-product-discard()` → main.rs | same |
| Esc | NOT HANDLED | — | — |

> **FRAGILE (F1, RULE-M-02):** Three parallel dismiss paths (X, Discard, backdrop). Any new cleanup
> logic added to the discard handler must be verified against all three paths.

---

## 5. RecipientDetailPanel (Sidebar)

**File:** `crates/app/ui/recipient-detail.slint`
**Visibility:** `recipient-detail-visible: bool` (in on DashboardWindow)
**Architecture:** Mounted sidebar — NOT a floating overlay. No backdrop, no click-outside dismiss. Sidebar content area shrinks the main card grid width when visible.

```mermaid
stateDiagram-v2
    [*] --> Hidden
    Hidden --> Mounted: set_recipient_detail_visible(true)

    state Mounted {
        [*] --> ViewingRecipient
        ViewingRecipient --> EditingPurpose: purpose edit clicked
        EditingPurpose --> ViewingRecipient: save or cancel
        ViewingRecipient --> EditingRxOD: Rx OD edit clicked
        EditingRxOD --> ViewingRecipient: save or cancel
        ViewingRecipient --> EditingRxOS: Rx OS edit clicked
        EditingRxOS --> ViewingRecipient: save or cancel
        ViewingRecipient --> EditingDiscord: Discord edit clicked
        EditingDiscord --> ViewingRecipient: save or cancel
    }

    Mounted --> Hidden: navigate-back() (Esc or legacy sidebar-close)
    Mounted --> EditCancelled: Esc when field in edit mode
    EditCancelled --> Mounted
```

### State Properties

| Property | Direction | Type | Owner | Purpose |
|----------|-----------|------|-------|---------|
| `recipient-detail-visible` | in | bool | Rust | Mount gate (Rust-owned) |
| `detail-recipient-id` | in | string | Rust | Identity for write-back callbacks |
| `detail-recipient-name` | in | string | Rust | Display |
| `detail-recipient-has-avatar` | in | bool | Rust | Shows image vs initials |
| `detail-recipient-avatar` | in | image | Rust | Avatar image |
| `detail-recipient-initial` | in | string | Rust | Fallback initials |
| `detail-recipient-purpose-color` | in | color | Rust | Avatar border / purpose pill |
| `detail-recipient-purpose` | in | string | Rust | Purpose field value |
| `detail-recipient-vision-rx-od` | in | string | Rust | Rx OD value |
| `detail-recipient-vision-rx-os` | in | string | Rust | Rx OS value |
| `detail-recipient-discord-username` | in | string | Rust | Discord handle |
| `detail-recipient-email` | in | string | Rust | Shopify email |
| `detail-recipient-shopify-customer-url` | in | string | Rust | Browser link |
| `detail-recipient-issue-url` | in | string | Rust | GH Issue link |
| `detail-recipient-purpose-options` | in | [string] | Rust | Dropdown options for purpose edit |
| `detail-editing-purpose` | in-out | bool | Slint | Edit mode flag |
| `detail-purpose-draft` | in-out | string | Slint | Draft value while editing |
| `detail-editing-rx-od` | in-out | bool | Slint | Rx OD edit mode |
| `detail-rx-od-draft` | in-out | string | Slint | Rx OD draft |
| `detail-editing-rx-os` | in-out | bool | Slint | Rx OS edit mode |
| `detail-rx-os-draft` | in-out | string | Slint | Rx OS draft |
| `detail-editing-discord-username` | in-out | bool | Slint | Discord edit mode |
| `detail-discord-username-draft` | in-out | string | Slint | Discord draft |

### Callback Chains

| Callback Name | Direction | Source | Handler | Side Effects |
|---------------|-----------|--------|---------|-------------|
| `sidebar-save-purpose(rid, val)` | out | Purpose save button | `main.rs:on_sidebar_save_purpose` (line 5987) | GH Project write-back, SQLite update, `apply_filters` |
| `sidebar-save-rx-od(rid, val)` | out | Rx OD save button | `main.rs:on_sidebar_save_rx_od` (line 5945) | GH Project write-back, SQLite update |
| `sidebar-save-rx-os(rid, val)` | out | Rx OS save button | `main.rs:on_sidebar_save_rx_os` (line 5966) | GH Project write-back, SQLite update |
| `sidebar-save-discord-username(rid, val)` | out | Discord save button | `main.rs:on_sidebar_save_discord_username` (line 6629) | GH Project write-back, Discord resolution, avatar fetch |
| `sidebar-copy-rx(rid)` | out | Copy Rx button | `main.rs:on_sidebar_copy_rx` (line 6017) | Clipboard write |
| `sidebar-view-shopify-customer(rid)` | out | Shopify link | `main.rs:on_sidebar_view_shopify_customer` (line 6030) | Opens browser |
| `sidebar-view-recipient-issue(rid)` | out | GH Issue link | `main.rs:on_sidebar_view_recipient_issue` (line 6043) | Opens browser |
| `sidebar-close()` | out | Legacy (retained from D-08) | `main.rs:on_sidebar_close` (line 6054) | `set_recipient_detail_visible(false)` |
| `card-name-navigate(rid)` | out | Card name click (recipient grid) | `main.rs` | Sets mode to ByRecipient, selects tile, opens sidebar |

### Esc Handling

`FocusScope` at component root. Two-level priority:

1. If any field is in edit mode (`editing-purpose`, `editing-rx-od`, `editing-rx-os`, `editing-discord-username`): cancel all edits, return accept.
2. Otherwise: fire `navigate-back()` callback — navigates to recipients grid (which dismisses sidebar). [Verified: recipient-detail.slint:55–69]

### Dismiss Paths

N/A — sidebar has no dismiss (no backdrop, no X button post-D-08). Dismissed via Esc (`navigate-back()`) or by navigating away.

> **FRAGILE (D-08):** `close-clicked()` callback is retained but the X button was removed in Phase
> 20.1.1.1. The callback remains because the FocusScope Esc handler calls it for two-level Esc
> dismissal via dashboard.slint forwarding chain. Removing this callback will silently break Esc dismiss.
>
> **FRAGILE:** Editing state (`detail-editing-*` props) lives on DashboardWindow as in-out properties,
> NOT re-initialized on mount. Stale editing state can persist if the close path does not clear
> all draft flags.

---

## 6. ProductDetailPanel (Sidecar)

**File:** `crates/app/ui/product-detail.slint`
**Visibility:** `product-detail-visible: bool` (in on DashboardWindow), conditional on `!show-option-grid` in dashboard.slint
**Architecture:** Mounted sidecar — occupies right side when product grid is in filtered card view. Not a full overlay.

```mermaid
stateDiagram-v2
    [*] --> Hidden
    Hidden --> Mounted: set_product_detail_visible(true)

    state Mounted {
        [*] --> ViewingProduct
        ViewingProduct --> CreatingUnit: [+] clicked
        CreatingUnit --> ViewingProduct: SN entered (create-unit-with-serial) or cancelled
        ViewingProduct --> UnitSelected: unit row clicked
        UnitSelected --> StateTransitionModal_Open: change-unit-state-clicked
        StateTransitionModal_Open --> UnitSelected: StateTransitionModal closed
    }

    Mounted --> Hidden: global-keys FocusScope Esc (via dashboard)
```

### State Properties

| Property | Direction | Type | Owner | Purpose |
|----------|-----------|------|-------|---------|
| `product-detail-visible` | in | bool | Rust | Mount gate (Rust-owned) |
| `detail-product-name/id/image` | in | various | Rust | Display data |
| `detail-has-image` | in | bool | Rust | Image vs placeholder |
| `detail-shopify-url` | in | string | Rust | Browser link |
| `detail-github-issue-ref` | in | string | Rust | GH Issue reference |
| `detail-units` | in | [ProductUnitDisplayData] | Rust | Serial units list |
| `detail-creation-in-progress` | in-out | bool | Slint | Disables [+] during creation |
| `detail-selected-unit-index` | in-out | int | Slint | Selected unit (-1 = none) |
| `detail-selected-unit-serial` | in-out | string | Slint | Selected serial ID |
| `unit-search-query` (component-local) | in-out | string | Slint | Filters unit list display |

**Sub-state:** `creating-unit` (in-out bool) controls inline SN entry. `serial-focus-trigger` (private counter) triggers auto-focus when creating-unit becomes true. [product-detail.slint:25–31]

### Callback Chains

| Callback Name | Direction | Source | Handler | Side Effects |
|---------------|-----------|--------|---------|-------------|
| `set-image-clicked(pid)` | out | Product image click | `dashboard.slint` → `on-product-sidecar-set-image(pid)` → `main.rs` | Opens native file picker, uploads image |
| `view-shopify-clicked(pid)` | out | Shopify link | `dashboard.slint` → `on-product-sidecar-view-shopify(pid)` → `main.rs` | Opens browser |
| `view-issue-clicked(pid)` | out | GH Issue link | `dashboard.slint` → `on-product-sidecar-view-issue(pid)` → `main.rs` | Opens browser |
| `archive-clicked(pid)` | out | Archive button | `dashboard.slint` → `on-product-sidecar-archive(pid)` → `main.rs` | Logged no-op (deferred) |
| `create-unit-with-serial(pid, sid)` | out | SN entry OK/Enter | `dashboard.slint` → `on-product-create-unit-with-serial(pid, sid)` → `main.rs` | Creates serial instance in SQLite/GH |
| `unit-selection-changed(pid, sid)` | out | Unit row click | `dashboard.slint` → `on-product-unit-selection-changed(pid, sid)` → `main.rs` | Updates selected-unit state |
| `change-unit-state-clicked(pid, sid)` | out | Unit state change entry | `dashboard.slint` → `on-product-change-unit-state(pid, sid)` → `main.rs:5248` | Sets `state-modal-visible=true` (entry point for modal #3) |
| `unit-search-changed(query)` | out | Search TextInput | `dashboard.slint` → `detail-unit-search-changed(query)` → `main.rs` | Rebuilds filtered unit list |

### Esc Handling

**NONE.** ProductDetailPanel is a mounted sidecar with no FocusScope and no key-pressed handlers. [Verified: product-detail.slint — no FocusScope] Global Esc (global-keys FocusScope in dashboard.slint) handles dismiss from this panel.

### Dismiss Paths

N/A — sidecar has no self-dismiss path. Dismissed via global-keys FocusScope Esc at the dashboard level.

---

## 7. Delete-Item Confirmation (Inline)

**File:** `dashboard.slint` (lines ~1141–1258, no separate component file)
**Visibility:** `delete-item-modal-visible: bool` (in-out on DashboardWindow)
**Architecture:** Inline in dashboard.slint — no separate component. Full-screen backdrop with centered confirmation panel.

```mermaid
stateDiagram-v2
    [*] --> Closed
    Closed --> Open: on_card_item_square_clicked sets delete-item-modal-visible=true

    state Open {
        [*] --> AwaitingConfirm
    }

    Open --> Closed: OK button (fires delete-item-confirmed then sets visible=false)
    Open --> Closed: Cancel button (inline visible=false)
    Open --> Closed: Backdrop click (inline visible=false)
    Closed --> [*]
```

### State Properties

| Property | Direction | Type | Owner | Purpose |
|----------|-----------|------|-------|---------|
| `delete-item-modal-visible` | in-out | bool | Shared | Render gate |
| `delete-item-product-name` | in-out | string | Rust | Product being deleted |
| `delete-item-serial-id` | in-out | string | Rust | Serial (empty for product-level) |
| `delete-item-assigned-to` | in-out | string | Rust | Who it's assigned to (if anyone) |
| `delete-item-card-name` | in-out | string | Rust | Card context |
| `delete-item-card-index` | in-out | int | Rust | Card index for confirmed callback |
| `delete-item-sq-index` | in-out | int | Rust | Item square index for confirmed callback |

### Callback Chains

| Callback Name | Direction | Source | Handler | Side Effects |
|---------------|-----------|--------|---------|-------------|
| `delete-item-confirmed(card_idx, sq_idx)` | out | OK button | `main.rs:on_delete_item_confirmed` (line 5437) | Removes item from card in SQLite, `apply_filters` |
| Cancel/backdrop (inline) | N/A | Cancel button or backdrop click | Slint inline: `delete-item-modal-visible = false` | No Rust handler for cancel |

### Esc Handling

**NONE.** No FocusScope, no key-pressed handler. Documented gap.

### Dismiss Paths

| Path | Source | Handler | Cleanup |
|------|--------|---------|---------|
| OK button | dashboard.slint inline | `delete-item-confirmed` → main.rs:5437, then `delete-item-modal-visible=false` | SQLite delete, apply_filters |
| Cancel button | dashboard.slint inline | `delete-item-modal-visible = false` | No Rust cleanup |
| Backdrop click | dashboard.slint inline | `delete-item-modal-visible = false` | No Rust cleanup |
| Esc | NOT HANDLED | — | — |

---

## 8. Start-Return Confirmation (Inline)

**File:** `dashboard.slint` (lines ~1260–1360, no separate component file)
**Visibility:** `start-return-modal-visible: bool` (in-out on DashboardWindow)
**Architecture:** Inline in dashboard.slint — no separate component. Full-screen backdrop with centered confirmation panel.

```mermaid
stateDiagram-v2
    [*] --> Closed
    Closed --> Open: card return button triggers, sets start-return-modal-visible=true

    state Open {
        [*] --> AwaitingConfirm
    }

    Open --> Closed: "Open Return Page" button (sets visible=false then fires card-start-return)
    Open --> Closed: Cancel button (inline visible=false)
    Open --> Closed: Backdrop click (inline visible=false)
    Closed --> [*]
```

### State Properties

| Property | Direction | Type | Owner | Purpose |
|----------|-----------|------|-------|---------|
| `start-return-modal-visible` | in-out | bool | Shared | Render gate |
| `start-return-card-index` | in-out | int | Rust | Card to return |
| `start-return-recipient-name` | in-out | string | Rust | Display |
| `start-return-item-label` | in-out | string | Rust | Item summary display |

### Callback Chains

| Callback Name | Direction | Source | Handler | Side Effects |
|---------------|-----------|--------|---------|-------------|
| `card-start-return(card_idx)` | out | "Open Return Page" button (after clearing modal) | `main.rs:on_card_start_return` (line 3959) | Opens Shopify return URL in browser, transitions serial state |
| Cancel/backdrop (inline) | N/A | Cancel button or backdrop click | Slint inline: `start-return-modal-visible = false` | No Rust handler for cancel |

### Esc Handling

**NONE.** No FocusScope, no key-pressed handler. Documented gap.

### Dismiss Paths

| Path | Source | Handler | Cleanup |
|------|--------|---------|---------|
| "Open Return Page" | dashboard.slint inline | sets `start-return-modal-visible=false`, fires `card-start-return` → main.rs:3959 | Opens browser, transitions state |
| Cancel button | dashboard.slint inline | `start-return-modal-visible = false` | No Rust cleanup |
| Backdrop click | dashboard.slint inline | `start-return-modal-visible = false` | No Rust cleanup |
| Esc | NOT HANDLED | — | — |

---

## 9. RecipientPickerModal

**File:** `crates/app/ui/recipient-picker.slint`
**Visibility:** `show-recipient-picker: bool` (in-out on DashboardWindow, dashboard.slint:218)
**Architecture:** Separate component, full overlay with backdrop rectangle. Modal is `visible: is-visible` at component root.

```mermaid
stateDiagram-v2
    [*] --> Closed
    Closed --> ListSearchView: set_show_recipient_picker(true) (main.rs:6260)

    state ListSearchView {
        [*] --> Searching
        note right of Searching: search-input auto-focused on visible=true\nlist-flick viewport reset to 0
    }

    state CreateRecipientView {
        [*] --> EnteringName
        note right of EnteringName: create-name pre-filled with customer-name
    }

    ListSearchView --> CreateRecipientView: "Create New Recipient" row clicked
    CreateRecipientView --> ListSearchView: "Back to List" button

    ListSearchView --> Closed: assign-recipient (entry clicked) (main.rs:6301)
    ListSearchView --> Closed: close-picker (X button, backdrop, Esc) → main.rs:3281 or search-changed path
    CreateRecipientView --> Closed: create-and-assign (main.rs:6446)
    CreateRecipientView --> Closed: close-picker (X button, Esc)
```

### State Properties

| Property | Direction | Type | Owner | Purpose |
|----------|-----------|------|-------|---------|
| `is-visible` | in | bool | Rust (via DashboardWindow.show-recipient-picker) | Render gate and backdrop visibility |
| `customer-name` | in | string | Rust | Pre-fills create-name field; shown in header |
| `recipients` | in | [RecipientPickerEntryData] | Rust | Filtered recipient list |
| `card-index` | in | int | Rust | Passed through to assign/create callbacks |
| `search-text` (component-local) | — | string | Slint | Search input state; cleared on close |
| `show-create-form` (component-local) | — | bool | Slint | Switches list view vs create form |
| `create-name` (component-local) | — | string | Slint | Name field in create form |

### Callback Chains

| Callback Name | Direction | Source | Handler | Side Effects |
|---------------|-----------|--------|---------|-------------|
| `close-picker()` | out | X button, backdrop click, Esc FocusScope | `dashboard.slint` (inline): sets `show-recipient-picker = false` | No Rust handler for pure close; Rust also sets false in Esc handler (main.rs:3281–3282) |
| `assign-recipient(idx, key)` | out | Recipient row click | `dashboard.slint` (inline): sets `show-recipient-picker = false`, then → `assign-recipient(idx, key)` → `main.rs:on_assign_recipient` (line 6301) | Links recipient to card in SQLite, `set_show_recipient_picker(false)` (main.rs:6326), `apply_filters` |
| `create-and-assign(idx, name)` | out | "Create & Assign" button | `dashboard.slint` (inline): sets `show-recipient-picker = false`, then → `create-and-assign-recipient(idx, name)` → `main.rs:on_create_and_assign_recipient` (line 6446) | Creates new recipient in SQLite/GH, assigns to card, `set_show_recipient_picker(false)` (main.rs:6474) |
| `picker-search-changed(query)` | out | search TextInput edited | `dashboard.slint` → `picker-search-changed(query)` → `main.rs:on_picker_search_changed` (line 6269) | Filters `recipients` list |

### Esc Handling

`FocusScope` at component root (recipient-picker.slint:31–45). On Esc: clears `search-text`, `show-create-form`, `create-name`, then calls `close-picker()`.

> **FRAGILE (RULE-M-04):** The search TextInput is auto-focused via `changed focus-trigger` handler
> when modal becomes visible (recipient-picker.slint:142–148). Once the TextInput has focus, the
> FocusScope Esc does NOT fire. The search TextInput has NO key-pressed handler. This means:
> Esc does nothing when the search input is focused. This is the same architectural gap as
> LookupModal vs D-4 in the RETRO findings. Known gap — not yet fixed.

### Dismiss Paths

| Path | Source | Handler | Cleanup |
|------|--------|---------|---------|
| X button (header) | recipient-picker.slint:104–113 | clears search-text/show-create-form/create-name inline, `close-picker()` → dashboard inline `show-recipient-picker=false` | Component local state cleared; Rust sees false |
| Backdrop click | recipient-picker.slint:48–57 | same inline clear + `close-picker()` | same |
| Esc (FocusScope — when no TextInput focused) | recipient-picker.slint:31–45 | inline clear + `close-picker()` | same |
| Esc (when search TextInput focused) | NOT HANDLED | — | — |
| assign-recipient | recipient-picker.slint:185–191 | clears search-text inline, `assign-recipient` → main.rs:6301 | SQLite link, `set_show_recipient_picker(false)` (main.rs:6326) |
| create-and-assign | recipient-picker.slint:353–360 | clears inline state, `create-and-assign` → main.rs:6446 | Create+link in SQLite, `set_show_recipient_picker(false)` (main.rs:6474) |

---

## Esc Priority Chain

Priority is determined by which FocusScope currently holds focus, in z-order from highest to lowest:

```
1. SettingsModal FocusScope (init => { self.focus(); } — grabs focus on modal-open=true)
   → fires cancel-clicked() → on_settings_cancel_clicked (main.rs:2948)

2. LookupModal TextInput key-pressed (when search input is focused — the normal state)
   → fires close-requested() directly → on_lookup_close (main.rs:4671)

3. LookupModal FocusScope (when no TextInput is focused in lookup)
   → fires close-requested() → on_lookup_close (main.rs:4671)

4. RecipientDetailPanel FocusScope
   → first press: cancels active edit (return accept)
   → second press (no edit active): fires navigate-back() → sidebar dismiss

5. global-keys FocusScope in dashboard.slint (always active when above are not focused)
   → fires esc-pressed() → main.rs handler → mode navigation / sidebar dismiss
   → also handles: show-recipient-picker=false (main.rs:3281–3282)

GAPS — Esc does nothing when these are open (no FocusScope grabs focus):
   - StateTransitionModal
   - AddProductForm
   - Delete-item modal (inline)
   - Start-return modal (inline)
   - RecipientPickerModal (when search TextInput is focused — which is immediately after open)
```

**Note:** RecipientPickerModal has a FocusScope but it loses priority to search-input's auto-focus.
The global-keys handler (level 5) explicitly clears `show-recipient-picker` (main.rs:3281–3282),
providing a partial workaround — but only if global-keys FocusScope is not blocked by another modal.

---

## Cross-Modal Interaction Map

| Trigger | Source Component | Target Component | Mechanism |
|---------|-----------------|-----------------|-----------|
| "Change unit state" button in product sidecar | ProductDetailPanel | StateTransitionModal | `change-unit-state-clicked` → `on_product_change_unit_state` (main.rs:5248) → sets `state-modal-visible=true` |
| LookupModal closes via Esc | LookupModal | global-keys FocusScope | `on_lookup_close` calls `global-keys.focus()` to restore global Esc handling |
| RecipientDetailPanel navigate-back | RecipientDetailPanel | Dashboard navigation | `navigate-back()` changes mode to recipients grid, clears sidebar visible flag |
| SettingsModal save | SettingsModal | Sync pipeline | `on_settings_save_clicked` (main.rs:3012) → `invoke_sync_cards_updated()` → triggers re-sync cycle |
| LookupModal item/unit selected | LookupModal | Card data + apply_filters | Confirm callbacks write to SQLite, call `apply_filters` to refresh card grid |
| RecipientPickerModal assign-recipient | RecipientPickerModal | Card data + apply_filters | `on_assign_recipient` (main.rs:6301) links recipient, `apply_filters` |

**Mutual exclusion:** No explicit mutex mechanism exists. Multiple modal visibility flags can technically be set simultaneously — guard against this in Rust by calling `set_*_visible(false)` for competing modals before setting the new one to true.

---

## Known Pitfalls

### Pitfall 1: Esc Swallowed by TextInput

**What goes wrong:** A modal has a FocusScope for Esc, but when a TextInput has keyboard focus, the FocusScope never sees the Esc key. The modal cannot be dismissed.

**Why it happens:** Slint routes keyboard events to the focused element first. TextInput consumes Escape if it has a key-pressed handler; if it doesn't have one, the FocusScope still may not be active.

**How to avoid:** Add `key-pressed(event) => { if event.text == Key.Escape { root.close-requested(); return accept; } return reject; }` to EVERY TextInput in a modal (RULE-M-04).

**Warning signs:** Esc works when you click the modal backdrop area but not when the cursor is in a text field.

**Affected modals:** LookupModal (partially fixed — search and create-name have handlers; SN entry does not close), RecipientPickerModal (search TextInput has NO handler — gap), SettingsModal (each TextInput has handler — compliant).

### Pitfall 2: State Not Reset on Close (bac9584 pattern)

**What goes wrong:** User opens modal, does some work, closes without confirming. Next open shows stale expanded product, stale reassign prompt, stale search text.

**Why it happens:** Slint `in-out` properties forwarded through dashboard stay dirty until explicitly overwritten. `set_lookup_modal_open(false)` does NOT automatically reset them.

**How to avoid:** Call `reset_lookup_modal_state()` (main.rs:4191) BEFORE calling `set_lookup_modal_open(false)`. For new modals, create an equivalent reset function (RULE-M-01).

**Warning signs:** Second modal open shows data from the previous session.

**Affected modals:** LookupModal (fixed — `reset_lookup_modal_state` exists). All other new modals with in-out forwarded props are at risk.

### Pitfall 3: unassign-checked Not Reset Between Opens

**What goes wrong:** User opens StateTransitionModal, checks "Also unassign from card", then cancels. Next open still has checkbox checked.

**Why it happens:** `unassign-checked` is a component-level in-out property. The close/cancel path does not reset it to false.

**How to avoid:** Reset `state-modal-unassign-checked = false` (via Rust or Slint) on every close path — both the cancel path and before any `set_state_modal_visible(true)` call.

**Warning signs:** Checkbox appears pre-checked on modal open.

**Affected modals:** StateTransitionModal (unresolved — documented gap).

### Pitfall 4: Parallel Dismiss Paths Diverge (F1 pattern)

**What goes wrong:** A change to the cancel handler misses one dismiss path. E.g., backdrop dismiss and X button used to do the same thing; a new side-effect is added to X button but not to the backdrop.

**Where it exists:** Settings modal (backdrop vs Cancel button both reset token fields), AddProductForm (X vs Discard vs backdrop), LookupModal (Esc via FocusScope vs Esc via TextInput key-pressed), RecipientPickerModal (X vs backdrop vs Esc FocusScope).

**How to avoid:** Before adding side-effects to any dismiss path, grep for ALL dismiss paths for that modal. All paths should call a single shared callback rather than duplicating logic (RULE-M-02).

**Historical violation:** `dc622640` added `invoke_sync_cards_updated()` to one settings path but not another.

### Pitfall 5: Modal Visibility Not Set on Rust Side After Confirm (Optimistic Dismiss)

**What goes wrong:** The state-selected callback in StateTransitionModal sets `state-modal-visible = false` from Slint (inline in dashboard.slint), then fires `state-modal-confirmed`. If a Rust-side bug prevents the handler from completing, the modal is already closed — no retry possible.

**Why it happens:** The pattern `set visible=false THEN fire callback` means the dismiss is optimistic. This is an intentional UX choice (modal closes immediately) but risks data loss on handler failure.

**Warning signs:** State appears to change (modal closes) but data does not update.

**Affected modals:** StateTransitionModal (only one that uses this pattern — all others dismiss from Rust side).

### Pitfall 6: INV-2 Violation in New Settings Paths

**What goes wrong:** A new action in the settings modal (new clear button, new save path) omits `invoke_sync_cards_updated()`. Cards appear stale after settings change.

**Why it happens:** The sync pipeline does not automatically detect settings changes — it only runs when explicitly triggered.

**Historical violation:** commit `dc622640` — `on_settings_shopify_clear_clicked` was missing `invoke_sync_cards_updated`, causing cards to disappear after clearing the Shopify token.

**How to avoid:** Every settings path that modifies tokens or config MUST call `invoke_sync_cards_updated()` (RULE-M-03). See CALLBACK_PIPELINE.md INV-2.

**Warning signs:** Cards appear stale or disappear after a settings save/clear.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Focus management on modal open | Custom focus-tracking code | Slint `FocusScope` with `init => { self.focus(); }` | Established pattern in settings-modal.slint; Slint owns the focus graph |
| Two-step confirmation | Custom timer state | Slint `Timer` component | Established in settings-modal.slint `clear-reset-timer` pattern |
| Sub-view switching within modal | Extra visibility flags | Single `show-create-form: bool` pattern | Established in lookup-modal.slint and recipient-picker.slint |
| Esc in TextInput | Custom key handler per field | `key-pressed(event) => { if event.text == Key.Escape { ... } }` on EVERY TextInput | Established pattern; required because FocusScope doesn't fire when TextInput has focus (RULE-M-04) |

---

## Update Log

- **2026-04-16 — Phase 20.5:** Initial audit covering 9 overlay components (LookupModal, SettingsModal, StateTransitionModal, AddProductForm, RecipientDetailPanel, ProductDetailPanel, Delete-item confirmation, Start-return confirmation, RecipientPickerModal). All state diagrams, callback chains, Esc handling, dismiss paths, and fragile patterns documented from direct source file inspection.

---

*Update this document in the same commit as any modal state machine change.*
