---
phase: 19.1
slug: lookupmodal-dual-mode-parent-product-adds-and-serial-unit-picks
status: draft
shadcn_initialized: false
preset: none
created: 2026-04-07
---

# Phase 19.1 — UI Design Contract

> Visual and interaction contract for Phase 19.1: LookupModal dual-mode — parent product adds and serial unit picks.
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none — Slint native (no shadcn, no component library) |
| Preset | not applicable |
| Component library | Slint built-ins (Rectangle, Text, TextInput, Flickable, TouchArea, FocusScope, VerticalLayout, HorizontalLayout) |
| Icon library | Unicode glyphs inline in Text (▶ U+25B6 collapsed, ▼ U+25BC expanded, ✏ U+270F edit — existing pattern) |
| Font | Slint default system font (unchanged from existing components) |

Source: `crates/app/ui/tokens.slint`, existing `.slint` component conventions.

---

## Spacing Scale

Declared values (must be multiples of 4):

| Token | Value | Usage |
|-------|-------|-------|
| xs | 4px | Icon gaps, badge inner x-pad (via Text x-offset), fine row adjustments |
| sm | 8px | Compact element spacing — padding-left/right on HorizontalLayouts, button gaps |
| md | 16px | Section padding, indent depth for unit rows under a product, VerticalLayout padding |
| lg | 24px | Modal panel edge padding (left: 24px, right: 24px on search + form inputs) |
| xl | 32px | Modal panel width buffer; not used for gaps |
| 2xl | 48px | Not used in this phase |
| 3xl | 64px | Not used in this phase |

Exceptions:
- Modal panel fixed at 480×580px (extended from existing 480×520px to accommodate unit tree scrollable area — +60px height). Source: 19.1-CONTEXT.md D-03 + existing `lookup-modal.slint` dimensions.
- Product row height: 32px (matches `product-picker.slint` established pattern).
- Available unit row height: 28px (matches `product-picker.slint` established pattern).
- Assigned unit row height: 36px (matches `product-picker.slint` established pattern — extra height for two-line serial-id + assigned-name).
- Inline SN create row height: 36px (matches TextInput inputs in existing create form).
- State badge pill height: 20px, border-radius: 8px (established in `product-picker.slint`).
- Touch target minimum: 28px height for interactive rows (existing component floors).

---

## Typography

All sizes from `crates/app/ui/tokens.slint` Typography global. No new sizes introduced.

| Role | Size | Weight | Line Height | Token |
|------|------|--------|-------------|-------|
| Modal title | 18px | 600 (semibold) | 1.2 | `Typography.size-lg` |
| Section header (Available / Assigned labels) | 11px | 400 (regular) | 1.2 | `Typography.size-xs` |
| Product row label | 12px | 400 (regular) | 1.0 (single line) | `Typography.size-sm` |
| Unit serial-id | 11px | 400 (regular) | 1.0 (single line) | `Typography.size-xs` |
| Body / inputs / result rows | 13px | 400 (regular) | 1.0 (single line) | `Typography.size-md` |
| Count summary text / match pill label | 11px | 400 (regular) | 1.0 | `Typography.size-xs` |
| State badge pill text | 11px | 400 (regular) | 1.0 | `Typography.size-xs` |
| Buttons (Create, Back, OK) | 13px | 400 (regular) | 1.0 | `Typography.size-md` |
| Reassignment prompt warning text | 11px | 400 (regular) | word-wrap | `Typography.size-xs` |

Rule: exactly 4 declared sizes (11px, 12px, 13px, 18px), 2 declared weights (400 regular, 600 semibold).

Source: `tokens.slint` Typography global; `lookup-modal.slint` and `product-picker.slint` existing usage.

---

## Color

All values from `crates/app/ui/tokens.slint` Colors global.

| Role | Value | Usage |
|------|-------|-------|
| Dominant (60%) | `#1a1e2a` (`Colors.background`) | Modal backdrop overlay base; search input background |
| Secondary (30%) | `#242838` (`Colors.surface`) | Modal panel background |
| Surface elevated | `#2a3050` (`Colors.surface-elevated`) | Reserved — not used in LookupModal panel itself |
| Surface popup | `#2d3348` (`Colors.surface-popup`) | Row hover state; reassignment prompt box background |
| Accent (10%) | `#4a7cff` (`Colors.accent`) | [+] add-to-card buttons; "Create New Product" row label + icon; "Create New Unit" button |
| Accent hover | `#3a6aee` (`Colors.accent-hover`) | [+] button hover background; primary action button hover state |
| Accent dim | `#3a5299` (`Colors.accent-dim`) | Reassignment "Move it" confirm button default background |
| Warning | `#f0a030` (`Colors.warning`) | Reassignment prompt border + text color; "Move it" button hover background |
| Error | `#e05050` (`Colors.error`) | Name-required validation border on create form inputs |
| Text primary | `#e0e4f0` (`Colors.text-primary`) | Product name, unit serial-id, button labels, TextInput text |
| Text secondary | `#c0c8da` (`Colors.text-secondary`) | Assigned unit sub-label (assigned-name) |
| Text muted | `#8a92a8` (`Colors.text-muted`) | Modal subtitle, form field labels, Close X glyph, "Back" button label, count summary |
| Text dim | `#6b7590` (`Colors.text-dim`) | Placeholder text, empty state message, section header labels (Available / Assigned) |
| Border default | `#3a4060` (`Colors.border-default`) | Search input border; form input borders; Close button hover background |

Accent reserved for:
1. The [+] add-to-card button on each parent product row (right-aligned).
2. The [+] add-to-card button on each available unit row (right-aligned).
3. The "Create New Unit" text button at the bottom of each expanded product's unit list.
4. The "Create New Product" row at the bottom of the entire product list (label + leading + icon).
5. The primary "Create" button in the create-product form page.

Accent is NOT used for: product row text, unit serial-id text, section headers, state badges, or the chevron expand icon.

Serial unit state badge background colors (established in `tokens.slint`, used unchanged):
- Available: `Colors.serial-state-available` (`#2d6b2d`)
- Assigned: `Colors.serial-state-assigned` (`#2a4578`)
- In Transit: `Colors.serial-state-in-transit` (`#7a6520`)
- Delivered: `Colors.serial-state-delivered` (`#1a7a1a`)
- Return Initiated: `Colors.serial-state-return-initiated` (`#8a5020`)
- Return In Transit: `Colors.serial-state-return-in-transit` (`#7a4a10`)
- Returned: `Colors.serial-state-returned` (`#5a2a6a`)
- Processing: `Colors.serial-state-processing` (`#2a5a6a`)

Source: `tokens.slint`, `product-picker.slint`, `lookup-modal.slint`.

---

## Interaction Contract

This section supplements the standard template with phase-specific interaction states required by the executor.

### Modal Shell (unchanged from existing LookupModal)
- Backdrop: `#00000066` semi-transparent overlay, full parent size.
- Clicking backdrop fires `close-requested`.
- Esc key in FocusScope or TextInput fires `close-requested`.
- Modal panel: 480×580px, `Colors.surface` background, `border-radius: 12px`, centered in parent.
- `search-focus-trigger` counter pattern: Rust increments to request TextInput focus on open.

### Search Input
- Position: `x: 24px, y: 0px` within content area (below title row at y=76px).
- Size: `width: parent.width - 48px, height: 36px`.
- Background: `Colors.background`, border `Colors.border-default`, `border-radius: 8px`.
- Placeholder: "Search products or serial numbers..." — shown when `search-text == ""` in `Colors.text-dim`.
- `edited` fires `search-changed(text)` which triggers dual search (product names + serial IDs).

### Product Tree (results area)

**Product row layout (height 32px):**
- Leading: chevron glyph (▶ collapsed / ▼ expanded), width 12px, `Colors.text-muted`, `Typography.size-xs`.
- Product name: `Typography.size-sm`, `Colors.text-primary`, `horizontal-stretch: 1`.
- Count summary (trailing, before [+]): "(N avail, M assigned)" — `Typography.size-xs`, `Colors.text-dim`. Hidden when search query is active and a match count pill is shown.
- Match count pill (trailing, when search active AND units match): pill Rectangle, `Colors.accent-dim` background, `border-radius: 8px`, height 20px. Inner Text: "N matches", `Typography.size-xs`, `#ffffff`. Shown only on products with matching units (D-11). Not shown on products that match only by name.
- [+] button (trailing-most): 24×24px Rectangle, `border-radius: 4px`. Default: transparent background, accent-colored "+" text. Hover: `Colors.accent` background, white "+" text. Clicking fires `product-selected(product_id, card_id)`.
- Row click area (covers everything except [+] button): fires `toggle-product-expanded(product_id)`. Does NOT add product to card (D-04).
- Row hover background: `Colors.surface-popup`.

**Product row states:**
- Default: collapsed (D-08). `expanded-product-id == ""` at open.
- Expanded: `expanded-product-id == product_id`.
- Single expansion: toggling an already-expanded product collapses it; expanding a new product collapses the previous one.
- Hidden (search active, no name match AND no unit matches): product row not rendered (D-14).

**Unit rows (indented 16px left, inside expanded product):**

Available section header: Text "Available", `Typography.size-xs`, `Colors.text-dim`, height 20px. Shown only if `available-count > 0`.

Available unit row (height 28px):
- Serial-id text: `Typography.size-xs`, `Colors.text-primary`, `horizontal-stretch: 1`.
- State badge pill: 20px height, `border-radius: 8px`, background from serial state color map. Inner text: state label, `Typography.size-xs`, `#ffffff`.
- [+] button: 24×24px, same style as product row [+]. Clicking fires `unit-selected(serial_id, product_id, card_id)`.
- Row hover: `Colors.surface-popup`.
- Search active: only show units whose serial-id matches the current query (D-13). When search cleared, show all units.

Assigned section header: Text "Assigned", `Typography.size-xs`, `Colors.text-dim`, height 20px. Shown only if `assigned-count > 0`.

Assigned unit row (height 36px, two-line):
- Line 1: serial-id, `Typography.size-xs`, `Colors.text-primary`.
- Line 2: assigned-name (e.g. "→ Matt"), `Typography.size-xs`, `Colors.text-secondary`. Shown only if `assigned-name != ""`.
- State badge pill: same style, y-offset 8px within row.
- [+] button: 24×24px, same accent style. Clicking triggers inline reassignment prompt (D-20) — does NOT immediately reassign.
- Row hover: `Colors.surface-popup`.
- Search active: same filter rule as available units (D-13).

**Reassignment prompt (inline, shown within modal content area when triggered):**
- Rectangle: `Colors.surface-popup` background, `border-radius: 8px`, `border-width: 1px`, `border-color: Colors.warning`, height 80px.
- Text: "{serial_id} is assigned to {assigned_name}. Move it to this card?" — `Typography.size-xs`, `Colors.warning`, `wrap: word-wrap`.
- "Move it" button: 72×24px, `border-radius: 4px`, default `Colors.accent-dim`, hover `Colors.warning`. Text: `Typography.size-xs`, `Colors.text-primary`. Clicking fires `unit-force-reassign`. (Width 72px matches "Keep Here" button — visually balanced pair.)
- "Keep Here" button: 72×24px, `border-radius: 4px`, transparent background, `border-width: 1px`, `border-color: Colors.border-default`. Text: `Typography.size-xs`, `Colors.text-muted`. Clicking dismisses prompt without action.

**"Create New Unit" button (bottom of each expanded unit list):**
- Text button (not [+] icon): "Create New Unit", `Typography.size-sm`, `Colors.accent`. (D-18)
- Height 28px. Hover background: `Colors.surface-popup`.
- Clicking fires inline SN entry mode for that product.

**Inline SN entry (appears above "Create New Unit" button when active):**
- Rectangle: `Colors.background` background, `border-width: 1px`, `border-color: Colors.border-default`, `border-radius: 4px`, height 36px, full available width (parent width minus 8px left/right padding).
- TextInput inside: auto-focused on creation (D-16). `Typography.size-md`, `Colors.text-primary`.
- Placeholder: "Serial number..." — `Colors.text-dim`.
- "OK" button: 40×24px Rectangle to the right of input, `Colors.accent` background, `border-radius: 4px`. Text: "OK", `Typography.size-xs`, `#ffffff`.
- Enter key OR "OK" click: fires `create-unit-clicked(product_id, serial_number)`. Clears inline input, hides entry mode. (D-17)
- Esc key: hides inline entry mode without creating.

**"Create New Product" row (bottom of entire product list, always visible):**
- Same appearance as existing `lookup-modal.slint` "Create New" row (preserved from D-19).
- Leading "+" glyph: `Typography.size-lg`, `Colors.accent`.
- Label "Create New Product": `Typography.size-md`, `Colors.accent`.
- Clicking: switches to `show-create-form = true` page (existing create form page, unchanged).

### Empty State (search returns no products)
- Single Text row: "No products matched" — `Typography.size-sm`, `Colors.text-dim`.
- "Create New Product" row still visible below empty state.

### Results Area Scrolling
- Flickable wraps the entire product tree.
- `viewport-height` computed dynamically based on total row heights (products + expanded units + section headers + inline SN entry if active + "Create New Product" row).

### Create Product Form (unchanged from existing LookupModal)
- Triggered by "Create New Product" row click.
- Display name input + Shopify URL input + Shopify fetch status + Create/Back buttons.
- Back button: fires `show-create-form = false`, re-focuses search input via `search-focus-trigger` increment.

---

## Copywriting Contract

| Element | Copy | Notes |
|---------|------|-------|
| Modal title | "Shipment Product Lookup" | |
| Modal subtitle | "Adding item to {target-card-name}" | |
| Search placeholder | "Search products or serial numbers..." | |
| Primary CTA — add parent product | "+" (button label, right-aligned on product row) | |
| Primary CTA — add available unit | "+" (button label, right-aligned on available unit row) | |
| Primary CTA — add assigned unit (triggers prompt) | "+" (button label; reassignment prompt appears instead of immediate action) | |
| Reassignment prompt | "{serial_id} is assigned to {assigned_name}. Move it to this card?" | |
| Reassignment confirm | "Move it" | |
| Reassignment cancel | "Keep Here" | |
| Create new unit button | "Create New Unit" | |
| Inline SN input placeholder | "Serial number..." | |
| Inline SN confirm button | "OK" | Locked by D-17: user explicitly chose "OK" as the label. Do not flag as generic. |
| Create new product row | "+ Create New Product" | |
| Empty state | "No products matched" | |
| Match count pill | "N matches" (e.g. "2 matches") | |
| Available section header | "Available" | |
| Assigned section header | "Assigned" | |
| Count summary | "(N avail, M assigned)" | |
| Error — name required in create form | "Enter a display name to continue" | Actionable: tells user what to do to resolve the error. |
| Create form — name field label | "Display name" | |
| Create form — URL field label | "Shopify product URL (optional)" | |
| Create form — fetch in progress | "Fetching image..." | |
| Create form confirm button | "Create" | |
| Create form back button | "Back" | |
| Close button | "X" (Unicode, existing pattern) | |

Source: 19.1-CONTEXT.md decisions D-04 through D-19; existing `lookup-modal.slint` copy preserved.

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| N/A — Slint native, no package registry | none | not applicable |

This project uses Slint (.slint files + Rust crate), not a JS/npm registry. shadcn and third-party block vetting do not apply.

---

## Component Inventory

Components modified or removed in this phase:

| Component | File | Action |
|-----------|------|--------|
| `LookupModal` | `crates/app/ui/lookup-modal.slint` | Modified — results area replaced with hierarchical product-unit tree |
| `LookupResultEntry` struct | `crates/app/ui/lookup-modal.slint` or `dashboard.slint` | Replaced — superseded by `PickerProductData`/`PickerUnitData` (migrated from product-picker.slint) |
| `ProductAddToCardPicker` | `crates/app/ui/product-picker.slint` | Removed — all functionality merged into LookupModal |
| `PickerProductData` struct | migrated into `lookup-modal.slint` | Retained as-is (D-DISCRETION: keep existing structs) |
| `PickerUnitData` struct | migrated into `lookup-modal.slint` | Retained as-is, add `matches-search: bool` field for D-13 filter |

New property added to `PickerProductData`: `match-count: int` — number of units whose serial-id matches current search query (used for match count pill, D-11).

New property added to LookupModal root:
- `in-out property <string> creating-unit-product-id: ""` — which product has the inline SN entry visible (empty = none).
- `in-out property <string> new-unit-serial-id: ""` — text in the inline SN TextInput.

New callbacks on LookupModal:
- `callback unit-selected(string, string, string)` — (serial_id, product_id, card_id): assign available unit.
- `callback unit-force-reassign(string, string, string)` — (serial_id, product_id, card_id): force reassign from another card.
- `callback create-unit-confirmed(string, string)` — (product_id, serial_number): create new unit inline.
- `callback toggle-product-expanded(string)` — (product_id): expand/collapse tree row.

Existing callbacks retained:
- `item-selected(string, string, string)` — fires for parent product add (product_id, display_name, image_hint).
- `create-confirmed(string, string)` — fires for new parent product creation (display_name, shopify_url).
- `search-changed(string)` — fires on every keystroke.
- `close-requested()` — fires on backdrop/Esc/X.

---

## Checker Sign-Off

- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS

**Approval:** pending
