# Phase 20.1.1: Notes History Popover + Recipient Detail Sidebar - Pattern Map

**Mapped:** 2026-04-15
**Files analyzed:** 11 new/modified files
**Analogs found:** 11 / 11

---

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `crates/app/ui/card.slint` | component | request-response | self (existing) | self-modification |
| `crates/app/ui/recipient-detail.slint` | component | request-response | `crates/app/ui/product-detail.slint` | exact |
| `crates/app/ui/dashboard.slint` | component | request-response | self (existing) | self-modification |
| `crates/app/ui/option-grid.slint` | component | event-driven | self (existing) | self-modification |
| `crates/core/src/domain/note.rs` | model | transform | self (existing) | self-modification |
| `crates/service/src/db/migrations/V012__notes_author.sql` | migration | CRUD | `V011__add_shopify_order_name.sql` | exact |
| `crates/service/src/db/sqlite.rs` | service | CRUD | self (existing) | self-modification |
| `crates/integrations/src/github/issues_client.rs` | service | request-response | `create_issue_comment` method (within same file) | exact |
| `crates/app/src/live_client.rs` | service | request-response | `save_note` method (within same file) | exact |
| `crates/app/src/dashboard/view_model.rs` | model | transform | self (existing) | self-modification |
| `crates/app/src/main.rs` | controller | event-driven | `on_tile_clicked` wiring (within same file) | exact |

---

## Pattern Assignments

### `crates/app/ui/recipient-detail.slint` (NEW component, request-response)

**Analog:** `crates/app/ui/product-detail.slint`

**Imports pattern** (product-detail.slint lines 1-2):
```slint
import { Colors, Typography } from "tokens.slint";
```

**Component shell pattern** (product-detail.slint lines 10-59):
```slint
export component RecipientDetailPanel inherits Rectangle {
    // in properties for data
    // in-out properties for editing state (editing-discord-username, rx-od-draft, etc.)
    // callbacks for saves

    width: 320px;
    background: Colors.surface;
    border-radius: 6px;

    VerticalLayout {
        spacing: 0px;
        alignment: stretch;

        // Fixed header area
        VerticalLayout {
            vertical-stretch: 0;
            padding: 12px;
            spacing: 8px;
            // avatar + large name row (HorizontalLayout, spacing: 8px)
            // data rows below
        }

        // Scrollable unit list area (if needed)
        Rectangle {
            clip: true;
            vertical-stretch: 1;
            // absolute y-positioned rows
        }
    }
}
```

**Header layout pattern — image + name side-by-side** (product-detail.slint lines 70-139):
```slint
HorizontalLayout {
    spacing: 8px;
    alignment: stretch;

    // Avatar (64x64 circle, clip: true) on left
    Rectangle {
        width: 64px;
        height: 64px;
        border-radius: 32px;
        clip: true;
        background: Colors.border-default;
        if root.has-avatar-image : Image {
            source: root.avatar-image;
            width: 64px;
            height: 64px;
            image-fit: cover;
        }
        if !root.has-avatar-image : Text {
            text: root.recipient-initial;
            font-size: Typography.size-lg;
            color: Colors.avatar-text;
            horizontal-alignment: center;
            vertical-alignment: center;
        }
    }

    // Name on right in large font
    VerticalLayout {
        spacing: 0px;
        alignment: center;
        Text {
            text: root.recipient-name;
            font-size: Typography.size-lg;
            font-weight: 700;
            color: Colors.text-primary;
            wrap: word-wrap;
        }
    }
}
```

**External link pattern — conditional render** (product-detail.slint lines 148-191):
```slint
// Render link only when URL is populated; show "Not linked" otherwise
if root.shopify-url != "" : Rectangle {
    height: 18px;
    Text {
        x: 0px;
        text: "View Shopify Profile";
        font-size: Typography.size-sm;
        color: Colors.accent;
        vertical-alignment: center;
    }
    shopify-touch := TouchArea {
        mouse-cursor: pointer;
        clicked => { root.view-shopify-clicked(root.recipient-id); }
    }
}
if root.shopify-url == "" : Text {
    text: "Not linked";
    font-size: Typography.size-sm;
    color: Colors.text-dim;
}
// ww-recipient issue link — conditional, placeholder only (no dead link)
if root.recipient-issue-url != "" : Rectangle {
    height: 18px;
    Text { x: 0px; text: root.recipient-issue-url; font-size: Typography.size-sm; color: Colors.accent; vertical-alignment: center; }
    TouchArea { mouse-cursor: pointer; clicked => { root.view-recipient-issue-clicked(root.recipient-id); } }
}
```

**Discord inline-edit pattern** (card.slint lines 1111-1180):
```slint
// CRITICAL: Use Rectangle wrapper (NOT HorizontalLayout) so TouchArea can ref parent.width without binding loop
// Source: card.slint:1110 comment + Phase 16.1-03 context

// Display mode with pencil affordance
if !root.editing-discord-username : Rectangle {
    height: 20px;

    discord-row-touch := TouchArea {
        width: parent.width;
        height: parent.height;
        mouse-cursor: pointer;
        clicked => {
            root.discord-username-draft = root.discord-username;
            root.editing-discord-username = true;
        }
    }

    HorizontalLayout {
        spacing: 4px;
        alignment: start;
        Text {
            text: root.discord-username-draft != "" && root.discord-username-draft != root.discord-username
                ? root.discord-username-draft
                : (root.discord-username != "" ? root.discord-username : "\u{2014}");
            font-size: Typography.size-sm;
            color: root.discord-username-draft != "" && root.discord-username-draft != root.discord-username
                ? Colors.accent : Colors.text-muted;
            vertical-alignment: center;
        }
        Text {
            text: "\u{270F}";
            font-size: Typography.size-xs;
            color: discord-row-touch.has-hover ? Colors.text-secondary : Colors.text-muted;
            vertical-alignment: center;
        }
    }
}

// Edit mode
if root.editing-discord-username : Rectangle {
    height: 26px;
    border-radius: 4px;
    background: Colors.background;
    border-width: 1px;
    border-color: Colors.accent;

    discord-username-input := TextInput {
        x: 6px; y: 4px;
        width: parent.width - 12px;
        height: 18px;
        text <=> root.discord-username-draft;
        font-size: Typography.size-sm;
        color: Colors.text-primary;
        accepted => {
            root.editing-discord-username = false;
            if (root.discord-username-draft != root.discord-username) {
                root.save-discord-username(root.discord-username-draft);
            }
            sidebar-focus.focus();    // return focus to FocusScope
        }
        key-pressed(event) => {
            if event.text == Key.Escape {
                root.editing-discord-username = false;
                root.discord-username-draft = root.discord-username;
                sidebar-focus.focus();
                return accept;
            }
            return reject;
        }
    }
}
```

**Editing state declaration rule** (card.slint lines 94-113):
```slint
// NOTE: All mutable editing state MUST live on the component, NOT inside PopupWindow.
// PopupWindow re-inits on every show(); state declared here survives close/reopen.
// For the sidebar this means state lives on RecipientDetailPanel or DashboardWindow, not inside any popup.
in-out property <bool> editing-discord-username: false;
in-out property <string> discord-username-draft: "";
in-out property <bool> editing-rx-od: false;
in-out property <bool> editing-rx-os: false;
in-out property <string> rx-od-draft: "";
in-out property <string> rx-os-draft: "";
in-out property <bool> editing-purpose: false;
in-out property <string> purpose-draft: "";
```

---

### `crates/app/ui/card.slint` — notes popover addition + Row 5 removal (component, request-response)

**Analog:** existing `summary-popup` in same file (card.slint lines 1011-1040)

**PopupWindow pattern with FocusScope** (card.slint lines 1011-1026):
```slint
// COPY THIS EXACT STRUCTURE for notes-popup
notes-popup := PopupWindow {
    close-policy: no-auto-close;          // REQUIRED — auto-close blocks TextInput focus
    x: parent.width - 54px;              // anchor to info-icon-rect x position
    y: root.height + 4px;               // below the card, 4px gap

    popup-focus := FocusScope {
        key-pressed(event) => {
            if event.text == Key.Escape {
                notes-popup.close();
                return accept;
            }
            return reject;
        }

        Rectangle {
            background: Colors.surface-popup;
            border-radius: 8px;
            border-width: 1px;
            border-color: Colors.border-muted;
            width: 280px;
            // fixed height; internal scroll via clipped Rectangle
        }
    }
}
```

**Scrollable list CORRECT pattern** (SLINT_TIPS.md + RESEARCH.md Area 1):
```slint
// WRONG — VerticalLayout in Flickable always bottom-aligns (SLINT_TIPS.md)
// Flickable { VerticalLayout { alignment: start; for note in notes: ... } }

// CORRECT — clipped Rectangle with absolute y-positioning
Rectangle {
    clip: true;
    vertical-stretch: 1;

    property <length> note-content-height: root.notes.length * 56px + 8px;

    for note[idx] in root.notes : Rectangle {
        x: 8px;
        y: idx * 56px + 4px;
        width: parent.width - 16px;
        height: 52px;
        border-radius: 4px;
        background: Colors.surface;
        HorizontalLayout {
            padding: 8px;
            spacing: 4px;
            // author + timestamp on one line, content below
        }
    }
}
```
The parent VerticalLayout must have `alignment: stretch`; header/composer siblings must have `vertical-stretch: 0`.

**Composer state — MUST live on RecipientCard** (card.slint lines 94-98 pattern):
```slint
// On RecipientCard (NOT inside PopupWindow — PopupWindow re-inits on show)
in-out property <bool> composer-expanded: false;
in-out property <string> note-draft: "";
```

**Ctrl+Enter submit pattern** (RESEARCH.md Area 1):
```slint
note-composer-input := TextInput {
    single-line: false;
    key-pressed(event) => {
        if event.modifiers.control && event.text == Key.Return {
            root.post-note(root.note-draft);
            root.note-draft = "";
            root.composer-expanded = false;
            return accept;
        }
        if event.text == Key.Escape {
            root.composer-expanded = false;
            root.note-draft = "";
            popup-focus.focus();
            return accept;
        }
        return reject;
    }
}
```

**info-icon-rect TouchArea to swap** (card.slint lines 188-203):
```slint
// BEFORE (lines 188-203) — opens summary-popup
info-hover := TouchArea {
    mouse-cursor: pointer;
    clicked => {
        root.editing-rx-od = false;
        ...
        root.summary-clicked();
        summary-popup.show();
    }
}

// AFTER — opens notes-popup, fires on-notes-popover-opened callback
info-hover := TouchArea {
    mouse-cursor: pointer;
    clicked => {
        notes-popup.show();
        root.on-notes-popover-opened(root.recipient-id);
    }
}
```

**name-area TouchArea to swap** (card.slint lines 144-164):
```slint
// BEFORE (lines 144-164) — summary-popup.show()
// AFTER — fire card-name-clicked callback, no popup
name-area := TouchArea {
    x: 0px; y: 0px; width: parent.width; height: 38px;
    mouse-cursor: pointer;
    clicked => {
        root.card-name-clicked(root.recipient-id);
    }
}
```

**Row 5 block to delete** (card.slint lines 557-666):
Full block from `// Row 5 note preview` through closing brace. Before deleting, verify and remove:
- `in property <string> note-preview` (card.slint ~line 22)
- `in-out property <bool> editing-note` (card.slint ~line 51)
- `in-out property <string> note-draft` (card.slint ~line 52)
- `callback save-note(string)` (card.slint ~line 72)
- `callback summary-clicked()` (card.slint ~line 73)

---

### `crates/app/ui/dashboard.slint` — new props, sidebar mount, CardData cleanup (component, request-response)

**Analog:** existing `ProductDetailPanel` mount pattern (dashboard.slint lines 621-644)

**ProductDetailPanel mount to copy for RecipientDetailPanel** (dashboard.slint lines 621-644):
```slint
// EXISTING PATTERN — copy/adapt for recipient-detail sidebar
if !root.show-option-grid && root.product-detail-visible : ProductDetailPanel {
    x: parent.width - 326px;
    y: 6px;
    width: 320px;
    height: parent.height - 12px;
    // data bindings...
}

// NEW — show on option grid (not filtered card view), ByRecipient mode only
if root.show-option-grid && root.show-recipient-grid && root.recipient-detail-visible : RecipientDetailPanel {
    x: parent.width - 326px;
    y: 6px;
    width: 320px;
    height: parent.height - 12px;
    // recipient data bindings + edit callbacks
}
```

**RecipientGrid width-shrink pattern** (dashboard.slint lines 595-605 + 646-653 analogy):
```slint
// EXISTING: card-flickable shrinks when product-detail-visible
// width: root.product-detail-visible && !root.show-option-grid ? parent.width - 330px : parent.width;

// NEW: RecipientGrid shrinks when recipient-detail-visible
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;
    tiles: root.recipient-tiles;
    tile-clicked(name) => { root.tile-clicked(name); }
}
```

**CardData struct fields to remove** (dashboard.slint lines 26, 706):
```slint
// REMOVE from CardData struct (line ~26):
note-preview: string,

// REMOVE from for card-data loop (line ~706):
note-preview: card-data.note-preview;

// REMOVE callbacks (line ~157, ~751-753):
callback card-save-note(int, string);
save-note(note-text) => { root.card-save-note(card-index, note-text); }
```

**New properties to add to DashboardWindow** (modeled after product-detail-visible pattern at line 247):
```slint
// Recipient detail sidebar properties (mirrors product-detail-visible pattern)
in property <bool> recipient-detail-visible: false;
in property <string> detail-recipient-id: "";
in property <string> detail-recipient-name: "";
in property <image> detail-recipient-avatar: {};
in property <bool> detail-recipient-has-avatar: false;
in property <string> detail-recipient-initial: "";
in property <color> detail-recipient-purpose-color: #ffffff;
in property <string> detail-recipient-purpose: "";
in property <string> detail-recipient-vision-rx-od: "";
in property <string> detail-recipient-vision-rx-os: "";
in property <string> detail-recipient-discord-username: "";
in property <string> detail-recipient-email: "";
in property <string> detail-recipient-shopify-url: "";
in property <string> detail-recipient-shopify-customer-url: "";

// Navigation callback: dashboard card name click -> Recipients tab + select
callback card-name-navigate(string);  // recipient_id
```

---

### `crates/app/ui/option-grid.slint` — tile selection state (component, event-driven)

**Analog:** existing tile-clicked callback pattern (option-grid.slint lines 29, 53-140)

**Current tile-clicked wiring** (option-grid.slint line 29):
```slint
export component RecipientGrid inherits Rectangle {
    in property <[RecipientTileData]> tiles;
    callback tile-clicked(string);
    // ...
    for tile-data[tile-index] in root.tiles : Rectangle {
        // ...
        tile-touch := TouchArea {
            mouse-cursor: pointer;
            clicked => { root.tile-clicked(tile-data.name); }
        }
    }
}
```

**Add selected-tile highlight** (mirror ProductGrid or add to RecipientTileData):
```slint
// Add to RecipientGrid:
in property <string> selected-tile-name: "";

// Use in tile render:
border-width: (tile-data.name == root.selected-tile-name || tile-touch.has-hover) ? 1px : 0px;
border-color: Colors.accent;
background: tile-data.name == root.selected-tile-name
    ? Colors.surface-elevated
    : tile-touch.has-hover ? Colors.surface-popup : Colors.surface;
```

---

### `crates/service/src/db/migrations/V012__notes_author.sql` (NEW migration, CRUD)

**Analog:** `V011__add_shopify_order_name.sql` (single ALTER TABLE ADD COLUMN)

**Pattern** (V011__add_shopify_order_name.sql, full file):
```sql
-- V011: Add shopify_order_name column to cards (e.g. "#BS039489663")
ALTER TABLE cards ADD COLUMN shopify_order_name TEXT;
```

**Apply same pattern** (V012):
```sql
-- V012: Add author column to notes for GH handle tracking (Phase 20.1.1, D-05)
ALTER TABLE notes ADD COLUMN author TEXT;
```

**CRITICAL: Windows CRLF warning** (SQLITE_TIPS.md):
Write this file with LF line endings only. Git's `core.autocrlf` can convert to CRLF, causing refinery `DivergentVersion` panic at startup. Verify with a binary check after creation.

---

### `crates/core/src/domain/note.rs` — add author field (model, transform)

**Analog:** self (current file, 8 lines)

**Current struct** (note.rs lines 1-8):
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoteEntry {
    pub date: String,    // ISO 8601
    pub content: String,
}
```

**Target struct:**
```rust
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct NoteEntry {
    pub date: String,       // ISO 8601
    pub content: String,
    pub author: Option<String>,  // GH handle, or None for optimistic local notes
}
```

Add `Default` derive so `NoteEntry { date, content, ..Default::default() }` compiles at all ~12 existing construction sites without touching each one. Then update the critical sites (sqlite.rs read_notes, live_client.rs save_note) to populate author properly.

---

### `crates/service/src/db/sqlite.rs` — notes read/write with author (service, CRUD)

**Analog:** `save_note` + `read_notes` methods (sqlite.rs lines 582-607)

**Current save_note** (sqlite.rs lines 584-591):
```rust
pub fn save_note(&self, card_id: &str, note: &NoteEntry) -> Result<(), rusqlite::Error> {
    let conn = self.conn.lock().unwrap();
    conn.execute(
        "INSERT INTO notes (card_id, note_date, content) VALUES (?1, ?2, ?3)",
        params![card_id, note.date, note.content],
    )?;
    Ok(())
}
```

**Updated save_note** (add author column):
```rust
pub fn save_note(&self, card_id: &str, note: &NoteEntry) -> Result<(), rusqlite::Error> {
    let conn = self.conn.lock().unwrap();
    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],
    )?;
    Ok(())
}
```

**Current read_notes** (sqlite.rs lines 594-607):
```rust
pub fn read_notes(&self, card_id: &str) -> Result<Vec<NoteEntry>, rusqlite::Error> {
    let conn = self.conn.lock().unwrap();
    let mut stmt = conn.prepare(
        "SELECT note_date, content FROM notes WHERE card_id = ?1 ORDER BY id",
    )?;
    let notes = stmt
        .query_map(params![card_id], |row| {
            Ok(NoteEntry {
                date: row.get(0)?,
                content: row.get(1)?,
            })
        })?
        .collect::<Result<Vec<_>, _>>()?;
    Ok(notes)
}
```

**Updated read_notes** (add author, newest-first for D-04):
```rust
pub fn read_notes(&self, card_id: &str) -> Result<Vec<NoteEntry>, rusqlite::Error> {
    let conn = self.conn.lock().unwrap();
    let mut stmt = conn.prepare(
        "SELECT note_date, content, author FROM notes WHERE card_id = ?1 ORDER BY id DESC",
    )?;
    let notes = stmt
        .query_map(params![card_id], |row| {
            Ok(NoteEntry {
                date: row.get(0)?,
                content: row.get(1)?,
                author: row.get(2)?,
            })
        })?
        .collect::<Result<Vec<_>, _>>()?;
    Ok(notes)
}
```

**New upsert_notes_for_card method** (diff-based, SQLITE_TIPS.md pattern — avoids DELETE + re-INSERT wiping synced_at):
```rust
/// Upsert notes from a GH comment fetch. Inserts new notes, leaves existing rows untouched.
/// Keyed on (card_id, note_date, content) — avoids wiping synced_at on existing rows.
/// SQLITE_TIPS.md: never DELETE + re-INSERT; diff instead.
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 {
        conn.execute(
            "INSERT INTO notes (card_id, note_date, content, author)
             VALUES (?1, ?2, ?3, ?4)
             ON CONFLICT DO NOTHING",   // keyed on implicit rowid — use unique index if needed
            params![card_id, note.date, note.content, note.author],
        )?;
    }
    Ok(())
}
```

---

### `crates/integrations/src/github/issues_client.rs` — add list_issue_comments (service, request-response)

**Analog:** `create_issue_comment` (issues_client.rs lines 382-406) and `list_issues_by_label` (lines 427-455)

**create_issue_comment pattern** (lines 385-406) — gh CLI subprocess + check_output:
```rust
pub fn create_issue_comment(&self, issue_number: i64, body: &str) -> Result<(), GhIssuesError> {
    let number_str = issue_number.to_string();
    let output = Command::new(&self.gh_path)
        .args(["issue", "comment", &number_str, "--repo", &self.repo_slug(), "--body-file", ...])
        .output()
        .map_err(|e| GhIssuesError::Transport(format!("gh exec failed: {}", e)))?;
    self.check_output(&output)?;
    Ok(())
}
```

**list_issues_by_label pattern** (lines 427-455) — JSON parsing via serde_json::from_str:
```rust
pub fn list_issues_by_label(&self, label: &str) -> Result<Vec<GhIssueRow>, GhIssuesError> {
    let output = Command::new(&self.gh_path)
        .args(["issue", "list", "--repo", &self.repo_slug(), "--json", "number,title,body,url", ...])
        .output()
        .map_err(|e| GhIssuesError::Transport(format!("gh exec failed: {}", e)))?;
    self.check_output(&output)?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    let rows: Vec<GhIssueRow> = serde_json::from_str(&stdout).map_err(|e| {
        GhIssuesError::Transport(format!("Failed to parse response: {}", e))
    })?;
    Ok(rows)
}
```

**New struct + method to add** (after line 60 for struct, after `create_issue_comment` for method):
```rust
// New data struct (after GhSubIssueRow ~line 60):
#[derive(Debug, Clone, serde::Deserialize)]
pub struct GhIssueComment {
    pub body: String,
    #[serde(rename = "createdAt")]
    pub created_at: String,    // ISO 8601
    pub author: GhCommentAuthor,
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct GhCommentAuthor {
    pub login: String,
}

// New method on GhIssuesClient impl (after create_issue_comment):
/// List comments on a GH issue, filtered to ww-note comments.
/// Uses: gh issue view {number} --repo {owner/repo} --comments --json comments
pub fn list_issue_comments(&self, issue_number: i64) -> Result<Vec<GhIssueComment>, GhIssuesError> {
    let number_str = issue_number.to_string();
    let output = Command::new(&self.gh_path)
        .args([
            "issue",
            "view",
            &number_str,
            "--repo",
            &self.repo_slug(),
            "--comments",
            "--json",
            "comments",
        ])
        .output()
        .map_err(|e| GhIssuesError::Transport(format!("gh exec failed: {}", e)))?;
    self.check_output(&output)?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    // Response: { "comments": [ { "body": "...", "createdAt": "...", "author": { "login": "..." } } ] }
    let val: serde_json::Value = serde_json::from_str(&stdout)
        .map_err(|e| GhIssuesError::Transport(format!("Failed to parse comments: {}", e)))?;
    let comments: Vec<GhIssueComment> = serde_json::from_value(val["comments"].clone())
        .map_err(|e| GhIssuesError::Transport(format!("Failed to parse comment array: {}", e)))?;
    Ok(comments)
}
```

**format_note_comment** (issues_client.rs lines 553-559) — read-back parser must invert this:
```rust
// Existing writer (lines 553-559):
pub fn format_note_comment(content: &str) -> String {
    let quoted = content
        .lines()
        .map(|line| format!("> _{}_", line))
        .collect::<Vec<_>>()
        .join("\n");
    format!("`ww-note`\n\n{}", quoted)
}

// New parser (pure free function, add after format_note_comment):
/// Parse a ww-note comment body back to plain text.
/// Returns None if the comment is not a ww-note.
pub fn parse_note_comment(body: &str) -> Option<String> {
    let trimmed = body.trim();
    if !trimmed.starts_with("`ww-note`") {
        return None;
    }
    let content = trimmed
        .lines()
        .skip(1)                         // skip "`ww-note`"
        .filter(|l| !l.trim().is_empty())
        .map(|line| {
            // Strip "> _" prefix and trailing "_"
            let s = line.trim_start_matches("> _");
            s.trim_end_matches('_')
        })
        .collect::<Vec<_>>()
        .join("\n");
    Some(content)
}
```

---

### `crates/app/src/live_client.rs` — add fetch_notes_for_card, update save_note (service, request-response)

**Analog:** existing `save_note` method (live_client.rs lines 350-422)

**save_note pattern to extend** (lines 350-422):
```rust
fn save_note(&self, package_id: &str, note: &str) -> Result<(), String> {
    use wit_core::domain::note::NoteEntry;
    let now = chrono_free_date();
    let entry = NoteEntry {
        date: now,
        content: note.to_string(),
        // ADD: author: None — displayed as "You" until GH confirms + popover refresh
        ..Default::default()
    };
    self.store
        .save_note(package_id, &entry)
        .map_err(|e| format!("SQLite note write error: {:?}", e))?;
    // ... existing background thread for GH comment (lines 361-418 unchanged) ...
    Ok(())
}
```

**New fetch_notes_for_card method** (model after save_note at line 422):
```rust
/// Targeted GH refresh for a single card's ww-note history (D-11).
/// Spawns a background thread; updates SQLite; triggers Slint event-loop rebuild via callback.
/// Pattern: mirrors the background thread in save_note (lines 369-418).
pub fn fetch_notes_for_card(&self, card_id: &str, on_complete: Arc<dyn Fn() + Send + Sync>) {
    let card_id_owned = card_id.to_string();
    let store_clone = Arc::clone(&self.store);
    if let Some(ref gh_client) = self.gh_issues {
        let gh_clone = Arc::clone(gh_client);
        std::thread::spawn(move || {
            // 1. Look up github_issue_number from SQLite
            let cards = match store_clone.read_all_cards() {
                Ok(c) => c,
                Err(e) => { eprintln!("[note-fetch] Failed to read cards: {:?}", e); return; }
            };
            let issue_number = cards.iter()
                .find(|c| c.card_id == card_id_owned)
                .and_then(|c| c.github_issue_number);

            if let Some(number) = issue_number {
                // 2. Fetch comments from GH
                match gh_clone.list_issue_comments(number) {
                    Ok(comments) => {
                        // 3. Parse ww-note comments into NoteEntry rows
                        let notes: Vec<NoteEntry> = comments.iter()
                            .filter_map(|c| {
                                integrations::github::issues_client::parse_note_comment(&c.body)
                                    .map(|content| NoteEntry {
                                        date: c.created_at.clone(),
                                        content,
                                        author: Some(c.author.login.clone()),
                                    })
                            })
                            .collect();
                        // 4. Diff-based upsert (SQLITE_TIPS.md — preserves synced_at)
                        let _ = store_clone.upsert_notes_for_card(&card_id_owned, &notes);
                        // 5. Trigger Slint event-loop rebuild
                        on_complete();
                    }
                    Err(e) => eprintln!("[note-fetch] list_issue_comments failed: {:?}", e),
                }
            }
        });
    }
}
```

**slint::invoke_from_event_loop pattern** (live_client.rs lines 142-148, main.rs lines 2686-2690):
```rust
// Pattern for the on_complete callback wiring in main.rs:
let window_weak = window.as_weak();
let on_complete: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
    let ww = window_weak.clone();
    let _ = slint::invoke_from_event_loop(move || {
        if let Some(w) = ww.upgrade() {
            // rebuild card model from SQLite
            // e.g. refresh_single_card(&w, &store, &card_id)
        }
    });
});
live.fetch_notes_for_card(&card_id, on_complete);
```

---

### `crates/app/src/dashboard/view_model.rs` — add notes field, remove note_preview (model, transform)

**Analog:** existing `DashboardCardViewModel` struct (view_model.rs lines 8-56)

**Fields to add:**
```rust
// Add to DashboardCardViewModel (after last_activity_products line ~48):
pub notes: Vec<NoteEntry>,    // loaded from SQLite; newest-first (D-04)
```

**Fields to remove:**
```rust
// REMOVE from DashboardCardViewModel (line ~15):
pub note_preview: String,
```

**NoteDisplayData struct** (new, for Slint binding — computed in view_model_to_card_data):
```rust
// The Slint UI needs relative timestamps computed in Rust (Slint has no date arithmetic)
// Add a helper struct or compute inline:
// note-relative-time: "2h ago" | "3d ago" | "just now"
// note-date: ISO 8601 (for tooltip)
// note-author: "username" | "You" (for None author)
// note-content: plain text
```

---

### `crates/app/src/main.rs` — wire new callbacks (controller, event-driven)

**Analog:** `on_tile_clicked` wiring (main.rs lines 3295-3319) and `slint::invoke_from_event_loop` pattern (lines 2686-2690)

**on_tile_clicked behavior change for ByRecipient** (main.rs lines 3301-3319):
```rust
// EXISTING — unconditionally calls apply_filters (switches to filtered card view):
window.on_tile_clicked(move |name| {
    let mode = { let mut r = rt.borrow_mut(); r.select_tile(&name); r.current_mode() };
    if let Some(w) = weak.upgrade() {
        apply_filters(&w, &cards.borrow(), &rt.borrow());
        if mode == DiscoveryMode::ByProductShipped { /* show product sidecar */ }
    }
});

// MODIFIED — add ByRecipient branch:
window.on_tile_clicked(move |name| {
    let mode = { let mut r = rt.borrow_mut(); r.select_tile(&name); r.current_mode() };
    if let Some(w) = weak.upgrade() {
        if mode == DiscoveryMode::ByRecipient {
            // D-19: stay on option grid; show recipient sidebar instead of filtered card view
            // populate recipient data from SQLite, call w.set_recipient_detail_visible(true)
            // do NOT call apply_filters here
        } else {
            apply_filters(&w, &cards.borrow(), &rt.borrow());
            if mode == DiscoveryMode::ByProductShipped {
                // existing product sidecar logic
            }
        }
    }
});
```

**New callback wiring to add** (after on_tile_clicked block, modeled on lines 3295-3319):
```rust
// Wire card name navigation (D-19)
{
    let weak = window.as_weak();
    let rt = runtime.clone();
    window.on_card_name_navigate(move |recipient_id| {
        let rid = recipient_id.to_string();
        if let Some(w) = weak.upgrade() {
            // 1. Switch to ByRecipient tab (index 2)
            w.invoke_tab_clicked(2);
            // 2. Select the tile in runtime state
            rt.borrow_mut().select_tile(&rid);
            // 3. Populate + show recipient-detail sidebar
            // populate_recipient_sidecar(&w, &store, &rid);
            w.set_recipient_detail_visible(true);
        }
    });
}

// Wire notes popover opened callback (D-11 targeted GH refresh)
{
    let weak = window.as_weak();
    let live_clone = /* Arc<LiveClient> */;
    window.on_notes_popover_opened(move |card_id| {
        let cid = card_id.to_string();
        let ww = weak.clone();
        let on_complete: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
            let w2 = ww.clone();
            let _ = slint::invoke_from_event_loop(move || {
                if let Some(w) = w2.upgrade() {
                    // rebuild card model
                }
            });
        });
        live_clone.fetch_notes_for_card(&cid, on_complete);
    });
}
```

**invoke_tab_clicked exists** (verified at main.rs line 1899):
```rust
w.invoke_tab_clicked(idx);  // already wired — safe to call from new navigation callback
```

---

## Shared Patterns

### PopupWindow State Isolation
**Source:** `crates/app/ui/card.slint` lines 94-113, RESEARCH.md Area 1
**Apply to:** notes-popup in card.slint
All mutable state (`composer-expanded`, `note-draft`) MUST be declared as `in-out` properties on `RecipientCard`, not inside the `PopupWindow`. `PopupWindow` re-inits on every `show()` call — any state declared inside it is lost on close.

### slint::invoke_from_event_loop for Background Thread UI Updates
**Source:** `crates/app/src/live_client.rs` lines 142-148, `crates/app/src/main.rs` lines 2686-2690
**Apply to:** `fetch_notes_for_card` callback, `on_notes_popover_opened` wiring in main.rs
```rust
let ww = window.as_weak();
let _ = slint::invoke_from_event_loop(move || {
    if let Some(w) = ww.upgrade() {
        // UI update here — safe on Slint event loop thread
    }
});
```

### pending_edit Queue Pattern for GH Writes
**Source:** `crates/app/src/live_client.rs` lines 399-415
**Apply to:** Any new GH write that can fail (notes post path already uses this — do not duplicate)
```rust
let payload = serde_json::json!({
    "card_id": card_id_owned,
    "issue_number": number,
    "content": note_content,
}).to_string();
let _ = store_clone.insert_pending_edit("card", &card_id_owned, "SaveNote", &payload);
crate::dashboard::pending_edit_flusher::notify();
```

### Error Toast Pattern (no inline error UI)
**Source:** `crates/app/src/main.rs` lines 6113-6117
**Apply to:** All fallible GH operations in the notes flow (D-10 — failures surface via toast only)
```rust
let _ = slint::invoke_from_event_loop(move || {
    if let Some(w) = w_weak.upgrade() {
        w.set_toast_is_warning(true);
        w.set_toast_message("Note saved locally. Failed to post to GitHub.".into());
        w.set_toast_visible(true);
    }
});
```

### Migration LF Endings
**Source:** `code_tips/SQLITE_TIPS.md`
**Apply to:** `V012__notes_author.sql`
Migration files must use LF (`\n`) line endings. Windows `core.autocrlf` can silently add CRLF, causing refinery `DivergentVersion` panic. Write with the `Write` tool (which produces LF by default) and do not open in Windows editors that add CRLF.

### Diff-Based Notes Upsert (preserve synced_at)
**Source:** `code_tips/SQLITE_TIPS.md`, `crates/service/src/db/sqlite.rs` lines 582-607
**Apply to:** `upsert_notes_for_card` in sqlite.rs
Use `INSERT ... ON CONFLICT DO NOTHING` (or a dedup check). Never `DELETE FROM notes WHERE card_id = ?` then re-INSERT — that wipes `synced_at` timestamps on existing rows.

---

## No Analog Found

All files have analogs. No entries in this section.

---

## Metadata

**Analog search scope:** `crates/app/ui/`, `crates/app/src/`, `crates/integrations/src/github/`, `crates/service/src/db/`, `crates/core/src/domain/`
**Files scanned:** ~15 source files read directly + grep results
**Pattern extraction date:** 2026-04-15
