# Phase 19.1: LookupModal Dual-Mode — Parent Product Adds and Serial Unit Picks - Research

**Researched:** 2026-04-07
**Domain:** Slint UI (lookup-modal.slint, product-picker.slint), Rust callback wiring (main.rs), SQLite read layer (sqlite.rs)
**Confidence:** HIGH — all findings from direct source inspection of the live codebase

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** Replace LookupModal's flat `LookupResultEntry` list with a hierarchical tree showing products with collapsible serial units underneath (same structure as current ProductAddToCardPicker).
- **D-02:** ProductAddToCardPicker (`product-picker.slint`) becomes redundant and is removed. All its functionality (Available/Assigned sections, reassignment prompts, state badges, unit creation) moves into LookupModal.
- **D-03:** LookupModal keeps its existing modal shell (backdrop, search input, close button, Esc handling). Only the results area is replaced with the hierarchical tree.
- **D-04:** Clicking a parent product row expands/collapses its unit list. It does NOT add the product to the card.
- **D-05:** Each parent product row has a right-aligned [+] button that adds the parent product to the card.
- **D-06:** Clicking a unit row does nothing (display only).
- **D-07:** Each unit row has a right-aligned [+] button that adds that specific unit to the card.
- **D-08:** Products default to collapsed state (consistent with Phase 19 D-22).
- **D-09:** Fuzzy search matches both product names and serial numbers simultaneously.
- **D-10:** Products whose name matches the search query are shown. Products with matching units are also shown (forced visible even if the parent name doesn't match).
- **D-11:** Parents with matching units display a match count pill (e.g., "2 matches"). Parents that match only by name show no pill.
- **D-12:** Matching parents are NOT auto-expanded. The user manually expands to see matching units.
- **D-13:** When a parent is expanded, only units matching the current search query are shown (not all units). When search is cleared, all units are shown on expand.
- **D-14:** Non-matching products (no name match AND no unit matches) are hidden entirely.
- **D-15:** When a product is expanded, a "Create New Unit" button appears at the bottom of the unit list.
- **D-16:** Clicking "Create New Unit" inserts an inline textbox above the button for serial number entry. The textbox is immediately focused.
- **D-17:** Enter key or clicking the neighboring "OK" button creates the new serial unit inline under that product.
- **D-18:** The button text is "Create New Unit" (not "+") to avoid confusion with the [+] add-to-card buttons on product/unit rows.
- **D-19:** "Create New Product" row remains at the bottom of the entire product list (below all products), preserving the existing page-switch to the create form with name + Shopify URL inputs.
- **D-20:** When a unit's [+] button is clicked and the unit is already assigned to another card, show the inline reassignment prompt. Prompt: "{serial_id} is assigned to {name}. Move it to this card?"
- **D-21:** Available/Assigned section split with state badges on each unit row (carried forward from Phase 19 D-21).

### Claude's Discretion

- Migration strategy for Rust callback wiring (adapting from two component callback sets to one)
- Exact highlight style for search-matched units
- Match count pill styling and positioning
- Whether to keep `PickerProductData`/`PickerUnitData` structs as-is or merge with `LookupResultEntry`

### Deferred Ideas (OUT OF SCOPE)

- Centralized modular search modal with shared UX patterns
- Product unit ownership audit trail via GH issue timeline comments
- Serial unit search box seeds new unit SN field (specifically — the "pre-fill SN field with search text" idea)
- Fix product sidecar stays visible when switching away from product tab
- Serial unit search box seeds new unit SN field
- Centralized modular search modal with shared UX patterns
- Product unit ownership audit trail via GH issue timeline comments
- Add Lists feature with viewing mode and card management
- Unassigned card view mode with conditional amber tab
- Chip toggle on-off breaks Product Shipped option grid
</user_constraints>

---

## Summary

Phase 19.1 merges two separate UI components — `LookupModal` (flat product search overlay) and `ProductAddToCardPicker` (hierarchical unit tree panel) — into a single unified modal. The merged modal serves both "add parent product to card" and "pick specific serial unit for card" use cases from one entry point. The `ProductAddToCardPicker` is eliminated.

The codebase already has all the necessary pieces. `product-picker.slint` contains the full hierarchical tree with collapsible products, Available/Assigned sections, state badges, reassignment prompt UI, and the `PickerProductData`/`PickerUnitData` data model. `lookup-modal.slint` contains the modal shell, search input with focus management, Esc handling, and the Create New Product form. The Rust wiring in `main.rs` contains the `on_on_show_product_picker` handler that populates unit data from SQLite — this logic moves to the `on_lookup_search_changed` handler.

The primary work is: (1) transplant the hierarchical tree from `product-picker.slint` into the results area of `lookup-modal.slint`, (2) add the four new callbacks to LookupModal, (3) add the two new properties (`creating-unit-product-id`, `new-unit-serial-id`) for inline unit creation, (4) add a `matches-search: bool` field to `PickerUnitData` and `match-count: int` to `PickerProductData` for search filtering, (5) rewire `on_lookup_search_changed` in Rust to do dual search (name + serial) and populate `PickerProductData` instead of `LookupResultEntry`, (6) redirect the dashboard's product-picker callbacks to the lookup modal, and (7) remove `ProductAddToCardPicker` from `dashboard.slint` and `product-picker.slint`.

**Primary recommendation:** Migrate the tree from `product-picker.slint` into `lookup-modal.slint` verbatim, then layer in the search filtering fields, new callbacks, and the inline SN entry UI. Remove the old picker wiring. The Rust search handler is the most complex change: it must now do a dual read (all products + all units) and build `PickerProductData` models with search-matched unit counts.

---

## Standard Stack

No new external dependencies needed. This phase is a pure Slint UI + Rust wiring change.

| Component | Version | Purpose | Status |
|-----------|---------|---------|--------|
| Slint (slint crate) | existing | UI component system | No change — existing dep |
| rusqlite (via SqliteStore) | existing | SQLite reads for products + units | No change — existing dep |
| `read_all_products()` | existing | Loads all products for picker population | Reused as-is |
| `read_units_by_product()` | existing | Loads units per product for picker population | Reused as-is |
| `read_all_units()` | existing | Can be used for serial-number-first search | Reused as-is |

### No New Dependencies

This phase is internal refactor only. `product-picker.slint` is removed, `lookup-modal.slint` absorbs its content.

---

## Architecture Patterns

### Existing Component Layout (before Phase 19.1)

```
dashboard.slint
  ├── lookup-modal-inst: LookupModal         ← flat product list, search, create form
  └── (if product-picker-visible) ProductAddToCardPicker  ← hierarchical unit tree
```

### Target Layout (after Phase 19.1)

```
dashboard.slint
  └── lookup-modal-inst: LookupModal         ← unified: hierarchical tree + search + create form
      (ProductAddToCardPicker: REMOVED)
```

### Pattern 1: Hierarchical Collapsible Tree in Slint (established in product-picker.slint)

**What:** Single-expansion tree using a string property `expanded-product-id`. Comparing `product-data.product-id == root.expanded-product-id` is the visibility gate for unit sub-lists.

**When to use:** Any collapsible list where only one item expands at a time.

**Example (from product-picker.slint, lines 224-365):**
```slint
// Gate on expanded-product-id string comparison
if product-data.product-id == root.expanded-product-id : VerticalLayout {
    // unit rows here
}
```

**Expand/collapse handler in dashboard.slint (lines 1063-1069):**
```slint
toggle-product-expanded(pid) => {
    if root.picker-expanded-product-id == pid {
        root.picker-expanded-product-id = "";
    } else {
        root.picker-expanded-product-id = pid;
    }
}
```
This logic moves into LookupModal as an in-component handler or stays in dashboard.slint wired to a new `expanded-product-id` property on LookupModal.

### Pattern 2: search-focus-trigger Counter for TextInput Focus

**What:** A `private property <int> search-focus-trigger` incremented by Rust. Inside the search Rectangle, a `local-focus-trigger` mirrors it via `<=>`. A `changed local-focus-trigger => { search-input.focus(); }` fires on each increment.

**Why:** `init` fires at construction (while modal is invisible). `changed` only fires when the value actually changes, so incrementing on each open reliably focuses the input.

**Source:** `lookup-modal.slint` lines 29-35 and 135-141. This pattern is preserved as-is.

### Pattern 3: Available/Assigned Section Split in Unit Lists

**What:** A single `units: [PickerUnitData]` array contains both available and assigned units. Available units come first (filtered by `is-available-section: bool`). Two `for` loops over the same array render each section, using `visible: unit-data.is-available-section` and `visible: !unit-data2.is-available-section` with matching conditional heights.

**Source:** `product-picker.slint` lines 237-365.

**Key insight:** The two-loop approach (one for available, one for assigned) avoids Slint's lack of mid-list conditional rendering. Each unit row renders twice — once in each `for` — with one of the two always height 0px and invisible.

### Pattern 4: Inline Reassignment Prompt

**What:** A Rectangle that appears inline (not as a separate overlay) using `if root.reassign-prompt-visible`. It shows the warning text + "Move it" / "Cancel" buttons.

**Source:** `product-picker.slint` lines 77-149.

**State carried into LookupModal:** `reassign-prompt-visible: bool`, `reassign-prompt-text: string`, `reassign-serial-id: string`, `reassign-product-id: string`. These become properties on LookupModal root.

### Pattern 5: Badge Pill Padding via x/y Offsets

**What:** Badge pills use `x: 4px; y: 0px; width: parent.width - 8px; height: parent.height` on the inner Text (not padding-left/right on the Rectangle) because Slint `padding` only works on layout elements, not absolute-positioned children.

**Source:** Phase 19 accumulated context: "Badge pill padding uses x/y offsets on inner Text (not padding-left/right on Rectangle)"

### Pattern 6: Flickable for Scrollable Lists

**What:** The results area is wrapped in a `Flickable`. `viewport-height` is manually computed based on total row heights (products × 32px, expanded units × 28/36px, section headers × 20px, etc.).

**Challenge for this phase:** The viewport-height formula becomes significantly more complex because it now depends on: number of products, which products are expanded, unit counts per expanded product, whether inline SN entry is active. The planner must address this.

### Pattern 7: Dual-Modal Entry Point Consolidation in Rust

**What:** Currently two separate entry points trigger the picker: `card-add-item` opens LookupModal (flat search), and `on-show-product-picker` opens ProductAddToCardPicker (hierarchical). After Phase 19.1, `card-add-item` opens LookupModal which already has hierarchical content. The `on-show-product-picker` callback and all `product-picker-visible` property wiring in `dashboard.slint` are removed.

**Migration path (discretion):** On `lookup-modal-open = true`, populate `PickerProductData` from SQLite (same logic as the current `on_on_show_product_picker` handler) before opening. The `on_lookup_search_changed` handler then re-filters the already-loaded model instead of re-querying SQLite on every keystroke.

### Anti-Patterns to Avoid

- **Re-querying SQLite on every keystroke:** Current `on_lookup_search_changed` calls `read_all_products()` every keypress. For the new dual search (products + units), this would require `read_all_products()` + N × `read_units_by_product()` calls on every keystroke. Instead, load the full product+unit data once when the modal opens (into an in-Rust data structure), then filter in-memory on each keystroke.
- **`is_collapsed: bool` on `PickerProductData`:** The field exists in the struct but the picker already uses `expanded-product-id` string comparison (not per-item `is-collapsed`). The `is-collapsed` field is effectively unused. Do NOT introduce new per-item collapse booleans; continue with the string comparison pattern.
- **Separate Flickable for search results vs. unit tree:** The existing LookupModal Flickable is sized for `results.length * 44px`. This formula must be replaced entirely with the new dynamic height computation.
- **`padding-left`/`padding-right` on badge Rectangle:** Use x/y offset pattern on inner Text instead (see Pattern 5).

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| In-memory search filtering on keystroke | Custom re-query on each keypress | Load once on open, filter the Vec in Rust | Avoids N+1 SQLite reads per keystroke |
| Serial unit creation serial-id generation | New ID scheme | Reuse existing `on_on_picker_create_unit` logic (prefix-NNN format, auto-increment from existing units) | Already exists and tested in main.rs:4568-4579 |
| Unit assignment state machine | New assignment logic | Reuse `try_assign_unit` / `force_assign_unit` from `dashboard::assignment` | Single-assignment enforcement already implemented |
| GH write-back for new units | New GH client calls | Reuse existing `create_unit_as_subissue` pattern from `on_on_picker_create_unit` | Already wired, battle-tested in Phase 19 |
| Product name → card item list update | New logic | Reuse `build_item_squares_from_product_names` utility called in existing picker handlers | Already used in both `on_picker_product_selected` and `on_picker_unit_selected` |

---

## Data Model Changes

### PickerUnitData: Add `matches-search: bool`

**Current struct** (product-picker.slint lines 3-11):
```slint
export struct PickerUnitData {
    serial-id: string,
    state: string,
    state-color: color,
    selectable: bool,
    product-id: string,
    assigned-name: string,
    is-available-section: bool,
}
```

**After Phase 19.1:**
```slint
export struct PickerUnitData {
    serial-id: string,
    state: string,
    state-color: color,
    selectable: bool,
    product-id: string,
    assigned-name: string,
    is-available-section: bool,
    matches-search: bool,   // true when serial-id matches current query; true when query is empty
}
```

`matches-search` controls visibility of unit rows under expanded products (D-13). When `search-text == ""`, all units set `matches-search = true` (show all). Filtering happens in Rust when building the model, not in Slint.

### PickerProductData: Add `match-count: int`

**Current struct** (product-picker.slint lines 13-20):
```slint
export struct PickerProductData {
    product-id: string,
    name: string,
    units: [PickerUnitData],
    is-collapsed: bool,
    available-count: int,
    assigned-count: int,
}
```

**After Phase 19.1:**
```slint
export struct PickerProductData {
    product-id: string,
    name: string,
    units: [PickerUnitData],
    is-collapsed: bool,        // still present but unused; keep for ABI compat
    available-count: int,
    assigned-count: int,
    match-count: int,          // number of units whose serial-id matches current search query
    name-matches-search: bool, // true when product name matches OR match-count > 0 OR search is empty
}
```

`name-matches-search` drives D-14 (hide non-matching products). The Rust handler computes this on every search change and rebuilds the model.

### LookupResultEntry: REMOVED

`LookupResultEntry` struct and `results: [LookupResultEntry]` property are removed from `lookup-modal.slint`. All references in `dashboard.slint` (line 9, 112) are removed. The `lookup-results: [LookupResultEntry]` property on the DashboardWindow becomes `picker-products: [PickerProductData]` (already exists on dashboard for the old picker — now unified).

### New LookupModal Properties

```slint
// Hierarchical product-unit data (replaces results: [LookupResultEntry])
in property <[PickerProductData]> products: [];
in property <string> target-card-id: "";

// Expand/collapse state
in-out property <string> expanded-product-id: "";

// Inline unit creation state
in-out property <string> creating-unit-product-id: "";  // empty = no inline entry active
in-out property <string> new-unit-serial-id: "";        // text in the inline SN TextInput

// Reassignment prompt state (migrated from ProductAddToCardPicker)
in-out property <bool> reassign-prompt-visible: false;
in-out property <string> reassign-prompt-text: "";
in-out property <string> reassign-serial-id: "";
in-out property <string> reassign-product-id: "";
```

### New LookupModal Callbacks

```slint
// New callbacks:
callback unit-selected(string, string, string);        // (serial_id, product_id, card_id)
callback unit-force-reassign(string, string, string);  // (serial_id, product_id, card_id)
callback create-unit-confirmed(string, string);        // (product_id, serial_number)
callback toggle-product-expanded(string);              // (product_id)

// Retained callbacks (unchanged):
callback item-selected(string, string, string);        // fires for parent product add (product_id, display_name, image_hint)
callback create-confirmed(string, string);             // fires for new parent product creation
callback search-changed(string);
callback close-requested();
```

---

## Rust Wiring Changes

### Entry Point Consolidation

The **critical architectural change** in Rust is that "opening the modal" now also triggers populating the hierarchical product+unit model. Currently:

- `card-add-item` → `on_lookup_search_changed` (queries products on each keystroke)
- `on-show-product-picker` → `on_on_show_product_picker` (queries all products + units once on open)

After Phase 19.1:

- `card-add-item` → triggers population of `PickerProductData` model (same logic as old `on_on_show_product_picker`) AND opens LookupModal
- `on_lookup_search_changed` → filters the already-loaded in-memory model (recomputes `matches-search` and `match-count` per unit/product, rebuilds the `PickerProductData` vec)

**Recommended approach (discretion):** Store the unfiltered product+unit data in a `Rc<RefCell<Vec<...>>>` scoped to the lookup modal wiring block. On modal open, populate from SQLite. On search change, filter in memory and push the filtered model to Slint.

### Callbacks to Wire (New in LookupModal)

1. **`unit-selected`** → same logic as existing `on_on_picker_unit_selected` (use `try_assign_unit`, update item_squares, close modal).
2. **`unit-force-reassign`** → same logic as existing `on_on_picker_unit_force_reassign` (use `force_assign_unit`, update item_squares, close modal).
3. **`create-unit-confirmed(product_id, serial_number)`** → new path — serial number is user-provided (not auto-generated). Create the GH subissue with the given serial_id, upsert to SQLite.
4. **`toggle-product-expanded(product_id)`** → expand/collapse single-product logic (handled in Slint or thin Rust callback that sets `expanded-product-id`).

### Callbacks to REMOVE from dashboard.slint/Rust

- `on-show-product-picker(int)` and `on_on_show_product_picker` handler
- `on-picker-product-selected` / `on_on_picker_product_selected`
- `on-picker-unit-selected` / `on_on_picker_unit_selected`
- `on-picker-unit-force-reassign` / `on_on_picker_unit_force_reassign`
- `on-picker-create-unit` / `on_on_picker_create_unit`
- `on-picker-close` / `on_on_picker_close`

### Properties to REMOVE from dashboard.slint

- `product-picker-visible: bool`
- `picker-products: [PickerProductData]`
- `picker-target-card-id: string`
- `picker-expanded-product-id: string`

### Properties to MOVE/REPLACE

- `lookup-results: [LookupResultEntry]` → replaced by `lookup-products: [PickerProductData]`

### Key Wiring Detail: `target-card-id` vs `target-card-index`

The LookupModal currently receives `target-card-name` (display only). It fires `item-selected` with the card index tracked via `lookup-target-card-index` on the dashboard. The `picker-target-card-id` was the string card ID for the picker. After unification, `LookupModal` needs both the card name (subtitle) and the card ID (for callbacks). The Rust wiring already computes `card_id_str` from `lookup-target-card-index` in `on_on_picker_unit_selected` — the same pattern continues.

---

## Common Pitfalls

### Pitfall 1: Viewport-Height Computation for Nested Dynamic Lists

**What goes wrong:** Flickable `viewport-height` must be set to the total height of all content. For a flat list this is `n * row_height`. For a nested tree with variable unit counts per product, it becomes: sum over all products of (32px product row + if expanded: (section headers + unit rows + inline SN entry + "Create New Unit" button)).

**Why it happens:** Slint does not auto-compute viewport height for Flickable. The developer must compute it explicitly.

**How to avoid:** Use Slint property expressions to compute this sum. One approach: make `viewport-height` a computed expression that iterates `products` and accumulates heights. Alternatively, use a VerticalLayout inside the Flickable (Slint does NOT support this directly without fixed heights or preferred-height). The safest approach for this codebase (which uses absolute positioning throughout) is to compute an upper-bound height via a formula in a property expression.

**Reference:** Existing pattern in `lookup-modal.slint` line 194-196 for the simpler flat case.

### Pitfall 2: Two-for-loop Pattern for Available/Assigned Sections

**What goes wrong:** Rendering a filtered sublist inside a `for` loop — Slint's `for` loop renders all items and visibility/height conditionals control which appear. Using two separate `for` loops over the same `units` array (one for available, one for assigned) produces doubled DOM nodes but is the established pattern.

**Why it happens:** Slint lacks `filter()` on model arrays for rendering. The two-loop pattern is the project's established workaround.

**How to avoid:** Keep the two-loop pattern from `product-picker.slint`. Do NOT attempt to filter the units array in Slint.

**Additional consideration:** When `matches-search: bool` is false, set `height: 0px; visible: false` on the unit row in addition to the `is-available-section` condition. Each unit's visibility is the AND of both conditions.

### Pitfall 3: Single Expansion Pattern — `expanded-product-id` vs per-item `is-collapsed`

**What goes wrong:** `PickerProductData` has an `is-collapsed: bool` field but the picker never uses it — expansion state is driven entirely by `expanded-product-id` string comparison. Attempting to update `is-collapsed` on individual items inside a Slint `for` loop will not work (Slint for-loops don't allow model mutation).

**Why it happens:** Phase 19 D-22 / Pitfall 4 documented this; `is-collapsed` was included in the struct but bypassed for the string comparison pattern.

**How to avoid:** Continue using `expanded-product-id` on LookupModal root. The `is-collapsed` field can remain in the struct (ABI compat) but is not read by any UI logic.

### Pitfall 4: Focus Management When Inline SN Entry Activates

**What goes wrong:** When "Create New Unit" is clicked and the inline TextInput appears, it must auto-focus. Using `init =>` on the TextInput's parent Rectangle fires when the Rectangle is first created (when `creating-unit-product-id` is set), which is exactly the right timing — but only if the TextInput is inside the conditionally-rendered element.

**How to avoid:** Wrap the inline SN entry block in `if root.creating-unit-product-id == product-data.product-id : Rectangle { ... init => { sn-input.focus(); } }`. The `init` fires when the conditional block appears, providing reliable focus.

### Pitfall 5: `on_on_picker_create_unit` Auto-Generates Serial ID

**What goes wrong:** The existing `on_on_picker_create_unit` handler auto-generates the serial ID using a prefix + sequential number scheme. Phase 19.1 D-16/D-17 require the user to provide the serial number via an inline text input. The new `create-unit-confirmed(product_id, serial_number)` callback receives a user-provided string. Do NOT reuse the auto-generation logic.

**How to avoid:** New `on_create_unit_confirmed` handler creates the GH subissue using the user-provided `serial_number` as the `serial_id`. The issue title becomes `"{product.name} ({serial_number})"`. Same GH API path, different serial_id source.

### Pitfall 6: `lookup-results` Removal Breaks Existing dashboard.slint References

**What goes wrong:** Removing `LookupResultEntry` and `lookup-results` from `lookup-modal.slint` will cause compile errors in `dashboard.slint` which imports `LookupResultEntry` at line 9 and declares `lookup-results: [LookupResultEntry]` at line 112.

**How to avoid:** Remove all `LookupResultEntry` references from `dashboard.slint` simultaneously with the `lookup-modal.slint` change. Replace `lookup-results` property with `lookup-products: [PickerProductData]` (or reuse the existing `picker-products` property if it's being unified).

### Pitfall 7: Reassignment Prompt Covers Multiple Products

**What goes wrong:** The reassignment prompt is modal within the picker (only one prompt shown at a time). If it's rendered inline inside the product's expanded unit list, scrolling past the product could hide it from view.

**How to avoid:** Render the reassignment prompt at the top of the results area (before the product tree), as in the current `product-picker.slint` (lines 77-149). It's a top-level element within the content Rectangle, not nested inside the for-loop.

---

## Viewport Height Computation

The `viewport-height` for the scrollable tree is the most mechanically complex part of the Slint work. A working formula (to be refined in implementation):

```slint
// Approximate: sum of all rendered row heights
viewport-height:
    // Product rows (always visible if name-matches-search)
    (count-visible-products * 32px)
    // Expanded product's unit section (only one product expanded)
    + (expanded-product-id != "" ? (
        (available-count-of-expanded > 0 ? 20px : 0px)   // Available header
        + (count-matching-available * 28px)
        + (assigned-count-of-expanded > 0 ? 20px : 0px)   // Assigned header
        + (count-matching-assigned * 36px)
        + 28px   // Create New Unit button
        + (creating-unit-product-id == expanded-product-id ? 36px : 0px)  // inline SN entry
    ) : 0px)
    // Create New Product row (always visible)
    + 40px
    // Empty state (when no products match)
    + (count-visible-products == 0 ? 40px : 0px);
```

Slint can compute this via a property expression. The hard part is getting `count-visible-products`, `count-matching-available`, and `count-matching-assigned` — these must be derived from the `products` model. Since Slint lacks array reduce, one approach is to compute these values in Rust and pass them as additional LookupModal properties. Alternatively, a fixed over-estimate (e.g., `products.length * 500px`) avoids the problem but produces excessive blank space.

**Recommended:** Compute `total-tree-height` in Rust (updated whenever the model changes) and expose it as an `in property <length> tree-viewport-height` on LookupModal, set by Rust alongside the `products` model update.

---

## Rust Search Handler: Dual-Search Logic

The new `on_lookup_search_changed` must:

1. Load the cached product+unit data (from initial modal open).
2. For each product, compute:
   - `name-matches-search`: product name contains query (case-insensitive)
   - `match-count`: count of units whose `serial_id` contains query
   - Whether the product should be visible: `name_matches || match_count > 0 || query.is_empty()`
3. For each unit in each product, set `matches-search = query.is_empty() || serial_id.contains(query)`.
4. Rebuild the `Vec<PickerProductData>` with all `matches-search` and `match-count` fields set.
5. Push to Slint via `w.set_lookup_products(ModelRc::from(...))`.
6. Update `tree-viewport-height` based on the new model.

**Loading strategy:** On `card-add-item` (modal open), load all products and all their units from SQLite into a `Vec<(ProductRow, Vec<ProductUnitRow>)>` in Rust memory. `on_lookup_search_changed` filters this in-memory data. This avoids N+1 SQLite reads per keystroke.

---

## Code Examples

### Inline SN Entry Focus Pattern (verified from existing LookupModal)

```slint
// From lookup-modal.slint lines 302-305 — init fires when conditional block appears
if root.show-create-form : Rectangle {
    init => {
        create-name-input.focus();
    }
}
```

Apply the same pattern for inline SN entry:
```slint
if root.creating-unit-product-id == product-data.product-id : Rectangle {
    init => {
        sn-input.focus();
    }
    // TextInput sn-input here...
}
```

### Expand/Collapse Handler Pattern (from dashboard.slint lines 1063-1069)

```slint
// This logic moves into LookupModal as an in-component handler:
toggle-product-expanded(pid) => {
    if root.expanded-product-id == pid {
        root.expanded-product-id = "";
    } else {
        root.expanded-product-id = pid;
    }
}
```

### Unit Visibility Under Search Filter

```slint
// Available unit row: visible when available section AND matches search
Rectangle {
    visible: unit-data.is-available-section && (root.search-text == "" || unit-data.matches-search);
    height: (unit-data.is-available-section && (root.search-text == "" || unit-data.matches-search)) ? 28px : 0px;
    // ...
}
```

### Product Visibility Under Search Filter

```slint
// Product row: visible when matches search OR has matching units OR search is empty
Rectangle {
    visible: product-data.name-matches-search;
    height: product-data.name-matches-search ? 32px : 0px;
    // ...
}
```

### Match Count Pill (when search active and units match)

```slint
if root.search-text != "" && product-data.match-count > 0 : Rectangle {
    height: 20px;
    min-width: 48px;
    border-radius: 8px;
    background: Colors.accent-dim;
    Text {
        x: 4px;
        y: 0px;
        width: parent.width - 8px;
        height: parent.height;
        text: product-data.match-count + " matches";
        font-size: Typography.size-xs;
        color: #ffffff;
        horizontal-alignment: center;
        vertical-alignment: center;
    }
}
```

---

## Dashboard.slint Wiring Changes

### Properties Added to DashboardWindow

```slint
// Replace:
in property <[LookupResultEntry]> lookup-results: [];
// With:
in property <[PickerProductData]> lookup-products: [];

// Replace product-picker block properties:
// in property <bool> product-picker-visible: false;       ← REMOVE
// in property <[PickerProductData]> picker-products;      ← REMOVE (consolidated)
// in property <string> picker-target-card-id;             ← REMOVE
// in-out property <string> picker-expanded-product-id: ""; ← REMOVE (moved into LookupModal)
```

### LookupModal Instantiation (lookup-modal-inst) Changes

```slint
lookup-modal-inst := LookupModal {
    // Keep:
    x: 0px; y: 0px; width: parent.width; height: parent.height;
    visible: root.lookup-modal-open;
    target-card-name: root.lookup-target-card-name;
    target-card-id: root.lookup-target-card-id;    // NEW
    products: root.lookup-products;                 // NEW (replaces results:)
    // ...existing search-text, show-create-form bindings...

    // Keep existing callbacks:
    search-changed(query) => { root.lookup-search-changed(query); }
    create-confirmed(...) => { ... }
    close-requested() => { root.lookup-close(); global-keys.focus(); }

    // Add new callbacks:
    item-selected(pid, name, hint) => {
        root.lookup-item-selected(root.lookup-target-card-index, pid, name, hint);
        global-keys.focus();
    }
    unit-selected(sid, pid, cid) => { root.lookup-unit-selected(sid, pid, cid); global-keys.focus(); }
    unit-force-reassign(sid, pid, cid) => { root.lookup-unit-force-reassign(sid, pid, cid); }
    toggle-product-expanded(pid) => {
        if lookup-modal-inst.expanded-product-id == pid {
            lookup-modal-inst.expanded-product-id = "";
        } else {
            lookup-modal-inst.expanded-product-id = pid;
        }
    }
    create-unit-confirmed(pid, sn) => { root.lookup-create-unit-confirmed(pid, sn); }
};

// REMOVE: entire product-picker-visible block (lines 1039-1073)
```

### New Callbacks on DashboardWindow

```slint
callback lookup-unit-selected(string, string, string);
callback lookup-unit-force-reassign(string, string, string);
callback lookup-create-unit-confirmed(string, string);
// Remove: on-show-product-picker, on-picker-*, on-picker-close
```

---

## Environment Availability

Step 2.6: SKIPPED — this phase is a pure code/config change. No external tools, services, runtimes, or CLI utilities beyond the existing Rust toolchain are required.

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | Rust built-in test framework (`cargo test`) |
| Config file | none — `#[cfg(test)]` modules inline in source files |
| Quick run command | `cargo test -p app 2>&1` |
| Full suite command | `cargo test --workspace 2>&1` |

### Phase Requirements to Test Map

| Behavior | Test Type | Automated Command | Notes |
|----------|-----------|-------------------|-------|
| Dual search: product name match | unit | `cargo test -p app -- search_matches 2>&1` | Extends existing filter_cards_by_search tests |
| Dual search: serial number match (in lookup context) | unit | `cargo test -p app -- search_matches_serial 2>&1` | Serial search tests already exist in discovery.rs |
| match-count computation when query matches units | unit | `cargo test -p app -- lookup_match_count 2>&1` | New test needed |
| name-matches-search when only units match | unit | `cargo test -p app -- lookup_parent_visible 2>&1` | New test needed |
| Inline SN creation: user-provided serial ID | unit | `cargo test -p app -- create_unit_confirmed 2>&1` | New test needed |
| Slint UI tree renders (visual) | manual | N/A — cargo test cannot test Slint rendering | Human UAT required |
| Reassignment prompt appears on assigned unit [+] | manual | N/A | Human UAT required |
| expand/collapse single product at a time | manual | N/A | Human UAT required |
| BUGSWEEPER smoke test of modal state | smoke | `cargo build --features bugsweeper 2>&1` + HTTP probe | Agent-executable pre-UAT |

### Wave 0 Gaps

- [ ] `crates/app/src/dashboard/lookup_search.rs` (or inline in discovery.rs) — unit tests for dual-search filtering logic (match-count, name-matches-search, per-unit matches-search bool)
- [ ] These tests can live in the existing `discovery.rs` test module or a new `lookup.rs` module under `dashboard/`

---

## Sources

### Primary (HIGH confidence)

All findings verified by direct source inspection of the live codebase:

- `crates/app/ui/lookup-modal.slint` — existing LookupModal component, full implementation
- `crates/app/ui/product-picker.slint` — existing ProductAddToCardPicker, full hierarchical tree implementation
- `crates/app/ui/dashboard.slint` — component declarations, property and callback inventory, instantiation bindings (lines 8-275, 971-1073)
- `crates/app/src/main.rs` — `on_lookup_search_changed` (3417-3467), `on_on_show_product_picker` (4212-4300), `on_on_picker_unit_selected` (4378-4465), `on_on_picker_unit_force_reassign` (4474-4546), `on_on_picker_create_unit` (4552-4616)
- `crates/service/src/db/sqlite.rs` — `ProductUnitRow`, `read_all_products()`, `read_units_by_product()`, `read_all_units()` (lines 85-96, 764-870)
- `.planning/phases/19.1-lookupmodal-dual-mode-parent-product-adds-and-serial-unit-picks/19.1-CONTEXT.md` — locked decisions D-01 through D-21
- `.planning/phases/19.1-lookupmodal-dual-mode-parent-product-adds-and-serial-unit-picks/19.1-UI-SPEC.md` — visual contract
- `.planning/STATE.md` — accumulated context: Phase 19 pitfalls (expanded-product-id pattern, badge pill padding, etc.)
- `code_tips/SQLITE_TIPS.md` — ON CONFLICT upsert pattern, get Option<String> gotcha

### Secondary (MEDIUM confidence)

- Phase 19 CONTEXT.md (D-21, D-22) and accumulated STATE.md decisions — confirm picker design decisions that carry forward

---

## Metadata

**Confidence breakdown:**
- Component structure: HIGH — read directly from .slint source
- Rust wiring changes: HIGH — read directly from main.rs handlers
- SQLite data layer: HIGH — read directly from sqlite.rs
- Slint viewport-height complexity: HIGH — confirmed from existing Flickable usage
- Inline SN entry focus behavior: HIGH — `init =>` pattern confirmed from create form in lookup-modal.slint

**Research date:** 2026-04-07
**Valid until:** 2026-05-07 (stable codebase — no fast-moving external deps)
