# Phase 20.5: Modal State Architectural Audit — Research

**Researched:** 2026-04-16
**Domain:** Slint UI modal/overlay state machines, Rust callback wiring in main.rs
**Confidence:** HIGH — all findings derived directly from source file inspection

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** Audit covers ALL overlay components with open/close state: lookup-modal, state-transition-modal, settings-modal, add-product-form, recipient-detail sidebar, product-detail sidecar. The sidebar/sidecar have state machines that interact with modals.
- **D-02:** State diagrams use Mermaid `stateDiagram-v2` syntax.
- **D-03:** Callback inventory uses structured markdown tables with columns: Callback Name | Direction (in/out) | Source File | Handler File | Side Effects.
- **D-04:** Document full callback chains end-to-end (e.g., `card.slint callback` → `dashboard.slint forwarding` → `main.rs handler` → SQLite write / sync trigger / UI update).
- **D-05:** Known fragile patterns documented inline within each modal's section, with cross-references to RETRO-AGENT-FAILURE-PATTERNS.md section IDs.

### Claude's Discretion

- Internal organization of MODAL-STATE.md sections (grouping by modal vs. by concern)
- Level of detail in Mermaid diagrams (simple vs. nested states)
- Whether to include a summary "interaction map" showing how modals interact with each other

### Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope. The following are explicitly deferred bugs/features:
- Unify Shopify and Discord token section styling in settings modal
- Centralized modular search modal with shared UX patterns
- Unit assignment not reflected in lookup modal or product detail sidecar (bug)
- Lookup modal — new options for assigned serial unit (feature)
- Creating product from lookup modal does not create ww-product GH Issue (bug)
- State change unassigns unit from card even without unassign checkbox (bug)
</user_constraints>

---

## Summary

Phase 20.5 is a pure documentation phase: read every overlay component in the codebase, trace every callback chain from the Slint component through dashboard.slint to main.rs, and write the result into `.planning/MODAL-STATE.md`. No code changes, no bug fixes.

The research below provides the raw material for that document: the complete inventory of all 8 overlay components found in the codebase (6 named in CONTEXT.md plus 2 additional inline modals in dashboard.slint), their state properties, their callback signatures, and their wiring patterns.

The single highest-value insight is the difference in Esc-handler architecture across modals: some use `FocusScope` inside the component, some rely on the parent, some have no Esc path at all. This inconsistency is the primary cause of C-3 (Modal close drops focus; global key handler dies) and D-4 (Slint focus / Esc handler priority not testable statically).

**Primary recommendation:** Organize MODAL-STATE.md by modal component (one section per overlay), with a cross-modal "Esc Priority Chain" section at the end. This directly serves the most common edit pattern — "I'm changing X modal, what do I need to know?"

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Modal visibility flags | Frontend (dashboard.slint properties) | Rust (set_* calls) | Slint properties are the source of truth for render; Rust sets them via set_* |
| Callback forwarding | Frontend (dashboard.slint) | — | Dashboard is the aggregation point; modal components only emit to their parent |
| Callback handling / side effects | Rust (main.rs) | — | All data mutations, sync triggers, and SQLite writes happen in Rust handlers |
| Esc / focus management | Frontend (FocusScope in modal or dashboard) | Rust (global-keys FocusScope) | Priority order is determined by Slint z-order and which FocusScope has focus |
| State reset on close | Both (Slint in-out props cleared by Rust via set_* calls) | — | bac9584 lesson: reset required in BOTH the component AND the dashboard parent |
| Inline form sub-states | Frontend (component-local in-out props) | — | Sub-states like `show-create-form`, `reassign-prompt-visible` live entirely in Slint |

---

## Complete Modal/Overlay Inventory

Eight overlay components were found. Six are named in CONTEXT.md. Two additional inline modal panels exist only in `dashboard.slint` (delete-item confirmation and start-return confirmation) — these have no separate component file.

[VERIFIED: direct source file inspection of all .slint files and dashboard.slint]

| # | 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, conditional render within component |
| 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) | `delete-item-modal-visible: bool` (in-out on DashboardWindow) | Inline in dashboard, no separate component |
| 8 | Start-return confirmation | `dashboard.slint` (inline) | `start-return-modal-visible: bool` (in-out on DashboardWindow) | Inline in dashboard, no separate component |

**Note:** `RecipientPickerModal` also exists (referenced at dashboard.slint line 1362). It was not in the CONTEXT.md list but should be documented in the audit for completeness. [VERIFIED: dashboard.slint line 1362–1379]

---

## Per-Modal State Inventory

### 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()`)

#### State Properties (Slint)

| Property | Direction | Type | Purpose |
|----------|-----------|------|---------|
| `lookup-modal-open` | in | bool | Render gate — `visible:` binding on lookup-modal-inst |
| `lookup-target-card-name` | in | string | Modal title subtitle |
| `lookup-target-card-id` | in | string | Passed through to unit-selected callback |
| `lookup-target-card-index` | in | int | Used to route item-selected to correct card |
| `lookup-products` | in | [PickerProductData] | Product tree data |
| `lookup-tree-viewport-height` | in | length | Flickable height for product tree |
| `lookup-search-text` | in-out | string | Search input two-way bind |
| `lookup-show-create-form` | in-out | bool | Switches between search view and create-product view |
| `shopify-fetch-in-progress` | in | bool | Shows "Fetching image..." in create form |
| `shopify-fetched-image-url` | in | string | Shows fetched image preview |
| `lookup-expanded-product-id` | in-out | string | Which product is expanded (aliased from inst) |
| `lookup-creating-unit-product-id` | in-out | string | Which product has inline SN entry open |
| `lookup-new-unit-serial-id` | in-out | string | In-progress SN text |
| `lookup-reassign-prompt-visible` | in-out | bool | Shows reassignment confirmation prompt |
| `lookup-reassign-prompt-text` (internal) | component | string | Prompt message text |
| `lookup-reassign-serial-id` (internal) | component | string | Serial being reassigned |
| `lookup-reassign-product-id` (internal) | component | string | Product of serial being reassigned |

**Sub-states (component-local):** The LookupModal has two sub-views controlled by `show-create-form` (bool). Within the search view, a reassignment prompt sub-state is controlled by `reassign-prompt-visible`. Within the expanded product view, an inline SN entry is controlled by `creating-unit-product-id`.

#### 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` | `set_lookup_modal_open(false)`, `reset_lookup_modal_state()` (main.rs:4191) |
| `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; also 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 the 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 exist.
1. `FocusScope` (modal-focus) in backdrop Rectangle — fires `close-requested()` [VERIFIED: lookup-modal.slint:87–95]
2. `TextInput` key-pressed in search input — fires `close-requested()` directly [VERIFIED: lookup-modal.slint:198–205]
3. `TextInput` key-pressed in create-name-input — fires `close-requested()` [VERIFIED: lookup-modal.slint:806–810]
4. `TextInput` key-pressed in SN entry — clears `creating-unit-product-id`, does NOT close modal [VERIFIED: lookup-modal.slint:637–643]

**FRAGILE PATTERN (C-3, D-4):** The modal-focus FocusScope gets focus only if no TextInput has focus. When search-input is focused (the normal state), Esc in the FocusScope will NOT fire — the TextInput's key-pressed fires instead. These paths happen to produce the same outcome today, but any change that intercepts one path without the other will break Esc for some users.

**FRAGILE PATTERN (bac9584 lesson):** State reset must happen in Rust (`reset_lookup_modal_state()` at main.rs:4191) AND the in-out forwarding properties on DashboardWindow must be cleared. If only one side is reset, stale state appears on next open.

---

### 2. SettingsModal

**File:** `crates/app/ui/settings-modal.slint`
**Visibility:** `settings-modal-open` (in-out prop on component; also mirrored as `settings-modal-open` in-out on DashboardWindow)

**Distinct architecture:** SettingsModal controls its own visibility via an `if root.modal-open` guard inside the component. This is different from all other modals, which are gated from the parent dashboard.

#### State Properties

| Property | Direction | Type | Purpose |
|----------|-----------|------|---------|
| `modal-open` | in-out | bool | Self-gating render |
| `store-slug-text` | in-out | string | Shopify store slug field |
| `github-project-url-text` | in-out | string | GH Project URL field |
| `error-message` | in-out | string | Error display |
| `shopify-token-configured` | in-out | bool | Switches between "Configured" status and text input |
| `shopify-token-text` | in-out | string | New token entry (password) |
| `shopify-token-changing` | in-out | bool | "Change Token" was clicked — shows text input |
| `saving-in-progress` | in-out | bool | Disables inputs; shows "Saving..." |
| `clear-confirming` | in-out | bool | Two-step clear confirm for Shopify token |
| `discord-token-configured` | in-out | bool | Discord token state |
| `discord-token-text` | in-out | string | New Discord token (password) |
| `discord-token-changing` | in-out | bool | "Add Token" or "Change Token" clicked |
| `discord-clear-confirming` | in-out | bool | 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. [VERIFIED: settings-modal.slint:79–94]

#### Callback Chains

| Callback Name | Direction | Source | Handler | Side Effects |
|---------------|-----------|--------|---------|-------------|
| `cancel-clicked()` | out | Cancel button OR backdrop click OR Esc | `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 site) |
| `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 site 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 |

**Backdrop dismiss resets:** Backdrop TouchArea manually resets `shopify-token-changing`, `clear-confirming`, `shopify-token-text`, `discord-token-changing`, `discord-clear-confirming`, `discord-token-text` BEFORE calling `cancel-clicked()`. The Cancel button does the same. These two paths are parallel — a classic F1 parallel-path-drift risk. [VERIFIED: settings-modal.slint:37–48, 648–657]

**Esc handling:** `FocusScope` (modal-focus) with `init => { self.focus(); }` — grabs focus on modal open to override search bar. [VERIFIED: settings-modal.slint:52–67] However, each TextInput also has its own `key-pressed(Escape) => root.cancel-clicked()`. These are redundant-but-consistent (FRAGILE: if TextInput is focused, FocusScope Esc does not fire).

**INV-2 risk site:** Both `on_settings_save_clicked` and `on_settings_shopify_clear_clicked` call `invoke_sync_cards_updated()`. This is a previously-violated pattern (commit `dc622640`). Any new settings path must also include this call.

---

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

**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`

#### State Properties

| Property | Direction | Type | Purpose |
|----------|-----------|------|---------|
| `state-modal-visible` | in-out | bool | Render gate; cleared by Slint inline on state-selected/close-requested |
| `state-modal-serial-id` | in-out | string | Target serial |
| `state-modal-current-state` | in-out | string | Display only (current state label) |
| `state-modal-product-name` | in-out | string | Display only |
| `state-modal-is-assigned` | in-out | bool | Controls whether unassign checkbox renders |
| `state-modal-show-unassign` | in-out | bool | true from card context, false from product detail |
| `unassign-checked` | in-out | bool (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 | 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 known gap.

**FRAGILE PATTERN:** `unassign-checked` is an in-out property on the component but is NOT reset between opens. If a user checks "Also unassign from card" and then cancels, the next open will still have it checked. [VERIFIED: state-transition-modal.slint — no reset on close-requested]

---

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

#### State Properties

| Property | Direction | Type | Purpose |
|----------|-----------|------|---------|
| `add-product-visible` | in | bool | Render gate (Rust-owned) |
| `add-product-name` | in-out | string | Name field two-way bind |
| `add-product-shopify-url` | in-out | string | URL field two-way bind |
| `add-product-suggestions` | in | [ShopifySuggestion] | Auto-suggest dropdown data |
| `add-product-show-suggestions` | in | bool | Whether dropdown is visible |
| `add-product-has-image` | in | bool | Image preview visibility |
| `add-product-image-preview` | in | image | Preview image |
| `add-product-validation-error` | in | string | 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, sets `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]

**Backdrop dismiss:** The backdrop TouchArea calls `on-add-product-discard()`. The X button and Discard button also call `discard-clicked()` which routes to the same Rust handler. Three paths, all parallel — F1 risk if the handler changes.

---

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

#### State Properties

| Property | Direction | Type | Purpose |
|----------|-----------|------|---------|
| `recipient-detail-visible` | in | bool | Mount gate (Rust-owned) |
| `detail-recipient-id` | in | string | Identity for write-back callbacks |
| `detail-recipient-name` | in | string | Display |
| `detail-recipient-has-avatar` | in | bool | Shows image vs initials |
| `detail-recipient-avatar` | in | image | Avatar image |
| `detail-recipient-initial` | in | string | Fallback initials |
| `detail-recipient-purpose-color` | in | color | Avatar border / purpose pill |
| `detail-recipient-purpose` | in | string | Purpose field value |
| `detail-recipient-vision-rx-od/os` | in | string | Rx field values |
| `detail-recipient-discord-username` | in | string | Discord handle |
| `detail-recipient-email` | in | string | Shopify email |
| `detail-recipient-shopify-customer-url` | in | string | Browser link |
| `detail-recipient-issue-url` | in | string | GH Issue link |
| `detail-recipient-purpose-options` | in | [string] | Dropdown options for purpose edit |
| `detail-editing-purpose` | in-out | bool | Edit mode flag |
| `detail-purpose-draft` | in-out | string | Draft value while editing |
| `detail-editing-rx-od` | in-out | bool | Rx OD edit mode |
| `detail-rx-od-draft` | in-out | string | Rx OD draft |
| `detail-editing-rx-os` | in-out | bool | Rx OS edit mode |
| `detail-rx-os-draft` | in-out | string | Rx OS draft |
| `detail-editing-discord-username` | in-out | bool | Discord edit mode |
| `detail-discord-username-draft` | in-out | string | 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, from D-08 retained close-clicked) | `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. 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]

**FRAGILE PATTERN (D-08):** `close-clicked()` callback is retained but the X button was removed from the component 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. Any modification that removes this callback will silently break Esc dismiss.

**FRAGILE PATTERN:** Sidebar is NOT a PopupWindow. Editing state (`detail-editing-*` props) lives on the DashboardWindow as in-out properties, NOT re-initialized on mount. This is intentional (to survive re-mounts) but means 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.

#### State Properties

| Property | Direction | Type | Purpose |
|----------|-----------|------|---------|
| `product-detail-visible` | in | bool | Mount gate (Rust-owned) |
| `detail-product-name/id/image/etc.` | in | various | Display data |
| `detail-has-image` | in | bool | Image vs placeholder |
| `detail-shopify-url` | in | string | Browser link |
| `detail-github-issue-ref` | in | string | GH Issue reference |
| `detail-units` | in | [ProductUnitDisplayData] | Serial units list |
| `detail-creation-in-progress` | in-out | bool | Disables [+] during creation |
| `detail-selected-unit-index` | in-out | int | Selected unit (-1 = none) |
| `detail-selected-unit-serial` | in-out | string | Selected serial ID |
| `unit-search-query` (component) | in-out | string | 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. [VERIFIED: 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 (D-18.2 entry point) |
| `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 sidebar with no FocusScope and no key-pressed handlers. [VERIFIED: product-detail.slint — no FocusScope] Global Esc (global-keys FocusScope in dashboard.slint) handles dismiss.

---

### 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)

#### State Properties

| Property | Direction | Type | Purpose |
|----------|-----------|------|---------|
| `delete-item-modal-visible` | in-out | bool | Render gate |
| `delete-item-product-name` | in-out | string | Product being deleted |
| `delete-item-serial-id` | in-out | string | Serial (empty for product-level) |
| `delete-item-assigned-to` | in-out | string | Who it's assigned to (if anyone) |
| `delete-item-card-name` | in-out | string | Card context |
| `delete-item-card-index` | in-out | int | Card index for confirmed callback |
| `delete-item-sq-index` | in-out | int | 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` |
| Inline: `delete-item-modal-visible = false` | N/A | Cancel button, backdrop click, OK button (before callback) | Slint inline | No Rust handler for cancel |

**Esc handling:** NONE. No FocusScope, no key-pressed handler.

---

### 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)

#### State Properties

| Property | Direction | Type | Purpose |
|----------|-----------|------|---------|
| `start-return-modal-visible` | in-out | bool | Render gate |
| `start-return-card-index` | in-out | int | Card to return |
| `start-return-recipient-name` | in-out | string | Display |
| `start-return-item-label` | in-out | string | 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 |
| Inline: `start-return-modal-visible = false` | N/A | Cancel button, backdrop click | Slint inline | No Rust handler for cancel |

**Esc handling:** NONE. No FocusScope, no key-pressed handler.

---

## Architecture Patterns

### System Architecture Diagram

```
User Interaction (click / keyboard)
          |
          v
[Slint Component] ──callback──> [dashboard.slint] ──callback──> [main.rs handler]
          |                           |                                  |
   (local state                (forwarding props               (SQLite write,
    mutated inline)             + visibility flags)             sync trigger,
                                                                apply_filters,
                                                                set_*_visible)
```

Data flow for modal open:
```
Card click / button → main.rs handler → set_*_visible(true) + populate props → Slint render
```

Data flow for modal confirm:
```
User clicks action → Slint callback → dashboard forwards → main.rs handler →
  [SQLite write] → [apply_filters OR set_row_data patch] → set_*_visible(false)
```

Data flow for modal cancel/dismiss:
```
Esc / backdrop / X button → close-requested() / discard-clicked() / inline state clear →
  main.rs handler (or Slint inline) → set_*_visible(false) → [optional state reset]
```

### Esc Priority Chain

Priority is determined by which FocusScope currently holds focus, in z-order from highest to lowest:

```
1. SettingsModal FocusScope (grabs focus on init when modal-open=true)
   → fires cancel-clicked()

2. LookupModal TextInput key-pressed (when search input is focused)
   → fires close-requested()

3. LookupModal FocusScope (when no TextInput is focused)
   → fires close-requested()

4. RecipientDetailPanel FocusScope
   → first press: cancels active edit; second press: navigate-back()

5. global-keys FocusScope in dashboard.slint (always active when above are not focused)
   → fires esc-pressed() → main.rs handler → mode navigation / sidebar dismiss

GAPS (Esc does nothing):
   - StateTransitionModal (open)
   - AddProductForm (open)
   - Delete-item modal (open)
   - Start-return modal (open)
   - ProductDetailPanel (mounted, no FocusScope)
```

### State Reset Pattern (bac9584 lesson)

For modals that use `in-out` forwarding properties on DashboardWindow:
1. Slint-side: in-out props stay dirty until overwritten
2. Rust-side: `reset_lookup_modal_state()` function (main.rs:4191) clears all lookup modal props before `set_lookup_modal_open(false)`

Any new modal with in-out forwarded props MUST follow this pattern or state bleeds across opens.

---

## 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 |
| Esc in TextInput | Custom key handler per field | `key-pressed(event) => { if event.text == Key.Escape { ... } }` | Established pattern; needed because FocusScope doesn't fire when TextInput has focus |

---

## Common 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, the event propagates, but the FocusScope may still 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. This is the pattern used in lookup-modal and settings-modal.
**Warning signs:** Esc works when you click the modal backdrop area but not when the cursor is in a text field.

### 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.
**Warning signs:** Second modal open shows data from the previous session.

### 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.
**Warning signs:** Checkbox appears pre-checked on modal open.

### Pitfall 4: Parallel Dismiss Paths (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 change adds side-effects to the X button but not to the backdrop.
**Where it exists:** Settings modal (backdrop vs Cancel button — both manually reset token fields), AddProductForm (X vs Discard vs backdrop), LookupModal (Esc via FocusScope vs Esc via TextInput key-pressed).
**How to avoid:** All dismiss paths should call a single shared callback rather than duplicating logic. When adding side-effects to a dismiss path, grep for ALL dismiss paths for that modal.

### Pitfall 5: Modal Visibility Not Set on Rust Side After Confirm
**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 `state-modal-confirmed` 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.
**Warning signs:** State appears to change (modal closes) but data does not update.

### 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.
**Historical violation:** commit `dc622640`.
**How to avoid:** Every settings path that modifies tokens or config MUST call `invoke_sync_cards_updated()`. See CALLBACK_PIPELINE.md INV-2.

---

## Known Fragile Patterns Cross-Reference

| Pattern | Modals Affected | Retro ID | Description |
|---------|----------------|----------|-------------|
| Esc swallowed by focused TextInput | Lookup, Settings | C-3, D-4 | FocusScope Esc path vs TextInput key-pressed — parallel paths |
| Modal close drops focus → global key handler dies | All floating overlays | C-3 | After close, global-keys FocusScope must regain focus |
| State not reset on close | Lookup (fixed), StateTransition (open issue) | bac9584 lesson | in-out props stay dirty across opens |
| Parallel dismiss paths diverge | Settings, AddProductForm, Lookup | F1 | Three dismiss paths per modal, each manually duplicating cleanup |
| Replacement-instead-of-refactoring | Lookup (historical) | F3 | bac9584 — replacing LookupModal silently dropped requirements |
| Backdrop dismiss and button dismiss parallel | Settings | F1, dc622640 | Both reset token fields manually; any new field must be added to both |

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | RecipientPickerModal is a separate component imported into dashboard.slint | Inventory | Low — it is referenced at dashboard.slint:1362 but not yet audited; the audit plan should include reading it |
| A2 | `on_card_start_return` at main.rs:3959 is the only Rust handler for the start-return modal | Start-Return section | Low — the grep found only one site; if another exists it would be additive |

---

## Open Questions

1. **RecipientPickerModal not inventoried**
   - What we know: It exists and is rendered at dashboard.slint:1362 with `is-visible: root.show-recipient-picker`
   - What's unclear: Its callback chains and state machine have not been read (no separate audit in CONTEXT.md scope)
   - Recommendation: Include in Phase 20.5 audit as a 9th overlay; CONTEXT.md D-01 says "ALL overlay components with open/close state"

2. **unassign-checked reset not implemented**
   - What we know: StateTransitionModal does not reset this property on cancel
   - What's unclear: Whether this causes visible user-facing bugs currently
   - Recommendation: Document as known gap in MODAL-STATE.md; tag for a future quick fix

3. **Esc gap in AddProductForm, StateTransitionModal, delete-item, start-return modals**
   - What we know: None of these four modals handle Esc
   - What's unclear: Whether global-keys FocusScope in dashboard catches Esc while these modals are open (it may not, since no FocusScope in these paths grabs focus explicitly)
   - Recommendation: Document explicitly as a known gap; include in the Esc Priority Chain diagram

---

## Environment Availability

Step 2.6: SKIPPED — this is a documentation-only phase with no external tool dependencies. The only tools needed are file reads and text writes.

---

## Validation Architecture

This phase produces a documentation file (MODAL-STATE.md). There are no automated tests for documentation correctness. The "test" is human review.

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| (none) | MODAL-STATE.md file exists and is non-empty | manual | N/A | Created in Wave 0 of this phase |
| (none) | All 8+ modals have entries in MODAL-STATE.md | manual review | N/A | N/A |
| (none) | Each modal has a Mermaid stateDiagram-v2 block | manual review | N/A | N/A |
| (none) | Each modal has a callback table with required columns | manual review | N/A | N/A |

### Sampling Rate
- **Per task commit:** N/A (documentation phase — no code to test)
- **Phase gate:** Manual review of MODAL-STATE.md completeness before `/gsd-verify-work`

### Wave 0 Gaps
None — no code files to create. Wave 0 consists of reading the source files (already done in this research).

---

## Security Domain

This is a documentation-only phase with no code changes. No ASVS categories apply. The settings modal does handle secrets (Shopify token, Discord bot token) but the security controls for those are already implemented and are being documented, not modified.

---

## Sources

### Primary (HIGH confidence)
- `crates/app/ui/lookup-modal.slint` — full read, all state props and callbacks verified
- `crates/app/ui/settings-modal.slint` — full read
- `crates/app/ui/state-transition-modal.slint` — full read
- `crates/app/ui/add-product-form.slint` — full read
- `crates/app/ui/recipient-detail.slint` — partial read (header + FocusScope)
- `crates/app/ui/product-detail.slint` — partial read (header + callbacks)
- `crates/app/ui/dashboard.slint` — targeted reads of lines 95–350 (properties/callbacks) and 1020–1380 (modal instantiation)
- `crates/app/src/main.rs` — grep for all modal-related handler lines
- `.planning/RETRO-AGENT-FAILURE-PATTERNS.md` — full read
- `code_tips/CALLBACK_PIPELINE.md` — full read
- `code_tips/SLINT_TIPS.md` — full read

### Secondary (MEDIUM confidence)
- `.planning/STATE.md` — accumulated decisions, specifically Phase 16.1, 20.1.1, 20.1.1.1 entries
- `.planning/MODAL-STATE.md` placeholder — confirmed placeholder status and known fragile patterns listed

---

## Metadata

**Confidence breakdown:**
- Modal inventory: HIGH — all files directly read
- Callback chains: HIGH — grep-verified against main.rs line numbers
- Esc handler analysis: HIGH — verified in each component file
- Fragile patterns: HIGH — cross-referenced against RETRO-AGENT-FAILURE-PATTERNS.md with specific commit SHAs
- unassign-checked reset gap: HIGH — verified absence of reset in state-transition-modal.slint

**Research date:** 2026-04-16
**Valid until:** Stable until any modal component is modified (fast-moving area — re-read source files before planning any modal change)
