# Phase 20.1.1.1: Phase 20.1.1 Gap Closure - Pattern Map

**Mapped:** 2026-04-15
**Files analyzed:** 6 primary modification targets (spanning D-01..D-12)
**Analogs found:** 6 / 6

---

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `crates/app/ui/dashboard.slint` | component | event-driven | self + `ProductDetailPanel` mount (lines 680-684) | self-modification |
| `crates/app/ui/recipient-detail.slint` | component | request-response | self (existing 466-line file) | self-modification |
| `crates/app/ui/card.slint` | component | event-driven | self (existing notes popover + item-squares area) | self-modification |
| `crates/app/src/main.rs` | controller | event-driven | `on_tile_clicked` block (lines 3375-3402), `on_tab_clicked` block (lines 3333-3362) | self-modification |
| `crates/app/src/dashboard/view_model.rs` | model | transform | self — `NoteDisplayEntry::from_note_entry` (lines 137-150) | self-modification |
| `crates/app/src/live_client.rs` | service | request-response | self — `save_note` (lines 355-421) | self-modification |

---

## Pattern Assignments

### D-01 / D-02 / D-03 — Sidebar Coexistence, Tab-Restore, Card-Name-Navigate

**Files:** `dashboard.slint` (sidebar mount condition), `main.rs` (on_tab_clicked restore + on_card_name_navigate)

#### D-01: Sidebar mount — remove `show-option-grid` guard

**Current code** (`dashboard.slint` lines 627-664):
```slint
// BUG: guard includes `show-option-grid` — only true in OptionGrid view state.
// If the mode transitions or view_state is ambiguous, the sidebar disappears.
if root.show-option-grid && root.show-recipient-grid && root.recipient-detail-visible : RecipientDetailPanel {
    x: parent.width - 326px;
    ...
}
```

**Fix — drop `show-option-grid` guard, keep `show-recipient-grid`:**
```slint
// PATTERN: mirrors product-detail mount at dashboard.slint line 680:
//   if !root.show-option-grid && root.product-detail-visible : ProductDetailPanel { ... }
// For recipient sidebar: recipient-grid context is show-recipient-grid, not show-option-grid.
if root.show-recipient-grid && root.recipient-detail-visible : RecipientDetailPanel {
    x: parent.width - 326px;
    y: 6px;
    width: 320px;
    height: parent.height - 12px;
    // all existing data bindings + editing-state <=> bindings unchanged
}
```

**RecipientGrid width-shrink binding** (`dashboard.slint` line 618 — already correct, no change needed):
```slint
// EXISTING — correct pattern, keep as-is:
if root.show-option-grid && root.show-recipient-grid : RecipientGrid {
    x: 0px;
    y: 0px;
    width: root.recipient-detail-visible ? parent.width - 330px : parent.width;
    height: parent.height;
    ...
}
```

**Analog — ProductDetailPanel coexistence** (`dashboard.slint` lines 680-710):
```slint
// This is the established pattern: sidecar at fixed right x; card-flickable shrinks via ternary
if !root.show-option-grid && root.product-detail-visible : ProductDetailPanel {
    x: parent.width - 326px;
    y: 6px;
    width: 320px;
    height: parent.height - 12px;
    ...
}
// card-flickable:
width: root.product-detail-visible && !root.show-option-grid ? parent.width - 330px : parent.width;
```

---

#### D-02: Tab-restore — re-show sidebar when returning to Recipients tab

**Current code** (`main.rs` lines 3338-3361 — the `on_tab_clicked` handler):
```rust
// EXISTING: hides sidebar on departure from tab 2 (line 3339-3341)
if index != 2 {
    w.set_recipient_detail_visible(false);
}
// MISSING: no restore block for ByRecipient on RETURN (contrast with ByProductShipped at lines 3348-3359)
```

**Fix — add ByRecipient restore block, mirroring the ByProductShipped pattern (lines 3348-3359):**
```rust
// PATTERN: copy from ByProductShipped restore (lines 3348-3359):
if mode == DiscoveryMode::ByRecipient {
    // Restore recipient grid tiles
    let tile_source_cards = cards.borrow();
    let (tiles, row_count) = build_recipient_tiles(&tile_source_cards, &std::collections::HashSet::new());
    let model = Rc::new(slint::VecModel::from(tiles));
    w.set_recipient_tiles(slint::ModelRc::from(model));
    w.set_recipient_viewport_rows(row_count);
    // Restore sidebar if a tile was previously selected
    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);
        }
    }
}
```

**Note on guard removal:** This tab-restore only works if the mount condition is NOT gated on `show-option-grid` (D-01 fix). Both fixes must land together.

**Note on hide guard:** The current `if index != 2` hide is too broad — it erases `recipient_detail_visible` even when navigating between sub-tabs within the same mode. Confirm via test: navigating tab 0→2→0→2 should restore the sidebar on the second visit to tab 2.

---

#### D-03: Card-name-navigate — already implemented in Phase 20.1.1 Plan 09

**Current code** (`main.rs` lines 5981-6000 — `on_card_name_navigate`):
```rust
window.on_card_name_navigate(move |rid| {
    let rid_s = rid.to_string();
    {
        let mut r = rt.borrow_mut();
        r.set_mode_by_index(2); // ByRecipient
        r.select_tile(&rid_s);
    }
    if let Some(w) = weak.upgrade() {
        w.invoke_tab_clicked(2);
        if let Some((store, cfg)) = &rx_handle_nav {
            populate_recipient_sidebar(&w, store, &rid_s, &cfg.shopify_store_slug);
            w.set_recipient_detail_visible(true);
        }
    }
});
```

**Status:** This is correctly implemented. D-03 still fails because D-01 (mount guard) and D-02 (tab-restore) are broken underneath it. Fix D-01+D-02 first; D-03 will work automatically.

---

### D-04 — Notes Popover Content-Driven Row Heights

**File:** `crates/app/ui/card.slint` — notes popover (lines 967-1029)

**Current fixed-height pattern** (`card.slint` lines 982-992):
```slint
for note[idx] in root.notes : Rectangle {
    x: 4px;
    y: idx * 60px + 4px;      // BUG: fixed 60px stride — multi-line content bleeds out
    width: parent.width - 8px;
    height: 56px;              // BUG: fixed height — no room for wrapped text
    ...
    VerticalLayout {
        padding: 6px;
        spacing: 2px;
        Text {
            text: note.content;
            wrap: word-wrap;   // wraps, but row height is fixed — content bleeds
        }
    }
}
```

**Fix — content-height rows:**
Two options. Option A (simpler, SLINT_TIPS.md compatible) uses a `preferred-height` + `min-height` approach. Option B uses a computed cumulative offset property. **Use Option A** per CONTEXT `## Specific Ideas`:

```slint
// PATTERN: content-height row in absolute-y list (SLINT_TIPS.md clipped-Rectangle approach)
// Each row's height is its VerticalLayout's preferred-height; stride tracks cumulative offset.
// Because absolute-y lists can't auto-stack, the simplest fix is to switch from absolute y
// to a VerticalLayout list (which CAN measure content height) INSIDE the clipped Rectangle.
//
// CORRECT approach — VerticalLayout inside clipped Rectangle (not inside Flickable):
Rectangle {
    vertical-stretch: 1;
    clip: true;

    VerticalLayout {
        alignment: start;
        spacing: 4px;
        padding: 4px;
        for note in root.notes : Rectangle {
            // No fixed height — let VerticalLayout measure preferred-height of children
            border-radius: 4px;
            background: Colors.surface;
            border-width: 1px;
            border-color: Colors.border-muted;

            VerticalLayout {
                padding: 6px;
                spacing: 2px;
                Text {
                    text: note.content;
                    font-size: Typography.size-sm;
                    color: Colors.text-primary;
                    wrap: word-wrap;
                    // preferred-height is auto-computed from wrapped text height
                }
                HorizontalLayout { ... }  // author + timestamp row (fixed height)
            }
        }
    }
}
```

**SLINT_TIPS.md caveat:** "VerticalLayout inside Flickable always bottom-aligns." This fix puts VerticalLayout inside a CLIPPED RECTANGLE (not Flickable) — this is the recommended pattern and does NOT exhibit the bottom-align bug. The parent outer VerticalLayout has `alignment: stretch`; header and composer siblings have `vertical-stretch: 0`.

**Analog — existing notes list scroll container** (`card.slint` lines 967-970, already clipped Rectangle):
```slint
Rectangle {
    vertical-stretch: 1;
    clip: true;
    // current: absolute-y for-loop; replace with VerticalLayout for-loop
}
```

---

### D-05 — Popover Stays Open After Note Post

**File:** `crates/app/src/main.rs` — `on_card_post_note` handler (lines 3479-3511)

**Current broken code** (`main.rs` lines 3505-3509):
```rust
// BUG: apply_filters re-renders the full card model, which collapses the PopupWindow
// (PopupWindow loses its open state when its parent RecipientCard is re-created).
if let Some(w) = weak.upgrade() {
    apply_filters(&w, &cards.borrow(), &rt.borrow());
}
```

**Fix — patch just the notes model for the affected card (same pattern as on_card_notes_popover_opened):**
```rust
// PATTERN: mirror the targeted model patch at main.rs lines 3462-3472 (on_card_notes_popover_opened):
if let Some(w) = weak.upgrade() {
    use std::time::UNIX_EPOCH;
    use app::dashboard::NoteDisplayEntry;
    let now_secs = std::time::SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64;
    if let Some((store, _)) = &store_opt {
        if let Ok(entries) = store.read_notes(&cid) {
            let items: Vec<NoteDisplayData> = entries.iter().map(|e| {
                let nd = NoteDisplayEntry::from_note_entry(e, now_secs);
                NoteDisplayData {
                    date: nd.date.into(),
                    content: nd.content.into(),
                    author_display: nd.author_display.into(),
                    relative_time: nd.relative_time.into(),
                }
            }).collect();
            let notes_model = slint::ModelRc::new(slint::VecModel::from(items));
            let cards_model = w.get_cards();
            for i in 0..cards_model.row_count() {
                if let Some(mut card_data) = cards_model.row_data(i) {
                    if card_data.package_id.as_str() == cid {
                        card_data.notes = notes_model;
                        cards_model.set_row_data(i, card_data);
                        break;
                    }
                }
            }
        }
    }
    // DO NOT call apply_filters — that collapses the PopupWindow
}
```

**Analog:** `on_card_notes_popover_opened` on_complete closure, `main.rs` lines 3426-3473 — same store.read_notes + targeted set_row_data pattern.

---

### D-06 — Notes Render from SQLite Immediately on Popover Open

**File:** `crates/app/src/main.rs` — `on_card_notes_popover_opened` (lines 3405-3476)

**Current state:** The handler fires on popover open, reads card_id from Slint model, then calls `client_rc.fetch_notes_for_card(&cid, on_complete)` which runs a background thread. The notes already in SQLite are displayed via the card model built by `apply_filters` before the popover opens — so notes from SQLite ARE visible immediately. The GH fetch runs in background and patches via `set_row_data`.

**Verify:** Check `apply_filters` builds `notes` field from SQLite for each card. If `notes` is populated in the card model before the popover opens (which it should be, since apply_filters calls `read_notes`), D-06 may already be working. If there is a visible delay, the gap is that `apply_filters` does NOT read notes — check view_model.rs `DashboardCardViewModel` and the map-to-CardData path.

**Analog — the "immediate SQLite + background reconcile" pattern** (`main.rs` lines 3441-3472):
```rust
// on_complete (fires after GH fetch) reads SQLite again and patches the model:
let notes_model = match store.read_notes(&card_id) { ... };
cards_model.set_row_data(i, card_data);
// PATTERN: same pattern must be used for D-05 post (see above).
```

---

### D-07 — Note Dedup: "You" vs GH Handle

**File:** `crates/app/src/live_client.rs` — `save_note` (line 361), `crates/app/src/dashboard/view_model.rs` — `from_note_entry` (lines 144-147)

**Root cause** (`view_model.rs` lines 144-147):
```rust
author_display: match &n.author {
    Some(a) if !a.is_empty() => a.clone(),
    _ => "You".to_string(),   // BUG: optimistic note has author: None → "You" label
},
```

**Root cause 2** (`live_client.rs` line 361):
```rust
author: None,   // BUG: optimistic post sets author to None — GH fetch returns real handle
```

**Fix — set author to the known GH handle at post time:**

The GH handle is NOT in `AppConfig`. It must be obtained from `gh api user --jq '.login'` or cached at startup. The cleanest approach per CONTEXT `## Specific Ideas` is to add a `github_user_login` field to `AppConfig` (populated at init from `gh api user`), and use it as `author` in `save_note`.

**Alternate (simpler) approach** — use `gh api user --jq '.login'` at `LiveClient::new` time and store as `self.gh_user_login: Option<String>`:
```rust
// In LiveClient struct (live_client.rs ~line 28):
gh_user_login: Option<String>,

// In LiveClient::new (~line 62): resolve GH login at startup
let gh_user_login = resolve_gh_user_login(&gh_path);  // gh api user --jq '.login'

// In save_note (~line 361):
author: self.gh_user_login.clone(),
// → NoteEntry { date, content, author: Some("brandon") }
// → from_note_entry: author_display = "brandon"
// → GH fetch returns comment with author.login = "brandon"  
// → upsert finds exact match → exactly 1 row
```

**Analog — gh CLI subprocess pattern** (`live_client.rs` background thread, lines 377-410; also `issues_client.rs` `list_issue_comments`):
```rust
use std::process::Command;
let output = Command::new(&self.gh_path)
    .args(["api", "user", "--jq", ".login"])
    .output()
    .ok()?;
let login = String::from_utf8_lossy(&output.stdout).trim().to_string();
```

---

### D-08 — Remove X Close Button from Sidebar

**File:** `crates/app/ui/recipient-detail.slint` — lines 74-92

**Current code to remove** (`recipient-detail.slint` lines 74-92):
```slint
// REMOVE this entire block:
HorizontalLayout {
    alignment: end;
    Rectangle {
        width: 20px;
        height: 20px;
        Text {
            text: "\u{2715}";
            ...
        }
        close-touch := TouchArea {
            mouse-cursor: pointer;
            clicked => { root.close-clicked(); }
        }
    }
}
```

**Also remove** (per Claude's Discretion — decide whether to stub or delete):
- `callback close-clicked()` declaration (`recipient-detail.slint` line 47)
- `close-clicked() => { root.sidebar-close(); }` forwarding in `dashboard.slint` line 663
- `on_sidebar_close` handler in `main.rs` lines 5966-5973 (or keep stubbed)

**Analog — the FocusScope Escape handler already handles sidebar dismissal** (`recipient-detail.slint` lines 53-67):
```slint
sidebar-focus := FocusScope {
    key-pressed(event) => {
        if (event.text == Key.Escape) {
            if (root.editing-purpose || ...) {
                // cancel edits first
                return accept;
            }
            root.close-clicked();   // this line can be deleted or left as no-op
            return accept;
        }
        return reject;
    }
    ...
}
```

---

### D-09 — Purpose Pill (Colored, Click-to-Edit)

**File:** `crates/app/ui/recipient-detail.slint` — Purpose section (lines 132-194)

**Current code — labeled Purpose field** (`recipient-detail.slint` lines 132-194):
```slint
VerticalLayout {
    spacing: 2px;
    Text { text: "Purpose"; font-size: Typography.size-xs; color: Colors.text-muted; }
    // ... Rectangle display + editing Rectangle ...
}
```

**Target — colored pill with no label, inline edit reuses existing TextInput pattern:**
```slint
// Pill replaces the labeled Purpose VerticalLayout. Sits directly below the header HorizontalLayout.
// Display mode: filled pill (background = purpose-color), text centered.
// Edit mode: same TextInput pattern as existing purpose edit, but inside the pill shape.

if !root.editing-purpose : Rectangle {
    height: 24px;
    width: root.purpose-draft != "" ? purpose-pill-text.preferred-width + 20px : 70px;
    border-radius: 12px;
    background: root.purpose-color;

    purpose-pill-touch := TouchArea {
        mouse-cursor: pointer;
        clicked => {
            root.purpose-draft = root.purpose;
            root.editing-purpose = true;
        }
    }

    purpose-pill-text := Text {
        text: root.purpose != "" ? root.purpose : "Purpose";
        font-size: Typography.size-xs;
        font-weight: 600;
        color: #ffffff;
        horizontal-alignment: center;
        vertical-alignment: center;
        width: parent.width;
        height: parent.height;
    }
}
// Edit mode — TextInput inside pill shape (reuse existing pattern from lines 165-193):
if root.editing-purpose : Rectangle {
    height: 26px;
    border-radius: 13px;
    background: root.purpose-color;
    border-width: 1px;
    border-color: Colors.accent;
    purpose-input := TextInput {
        x: 8px; y: 4px;
        width: parent.width - 16px;
        height: 18px;
        text <=> root.purpose-draft;
        font-size: Typography.size-xs;
        color: #ffffff;
        accepted => {
            root.editing-purpose = false;
            if (root.purpose-draft != root.purpose) { root.save-purpose(root.purpose-draft); }
            sidebar-focus.focus();
        }
        key-pressed(event) => {
            if (event.text == Key.Escape) {
                root.editing-purpose = false;
                root.purpose-draft = root.purpose;
                sidebar-focus.focus();
                return accept;
            }
            return reject;
        }
    }
}
```

**Pill color source — same as card ring and tile ring:**
- `card.slint` line 267: `background: root.is-unassigned ? Colors.background : root.purpose-color;`
- `option-grid.slint` line 94: `background: tile-data.is-unassigned ? Colors.background : tile-data.purpose-color;`
- `recipient-detail.slint` line 104: `background: root.purpose-color;` (avatar ring already uses it)
- `populate_recipient_sidebar` (`main.rs` line 2527): `parse_hex_to_slint_color(&hex)` — existing setter call, no change needed

**Inline-edit analog** — exact same TextInput pattern already in `recipient-detail.slint` lines 165-193, `crates/app/ui/card.slint` lines (discord-username, rx-od, rx-os blocks). CRITICAL: keep `Rectangle` wrapper (NOT `HorizontalLayout`) so `TouchArea` can reference `parent.width` without binding loop — per SLINT_TIPS note in 20.1.1-PATTERNS.md line 147.

---

### D-10 — Field Order: Discord / Shopify Email / Shopify Customer at TOP

**File:** `crates/app/ui/recipient-detail.slint`

**Current order** (inside the sidebar `VerticalLayout`, after header):
1. X close button (D-08 — remove)
2. Header: avatar + name (lines 94-130)
3. Purpose field (lines 132-194) → becomes pill (D-09)
4. Rx OD (lines 196-258)
5. Rx OS (lines 260-322)
6. Copy Rx button (lines 324-341)
7. Discord username (lines 343-407)
8. Shopify email (lines 409-418)
9. Shopify customer link (lines 420-443)
10. GitHub issue link (lines 445-463)

**Target order** (D-10):
1. Header: avatar + name
2. Purpose pill (D-09)
3. **Discord username** (move from position 7)
4. **Shopify email** (move from position 8)
5. **Shopify customer link** (move from position 9)
6. Rx OD
7. Rx OS
8. Copy Rx button
9. GitHub issue link

**Pattern:** Move the existing `VerticalLayout` blocks for Discord, Shopify email, and Shopify customer link from their current positions to immediately after the Purpose pill. No code changes to their internals — cut and paste reorder only.

**Analog — field-order reorder** is a pure structural move; the Discord inline-edit pattern (`recipient-detail.slint` lines 343-407) is the template for how all editable fields look. Field internals unchanged.

---

### D-11 / D-12 — Product-Add Button Hover Visibility + Alignment

**File:** `crates/app/ui/card.slint` — add-btn Rectangle (lines 487-513)

**Current code** (`card.slint` lines 487-513):
```slint
// Round + add button — positioned after last item square (stride=52px); y centers in 80px row
Rectangle {
    x: root.item-squares.length * 52px;
    y: 18px;        // D-12: y=18px offsets button to center in 80px row — may misalign
    width: 44px;
    height: 44px;
    border-radius: 22px;
    background: add-btn-touch.has-hover ? #1a3a1a : Colors.surface-popup;
    opacity: add-btn-touch.has-hover ? 1.0 : 0.6;   // D-11: opacity fade ≠ hidden; always visible
    ...
}
```

**Pre-Phase-20.1.1 code** (commit `3fe30c1^`, i.e. commit before Row 5 removal): The button was 36x36 at `y: 0px` and ALSO always visible (no explicit `visible` guard). The "permanently visible" defect is about `opacity: 0.6` making it dim but always present. The CONTEXT says "should only appear on card hover" — this is a new rule, not a restoration.

**D-11 fix — gate entire button on card-level hover:**
```slint
// PATTERN: Add a card-level TouchArea that covers the whole card to detect hover,
// then use its has-hover to gate the add-button.
// name-area (card.slint line 122) covers only top 38px — insufficient.
// Add a new card-hover TouchArea OR use name-area.has-hover as a proxy.
//
// Recommended: add a passive card-hover TouchArea behind all content:
card-hover-zone := TouchArea {
    x: 0px;
    y: 0px;
    width: parent.width;
    height: parent.height;
    // passive — no clicked handler; only provides has-hover
}

// Then gate the add button:
if card-hover-zone.has-hover : Rectangle {
    x: root.item-squares.length * 52px;
    y: 18px;
    width: 44px;
    height: 44px;
    ...
    // opacity: always 1.0 when visible (no fade needed once hidden by default)
}
```

**D-12 fix — vertical alignment in 80px row:**

Pre-Row5-removal button was 36x36 at `y: 0px` inside a row that was smaller. After Row 5 removal, the row grew to 80px and the button was sized to 44x44 at `y: 18px` to center it (`(80 - 44) / 2 = 18`). The "lopsided" offset suggests the item squares DON'T start at `y: 0px` in the 80px row.

**Check item square y-positions** (`card.slint` line 389): item squares are at `y: 0px`, height 80px. The button at `y: 18px` should center it within the 80px row. The "lopsided" complaint may be relative to the item square's content (images sit at top of 46px square, labels below). Fix: set `y` to align the button's center with the item square images' center:

```slint
// Item square image is in a 46x46 clip at y:0 inside the 80px outer rect.
// Center of 44px button in 80px row: y = (80 - 44) / 2 = 18px (currently 18px — correct math)
// If still visually off, compare against sq-touch.y + sq-touch.height/2:
y: 0px;                   // align top with item squares
// OR keep y:18px but verify image rect position in item squares
```

**Analog — item-square y layout** (`card.slint` lines 387-392):
```slint
for sq[sq-index] in root.item-squares : Rectangle {
    x: sq-index * 52px;
    y: 0px;           // item squares start at y:0 in the 80px row
    width: 46px;
    height: 80px;
    ...
}
```

---

## Shared Patterns

### Inline-Edit: Rectangle Wrapper (NOT HorizontalLayout)

**Source:** `crates/app/ui/recipient-detail.slint` lines 136-193 (purpose), 200-258 (rx-od), 343-407 (discord)
**Apply to:** D-09 Purpose pill edit, any new editable field
```slint
// CRITICAL: Use Rectangle wrapper. TouchArea inside HorizontalLayout cannot reference
// parent.width without a binding loop. Rectangle wrapper breaks the cycle.
// See SLINT_TIPS commentary in 20.1.1-PATTERNS.md line 147.
if !root.editing-X : Rectangle {
    height: 22px;
    x-row-touch := TouchArea {
        width: parent.width;
        height: parent.height;
        ...
    }
    HorizontalLayout { ... }
}
if root.editing-X : Rectangle {
    height: 26px;
    border-radius: 4px;
    background: Colors.background;
    border-width: 1px;
    border-color: Colors.accent;
    x-input := TextInput {
        x: 6px; y: 4px;
        width: parent.width - 12px;
        height: 18px;
        ...
        accepted => { root.editing-X = false; sidebar-focus.focus(); }
        key-pressed(event) => { if Escape: cancel + sidebar-focus.focus(); return accept; }
    }
}
```

### Targeted Notes Model Patch (Do NOT call apply_filters)

**Source:** `crates/app/src/main.rs` lines 3462-3472 (on_card_notes_popover_opened on_complete)
**Apply to:** D-05 (on_card_post_note fix), D-06 (background fetch reconcile)
```rust
// Pattern: read notes from SQLite for one card; update just that card's row in Slint model
let cards_model = w.get_cards();
for i in 0..cards_model.row_count() {
    if let Some(mut card_data) = cards_model.row_data(i) {
        if card_data.package_id.as_str() == card_id {
            card_data.notes = notes_model;
            cards_model.set_row_data(i, card_data);
            break;
        }
    }
}
// NEVER call apply_filters here — it rebuilds the entire card model,
// collapsing any open PopupWindow (notes popover loses state).
```

### Sidebar Restore on Tab Return (ByProductShipped analog)

**Source:** `crates/app/src/main.rs` lines 3348-3359 — `ByProductShipped` restore in `on_tab_clicked`
**Apply to:** D-02 (ByRecipient restore block)
```rust
// Existing ByProductShipped block (analog):
if mode == DiscoveryMode::ByProductShipped {
    let selected_tile = rt.borrow().current_mode_state().selected_tile.clone();
    if let Some(tile_name) = selected_tile {
        if let Some((store, _)) = &rx_handle {
            if populate_product_sidecar(&w, store, &all_cards_snapshot, &tile_name) {
                w.set_product_detail_visible(true);
            }
        }
    }
}
// Clone this structure for ByRecipient:
// populate_recipient_sidebar(&w, store, &rid, &cfg.shopify_store_slug);
// w.set_recipient_detail_visible(true);
```

### Ring / Purpose Color Source

**Source:** `crates/app/src/main.rs` `populate_recipient_sidebar` lines 2526-2527
**Apply to:** D-09 pill (color is already wired; sidebar gets `detail-recipient-purpose-color` from Rust)
```rust
let hex = row.purpose_color.clone().unwrap_or_else(|| "#ffffff".to_string());
window.set_detail_recipient_purpose_color(parse_hex_to_slint_color(&hex));
```
In Slint, the pill reads `root.purpose-color` (already `in property` on `RecipientDetailPanel`). No new plumbing needed — the color is already delivered via `dashboard.slint` line 638: `purpose-color: root.detail-recipient-purpose-color;`.

### VerticalLayout in Clipped Rectangle (NOT in Flickable)

**Source:** `code_tips/SLINT_TIPS.md` (VerticalLayout-in-Flickable bottom-align bug)
**Apply to:** D-04 (content-driven notes row heights)
```slint
// SAFE: VerticalLayout inside a CLIPPED RECTANGLE — items align from top
Rectangle {
    vertical-stretch: 1;
    clip: true;
    VerticalLayout {
        alignment: start;
        for item in model : Rectangle { /* no fixed height */ }
    }
}
// UNSAFE: VerticalLayout inside Flickable — items bottom-align regardless of alignment: start
```

---

## No Analog Found

All decisions map to existing patterns in the codebase. No entries needed.

---

## Metadata

**Analog search scope:** `crates/app/ui/` (dashboard.slint, card.slint, recipient-detail.slint, option-grid.slint), `crates/app/src/` (main.rs, live_client.rs, dashboard/view_model.rs, config.rs), `crates/integrations/src/github/issues_client.rs`
**Files read directly:** dashboard.slint, recipient-detail.slint, card.slint, option-grid.slint, main.rs (key sections), live_client.rs (save_note), view_model.rs (NoteDisplayEntry), config.rs
**Git revisions examined:** `3fe30c1^` (pre-Row5-removal card.slint), `e2350a4` (Phase 19.1 card.slint)
**Pattern extraction date:** 2026-04-15
