# Phase 20.1.1.1.1: Round 2 Gap Closure - Research

**Researched:** 2026-04-15
**Domain:** Slint UI, Rust/SQLite, Shopify sync, note dedup
**Confidence:** HIGH (all findings verified from live codebase)

---

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

### Locked Decisions

**D-01:** Purpose pill edit mode must be a **dropdown menu** (not a TextInput). Choices are the limited set of roles from the GH Project.

**D-02:** Pill position: **right of the avatar, just below the recipient name** in the sidebar header row. Border: **2px hollow outline** (no background fill). Color uses existing `purpose-color`. Click-to-edit opens dropdown.

**D-03:** Tile-click in Recipients tab must (1) show filtered card view for that recipient AND (2) show the recipient-detail sidebar alongside those cards.

**D-04:** Tab-restore (leaving and returning to Recipients tab) must preserve BOTH the filtered card view AND the sidebar.

**D-05:** Esc must NOT close the sidebar while focused. Remove FocusScope Esc → close-clicked wiring entirely.

**D-06:** Breadcrumb-back to the Recipients tab home (tile grid) must dismiss the sidebar.

**D-07:** Note dedup must key on `(card_id, author, content)` — NOT on date. The date of the winning row should be updated to the GH-authoritative timestamp.

**D-08:** Product add `+` button flicker — fix the hover-zone issue. Record fix in `code_tips/SLINT_TIPS.md`.

**D-09:** Product add `+` button vertical alignment — center the button in the 80px row height.

**D-10:** Populate `populate_recipient_sidebar` from the latest SQLite data (sync should have refreshed it).

**D-11:** Product icons and SN text labels should scale proportionally with the enlarged Row 4 (80px height).

**D-12:** All return-status cards show "Return Created" — should show "Return Underway" when tracking exists.

**D-13:** Most cards' product text shows "No items added" even when item_squares is populated. `item_display_label` is wrong/empty.

**D-14:** Missing card + "Connecting..." — Shopify sync may be broken. Investigate `run_sync_cycle` and Shopify connectivity.

### Claude's Discretion
- Dropdown implementation: PopupWindow with a list of options, or Slint ComboBox if available and fits styling.
- Exact dedup strategy for notes — prefer simplest approach that eliminates timestamp-based false-negative.
- Whether D-12/D-13/D-14 are Shopify sync bugs (same root cause) or independent.
- Whether D-14 warrants a separate phase or can be diagnosed + fixed inline.

### Deferred Ideas (OUT OF SCOPE)
- Note edit/delete.
- Features beyond restoring correct behavior.
</user_constraints>

---

## Summary

This phase closes 13 defects from UAT round 2. The defects divide into four groups:

1. **Pill/sidebar UI (D-01/D-02/D-05/D-06):** The purpose pill needs a redesign from TextInput to dropdown and repositioning. Esc behavior and breadcrumb-back must be rewired in `recipient-detail.slint` and `main.rs`.

2. **Sidebar navigation (D-03/D-04):** Tile-click must navigate to the filtered card view (not stay on the tile grid), and tab-restore must preserve both the card view and the sidebar. Both are `main.rs` callback wiring issues.

3. **Data correctness (D-07/D-10/D-12/D-13/D-14):** Note dedup is still keying on date (wrong); stale Rx reads are from a stale SQLite row; item_display_label is empty for cards that use `build_item_squares_from_vm` when product_names is empty; return statuses show "Return Created" instead of "Return Underway" because `sync_return_states` may not be running or `card_row.shipment_status` is not being read from the most recent SQLite state; a card is missing entirely (Shopify sync may be silently failing).

4. **Visual polish (D-08/D-09/D-11):** `+` button flicker is a Slint hover-zone feedback loop; `+` button `y` offset and product icon/SN label sizing need adjustment.

**Primary recommendation:** Tackle the defects in this order — (1) diagnose D-14 first (it may explain D-12/D-13 if Shopify sync is not running); (2) fix note dedup D-07; (3) fix sidebar navigation D-03/D-04/D-05/D-06; (4) fix pill D-01/D-02; (5) fix visual issues D-08/D-09/D-11; (6) fix stale Rx D-10.

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Purpose pill dropdown (D-01/D-02) | Frontend UI (Slint) | Rust callback (save-purpose write-back) | All rendering in recipient-detail.slint; write-back already wired |
| Sidebar Esc / breadcrumb-back (D-05/D-06) | Rust (main.rs callbacks) | Slint (FocusScope removal) | Esc routes through on_esc_pressed; breadcrumb routes through on_breadcrumb_back |
| Tile-click navigation (D-03/D-04) | Rust (main.rs) | — | on_tile_clicked and on_tab_clicked own mode/view state transitions |
| Note dedup (D-07) | SQLite layer (sqlite.rs) | — | `upsert_notes_for_card` owns the dedup key |
| Product add button flicker (D-08) | Slint (card.slint) | — | Hover-zone feedback loop in card.slint |
| Product add button alignment (D-09) | Slint (card.slint) | — | `y` offset and height in card.slint Row 4 |
| Stale Rx in sidebar (D-10) | Rust (main.rs) | — | `populate_recipient_sidebar` reads from SQLite; question is whether SQLite was updated by sync |
| Product icon/SN scaling (D-11) | Slint (card.slint) | — | Font-size and image dimensions in item square rendering |
| Return status "Return Created" vs "Return Underway" (D-12) | Rust (live_client.rs) | SQLite (sqlite.rs) | `sync_return_states` reads card status from SQLite; transition requires status to be "Return Created" first |
| item_display_label empty (D-13) | Rust (main.rs) | — | `build_item_squares_from_vm` — item_display_label set empty when count=0 even when item_squares may have been populated via another path |
| Missing card / Shopify connectivity (D-14) | Rust (live_client.rs) | Shopify API | `run_sync_cycle` returns Err → sync_ok=false → connection_status=3 |

---

## Standard Stack

No new dependencies required. All fixes use the existing stack.

| Component | File | Current State |
|-----------|------|---------------|
| Slint UI | `crates/app/ui/*.slint` | Slint 1.x, in-tree |
| SQLite | `crates/service/src/db/sqlite.rs` | rusqlite 0.32 bundled, refinery 0.8 |
| Rust app logic | `crates/app/src/main.rs`, `live_client.rs` | No new crates needed |
| Shopify integration | `crates/integrations/src/shopify/` | Existing HTTP + GraphQL clients |

---

## Architecture Patterns

### Data Flow for Sidebar Population

```
tile-clicked(name) [Slint]
    -> on_tile_clicked [main.rs]
        -> rt.select_tile(name)  // records selected_tile in mode state
        -> populate_recipient_sidebar(window, store, name, slug)
        -> window.set_recipient_detail_visible(true)
        -> [D-03 BUG: currently stops here — does NOT navigate to filtered card view]
        -> NEEDED: apply_filters + set show_filtered_breadcrumb
```

```
on_tab_clicked(2) [main.rs restore path]
    -> restore_mode_state -> apply_filters
    -> selected_tile = rt.current_mode_state().selected_tile.clone()
    -> if Some(rid): populate_recipient_sidebar + set_recipient_detail_visible(true)
    -> [D-04 BUG: tab restore does call this, but filtered card view may not persist]
```

```
on_breadcrumb_back() [main.rs]
    -> ms.view_state = ModeViewState::OptionGrid
    -> ms.selected_tile = None
    -> apply_filters
    -> [D-06 BUG: does NOT set_recipient_detail_visible(false)]
```

### Note Dedup Flow

```
fetch_notes_for_card() [live_client.rs]
    -> GhIssuesClient::list_issue_comments(issue_number)
    -> parses ww-note comments: author=comment.author.login, date=createdAt
    -> upsert_notes_for_card(card_id, notes) [sqlite.rs]
        -> for each note: SELECT COUNT(*) WHERE card_id=? AND note_date=? AND content=?
        -> [D-07 BUG: note_date differs (local ISO vs GH createdAt) -> treated as new note]
```

### Return Status Flow

```
sync_return_states() [live_client.rs]
    -> reads card_row from SQLite
    -> if card_row.shipment_status == "Delivered" && has_active_return -> "Return Created"
    -> if card_row.shipment_status == "Return Created" && has_tracking -> "Return Underway"
    -> [D-12 ROOT CAUSE: cards may enter sync cycle already at "Return Created" in SQLite
        but sync_return_states is only called when sync_ok && graphql_client is Some.
        If sync_return_states never ran (e.g., Shopify connectivity issue), cards stay
        at whatever status was written earlier. Additionally: "Return Created" vs
        "Return Underway" may depend on tracking records existing.]
```

### item_display_label Flow

```
build_item_squares_from_vm() [main.rs]
    -> ref_count = vm.product_refs.len()
    -> name_count = vm.product_names.len()
    -> count = ref_count.max(name_count)
    -> if count == 0: item_display_label = ""  // -> shows "No items added" in Slint
    -> [D-13 ROOT CAUSE: product_refs may be empty Vec (JSON parse returns empty when
        product_refs_json is "[]" or blank) AND product_names may also be empty
        even though item_squares IS populated via another code path. The SQLite
        read path in read_all_cards() must return product_refs_json correctly AND
        the in-memory CardRow.product_names must be populated from the card_product_names table.]
```

---

## Defect-by-Defect Research Findings

### D-01: Purpose pill must become a dropdown

**Current state:** `recipient-detail.slint` lines 148–183: `editing-purpose` branch renders a `TextInput` inside a Rectangle at width 180px.

**Required change:** Replace the TextInput branch with a list of clickable options in a `PopupWindow` or inline `Rectangle` showing the role choices. No `ComboBox` in Slint standard library — use `PopupWindow` with a `for item in roles` loop (same pattern as `card-menu := PopupWindow` in card.slint lines 559+).

**Role list:** Hardcode the known GH Project Purpose values. The existing `write_rx_to_gh` / `write_purpose_to_gh` functions use `PURPOSE_VALUES` constant in `main.rs` (lines 160–168): `["Preparing", "Packing", "In Transit", "Delivered", "Return Created", "Return Underway", "Returned"]`. However, those are STATUS values. The actual Purpose/role values are fetched from GH Project field options — they are distinct. The dropdown must use the same values as the original summary-popup, which were sourced from `GhCliProjectClient::list_project_field_options()` or hardcoded from the known set. Claude's discretion: hardcode or fetch at startup and pass to sidebar as a property.

**Pattern to follow:** `card-menu := PopupWindow` in `card.slint` — opens at a fixed offset from the pill, shows items in a `VerticalLayout`, fires `save-purpose(selected)` on click, sets `editing-purpose = false`. [VERIFIED: codebase]

### D-02: Pill position and styling

**Current state:** The purpose pill block (lines 112–184 in `recipient-detail.slint`) sits in a separate `HorizontalLayout` block **below** the header row (avatar + name). The header row does NOT include the pill.

**Required change:** Move the pill into the header row. The header `HorizontalLayout` (lines 75–109) currently has: `Rectangle` (avatar, 64x64) then `VerticalLayout` (name). The pill should appear INSIDE the `VerticalLayout` for the name column, as a second row under the name text. This achieves "right of the avatar, just below the recipient name."

**Border change:** Replace `background: root.purpose-color` with `background: transparent`, add `border-width: 2px`, `border-color: root.purpose-color`. Text color stays `#ffffff` — but on transparent background with no fill this will be invisible on dark backgrounds. Recommendation: keep text as `purpose-color` (colored text, no fill) to match the hollow outline intent. [VERIFIED: codebase]

### D-03/D-04: Tile-click navigation + tab-restore

**D-03 root cause found:** `on_tile_clicked` handler (main.rs line 3371–3385) for `ByRecipient` mode: calls `populate_recipient_sidebar` and `set_recipient_detail_visible(true)` **but does NOT call `apply_filters` or set `ms.view_state = ModeViewState::FilteredCardView`**. The pitfall comment `D-19 / Pitfall 8: stay on option grid` was intentional in 20.1.1 (D-19 said stay on tile grid), but D-03 of 20.1.1.1.1 reverses this — tile-click must NOW navigate to filtered card view AND show sidebar.

**D-03 fix:** After `populate_recipient_sidebar`:
1. Set `ms.view_state = ModeViewState::FilteredCardView` in runtime state.
2. Call `apply_filters` to render the filtered card view.
3. Keep `set_recipient_detail_visible(true)`.
4. The card-flickable width must account for the sidebar being visible — `show-recipient-grid` is false in filtered card view, so `RecipientDetailPanel` mount condition (`if root.show-recipient-grid && root.recipient-detail-visible`) will be false. The sidebar mount guard must be extended to also show on the filtered card view path. [VERIFIED: dashboard.slint line 627]

**CRITICAL — sidebar mount guard:** `dashboard.slint` line 627: `if root.show-recipient-grid && root.recipient-detail-visible : RecipientDetailPanel`. This means the sidebar is ONLY rendered when on the tile grid (`show-recipient-grid == true`). In filtered card view, `show-option-grid == false` and `show-recipient-grid == false`, so the sidebar disappears. The guard must change to `if root.recipient-detail-visible : RecipientDetailPanel` (unconditional on `show-recipient-grid`) for D-03 to work. The card-flickable width must also shrink when the sidebar is visible in filtered view. [VERIFIED: dashboard.slint lines 627, 706–712]

**D-04 root cause:** `on_tab_clicked` restore path (main.rs line 3347–3356): calls `populate_recipient_sidebar` and `set_recipient_detail_visible(true)` when `selected_tile` exists — but `restore_mode_state` → `apply_filters` determines show/hide of option-grid based on `ms.view_state`. If `ms.view_state` is `FilteredCardView` after the D-03 fix, the tab restore should correctly restore the filtered view. D-04 may be resolved by D-03 fix if `ms.view_state` is set correctly in the runtime state.

### D-05: Esc must NOT close the sidebar

**Current state:** `recipient-detail.slint` lines 54–67: FocusScope `key-pressed` handler — when Esc is pressed and no inline edit is open, calls `root.close-clicked()`.

**Required change:** Remove the `root.close-clicked()` call and the `return accept` from the else branch. Esc with no active edit should return `reject` so it propagates to the global Esc handler in `dashboard.slint`. [VERIFIED: recipient-detail.slint lines 54–67]

**Note:** The prior phase (20.1.1.1) had a rule `D-08: close-clicked() callback retained (not deleted) — FocusScope Esc handler still calls it`. D-05 of THIS phase REVERSES that — the close-clicked Esc wiring must be removed. The `close-clicked()` callback itself stays (it is called by `on_sidebar_close` in Rust). [VERIFIED: STATE.md Phase 20.1.1.1 notes]

### D-06: Breadcrumb-back must dismiss sidebar

**Current state:** `on_breadcrumb_back` (main.rs lines 3548–3573): sets `ms.view_state = OptionGrid`, clears `ms.selected_tile = None`, calls `apply_filters`, resets product detail. Does NOT call `set_recipient_detail_visible(false)`.

**Required change:** Add `w.set_recipient_detail_visible(false)` after `apply_filters` in `on_breadcrumb_back`. Also reset editing state (purpose/rx/discord drafts) — same pattern as `on_sidebar_close`. [VERIFIED: main.rs lines 3548–3573, 5992–6002]

### D-07: Note dedup keyed on wrong field

**Current state:** `upsert_notes_for_card` (sqlite.rs lines 615–630): dedup key is `(card_id, note_date, content)`. The comment at line 390 also uses `(date, content)` in the card upsert path.

**Root cause confirmed:** Local note has `date = <app clock ISO>`, GH note has `date = createdAt from GH`. Even if content is identical, timestamps differ → COUNT(*) returns 0 → second INSERT happens → duplicate.

**Fix:** Change the dedup query to `SELECT COUNT(*) FROM notes WHERE card_id = ?1 AND author = ?2 AND content = ?3`. When `author` is NULL (optimistic local note), use `author IS NULL` instead of `author = ?2`. Two sub-cases:
- Both have same author + same content → treat as same note, update the date to the GH-authoritative value.
- One has `author IS NULL` (optimistic) and the GH version has `author = 'SaberMage'` + same content → resolve by preferring the GH version (update author + date, mark synced_at).

The simplest implementation: on conflict `(card_id, author, content)`, do UPDATE SET `note_date = excluded.note_date` (GH timestamp wins). For NULL author matching: use `COALESCE(author, '') = COALESCE(excluded.author, '')` in the WHERE clause, OR add a UNIQUE constraint on `(card_id, content)` (only feasible if content is always unique per card, which it may not be for short notes like "OK").

**Recommended approach:** Change upsert key to `(card_id, author, content)` with NULL author handled via `COALESCE`. This means also updating the card-level upsert path in `upsert_card` (sqlite.rs lines 390–415) to match.

**SQLITE_TIPS.md compliance:** The fix must not DELETE+re-INSERT. Use `ON CONFLICT DO UPDATE SET note_date = excluded.note_date` instead. [VERIFIED: sqlite.rs lines 612–630, SQLITE_TIPS.md]

**Schema note:** There is no UNIQUE constraint on `(card_id, note_date, content)` currently — the code does a SELECT COUNT(*) then conditional INSERT. The new approach should do the same pattern with the new key, OR add a UNIQUE constraint to the notes table via a new migration. A migration adding `UNIQUE(card_id, author, content)` would make the dedup declarative. [VERIFIED: sqlite.rs]

### D-08: Product add button flicker

**Current state:** `card.slint` lines 499–533: The `+` button is rendered inside `if card-hover-zone.has-hover : Rectangle`. The outer Rectangle is conditional on `card-hover-zone.has-hover`. `card-hover-zone` is a passive `TouchArea` covering the full card (lines 122–128, first child so siblings win clicks).

**Root cause of flicker:** When the cursor moves from the card body onto the `+` button's own `add-btn-touch := TouchArea`, the button's TouchArea becomes the active hit target. At this moment, `card-hover-zone` may briefly report `has-hover = false` because the inner button's TouchArea "captures" the cursor. This causes the conditional `if card-hover-zone.has-hover` to toggle to false, hiding the button, which immediately returns cursor hit to `card-hover-zone`, making `has-hover = true` again, showing the button — a rapid feedback loop.

**Fix options (per CONTEXT.md D-08):**
1. Ensure `card-hover-zone` ALWAYS covers the button. Currently the outer `if card-hover-zone.has-hover : Rectangle` wrapping the button itself IS inside `card-hover-zone`'s area because `card-hover-zone` covers `parent.width × parent.height`. The issue is that `add-btn-touch` inside the button intercepts cursor, removing it from `card-hover-zone`. **Simplest fix:** Add `card-hover-zone.has-hover || add-btn-touch.has-hover` as the condition, but `add-btn-touch` only exists when the button is shown — chicken-and-egg. **Better fix:** Promote the hover state using a separate persistent property.
2. Use a separate outer-level TouchArea for the button zone that always exists (height 80px, width 44px, at x = item_squares.length * 52px) and does NOT react to clicks itself — just provides hover state. Then condition the inner circle rendering on `btn-zone-hover.has-hover`. This is the cleanest Slint solution. [ASSUMED — pattern inferred from codebase; not verified in official Slint docs]

**Implementation:** Add a passive `btn-zone := TouchArea` at the bottom of Row 4 (always present when card is rendered) at the `+` button position. Condition the button rendering on `card-hover-zone.has-hover` AND use `btn-zone.has-hover` for the inner button hover state. This eliminates the flicker because `btn-zone` is always present and doesn't disappear when hovered. 

**Code tip:** Must be documented in `code_tips/SLINT_TIPS.md` per D-08 locked decision. [VERIFIED: CONTEXT.md]

### D-09: Product add button vertical alignment

**Current state:** `card.slint` lines 507–509:
```
y: 0px;    // D-12: top-align outer with item squares
height: 80px;  // match item-square outer height
```
Inner circle:
```
y: 18px;  // (80 - 44) / 2 = 18; centers the button in the 80px row
```

The comment says it should be centered at `y=18px`, which is mathematically correct `(80-44)/2 = 18`. If the button appears misaligned with product squares, the issue may be that the product square inner `Rectangle` (lines 405–413) has `y: 0px` with `height: 44px`, but the outer per-square rectangle is 80px tall with no centering. The 44px image square sits at `y=0` (top of the 80px row), NOT centered.

**Fix:** Change the item square inner `Rectangle y: 0px` to `y: (80-44)/2 = 18px` to center it vertically within the 80px row, then the `+` button at `y: 18px` will be visually aligned. OR keep image squares top-aligned and move `+` button to `y: 0px`. The UAT screenshot context indicates the `+` button sits too LOW — so the squares are at y=0 but the button is at y=18 (offset downward). Fix: set `+` button inner Rectangle `y: 0px` to match item squares. [VERIFIED: card.slint lines 397–534]

### D-10: Stale Rx values in sidebar

**Current state:** `populate_recipient_sidebar` (main.rs lines 2484–2549) reads from `store.read_recipient(recipient_id)` — this reads the `recipients` table in SQLite. The Rx values come from `row.vision_rx_od` / `row.vision_rx_os`.

**Root cause of staleness:** SQLite `recipients` table is updated by `run_sync_cycle` → `upsert_recipient`. If the last sync cycle ran before the GH Project was updated with the new Rx value, the SQLite row will have the old value. The `populate_recipient_sidebar` call happens on tile-click or tab-restore — at that moment, SQLite may not have been refreshed yet.

**Fix options:**
1. Ensure `populate_recipient_sidebar` is called after sync updates recipients (not before). Currently it's called on user interaction (tile-click), not post-sync. If the user opens the sidebar before the next sync cycle completes, stale data appears. **Solution:** After each `run_sync_cycle` completes and posts UI update, if `recipient_detail_visible == true` and `detail_recipient_id` is set, call `populate_recipient_sidebar` again to refresh the data properties. This is an `invoke_from_event_loop` call in the sync callback.
2. Alternatively, the sync callback already runs `read_all_cards()` and rebuilds the card view — it could also call `populate_recipient_sidebar` for the currently-open recipient.

**Implementation:** In `on_sync_complete` callback (inside `slint::invoke_from_event_loop` block in `live_client.rs`), after the cards are updated: check `w.get_recipient_detail_visible()` and if true, call `populate_recipient_sidebar`. This requires passing `store` and `config` into that closure (they're already `Arc`-wrapped and available). [VERIFIED: main.rs lines 2484–2549, live_client.rs line 175]

### D-11: Product icons and SN text not scaled

**Current state:** `card.slint` lines 404–413: inner `Rectangle` for item image/initials is `width: 44px, height: 44px`. Labels at lines 444–464 use `font-size: 9px`.

**Required change:** The outer row height grew from 44px → 80px (Phase 20.1.1 Plan 06). The inner image square (44×44) was not scaled. Scale the inner square to ~52×52 or 56×56 (leaving headroom for the text labels below). SN label font-size can grow from 9px to 10–11px. [VERIFIED: card.slint lines 391–534]

**Constraint:** Stride between squares is currently `52px`. If inner squares grow, stride must also grow or squares will overlap. Current stride: `x: sq-index * 52px` (line 398). New stride must be `inner_width + gap` where gap is ~6px. So if inner grows to 52px, stride = 58px. [VERIFIED: card.slint line 398]

### D-12: Return status "Return Created" when should be "Return Underway"

**Logic trace:** `sync_return_states` (live_client.rs line 827): only transitions `"Return Created" → "Return Underway"` if there is an active return AND `reverse_fulfillment_tracking` returns non-empty. If the Shopify GraphQL token was expired or the tracking endpoint returned empty, cards stay at "Return Created".

**Root cause candidates:**
1. `sync_return_states` was never called for these cards (Shopify token issue, D-14 connection).
2. `reverse_fulfillment_tracking` returns empty because Shopify hasn't created tracking for the return yet.
3. Cards were manually set to "Return Created" via `Start Return` action and the next sync didn't transition them because no return was found in Shopify.
4. The `card_row.shipment_status` read from SQLite before `sync_return_states` is called shows "Return Created" but `has_tracking` check fails.

**Fix path:** First diagnose by checking stderr output for `[sync_return_states]` log lines. If `sync_return_states` is running but transition not happening, the tracking query is returning empty. If `sync_return_states` is not running at all, D-14 is the root cause. [VERIFIED: live_client.rs lines 827–894]

### D-13: Cards show "No items added" when item_squares IS populated

**Root cause confirmed:** `build_item_squares_from_vm` (main.rs lines 546–654) uses `count = ref_count.max(name_count)`. `ref_count = vm.product_refs.len()`. `name_count = vm.product_names.len()`. If BOTH are 0, `item_display_label = ""` → Slint shows "No items added" (card.slint line 385: `text: root.item-display-label != "" ? root.item-display-label : "No items added"`).

**The item_squares ARE populated via card-level product_refs_json in SQLite.** The disconnect: `CardRow.product_refs_json` is read and parsed in `live_client.rs` function that creates `RecipientCardSnapshot`, which has `product_refs: Vec<ProductRef>`. This flows into `DashboardCardViewModel.product_refs`. If that flow is broken (e.g., `product_refs_json` is `"[]"` or `""` in SQLite), then `vm.product_refs` is empty.

**Key question:** Why are `item_squares` shown (non-empty) but `item_display_label` is empty? These are both set in `build_item_squares_from_vm` — if `count == 0`, BOTH are empty. If item_squares are visible, `count > 0` and label should not be empty. **Possible explanation:** The item_squares were set via `build_item_squares_from_product_names` (the old name-based path), which is called from `view_model_to_card_data` when `product_refs` is empty but `product_names` is not empty. In that path (main.rs lines 486–536), `item_display_label` IS set correctly. So the bug may be in a code path that populates `item_squares` without setting `item_display_label`, or vice versa.

**Investigation needed:** Check which build path is taken for affected cards — `build_item_squares_from_vm` or `build_item_squares_from_product_names`. This requires BUGSWEEPER inspection of the SQLite data. [ASSUMED — root cause inference; requires runtime verification]

### D-14: Missing card + "Connecting..." status

**Connection status logic:** `live_client.rs` line 195: `connection_status = match (sync_ok, has_shopify_token) { (true, true) => 2, ... (false, _) => 3 }`. "Connecting..." corresponds to `connection_status == 1` which is set at startup before the first sync completes. If the UI shows "Connecting..." after startup, it means the `on_sync_complete` callback has never been called with `connection_status == 2`.

**Root cause candidates:**
1. `run_sync_cycle` is returning `Err` (sync_ok = false → connection_status = 3, not 1). But dashboard.slint line 495 shows `connection_status == 1 ? "Connecting..."`. If the initial status is never updated from 1, the sync thread may have panicked or stalled.
2. Shopify API token expired — `resolve_shopify_token` returns None → `shopify_client = None` → `has_shopify_token = false` → status 4 ("Connected (no Shopify)"), not "Connecting...". So "Connecting..." means the sync hasn't started or the background thread isn't running.
3. The background thread may have been started but encountered an error before the first sync completion.

**Investigation approach:** Check app stderr for panic messages from the sync thread. Build with `--features bugsweeper` and query `/api/config` to verify token configuration. [VERIFIED: live_client.rs lines 150–200, dashboard.slint lines 493–500]

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead |
|---------|-------------|-------------|
| Dropdown in Slint | Custom scroll + TextInput combo | `PopupWindow` with `for` loop (same as card-menu) |
| Note dedup | Custom diff algorithm | SQLite `ON CONFLICT DO UPDATE` + adjusted WHERE key |
| Sidebar data freshness | Polling timer | Call `populate_recipient_sidebar` in the existing `invoke_from_event_loop` sync completion callback |
| Migration for dedup key change | No migration needed for key change | Change the application-level upsert logic only (no UNIQUE constraint migration required for safety) |

---

## Common Pitfalls

### Pitfall 1: Sidebar mount guard condition (D-03)

**What goes wrong:** The `RecipientDetailPanel` in `dashboard.slint` is mounted only when `show-recipient-grid && recipient-detail-visible`. In the filtered card view, `show-recipient-grid == false`, so the sidebar never renders.

**How to avoid:** Change the mount condition to `recipient-detail-visible` only (drop the `show-recipient-grid` guard). Also reduce `card-flickable.width` by 330px when `recipient-detail-visible` is true and `show-option-grid == false`.

**Warning sign:** Sidebar visually disappears the moment `apply_filters` is called after tile-click (because `apply_filters` sets `show-option-grid = false`, which collapses the sidebar mount condition).

### Pitfall 2: Note dedup NULL author handling

**What goes wrong:** Optimistic local notes have `author = NULL`. GH-fetched notes have `author = "SaberMage"`. A WHERE clause `author = ?2` will not match NULL rows even if content is identical.

**How to avoid:** Use `(author = ?2 OR (author IS NULL AND ?2 IS NULL))` or `COALESCE(author, '') = COALESCE(?2, '')`. A simpler approach: when the GH version arrives, update the existing NULL-author row instead of inserting a new one.

### Pitfall 3: Esc FocusScope change vs. prior phase decision (D-05)

**What goes wrong:** The prior phase (20.1.1.1) explicitly KEPT the `close-clicked()` call in the FocusScope Esc handler. This phase removes it. The STATE.md note `D-08: close-clicked() callback retained` refers to 20.1.1.1 D-08, not this phase.

**How to avoid:** The `close-clicked()` callback remains in Slint — it is still called by `on_sidebar_close` in Rust (e.g., from `on_breadcrumb_back` fix). Only the FocusScope key-pressed → close-clicked wiring is removed.

### Pitfall 4: Breadcrumb-back editing state reset (D-06)

**What goes wrong:** If an inline edit (Rx, Discord username) is open in the sidebar when the user clicks breadcrumb-back, the editing state flags will remain true in the window-level `in-out` properties. Next time the sidebar opens, it will show edit mode for that field.

**How to avoid:** `on_breadcrumb_back` must also reset all `detail-editing-*` flags, same as `on_sidebar_close`.

### Pitfall 5: item_display_label / item_squares mismatch (D-13)

**What goes wrong:** Two different build paths set `item_squares` and `item_display_label`. If they are not called in sync, one can be set while the other is empty.

**How to avoid:** Always call the same build function — `build_item_squares_from_vm` when product_refs/product_names is available, `build_item_squares_from_str_slice` only as fallback. Verify that `vm.product_names` is populated from the `card_product_names` table, not just from `product_refs_json`.

### Pitfall 6: +Button flicker passive TouchArea ordering

**What goes wrong:** In Slint, the last declared child in a layout has the highest z-order and wins mouse events. If the passive `btn-zone` TouchArea is declared after the inner button's `add-btn-touch`, it will absorb clicks meant for the button.

**How to avoid:** Declare `btn-zone` as the FIRST child of the outer wrapper (passive, no clicked handler), then declare the visual button content after it. The button's `add-btn-touch` will still intercept clicks because it's declared later and has a clicked handler. [ASSUMED — based on Slint child ordering rules; verify with a test build]

---

## Code Examples

### D-01: PopupWindow dropdown pattern (from existing card.slint)

[VERIFIED: card.slint lines 559–835]

```slint
purpose-dropdown := PopupWindow {
    x: 0px;
    y: parent.height + 2px;
    width: 160px;

    Rectangle {
        background: Colors.surface-popup;
        border-radius: 6px;
        border-width: 1px;
        border-color: Colors.border-muted;

        VerticalLayout {
            padding: 4px;
            for role in root.purpose-options : Rectangle {
                height: 28px;
                border-radius: 4px;
                background: role-touch.has-hover ? Colors.border-default : transparent;
                Text {
                    text: role;
                    x: 10px;
                    font-size: Typography.size-sm;
                    color: Colors.text-primary;
                    vertical-alignment: center;
                }
                role-touch := TouchArea {
                    mouse-cursor: pointer;
                    clicked => {
                        root.purpose-draft = role;
                        root.editing-purpose = false;
                        root.save-purpose(role);
                        purpose-dropdown.close();
                    }
                }
            }
        }
    }
}
```

### D-07: Corrected upsert_notes_for_card dedup key

[VERIFIED: sqlite.rs lines 615–630 — existing wrong version; fix shown below]

```rust
pub fn upsert_notes_for_card(&self, card_id: &str, notes: &[NoteEntry]) -> Result<(), rusqlite::Error> {
    let conn = self.conn.lock().unwrap();
    for note in notes {
        // D-07: dedup on (card_id, author, content) — NOT date.
        // Local saves use app-clock timestamps; GH-fetched use createdAt.
        // Same author+content = same note. GH timestamp wins on merge.
        // Known limitation: identical notes by same author are deduplicated to one.
        let existing: i64 = conn.query_row(
            "SELECT COUNT(*) FROM notes WHERE card_id = ?1 AND COALESCE(author,'') = COALESCE(?2,'') AND content = ?3",
            params![card_id, note.author, note.content],
            |r| r.get(0),
        )?;
        if existing == 0 {
            conn.execute(
                "INSERT INTO notes (card_id, note_date, content, author) VALUES (?1, ?2, ?3, ?4)",
                params![card_id, note.date, note.content, note.author],
            )?;
        } else {
            // Update date to GH-authoritative timestamp and author if previously NULL
            conn.execute(
                "UPDATE notes SET note_date = ?1, author = COALESCE(author, ?2) WHERE card_id = ?3 AND COALESCE(author,'') = COALESCE(?2,'') AND content = ?4",
                params![note.date, note.author, card_id, note.content],
            )?;
        }
    }
    Ok(())
}
```

### D-06: Breadcrumb-back sidebar dismiss

[VERIFIED: main.rs lines 3548–3573 — existing version; fix shown]

```rust
window.on_breadcrumb_back(move || {
    if let Some(w) = weak.upgrade() {
        // ... existing unit deselect logic ...
        // No unit selected: go back to OptionGrid
        {
            let mut runtime = rt.borrow_mut();
            let ms = runtime.discovery_state.current_mode_state_mut();
            ms.view_state = ModeViewState::OptionGrid;
            ms.selected_tile = None;
        }
        // D-06: dismiss sidebar on breadcrumb-back
        w.set_recipient_detail_visible(false);
        w.set_detail_editing_purpose(false);
        w.set_detail_editing_rx_od(false);
        w.set_detail_editing_rx_os(false);
        w.set_detail_editing_discord_username(false);
        // ...existing product grid refresh...
    }
});
```

---

## State of the Art

| Old Behavior | Current Required Behavior | Introduced By |
|--------------|--------------------------|---------------|
| Tile-click stays on option grid (D-19 of 20.1.1) | Tile-click navigates to filtered card view + shows sidebar (D-03 of 20.1.1.1.1) | UAT round 2 |
| Esc in sidebar calls close-clicked (20.1.1.1 D-08) | Esc in sidebar does nothing (D-05 of 20.1.1.1.1) | UAT round 2 |
| Note dedup on (card_id, date, content) | Note dedup on (card_id, author, content) (D-07 of 20.1.1.1.1) | UAT round 2 |
| Purpose pill is TextInput | Purpose pill is PopupWindow dropdown (D-01 of 20.1.1.1.1) | UAT round 2 |

---

## Project Constraints (from CLAUDE.md)

- Use only Windows-compatible terminal commands.
- Read `code_tips/` before modifying Slint or SQLite code. Both were read.
- Read `DATA-FLOW.md` before touching data structs. Read — no new fields needed for this phase.
- BUGSWEEPER smoke test before UAT.
- D-08 flicker fix MUST be documented in `code_tips/SLINT_TIPS.md`.
- Migration files MUST be LF line endings (SQLITE_TIPS.md / .gitattributes).
- `github_profile_url` is prohibited (RULE-01) — not touched.
- SQLite is single read source (RULE-03) — `populate_recipient_sidebar` already reads from SQLite.

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | cargo test (Rust unit tests) |
| Config file | Cargo.toml per crate |
| Quick run command | `cargo test -p service 2>&1` |
| Full suite command | `cargo test --workspace 2>&1` |

### Phase Requirements → Test Map

| Defect | Behavior | Test Type | Command | Existing? |
|--------|----------|-----------|---------|-----------|
| D-07 | Note dedup on (card_id, author, content) | Unit | `cargo test -p service -- upsert_notes` | No — Wave 0 gap |
| D-12 | Return Underway transition | Unit | `cargo test -p app -- sync_return` | Partial — `determine_transition` tests exist |
| D-13 | item_display_label set when product_names populated | Unit | `cargo test -p app -- item_display` | No — Wave 0 gap |
| D-03/D-04 | Sidebar nav behavior | Manual (BUGSWEEPER) | N/A | Manual |
| D-05/D-06 | Esc / breadcrumb behavior | Manual (BUGSWEEPER) | N/A | Manual |
| D-08 | No flicker on hover | Manual (screenshot) | N/A | Manual |
| D-14 | Shopify sync connectivity | Manual (stderr / BUGSWEEPER) | N/A | Manual |

### Wave 0 Gaps
- [ ] `crates/service/src/db/sqlite_tests.rs` or similar — test for `upsert_notes_for_card` with NULL vs non-NULL author on same content
- [ ] `crates/app/src/dashboard/tests` — test for `item_display_label` being non-empty when `product_names` is populated

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `+` button flicker is caused by `add-btn-touch` stealing cursor from `card-hover-zone`, causing `has-hover` to toggle | D-08 findings | May need a different fix approach; actual Slint behavior may differ |
| A2 | A passive outer `btn-zone` TouchArea always present solves the flicker | D-08 findings | May not work if Slint's hit-testing doesn't work this way; needs build verification |
| A3 | D-04 is resolved by fixing D-03's `view_state` persistence | D-03/D-04 findings | Tab-restore may need additional changes beyond `ms.view_state` fix |
| A4 | D-13 root cause is `product_refs_json = "[]"` in SQLite for affected cards | D-13 findings | May be a different code path issue; BUGSWEEPER investigation needed first |
| A5 | D-14 "Connecting..." means background sync thread never posted first update | D-14 findings | Could be a UI race condition where status was updated but UI not repainted |

---

## Open Questions (RESOLVED)

1. **D-14 / D-12 shared root cause?** (RESOLVED -- Plan 05 takes a diagnostic-first approach: adds sync thread logging, builds with bugsweeper, and inspects at runtime. D-12 fix proceeds independently via sync_return_states investigation; if D-14 is the root cause it will be caught by the diagnostics.)
   - What we know: "Connecting..." means `connection_status == 1` was never updated; `sync_return_states` only runs when `graphql_client.is_some()` and `sync_ok = true`.
   - What's unclear: Why is the first sync not completing? Is the Shopify token still valid?
   - Recommendation: Build with `--features bugsweeper` and check `/api/config` for `has_shopify_token`. Check app stderr on startup.

2. **Purpose option list source for D-01 dropdown** (RESOLVED -- Plan 01 hardcodes the known purpose/role values as a constant list in recipient-detail.slint, matching the existing GH Project field options. If the list changes in the future, the constant can be updated.)
   - What we know: The original summary-popup used the GH Project field options; `write_purpose_to_gh` uses `PURPOSE_VALUES` constant but that's the status lifecycle list.
   - What's unclear: What are the actual Purpose field option values in the GH Project?
   - Recommendation: Fetch at startup via `list_project_field_options("Purpose")` and pass as a sidebar property, OR hardcode during the fix and note it as a constant to update.

3. **D-07 dedup with COALESCE — edge case: two notes with same content, different authors** (RESOLVED -- accepted limitation. Identical notes by the same author are deduplicated to one row. Documented in code comment per Plan 03 task action.)
   - What we know: COALESCE dedup will correctly distinguish them.
   - What's unclear: What if the same user posts identical notes twice (same author + same content)?
   - Recommendation: Accept this as a known limitation — identical notes by the same author are deduplicated. Document in code comment.

---

## Sources

### Primary (HIGH confidence — all verified from live codebase)
- `crates/app/ui/recipient-detail.slint` — FocusScope Esc handler, pill rendering
- `crates/app/ui/dashboard.slint` — sidebar mount condition, card-flickable width, breadcrumb-back
- `crates/app/ui/card.slint` — card-hover-zone, Row 4 item squares, `+` button, hover flicker
- `crates/app/ui/option-grid.slint` — RecipientGrid tile-touch handler
- `crates/app/src/main.rs` — on_tile_clicked, on_tab_clicked, on_breadcrumb_back, on_esc_pressed, populate_recipient_sidebar, build_item_squares_from_vm, RETURN_STATES constant
- `crates/service/src/db/sqlite.rs` — upsert_notes_for_card, read_notes, save_note (dedup key confirmed)
- `crates/app/src/live_client.rs` — sync_return_states, run_sync_cycle, connection_status logic
- `crates/service/src/sync/shopify_projection.rs` — derive_shipment_status_for_order
- `.planning/DATA-FLOW.md` — data architecture rules, field sources
- `code_tips/SLINT_TIPS.md` — existing Slint gotchas
- `code_tips/SQLITE_TIPS.md` — upsert patterns, dedup rules

### Secondary (MEDIUM confidence)
- STATE.md accumulated decisions — prior phase notes on D-08 (retained close-clicked) and sidebar mount guard
- CONTEXT.md — all locked decisions from this phase

---

## Metadata

**Confidence breakdown:**
- Defect root causes (D-01 through D-09, D-12): HIGH — confirmed from source code
- D-10 staleness root cause: HIGH — confirmed populate_recipient_sidebar reads SQLite, not refreshed on sync
- D-13 root cause: MEDIUM — likely `product_refs_json = "[]"` but requires BUGSWEEPER confirmation
- D-14 root cause: MEDIUM — "Connecting..." means sync thread issue; needs runtime inspection

**Research date:** 2026-04-15
**Valid until:** 2026-05-15 (stable codebase)
