# Phase 20.1.1.1.1: Round 2 Gap Closure - Pattern Map

**Mapped:** 2026-04-15
**Files analyzed:** 11 (8 modified source files + code_tips + 2 test gaps)
**Analogs found:** 11 / 11

---

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `crates/app/ui/recipient-detail.slint` | UI component | event-driven | `crates/app/ui/card.slint` (PopupWindow, hover pattern) | role-match |
| `crates/app/ui/dashboard.slint` | UI layout | request-response | `crates/app/ui/dashboard.slint` lines 679–712 (ProductDetailPanel mount) | exact |
| `crates/app/ui/card.slint` | UI component | event-driven | `crates/app/ui/card.slint` lines 119–128 (card-hover-zone pattern itself) | exact |
| `crates/app/ui/option-grid.slint` | UI component | event-driven | `crates/app/ui/option-grid.slint` lines 54–130 (tile loop, no changes) | exact |
| `crates/app/src/main.rs` | controller | request-response | `crates/app/src/main.rs` lines 5992–6003 (`on_sidebar_close`) | exact |
| `crates/app/src/live_client.rs` | service | event-driven | `crates/app/src/live_client.rs` lines 150–200 (`invoke_from_event_loop` sync callback) | exact |
| `crates/app/src/dashboard/view_model.rs` | model/utility | transform | `crates/app/src/dashboard/projection.rs` lines 17–48 | role-match |
| `crates/service/src/db/sqlite.rs` | service | CRUD | `crates/service/src/db/sqlite.rs` lines 615–630 (`upsert_notes_for_card` itself) | exact |
| `crates/app/src/dashboard/projection.rs` | utility | transform | `crates/service/src/sync/shopify_projection.rs` lines 47–70 (status normalization) | role-match |
| `crates/service/src/sync/shopify_projection.rs` | service | transform | `crates/service/src/sync/shopify_projection.rs` lines 76–100 | exact |
| `code_tips/SLINT_TIPS.md` | docs | — | `code_tips/SLINT_TIPS.md` existing tip entries | exact |

---

## Pattern Assignments

### `crates/app/ui/recipient-detail.slint` — D-01/D-02/D-05 changes

**Defects addressed:** D-01 (pill → dropdown), D-02 (pill repositioning + hollow border), D-05 (Esc FocusScope removal)

---

#### D-01: Replace TextInput pill with PopupWindow dropdown

**Analog:** `crates/app/ui/card.slint` lines 558–594 (card-menu PopupWindow pattern)

**PopupWindow item loop pattern** (card.slint lines 558–594):
```slint
card-menu := PopupWindow {
    x: parent.width - 170px;
    y: 34px;
    width: 160px;

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

        VerticalLayout {
            padding: 4px;

            if root.is-unassigned : Rectangle {
                height: 28px;
                border-radius: 4px;
                background: pick-touch.has-hover ? Colors.border-default : transparent;
                Text {
                    text: "Pick Recipient";
                    x: 10px;
                    font-size: Typography.size-sm;
                    color: Colors.warning;
                    vertical-alignment: center;
                }
                pick-touch := TouchArea {
                    mouse-cursor: pointer;
                    clicked => {
                        root.pick-recipient-clicked();
                        card-menu.close();
                    }
                }
            }
        }
    }
}
```

**New purpose dropdown pattern for recipient-detail.slint** — replace the `if root.editing-purpose : Rectangle` block (lines 148–183) with:
```slint
// D-01: dropdown PopupWindow — replaces TextInput branch
// Declare purpose-options as in property <[string]> on RecipientDetailPanel
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-xs;
                    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();
                    }
                }
            }
        }
    }
}
```

The pill `TouchArea.clicked` handler becomes:
```slint
purpose-pill-touch := TouchArea {
    mouse-cursor: pointer;
    clicked => {
        root.purpose-draft = root.purpose;
        root.editing-purpose = true;
        purpose-dropdown.show();
    }
}
```

Add `in property <[string]> purpose-options: [];` to `RecipientDetailPanel`'s `in` properties block (after line 27).

---

#### D-02: Hollow border pill, repositioned into header VerticalLayout

**Analog:** `crates/app/ui/recipient-detail.slint` lines 75–110 (header HorizontalLayout, VerticalLayout for name)

**Current header VerticalLayout** (lines 100–109):
```slint
VerticalLayout {
    alignment: center;
    Text {
        text: root.recipient-name;
        font-size: Typography.size-lg;
        font-weight: 700;
        color: Colors.text-primary;
        wrap: word-wrap;
    }
}
```

**Replace with** (add pill as second row inside the name VerticalLayout):
```slint
VerticalLayout {
    alignment: center;
    Text {
        text: root.recipient-name;
        font-size: Typography.size-lg;
        font-weight: 700;
        color: Colors.text-primary;
        wrap: word-wrap;
    }
    // D-02: pill lives here, just below the name
    if !root.editing-purpose : Rectangle {
        height: 24px;
        width: purpose-pill-text.preferred-width + 20px;
        border-radius: 12px;
        background: transparent;             // D-02: hollow — no fill
        border-width: 2px;                   // D-02: 2px outline
        border-color: root.purpose-color;    // D-02: ring color from purpose-color

        purpose-pill-touch := TouchArea {
            mouse-cursor: pointer;
            clicked => {
                root.purpose-draft = root.purpose;
                root.editing-purpose = true;
                purpose-dropdown.show();
            }
        }
        purpose-pill-text := Text {
            text: root.purpose != "" ? root.purpose : "Purpose";
            font-size: Typography.size-xs;
            font-weight: 600;
            color: root.purpose-color;       // D-02: colored text matches border
            horizontal-alignment: center;
            vertical-alignment: center;
            width: parent.width;
            height: parent.height;
        }
    }
}
```

Remove the entire `HorizontalLayout` pill block (lines 112–184) that was previously below the header.

---

#### D-05: Remove Esc → close-clicked from FocusScope

**Current FocusScope** (lines 53–67):
```slint
sidebar-focus := FocusScope {
    key-pressed(event) => {
        if (event.text == Key.Escape) {
            if (root.editing-purpose || root.editing-rx-od || root.editing-rx-os || root.editing-discord-username) {
                root.editing-purpose = false;
                root.editing-rx-od = false;
                root.editing-rx-os = false;
                root.editing-discord-username = false;
                return accept;
            }
            root.close-clicked();   // <-- REMOVE THIS LINE
            return accept;           // <-- CHANGE TO: return reject;
        }
        return reject;
    }
```

**Required change** — the else branch (no active edit, Esc pressed) must propagate, not consume:
```slint
sidebar-focus := FocusScope {
    key-pressed(event) => {
        if (event.text == Key.Escape) {
            if (root.editing-purpose || root.editing-rx-od || root.editing-rx-os || root.editing-discord-username) {
                root.editing-purpose = false;
                root.editing-rx-od = false;
                root.editing-rx-os = false;
                root.editing-discord-username = false;
                return accept;
            }
            // D-05: no dismiss on Esc — propagate to global Esc handler
            return reject;
        }
        return reject;
    }
```

`close-clicked()` callback declaration (line 47) stays — it is still used by `on_sidebar_close` in Rust.

---

### `crates/app/ui/dashboard.slint` — D-03/D-04/D-06 changes

**Defects addressed:** D-03/D-04 (sidebar mount guard), D-06 (card-flickable width when sidebar visible in filtered view)

---

#### D-03: Sidebar mount guard — drop `show-recipient-grid` prerequisite

**Current mount condition** (line 627):
```slint
if root.show-recipient-grid && root.recipient-detail-visible : RecipientDetailPanel {
```

**Required change:**
```slint
if root.recipient-detail-visible : RecipientDetailPanel {
```

The sidebar must render in any view state (tile grid or filtered card view) as long as `recipient-detail-visible` is true.

---

#### D-03: card-flickable width — shrink when sidebar visible in filtered card view

**Analog:** `crates/app/ui/dashboard.slint` lines 705–712 (ProductDetailPanel coexistence pattern):
```slint
card-flickable := Flickable {
    x: 0px;
    y: 0px;
    width: root.product-detail-visible && !root.show-option-grid ? parent.width - 330px : parent.width;
    height: parent.height;
```

**Required change** — add recipient sidebar coexistence alongside existing product sidebar coexistence:
```slint
card-flickable := Flickable {
    x: 0px;
    y: 0px;
    width: (root.product-detail-visible && !root.show-option-grid) || (root.recipient-detail-visible && !root.show-option-grid)
        ? parent.width - 330px
        : parent.width;
    height: parent.height;
```

---

### `crates/app/ui/card.slint` — D-08/D-09/D-11 changes

**Defects addressed:** D-08 (+ button flicker), D-09 (+ button vertical alignment), D-11 (product icon/SN scaling)

---

#### D-08: Fix hover flicker — passive btn-zone TouchArea

**Root cause:** `if card-hover-zone.has-hover : Rectangle` wraps the `+` button. When cursor enters `add-btn-touch` (inside the button), `card-hover-zone.has-hover` briefly flips false, hiding the wrapper, returning cursor to `card-hover-zone`, which flips true again — rapid feedback loop.

**Analog:** `crates/app/ui/card.slint` lines 119–128 (`card-hover-zone` passive TouchArea pattern — same principle):
```slint
// D-11: passive card-level hover detector — placed FIRST so later siblings win clicks
card-hover-zone := TouchArea {
    x: 0px;
    y: 0px;
    width: parent.width;
    height: parent.height;
    // no clicked handler — passive, only provides has-hover
}
```

**Fix pattern** — add a persistent passive `btn-zone` TouchArea at the + button position (always present when Row 4 is rendered). Declare it BEFORE the button visual content so the button's own `add-btn-touch` wins clicks (later-declared siblings win in Slint):

```slint
// Row 4 outer Rectangle (height: 80px)
Rectangle {
    height: 80px;

    // ... item squares for loop ...

    // D-08 FIX: persistent passive hover zone for + button position
    // Always present — eliminates has-hover feedback loop
    // Declared FIRST so add-btn-touch (declared later) wins click events
    btn-zone := TouchArea {
        x: root.item-squares.length * 52px;
        y: 0px;
        width: 44px;
        height: 80px;
        // no clicked handler — passive hover detection only
    }

    // D-11: product-add button — visible when card or button zone is hovered
    if card-hover-zone.has-hover || btn-zone.has-hover : Rectangle {
        x: root.item-squares.length * 52px;
        y: 0px;
        width: 44px;
        height: 80px;
        background: transparent;

        Rectangle {
            x: 0px;
            y: 0px;                    // D-09 FIX: 0px to align with item squares (not 18px)
            width: 44px;
            height: 44px;
            border-radius: 22px;
            background: add-btn-touch.has-hover ? #1a3a1a : Colors.surface-popup;

            Text {
                text: "+";
                width: parent.width;
                height: parent.height;
                horizontal-alignment: center;
                vertical-alignment: center;
                color: Colors.success;
                font-size: Typography.size-md;
            }

            add-btn-touch := TouchArea {
                mouse-cursor: pointer;
                clicked => { root.add-item-clicked(); }
            }
        }
    }
}
```

**code_tips/SLINT_TIPS.md entry to add** (D-08 locked requirement):

> ## Hover-conditional element flicker (feedback loop)
>
> **Problem:** An element conditioned on `outer-touch.has-hover` contains an inner `TouchArea` (e.g., a button). When the cursor moves onto the inner TouchArea, it briefly "captures" the cursor away from `outer-touch`, setting `outer-touch.has-hover = false`. This hides the element, immediately returning the cursor to `outer-touch`, which sets `has-hover = true` again — causing rapid visible flickering.
>
> **Solution:** Add a persistent passive `TouchArea` at the button's position that always exists (not wrapped in an `if`). Condition the button rendering on `outer-touch.has-hover || passive-zone.has-hover`. The passive zone never disappears, so there is no feedback loop. Declare the passive zone BEFORE the interactive button content — Slint's last-declared-child-wins rule ensures the interactive `TouchArea` inside the button still receives clicks.
>
> ```slint
> // WRONG — flickers when cursor moves onto inner add-btn-touch
> if card-hover-zone.has-hover : Rectangle {
>     add-btn-touch := TouchArea { clicked => { ... } }
> }
>
> // CORRECT — btn-zone always present; no feedback loop
> btn-zone := TouchArea { x: ...; y: ...; width: ...; height: ...; }  // passive, no clicked
> if card-hover-zone.has-hover || btn-zone.has-hover : Rectangle {
>     add-btn-touch := TouchArea { clicked => { ... } }
> }
> ```

---

#### D-09: Fix + button vertical alignment

**Current inner Rectangle** (card.slint line 509): `y: 18px` — offsets button downward by 18px relative to the item squares which start at `y: 0px`.

**Fix:** Change inner button `y` from `18px` to `0px` so button top-aligns with item square images:
```slint
// Inner 44x44 circle — D-09: y: 0px to align with item squares (was 18px)
Rectangle {
    x: 0px;
    y: 0px;    // D-09 FIX
    width: 44px;
    height: 44px;
    border-radius: 22px;
    ...
}
```

---

#### D-11: Scale item square inner Rectangle and label font-size

**Current inner item square** (card.slint lines 405–413): `width: 44px, height: 44px`. SN labels (lines 446–463): `font-size: 9px`, `y: 46px`, `width: 46px`.

**Fix** — grow inner square and adjust label position/stride. Outer stride (line 398) is currently `52px` per square:
```slint
// Outer per-square rectangle — stride grows from 52→58px
for sq[sq-index] in root.item-squares : Rectangle {
    x: sq-index * 58px;    // D-11: stride 52→58px to avoid overlap
    y: 0px;
    width: 52px;            // D-11: outer width 46→52px
    height: 80px;
    background: transparent;

    // Clipped inner square — D-11: 44→52px
    Rectangle {
        x: 0px;
        y: 0px;
        width: 52px;        // D-11: 44→52px
        height: 52px;       // D-11: 44→52px
        border-radius: 6px;
        clip: true;
        ...
    }

    // SN label — D-11: y 46→54px, font-size 9→10px, width 46→52px
    if sq.serial-label != "" : Text {
        x: 0px;
        y: 54px;            // D-11: 46→54px (inner height + 2px gap)
        width: 52px;        // D-11: 46→52px
        text: sq.serial-label;
        font-size: 10px;    // D-11: 9→10px
        color: Colors.text-muted;
        horizontal-alignment: center;
        overflow: elide;
    }
    // product-name label same adjustments
}
```

Also update the `+` button x position (line 500): `x: root.item-squares.length * 58px` (was `52px`).
Also update hover label x offset (line 478): `x: 0px - sq-index * 58px` (was `52px`).
Also update `sq-touch` width/height (lines 468–473): `width: 52px` (was `46px`).

---

### `crates/app/src/main.rs` — D-03/D-04/D-06/D-10/D-13 changes

---

#### D-03: on_tile_clicked — navigate to filtered card view on recipient tile click

**Current handler** (lines 3371–3384):
```rust
window.on_tile_clicked(move |name| {
    let name_s = name.to_string();
    let mode = {
        let mut r = rt.borrow_mut();
        r.select_tile(&name_s);
        r.current_mode()
    };
    if let Some(w) = weak.upgrade() {
        if mode == DiscoveryMode::ByRecipient {
            // D-19 / Pitfall 8: stay on option grid; populate + show recipient sidebar
            if let Some((store, cfg)) = &rx_handle_tile {
                populate_recipient_sidebar(&w, store, &name_s, &cfg.shopify_store_slug);
                w.set_recipient_detail_visible(true);
            }
        }
```

**Fix** — after `populate_recipient_sidebar`, also transition to `FilteredCardView` and call `apply_filters`:
```rust
if mode == DiscoveryMode::ByRecipient {
    // D-03: navigate to filtered card view AND show sidebar
    {
        let mut r = rt.borrow_mut();
        let ms = r.discovery_state.current_mode_state_mut();
        ms.view_state = ModeViewState::FilteredCardView;
        // selected_tile already set by r.select_tile() above
    }
    apply_filters(&w, &cards.borrow(), &rt.borrow());
    if let Some((store, cfg)) = &rx_handle_tile {
        populate_recipient_sidebar(&w, store, &name_s, &cfg.shopify_store_slug);
        w.set_recipient_detail_visible(true);
    }
}
```

**Analog:** `crates/app/src/main.rs` lines 3385–3391 (ByProductShipped tile-click path, which DOES call `apply_filters` after `select_tile`):
```rust
} else {
    apply_filters(&w, &cards.borrow(), &rt.borrow());
    // For ByProductShipped: auto-show sidecar for the clicked product tile
    if mode == DiscoveryMode::ByProductShipped {
```

---

#### D-04: on_tab_clicked — tab-restore preserves filtered card view

**Current tab restore** (lines 3345–3356) — tab restore calls `populate_recipient_sidebar` when `selected_tile` exists but `view_state` may already be `FilteredCardView` after the D-03 fix. D-04 is resolved automatically by D-03 if `ms.view_state = FilteredCardView` is persisted into the runtime state before `apply_filters`. Verify that `restore_mode_state` (called by `on_tab_clicked`) reads `ms.view_state` and calls `apply_filters` with it — if so, no additional change needed.

**Confirmation check** (same pattern as existing restore, lines 3348–3355):
```rust
if mode == DiscoveryMode::ByRecipient {
    let selected_tile = rt.borrow().current_mode_state().selected_tile.clone();
    if let Some(rid) = selected_tile {
        if let Some((store, cfg)) = &rx_handle {
            populate_recipient_sidebar(&w, store, &rid, &cfg.shopify_store_slug);
            w.set_recipient_detail_visible(true);
        }
    }
}
```
This block already re-shows the sidebar; `restore_mode_state` drives `apply_filters` for the view state.

---

#### D-06: on_breadcrumb_back — dismiss sidebar and reset editing state

**Analog:** `crates/app/src/main.rs` lines 5992–6003 (`on_sidebar_close` — the authoritative reset pattern):
```rust
window.on_sidebar_close(move || {
    let Some(w) = weak.upgrade() else { return };
    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);
});
```

**Fix for `on_breadcrumb_back`** (lines 3548–3573) — add the same reset after `apply_filters`:
```rust
w.set_product_detail_visible(false);
apply_filters(&w, &cards.borrow(), &rt.borrow());
// D-06: dismiss sidebar and reset editing state 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);
```

---

#### D-10: populate_recipient_sidebar — refresh after each sync cycle

**Current state:** `populate_recipient_sidebar` (lines 2484–2552) reads from SQLite via `store.read_recipient(recipient_id)`. It is only called on user interaction (tile-click, tab-restore), not post-sync. SQLite may have stale data at call time if sync has not yet refreshed the recipient row.

**Analog:** `crates/app/src/live_client.rs` lines 169–200 — `invoke_from_event_loop` block post-sync. The sync callback already rebuilds cards. It must also re-populate the sidebar when one is open.

**Pattern to follow** (from `on_card_name_navigate` at lines 6005–6031 — calling `populate_recipient_sidebar` from inside a callback that has access to `weak` + `store` + `cfg`):
```rust
// Inside the on_sync_complete callback (live_client.rs invoke_from_event_loop block),
// after the cards model is updated:
if w.get_recipient_detail_visible() {
    let rid = w.get_detail_recipient_id().to_string();
    if !rid.is_empty() {
        if let Some((store, cfg)) = &rx_handle {
            populate_recipient_sidebar(&w, store, &rid, &cfg.shopify_store_slug);
        }
    }
}
```

The `on_sync_complete` callback is constructed in `main.rs` around line 2606 (the `Arc<dyn Fn(...)>` closure). The `rx_write_handle` (`store` + `cfg`) must be cloned and moved into that closure for the refresh call.

---

#### D-13: item_display_label empty — investigation path

**Code under investigation:** `build_item_squares_from_vm` (lines 546–654). The two build paths that set `item_display_label`:
- Path A (line 558–559): `count == 0` → `item_display_label = ""` (shows "No items added")
- Path B (lines 486–536, `build_item_squares_from_product_names`): sets `item_display_label` when `product_names` is non-empty

**Root of the mismatch:** If a card has `item_squares` populated via Path B but `item_display_label` is still empty, it means Path B was called but its `item_display_label` assignment was skipped, OR Path A was called (count=0) despite item_squares being set.

**Pattern for the fix** — ensure `item_display_label` is always set consistently with the count. The existing pattern in the same file (lines 634–641 — the end of `build_item_squares_from_vm`) sets the label:
```rust
let label = if count == 1 {
    items[0].product_name.to_string()
} else {
    format!("{} items", count)
};
card.item_display_label = label.into();
```
Verify this branch is reached when `count > 0`. If `vm.product_refs` is empty but `vm.product_names` is not, the `ref_count.max(name_count)` should still yield `count > 0`. BUGSWEEPER query needed to confirm which path is taken at runtime.

---

### `crates/app/src/live_client.rs` — D-12/D-14 changes

---

#### D-12: sync_return_states — "Return Underway" transition

**Current logic** (lines 827–894): transition `"Return Created" → "Return Underway"` only when `reverse_fulfillment_tracking` returns non-empty. If the Shopify connection is broken (D-14), `sync_return_states` is never called (`graphql_client` is None or `sync_ok = false`).

**Pattern to follow:** `crates/app/src/live_client.rs` lines 842–890 (the `for card in cards` return-state loop). No structural change needed — the logic is correct. The fix is diagnostic (confirm `sync_return_states` is being called by checking stderr for `[sync_return_states]` log lines) and connection recovery (D-14).

If `sync_return_states` IS running but transitions are not happening, add a log before the `has_tracking` check:
```rust
eprintln!("[sync_return_states] Card {} status='{}', checking tracking...", card_id, card_status);
```

---

#### D-14: run_sync_cycle — "Connecting..." stuck diagnosis

**Current connection_status logic** (lines 195–200):
```rust
let connection_status = match (sync_ok, has_shopify_token) {
    (true, true)  => 2i32,
    (true, false) => 4i32,
    (false, _)    => 3i32,
};
cb(snapshots, connection_status);
```

"Connecting..." = `connection_status == 1` (set at startup, never updated). This means `invoke_from_event_loop` callback was never fired. Root causes:
- Background thread panicked before the first `invoke_from_event_loop` call
- `run_sync_cycle` returned `Err` but `cb` was not called (unlikely — `cb` is called regardless of `sync_ok`)

**Investigation pattern:** Add an early `eprintln!` at the top of the sync thread loop body to confirm the thread is alive and reaching `run_sync_cycle`:
```rust
eprintln!("[sync_thread] Starting sync cycle {}", cycle_count);
let result = run_sync_cycle(...);
eprintln!("[sync_thread] run_sync_cycle result: {}", result.is_ok());
```

**Also check:** `resolve_shopify_token` — if the token lookup fails, `shopify_client = None` and `has_shopify_token = false`, which should yield `connection_status = 4`, not `1`. If status stays at `1`, the callback is not firing at all.

---

### `crates/service/src/db/sqlite.rs` — D-07 change

**Defect:** `upsert_notes_for_card` (lines 612–631) keys on `(card_id, note_date, content)`. Local notes use app-clock timestamps; GH-fetched notes use GH `createdAt` timestamps. Same content with different timestamps = false "new note" → duplicate insert.

**Current dedup query** (lines 618–621):
```rust
let existing: i64 = conn.query_row(
    "SELECT COUNT(*) FROM notes WHERE card_id = ?1 AND note_date = ?2 AND content = ?3",
    params![card_id, note.date, note.content],
    |r| r.get(0),
)?;
```

**Fix pattern** — change key to `(card_id, author, content)` with COALESCE for NULL author. Follow the existing SELECT COUNT then conditional INSERT/UPDATE pattern (no DELETE+re-INSERT per SQLITE_TIPS.md):
```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 FIX: key on (card_id, author, content) — NOT date
        // COALESCE handles NULL author (optimistic local notes)
        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; fill in 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(())
}
```

**Edge case note (add as code comment):** Two notes by the same author with identical content will be deduplicated to one. This is an accepted known limitation — append-only notes rarely have this collision.

---

### `crates/app/src/dashboard/view_model.rs` — D-13 investigation

This file defines `NoteDisplayEntry` and `item_display_label`-related transforms. No structural changes expected until BUGSWEEPER investigation confirms the root cause. The key pattern to verify is which build path is reached:

**Analog:** `crates/app/src/dashboard/projection.rs` lines 17–48 (`compute_last_activity` — similar "find qualifying item in a collection and derive a display label" pattern). The same defensively-nullable field access style applies.

---

### `crates/service/src/sync/shopify_projection.rs` — D-12 investigation

`derive_shipment_status_for_order` (lines 76–100) maps fulfillment status to the `"In Transit"` / `"Delivered"` lifecycle labels. This file is read-only for D-12 — the status derivation logic is correct. The issue is upstream in `sync_return_states` not running.

**No change needed** unless investigation reveals the initial `"Delivered"` → `"Return Created"` status was set incorrectly by this projection. Retain as a reference for understanding the status value space.

---

### `code_tips/SLINT_TIPS.md` — D-08 required addition

**Locked requirement (D-08 from CONTEXT.md):** The hover-flicker fix MUST be documented here.

**Pattern to follow:** Existing tip structure in `code_tips/SLINT_TIPS.md` — heading, Problem, Root cause, Solution, code block.

**New tip text** (append to end of file):

```markdown
## Hover-conditional element flicker (TouchArea feedback loop)

**Problem:** An element wrapped in `if some-touch.has-hover : ...` contains its own inner `TouchArea`. When the cursor moves from the card body onto the inner TouchArea, the inner element briefly "captures" the cursor, setting `some-touch.has-hover = false`. This hides the outer wrapper, returning the cursor to `some-touch` which sets `has-hover = true` again — causing rapid visible flickering.

**Root cause:** Slint's hit-testing routes cursor to the deepest TouchArea. When the conditional element is visible, its inner TouchArea wins cursor hits, temporarily removing it from the outer PassiveTouchArea, toggling the condition.

**Solution:** Add a persistent passive `TouchArea` (no `clicked` handler) at the button's position, declared BEFORE the conditional content. Condition the element on `outer-touch.has-hover || passive-zone.has-hover`. The passive zone always exists — no feedback loop.

```slint
// WRONG — flickers when cursor moves onto add-btn-touch
if card-hover-zone.has-hover : Rectangle {
    add-btn-touch := TouchArea { clicked => { root.add-item-clicked(); } }
}

// CORRECT — btn-zone always present; hover state never drops
btn-zone := TouchArea { x: btn-x; y: 0px; width: 44px; height: 80px; }  // passive, no clicked
if card-hover-zone.has-hover || btn-zone.has-hover : Rectangle {
    add-btn-touch := TouchArea { clicked => { root.add-item-clicked(); } }
}
```

**Child order rule:** Declare the passive zone FIRST. Slint's last-declared-child-wins rule ensures the interactive `add-btn-touch` (declared later, inside the conditional) still receives clicks.
```

---

## Shared Patterns

### Sidebar editing state reset
**Source:** `crates/app/src/main.rs` lines 5992–6003 (`on_sidebar_close`)
**Apply to:** D-06 (`on_breadcrumb_back`), and any future sidebar dismiss paths
```rust
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);
```

### PopupWindow with for-loop item list
**Source:** `crates/app/ui/card.slint` lines 558–835 (`card-menu := PopupWindow`)
**Apply to:** D-01 (purpose dropdown in `recipient-detail.slint`)
Pattern: `PopupWindow` → `Rectangle { background: Colors.surface-popup; border-radius: 6px; }` → `VerticalLayout { padding: 4px; }` → `for item in list : Rectangle { height: 28px; ... TouchArea { clicked => { ...; popup.close(); } } }`

### Passive hover-zone TouchArea (first child)
**Source:** `crates/app/ui/card.slint` lines 119–128 (`card-hover-zone`)
**Apply to:** D-08 (`btn-zone` in card.slint Row 4)
Pattern: `passive-zone := TouchArea { x:; y:; width:; height:; /* no clicked */ }`

### SQLite SELECT COUNT then INSERT/UPDATE (no DELETE)
**Source:** `crates/service/src/db/sqlite.rs` lines 615–630 (`upsert_notes_for_card`)
**Apply to:** D-07 (same function, new key)
Pattern: `SELECT COUNT(*) WHERE key_fields` → if 0: `INSERT` else: `UPDATE SET non-key fields`

### invoke_from_event_loop sidebar refresh after sync
**Source:** `crates/app/src/live_client.rs` lines 169–200 (sync callback)
**Apply to:** D-10 (add `populate_recipient_sidebar` call inside the existing `invoke_from_event_loop` block)
Pattern: check `w.get_recipient_detail_visible()`, if true call `populate_recipient_sidebar(&w, store, &rid, &slug)`

---

## No Analog Found

All modified files have direct analogs in the codebase. No net-new patterns required.

---

## Metadata

**Analog search scope:** `crates/app/ui/*.slint`, `crates/app/src/main.rs`, `crates/app/src/live_client.rs`, `crates/app/src/dashboard/`, `crates/service/src/db/sqlite.rs`, `crates/service/src/sync/shopify_projection.rs`, `code_tips/`
**Files scanned:** 12
**Pattern extraction date:** 2026-04-15
