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

**Researched:** 2026-04-15
**Domain:** Slint UI (PopupWindow, scrolling, inline edit), SQLite notes schema, GH Issues comments read/write, view-model extension
**Confidence:** HIGH (all findings verified against source code)

---

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

### Locked Decisions

- D-01: (i) button repurposed to open notes-history popover (no longer opens summary-popup)
- D-02: Notes popover is a `PopupWindow` anchored to the (i) button, same positioning as summary-popup. Closes on outside click / Esc / explicit close.
- D-03: Fixed-height popover with internal scrolling. Empty state = "No notes yet". All notes load up front.
- D-04: Notes sort newest-first. Scroll down for older.
- D-05: Each note row: author (GH handle) + relative timestamp. Hover/tooltip = absolute datetime.
- D-06: Append-only notes this phase.
- D-07: Composer collapses to "Add note" button when idle; expands to multi-line textbox on click.
- D-08: Ctrl+Enter submits, Enter = newline. Send button also present when expanded.
- D-09: Optimistic UI — note appears instantly at top, composer collapses. No pending indicator.
- D-10: Failures via existing toast/error pipeline only.
- D-11: Opening popover also triggers targeted GH Issues fetch for this card's ww-note history. Updates SQLite; popover re-renders.
- D-12: Write path: new notes queued through existing `pending_edit` mechanism.
- D-13: Offline-first: note lives in pending_edit, appears in history, flusher retries on reconnect.
- D-14: Row 5 note block (card.slint:557-666) removed. Verify no external consumers of `note-preview`, `editing-note`, `note-draft`, `save-note` callback before removing.
- D-15: Freed vertical space grows product squares proportionally (same count, same card height).
- D-16: summary-popup deleted from card.slint. Its content migrates to the new recipient-detail sidebar.
- D-17: Sidebar layout: avatar + large name, then Purpose / Vision Rx (OD/OS + inline edit + Copy Rx) / Discord (editable) / Shopify email / Shopify customer link / placeholder ww-recipient link (conditional).
- D-18: Sidebar lives in Recipients tab ONLY — not dashboard.
- D-19: Recipients tab tile click opens/switches sidebar. Dashboard card name-click navigates to Recipients tab and auto-selects.
- D-20: Dismissal and edit affordances match product-detail.slint exactly.

### Claude's Discretion
- Exact popover dimensions
- Size scaling factor for product squares
- Whether product text sizes need minor bump
- Exact wire-up pattern for targeted per-card GH refresh on popover open
- Whether name-click-to-Recipients needs new callback or can piggyback existing
- How optimistic note is represented before flusher confirms

### Deferred Ideas (OUT OF SCOPE)
- Edit/delete existing notes
- Per-note pending/sync indicators
- ww-recipient GH Issues integration (placeholder link only)
- Lazy-loading large note histories
- Increased product square density
</user_constraints>

---

## Summary

This phase makes three coordinated changes: (1) replaces the (i) button's summary popover with a notes-history popover including an inline new-note composer; (2) removes Row 5 (note preview) from the card face and grows the product squares into the freed space; and (3) creates a new recipient-detail sidebar in the Recipients tab mirroring `product-detail.slint`, deleting the old `summary-popup`.

The code is well-understood with verified file:line citations for every change site. The main research revelations are: **NoteEntry lacks an `author` field** (D-05 requires GH handle — schema migration needed); **no GH Issues comment-listing method exists** (targeted refresh per D-11 requires a new `list_issue_comments` method on `GhIssuesClient`); and **notes are written to GH but never read back from GH** (sync only pushes, never pulls comments). These are the three blockers the planner must address in Wave 0 tasks.

The existing `save_note` → SQLite → fire-and-forget GH comment flow (live_client.rs:350-422) is reusable as-is for D-12. The `pending_edit` `SaveNote` type (flusher already handles it) covers D-13. The `PopupWindow` + `FocusScope` pattern from `summary-popup` (card.slint:1011-1040) is the direct template for the notes popover. The `ProductDetailPanel` pattern (product-detail.slint) is the direct template for the sidebar.

**Primary recommendation:** Plan in five natural waves: (W0) schema + new `NoteEntry` author field + `list_issue_comments` method; (W1) notes popover Slint + write path; (W2) card-face Row 5 removal + product square growth; (W3) recipient-detail sidebar; (W4) name-click navigation wiring.

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Notes popover UI | Frontend (Slint) | — | Pure presentation layer; data flows in via CardData/callbacks |
| Notes read path | SQLite (SqliteStore) | GH Issues (on popover open) | RULE-03: SQLite is single read source; GH refresh is a targeted cache refresh |
| New-note write path | SQLite (save_note) | GH Issues (pending_edit flusher) | Existing pattern; SQLite first, GH async |
| Optimistic note display | In-memory view model | SQLite | Note appears before flusher confirms; must union pending with confirmed |
| Author field on notes | SQLite notes table + NoteEntry struct | GH comment author field | New field; requires migration V012 |
| GH comment list/read | GH Issues client | — | New method needed; no current read-back path |
| Recipient-detail sidebar | Frontend (Slint) | — | Mirrors ProductDetailPanel layout/pattern |
| Name-click navigation | Rust main.rs (on_card_name_clicked callback) | Slint (new callback) | Tab switch + tile select is Rust-side logic |
| Product square growth | Slint card.slint | — | Height change in Row 4 Rectangle only |
| summary-popup deletion | Slint card.slint + Rust view_model.rs | — | Remove dead Slint + unused Rust props |

---

## Standard Stack

### Core (no new dependencies needed)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Slint | existing | UI framework | Project standard |
| rusqlite | 0.32 bundled | SQLite access | Project standard (Phase 15) |
| refinery | 0.8 | SQL migrations | Project standard (Phase 15) |
| serde_json | existing | pending_edit payload | Project standard |

**No new Cargo.toml dependencies required.** All capabilities are served by existing crates.

---

## Architecture Patterns

### System Architecture Diagram

```
User clicks (i) button
        │
        │ Slint: info-hover TouchArea clicked (card.slint:188)
        ▼
notes-popup.show()  ──► fires on_notes_popover_opened(card_id) callback
        │                       │
        │                       │ Rust main.rs: targeted GH refresh (background thread)
        │                       │    GhIssuesClient::list_issue_comments(issue_number)
        │                       │    → parse ww-note comments → NoteEntry{date, content, author}
        │                       │    → SqliteStore::upsert_notes_for_card(card_id, notes)
        │                       │    → window.set_cards(rebuild model)  [Slint event loop]
        │                       ▼
        │               SQLite notes table (canonical source)
        │
        │ popover reads notes via CardData.notes field (already loaded on card render)
        ▼
Notes popover renders history (newest-first)

User submits note (Ctrl+Enter / Send button)
        │
        │ Slint: on_post_note(card_id, text) callback
        ▼
live_client::save_note(card_id, text)  [existing method]
        │
        ├── SqliteStore::save_note(card_id, NoteEntry{date, content, author="local"})
        │           [SQLite write — optimistic display source]
        │
        └── background thread: GhIssuesClient::create_issue_comment(issue_number, body)
                    │
                    ├── Success: mark_note_synced(rowid, now)
                    └── Failure: insert_pending_edit("card", card_id, "SaveNote", payload)
                                 pending_edit_flusher::notify()

Dashboard card name clicked
        │
        │ Slint: new on-card-name-clicked(card_id) callback on RecipientCard
        ▼
Rust main.rs: invoke_tab_clicked(2)  [Recipients tab index=2]
        + rt.borrow_mut().select_tile(recipient_id)
        + restore_mode_state() shows recipient grid with tile selected
        + set_recipient_detail_visible(true) with recipient data
```

### Recommended Project Structure (changes only)
```
crates/
├── core/src/domain/note.rs          # Add author: Option<String> to NoteEntry
├── service/src/db/
│   ├── migrations/V012__notes_author.sql   # ADD COLUMN author TEXT
│   └── sqlite.rs                    # update save_note, read_notes, read_unsynced_notes
├── integrations/src/github/
│   └── issues_client.rs             # Add list_issue_comments() + parse_ww_note_comment()
├── app/src/
│   ├── live_client.rs               # Add fetch_notes_for_card(); update save_note author
│   ├── dashboard/view_model.rs      # Add notes: Vec<NoteEntry> to DashboardCardViewModel
│   └── main.rs                      # Wire new callbacks; add on_notes_popover_opened
└── app/ui/
    ├── card.slint                   # notes popover, remove Row 5, grow Row 4
    ├── dashboard.slint              # CardData gains notes field; new sidebar props/callbacks
    ├── option-grid.slint            # tile-clicked triggers sidebar open (Recipients mode)
    └── recipient-detail.slint       # NEW — modeled on product-detail.slint
```

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Scrollable list in popup | Flickable+VerticalLayout | Clipped Rectangle with absolute y-positioning | SLINT_TIPS.md: VerticalLayout in Flickable always bottom-aligns. See pattern below. |
| Note submission to GH | New HTTP/CLI path | Existing `save_note` + `create_issue_comment` | Already handles offline queuing, retry, backoff |
| Relative timestamps | Custom time math | Format in Rust before passing to Slint | Slint has no date arithmetic; compute in view_model_to_card_data() |
| Keyboard handling in popup | Custom FocusScope per TextInput | FocusScope wrapping entire popup (copy summary-popup pattern) | Ensures Esc works and Close button clicks are not blocked by TextInput focus |
| Note de-duplication on re-sync | DELETE + re-INSERT | Diff-based upsert (SQLITE_TIPS.md pattern) | DELETE + re-INSERT wipes `synced_at` timestamps — existing upsert_card already avoids this |

---

## Key Implementation Details by Area

### Area 1: Slint PopupWindow Pattern (VERIFIED)

**Anchor position:** `summary-popup` is positioned at `x: 0px; y: root.height + 4px` (card.slint:1012-1014). Notes popover anchors identically, but to the (i) button position: `x: parent.width - 54px; y: root.height + 4px` (or relative to info-icon-rect).

**PopupWindow re-init rule** [VERIFIED: code_tips/SLINT_TIPS.md + card.slint:94-98]:
```
// NOTE: state MUST live on RecipientCard, NOT inside PopupWindow (PopupWindow re-inits on show)
in-out property <bool> editing-rx-od: false;  // card.slint:95
in-out property <bool> editing-rx-os: false;  // card.slint:96
```
The new composer state (`composer-expanded: bool`, `note-draft: string`) **must be declared on RecipientCard**, not inside the popup. Same rule applies to any scroll offset the popover wants to remember.

**close-policy:** `summary-popup` uses `close-policy: no-auto-close` (card.slint:1012). This is intentional — auto-close prevents TextInput focus from working correctly inside popups. Keep this for the notes popup; close manually on Esc (via FocusScope) and on outside click.

**FocusScope wrapper:** `summary-popup` wraps its content in a `popup-focus := FocusScope` (card.slint:1019-1025) that handles Esc. The notes popup needs the same pattern, but the FocusScope must NOT absorb Ctrl+Enter — see keyboard handling below.

**Ctrl+Enter keyboard handling:** Slint TextInput's `key-pressed` event fires before `accepted`. The pattern for multi-line + Ctrl+Enter submit:
```slint
note-composer-input := TextInput {
    single-line: false;  // allow newlines
    key-pressed(event) => {
        if event.modifiers.control && event.text == Key.Return {
            // submit
            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;
    }
}
```
[ASSUMED: Slint multi-line TextInput + modifiers.control pattern — verify against Slint docs if compilation fails]

**Scrollable list inside popup (CRITICAL):** [VERIFIED: code_tips/SLINT_TIPS.md]
```slint
// WRONG — items land at bottom
Flickable { VerticalLayout { alignment: start; for note in notes: ... } }

// CORRECT — use clipped Rectangle with absolute y-positioning
Rectangle {
    clip: true;
    vertical-stretch: 1;
    // compute content height: notes.length * ROW_H
    property <length> content-h: notes.length * 52px + 8px;
    for note[idx] in notes : Rectangle {
        x: 8px;
        y: idx * 52px + 4px;
        width: parent.width - 16px;
        height: 48px;
    }
}
```
The parent popup VerticalLayout must have `alignment: stretch` and the composer/header must have `vertical-stretch: 0`.

**Tooltip for absolute datetime (D-05):** Slint has no native tooltip. Pattern: show an absolute-positioned overlay Rectangle that becomes visible on hover. Since the note row is inside a clipped Rectangle with absolute positioning, the tooltip Rectangle must be placed at the popup level (outside the clip) to avoid being clipped. Alternative: show the absolute date inline in small muted text below the relative time, toggled by hover on the note row.

### Area 2: NoteEntry Author Field — SCHEMA GAP (VERIFIED)

`NoteEntry` (crates/core/src/domain/note.rs):
```rust
pub struct NoteEntry {
    pub date: String,    // ISO 8601
    pub content: String,
}
```
**No `author` field exists.** D-05 requires showing the GH handle. A new migration and struct field are required:

**Migration V012** (crates/service/src/db/migrations/V012__notes_author.sql):
```sql
ALTER TABLE notes ADD COLUMN author TEXT;
```

**NoteEntry change:**
```rust
pub struct NoteEntry {
    pub date: String,
    pub content: String,
    pub author: Option<String>,  // GH handle or "local" for optimistic notes
}
```

**All NoteEntry construction sites must be updated** — there are ~12 call sites in tests + live_client.rs. For locally-composed notes, `author` = `None` or `"local"` (displayed as "You" in the UI) until the flusher confirms and popover refresh reads back the actual GH handle.

**Relative timestamp:** Compute in Rust (view_model or live_client) before passing to Slint. Slint struct field: `note-relative-time: string` (e.g. "2h ago", "3d ago"). Exact datetime: `note-date: string`. Both passed as fields in a `NoteDisplayData` struct.

### Area 3: GH Comment Read-Back — MISSING METHOD (VERIFIED)

`GhIssuesClient` (issues_client.rs) has:
- `create_issue_comment(issue_number, body)` — write only
- `list_issues_by_label(label)` — lists issues, not comments

**No `list_issue_comments` method exists.** D-11 (targeted GH refresh on popover open) requires:

```rust
/// List ww-note comments on a card's GH issue.
/// Returns parsed NoteEntry rows (date from comment created_at, content from body, author from user.login).
pub fn list_issue_comments(&self, issue_number: i64) -> Result<Vec<GhNoteComment>, GhIssuesError> {
    // gh issue view {number} --repo {owner/repo} --comments --json comments
    // parse .comments[].body for "`ww-note`" tag
    // parse .comments[].createdAt for date
    // parse .comments[].author.login for GH handle
}

pub struct GhNoteComment {
    pub author: String,       // GH handle
    pub created_at: String,   // ISO 8601
    pub content: String,      // stripped of ww-note wrapper
}
```

The `format_note_comment` function (issues_client.rs:553-559) uses the pattern:
```
`ww-note`

> _content line_
```
The read-back parser must filter for comments containing `` `ww-note` `` as the first line and extract the blockquote content (strip `> _` prefix and `_` suffix per line).

**Targeted refresh flow (D-11):**
```
on_notes_popover_opened(card_id: String) in main.rs:
  1. Look up github_issue_number from SQLite
  2. If Some(number): spawn background thread:
       let comments = gh_client.list_issue_comments(number)
       let notes = parse to NoteEntry (with author)
       store.upsert_notes_for_card(card_id, notes)  // diff-based, preserves synced_at
       trigger window rebuild via Slint event loop
  3. Rebuild CardData with fresh notes from SQLite
```

**Note: the re-render after refresh** must happen on the Slint event loop thread. The pattern used elsewhere (e.g. sync_card_issues in live_client.rs) is to call the weak-window rebuild inside the background thread body using `slint::invoke_from_event_loop`. Check existing refresh pattern for the exact idiom.

### Area 4: pending_edit Pipeline for Notes (VERIFIED)

The entire pipeline is already in place. From live_client.rs:350-422:

1. `save_note(card_id, text)` writes to SQLite immediately
2. Background thread attempts `create_issue_comment`
3. On failure OR missing `github_issue_number`: calls `insert_pending_edit("card", card_id, "SaveNote", payload)`
4. `pending_edit_flusher::notify()` wakes the flusher thread

Flusher (pending_edit_flusher.rs:85-100) already handles `"SaveNote"` edit type. Backoff: 60s, 120s, 240s, 480s, 900s (capped). Max retries: 15.

**Optimistic display:** The note is in SQLite immediately after `save_note`. The `read_notes(card_id)` call will return it. The popover just needs to re-read from SQLite after submit (trigger a card model rebuild with the fresh note visible). Since the GH comment fires async, there's no visible pending state to the user — D-09 confirmed.

**Author for locally-submitted notes:** When `save_note` is called, we don't have the GH handle available in live_client (it's not in AppConfig). Options:
- Store `author = None` locally; after GH comment succeeds + popover refresh reads back from GH, the author populates
- Store author from a "current user" config field (not currently tracked)
- Display `None`/empty author as "You" in the UI

[ASSUMED: "You" as the display for locally-authored notes before GH confirmation — simplest approach that matches D-09's "no pending indicator" requirement]

### Area 5: Card Face Reclamation — Pixel Budget (VERIFIED)

Card height is **fixed at 196px** (dashboard.slint:699). The VerticalLayout (card.slint:280-667) has:
- `padding-top: 8px`, `padding-bottom: 4px` = 12px total
- `spacing: 4px`
- Row 1 (avatar+name): ~44px (avatar height)
- Row 2 (status): ~20px
- Row 3 (item label): 16px (explicit `height: 16px`)
- Row 4 (item squares): 56px (explicit `height: 56px`)
- Row 5 (note): 20px (explicit `height: 20px`)
- Spacings (4 gaps): 4 × 4px = 16px
- Total content: 44+20+16+56+20 = 156px + 16px spacing + 12px padding = **184px**

The 12px gap between 184px and 196px is the existing slack (Slint layout + rounding). **Removing Row 5 frees 24px** (20px row + 4px spacing). Row 4 can grow from 56px → **80px**.

At 80px Row 4 height with 52px stride (unchanged), the inner square grows from 36px → approximately **48px** (square stays square, inner rect height drives it). Update: `Rectangle { height: 80px }` outer, inner clipped square `width: 48px; height: 48px; x: (46px - 48px)/2 = -1px` — use 44px to be safe: `width: 44px; height: 44px`. The stride (x position per square) can stay at 52px or grow to 56px.

**Properties to delete from RecipientCard** (after verifying no consumers outside card.slint):
- `in property <string> note-preview` — check `dashboard.slint:706` (it's there: `note-preview: card-data.note-preview`)
- `in-out property <bool> editing-note`
- `in-out property <string> note-draft`
- `callback save-note(string)`
- `CardData.note-preview` field in dashboard.slint
- `DashboardCardViewModel.note_preview` in view_model.rs

**Consumers to update:**
- `dashboard.slint:706`: `note-preview: card-data.note-preview;` — remove line
- `dashboard.slint:157`: `callback card-save-note(int, string);` — remove
- `dashboard.slint:751-753`: `save-note(note-text) => { root.card-save-note(card-index, note-text); }` — remove
- `main.rs`: `window.on_card_save_note(...)` callback wiring — find and remove
- `view_model.rs`: `note_preview` field on `DashboardCardViewModel` — remove
- `view_model_to_card_data()` function — remove `note_preview` mapping

### Area 6: summary-popup Deletion (VERIFIED)

`summary-popup` occupies card.slint lines ~1011-end (the rest of the file after line 1011). It contains:
- Purpose (editable, dropdown)
- Email (display-only)
- Discord Username (editable, pencil icon — Phase 16.1 pattern)
- Products on Hand
- Last Activity
- Last Status Update
- Vision Rx (OD/OS editable + Copy Rx)

**All editable fields have callbacks on RecipientCard** that survive the popup deletion:
- `save-rx-od(string)`, `save-rx-os(string)`, `save-purpose(string)`, `copy-rx()`, `save-discord-username(string)`
- The in-out draft properties (`rx-od-draft`, `rx-os-draft`, `purpose-draft`, `discord-username-draft`) must be retained on RecipientCard for use by the sidebar (or moved to the sidebar's own state)

**name-area TouchArea** (card.slint:144-164) currently calls `summary-popup.show()`. This entire TouchArea should be replaced with a callback that fires `on-card-name-clicked(card_id)` for navigation. No popup opens from the dashboard name click (D-19 explicitly prohibits this).

**info-icon-rect TouchArea** (card.slint:188-201) currently calls `summary-popup.show()`. Replace with `notes-popup.show()` and remove the draft-reset initialization block (no longer needed for notes popup).

### Area 7: recipient-detail.slint — New File (VERIFIED via product-detail.slint)

`ProductDetailPanel` (product-detail.slint) is the template. Key layout decisions:
- `width: 320px` fixed sidebar width [VERIFIED: product-detail.slint:57]
- `background: Colors.surface` [VERIFIED: product-detail.slint:58]
- `border-radius: 6px` [VERIFIED: product-detail.slint:59]
- Positioned: `x: parent.width - 326px; y: 6px; height: parent.height - 12px` in dashboard.slint:622-625
- Mounted conditionally: `if !root.show-option-grid && root.product-detail-visible`

For `RecipientDetailPanel`, the condition will be:
```slint
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;
    ...
}
```
The recipient grid (RecipientGrid) must shrink to `width: parent.width - 330px` when the sidebar is open, similar to the card-flickable shrink for ProductDetailPanel.

**Discord inline-edit pattern (Phase 16.1, VERIFIED: card.slint:1106-1180):**
```slint
// Display mode with pencil
if !root.editing-discord-username : Rectangle {
    height: 20px;
    discord-row-touch := TouchArea { ... clicked => { root.editing-discord-username = true; } }
    HorizontalLayout {
        Text { text: root.discord-username != "" ? root.discord-username : "\u{2014}"; }
        Text { text: "\u{270F}"; color: discord-row-touch.has-hover ? Colors.text-secondary : Colors.text-muted; }
    }
}
// Edit mode
if root.editing-discord-username : Rectangle {
    height: 26px;
    background: Colors.background;
    border-color: Colors.accent;
    discord-username-input := TextInput {
        text <=> root.discord-username-draft;
        accepted => { root.save-discord-username(root.discord-username-draft); ... }
        key-pressed(event) => { if event.text == Key.Escape { ... } }
    }
}
```
**CRITICAL:** Use Rectangle wrapper (not HorizontalLayout) for the display row so TouchArea can reference `parent.width` without a binding loop [VERIFIED: card.slint:94-96 comment; Phase 16.1-03 accumulated context].

**The sidebar's editing state** (editing-discord-username, rx-od-draft, etc.) must live on `DashboardWindow` or on the sidebar component itself — NOT on RecipientCard (since the sidebar is a separate component in the Recipients tab). This is a clean separation: card.slint retains the callbacks (save-rx-od, etc.) for the cards in the main view; the sidebar has its own in-out state.

### Area 8: Name-Click Navigation Wiring (VERIFIED by inspection)

**Current name-area behavior** (card.slint:144-164): triggers `summary-clicked()` callback and shows `summary-popup`. Both must be replaced.

**New behavior:** Add `callback card-name-clicked(string)` to `RecipientCard` (takes `recipient-id`). In dashboard.slint, wire it:
```slint
card-name-clicked(rid) => {
    root.card-name-navigate(rid);
}
```
In main.rs, `on_card_name_navigate(|rid| { ... })`:
1. Call `invoke_tab_clicked(2)` to switch to By Recipient tab
2. Call `rt.borrow_mut().select_tile(rid)`
3. Call `restore_mode_state()` — this shows the recipient grid with the tile selected
4. Populate and show the recipient-detail sidebar for that recipient

The tab switching already has all the infrastructure (on_tab_clicked wiring at main.rs:3263-3288). The tile selection uses `rt.borrow_mut().select_tile(&name)` (main.rs:3304). The new sidebar display is the only new piece.

**Existing `summary-clicked` callback** (card.slint:73, dashboard.slint:760-762) becomes dead code after this change. Remove it.

### Area 9: Recipients Tab Sidebar Wiring (VERIFIED)

Current tile-clicked flow (main.rs:3301-3319):
1. `select_tile(name)` on runtime
2. `apply_filters` (shows filtered card view)
3. For `ByProductShipped`: shows ProductDetailPanel sidecar

For `ByRecipient` mode, tile-clicked should instead:
1. Select the tile (mark it)
2. NOT transition to filtered card view — stay on the RecipientGrid (option-grid view)
3. Show `recipient-detail-visible = true` with recipient data

This is a **behavior change** to `on_tile_clicked` for ByRecipient mode. Currently the click navigates to the filtered card view. D-19 says "clicking any recipient tile opens/switches the sidebar" — this means the option grid stays visible alongside the sidebar, similar to how product-detail shows beside the card grid when a product tile is clicked from the ByProductShipped option grid (not filtered view).

Actually, re-examining dashboard.slint:621-644: `ProductDetailPanel` is shown when `!show-option-grid && product-detail-visible`. It shows beside the card grid in filtered view. For the recipients sidebar, D-18/D-19 describe showing it in the Recipients tab. The cleanest model is: tile click in ByRecipient shows the sidebar WITHOUT transitioning away from the option grid. The sidebar overlays the right side of the option grid (same as product-detail overlays the card grid).

**Implementation:** For `ByRecipient` mode in `on_tile_clicked`, skip `apply_filters` (which switches to filtered card view) and instead set `recipient-detail-visible = true`. For the option grid itself, it needs to shrink from `width: parent.width` to `width: parent.width - 330px` when the sidebar is open (same shrink pattern as card-flickable for ProductDetailPanel).

---

## Common Pitfalls

### Pitfall 1: VerticalLayout in Scrollable Popup Always Bottom-Aligns
**What goes wrong:** Notes render at the bottom of the popup instead of the top.
**Why it happens:** Flickable stretches VerticalLayout to viewport height; Slint layout algorithm bottom-aligns with excess space.
**How to avoid:** Use clipped Rectangle + absolute y-positioning for note rows (SLINT_TIPS.md).
**Warning signs:** Empty state or short lists render at the very bottom of the scroll area.

### Pitfall 2: PopupWindow Re-inits on Show — State Lost
**What goes wrong:** Composer draft text disappears every time the popup is closed and reopened.
**Why it happens:** PopupWindow component re-initializes on each `show()` call.
**How to avoid:** All mutable state (`composer-expanded`, `note-draft`) declared as in-out properties on `RecipientCard`, not inside the popup.
**Warning signs:** Typing in the composer, closing popup, reopening — text is gone.

### Pitfall 3: TextInput in Popup Blocks Close Affordance
**What goes wrong:** Click on Close/X button inside popup doesn't fire.
**Why it happens:** TextInput captures focus; its TouchArea consumes the click before Close's TouchArea sees it.
**How to avoid:** FocusScope wrapping the entire popup content (copy from summary-popup). After save/dismiss actions, call `popup-focus.focus()` to return focus to FocusScope.
**Warning signs:** Close button appears to do nothing when composer is focused.

### Pitfall 4: NoteEntry Author Field — Null Explosion
**What goes wrong:** All existing NoteEntry construction sites fail to compile after adding `author: Option<String>`.
**Why it happens:** Struct literal syntax in Rust requires all fields. There are ~12 NoteEntry construction sites.
**How to avoid:** Add `..Default::default()` or implement `Default` for `NoteEntry`, or update all sites. Verify: grep for `NoteEntry {` across crates.
**Warning signs:** Compilation errors at every test file.

### Pitfall 5: Migration CRLF on Windows
**What goes wrong:** `DivergentVersion` panic on app startup after new migration.
**Why it happens:** Git core.autocrlf converts LF to CRLF in working copy; refinery checksums are line-ending-sensitive.
**How to avoid:** Write migration files with LF endings; add `.gitattributes` for `*.sql eol=lf` if not already present. (SQLITE_TIPS.md)
**Warning signs:** Migration checksum error in stderr at startup.

### Pitfall 6: summary-popup Properties Consumed by Dashboard.slint
**What goes wrong:** Removing `note-preview` from RecipientCard breaks compilation at all RecipientCard instantiation sites.
**Why it happens:** `CardData` struct in dashboard.slint has `note-preview: string` and it's assigned on every card in the `for` loop.
**How to avoid:** Remove `note-preview` from `CardData` (dashboard.slint:27), from the `for card-data` binding (line ~706), and from `DashboardCardViewModel` in view_model.rs before removing the property from RecipientCard.
**Warning signs:** "Unknown property note-preview" Slint compilation error.

### Pitfall 7: DELETE + Re-INSERT Wipes synced_at on Notes
**What goes wrong:** Targeted GH refresh on popover open deletes all existing notes and reinserts — destroying `synced_at` metadata.
**Why it happens:** Naive upsert pattern.
**How to avoid:** The existing `upsert_card` already uses the diff-based notes upsert pattern (sqlite.rs:390-415). The new `upsert_notes_for_card` method for the targeted refresh must follow the same pattern: only delete removed notes, only insert new notes.
**Warning signs:** Notes that were marked synced appear unsynced after popover refresh.

### Pitfall 8: on_tile_clicked for ByRecipient Currently Navigates Away
**What goes wrong:** Clicking recipient tile in D-19 shows sidebar but also transitions to filtered card view (losing the option grid).
**Why it happens:** `on_tile_clicked` calls `apply_filters` unconditionally.
**How to avoid:** In `on_tile_clicked`, check current mode. For `ByRecipient`, skip `apply_filters` and instead show sidebar. The option grid stays visible.
**Warning signs:** Sidebar shows but option grid disappears (filtered card view appears instead).

---

## Code Examples

### Notes Scrollable List (CORRECT Pattern)
```slint
// Source: code_tips/SLINT_TIPS.md — clipped Rectangle pattern
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: 6px;
            VerticalLayout {
                horizontal-stretch: 1;
                Text { text: note.content; font-size: Typography.size-sm; color: Colors.text-primary; wrap: word-wrap; }
                HorizontalLayout {
                    spacing: 8px;
                    Text { text: note.author != "" ? note.author : "You"; font-size: Typography.size-xs; color: Colors.text-muted; }
                    Text { text: note.relative-time; font-size: Typography.size-xs; color: Colors.text-dim; }
                }
            }
        }
    }
}
```

### PopupWindow Structure for Notes (CORRECT Pattern)
```slint
// Source: card.slint:1011-1019 (summary-popup pattern, adapted)
notes-popup := PopupWindow {
    close-policy: no-auto-close;
    x: root.width - 54px;  // anchor to (i) button x position
    y: root.height + 4px;
    width: 280px;

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

        Rectangle {
            background: Colors.surface-popup;
            border-radius: 8px;
            border-width: 1px;
            border-color: Colors.border-muted;
            height: 380px;  // fixed height per D-03
            VerticalLayout {
                alignment: stretch;
                // Composer (vertical-stretch: 0)
                // Notes list (vertical-stretch: 1, clipped Rectangle)
            }
        }
    }
}
```

### Discord Inline Edit Pattern (Port to Sidebar)
```slint
// Source: card.slint:1111-1180 (Phase 16.1 pattern)
// State must live on the parent component (sidebar), not inside popup
// Rectangle wrapper is REQUIRED to avoid binding loop in TouchArea parent.width ref

if !root.editing-discord-username : Rectangle {
    height: 20px;
    discord-row-touch := TouchArea {
        mouse-cursor: pointer;
        clicked => { root.editing-discord-username = true; }
    }
    HorizontalLayout {
        spacing: 4px;
        alignment: start;
        Text { text: root.discord-username != "" ? root.discord-username : "\u{2014}";
               color: Colors.text-muted; font-size: Typography.size-sm; vertical-alignment: center; }
        Text { text: "\u{270F}";
               color: discord-row-touch.has-hover ? Colors.text-secondary : Colors.text-muted;
               font-size: Typography.size-xs; vertical-alignment: center; }
    }
}
if root.editing-discord-username : Rectangle {
    height: 26px; border-radius: 4px; background: Colors.background; border-color: Colors.accent;
    discord-input := TextInput {
        text <=> root.discord-username-draft;
        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();
        }
        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;
        }
    }
}
```

### GhIssuesClient::list_issue_comments (New Method)
```rust
// Source: issues_client.rs pattern (create_issue_comment at line 385)
pub fn list_issue_comments(&self, issue_number: i64) -> Result<Vec<GhNoteComment>, 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 json: serde_json::Value = serde_json::from_slice(&output.stdout)
        .map_err(|e| GhIssuesError::Transport(format!("JSON parse failed: {}", e)))?;
    let comments = json["comments"].as_array().unwrap_or(&vec![]).to_vec();
    let mut result = Vec::new();
    for c in &comments {
        let body = c["body"].as_str().unwrap_or("");
        if body.starts_with("`ww-note`") {
            result.push(GhNoteComment {
                author: c["author"]["login"].as_str().unwrap_or("").to_string(),
                created_at: c["createdAt"].as_str().unwrap_or("").to_string(),
                content: parse_ww_note_body(body),
            });
        }
    }
    Ok(result)
}
```

### Product Square Growth (Row 4 Height Change)
```slint
// Source: card.slint:423 — change height from 56px to 80px
// Inner squares grow from 36x36 to 44x44

// BEFORE
Rectangle { height: 56px;
    for sq[sq-index] in root.item-squares : Rectangle {
        x: sq-index * 52px; y: 0px; width: 46px; height: 56px;
        Rectangle { x: 5px; y: 0px; width: 36px; height: 36px; ... }  // inner square
    }
}

// AFTER
Rectangle { height: 80px;
    for sq[sq-index] in root.item-squares : Rectangle {
        x: sq-index * 52px; y: 0px; width: 50px; height: 80px;
        Rectangle { x: 3px; y: 0px; width: 44px; height: 44px; ... }  // inner square: +8px
        // serial-label text moves down accordingly
    }
}
```

---

## Runtime State Inventory

This phase is NOT a rename/refactor phase. No runtime state inventory required.

---

## Environment Availability

This phase requires no external dependencies beyond the existing project stack (Slint, Rust, gh CLI). The `gh` CLI is already required and used for GH comment creation; `list_issue_comments` uses `gh issue view --comments` which requires the same auth scope.

Skip condition applies: no new external tools.

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `modifiers.control` is the correct Slint field for detecting Ctrl key in `key-pressed` | Area 1 (Keyboard handling) | Ctrl+Enter submit won't work; check Slint KeyEvent docs |
| A2 | `gh issue view --comments --json comments` returns `comments[].author.login` | Area 3 (list_issue_comments) | Author field won't populate; adjust JSON path |
| A3 | `"You"` as display for locally-authored notes without GH confirmation | Area 4 (Optimistic display) | Minor UX issue only; no correctness impact |
| A4 | RecipientGrid can be made to shrink (width binding) when sidebar is open | Area 7 (Sidebar layout) | Sidebar overlaps grid; use different positioning |
| A5 | `close-policy: no-auto-close` required for notes popup TextInput to work | Area 1 (PopupWindow) | Close button may become unresponsive; confirmed by summary-popup pattern |

---

## Open Questions

1. **Relative timestamp computation location**
   - What we know: Slint has no date arithmetic; `NoteEntry.date` is ISO 8601 string
   - What's unclear: Should relative-time be computed in `view_model_to_card_data()` (recomputed on every full model rebuild) or on-demand when the popover is opened?
   - Recommendation: Compute in `view_model_to_card_data()` — consistent with other display transformations. Accept that "3 days ago" won't update in real-time while the app is open (acceptable for this use case).

2. **Current user GH handle for locally-submitted notes**
   - What we know: `AppConfig` stores GH token but not the authenticated username
   - What's unclear: Should we call `gh api user` once at startup to get the current user's login and store it in `AppConfig`?
   - Recommendation: Display `None` as "You" (see A3). A future phase can add the current-user lookup if the team finds it confusing.

3. **Sidebar dismissal model in Recipients tab**
   - What we know: D-20 says "match product-detail.slint exactly"
   - What's unclear: ProductDetailPanel has no explicit close/X button — it closes via breadcrumb-back. Should the recipient sidebar have an X button?
   - Recommendation: Add an X button at the top-right of the sidebar (standard sidebar UX). Clicking it sets `recipient-detail-visible = false`. Also close on Esc if a FocusScope is present.

4. **notes field in CardData struct** — pass full notes array or just for the open card
   - What we know: `CardData` is an array of all cards; `notes: Vec<NoteEntry>` would add notes to every card in the model even if only one popover is open
   - What's unclear: Performance cost of serializing all notes in all cards into Slint model on every rebuild
   - Recommendation: Add `notes: [NoteDisplayData]` to `CardData` — Slint model is rebuilt on change events, not continuously. The cost is acceptable for current scale. If it becomes a problem, notes can be fetched lazily via a separate Slint callback.

---

## Validation Architecture

(nyquist_validation presumed enabled — no config.json override observed)

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust `#[test]` (cargo test) |
| Config file | Cargo.toml workspace |
| Quick run command | `cargo test -p app -p service -p integrations -- --test-output immediate 2>&1` |
| Full suite command | `cargo test --workspace 2>&1` |

### Phase Requirements → Test Map

| Behavior | Test Type | Automated Command | Notes |
|----------|-----------|-------------------|-------|
| NoteEntry author field persists in SQLite | unit | `cargo test -p service test_save_and_read_notes` | Extend existing test |
| read_notes returns author | unit | `cargo test -p service test_save_and_read_notes` | Extend existing test |
| list_issue_comments parses ww-note format | unit | `cargo test -p integrations -- list_issue_comments` | New test needed |
| parse_ww_note_body strips formatting correctly | unit | `cargo test -p integrations -- parse_ww_note_body` | New test needed |
| save_note still writes to SQLite (regression) | unit | `cargo test -p app save_note_writes_to_sqlite` | Existing test — run to verify |
| pending_edit SaveNote handles missing issue_number | unit | `cargo test -p app -- pending_edit` | Existing tests cover this |
| V012 migration applies cleanly | integration | `cargo test -p service` (runs all migrations) | Refinery runs migrations in tests |
| CardData notes field populates correctly | unit | `cargo test -p app view_model_to_card_data` | New test or extend existing |
| Slint compilation succeeds after Row 5 removal | build | `cargo build -p app` | Build is the test |
| Slint compilation succeeds for notes popup | build | `cargo build -p app` | Build is the test |

### Wave 0 Gaps
- [ ] `crates/core/src/domain/note.rs` — add `author: Option<String>` to `NoteEntry`
- [ ] `crates/service/src/db/migrations/V012__notes_author.sql` — `ALTER TABLE notes ADD COLUMN author TEXT`
- [ ] `crates/integrations/src/github/issues_client.rs` — add `list_issue_comments` + `parse_ww_note_body`
- [ ] `crates/integrations/tests/` — new test for `parse_ww_note_body` + `list_issue_comments` parsing

---

## Security Domain

This phase does not introduce new authentication, session, input validation beyond the existing GH API call pattern. The `list_issue_comments` method uses the same `gh` CLI subprocess pattern as all other GH operations — no new auth scope needed. No user-controlled data reaches SQL parameters directly (parameterized queries throughout sqlite.rs). No new external endpoints.

Security enforcement: no new concerns introduced by this phase.

---

## Natural Plan Boundaries

The planner should use these as wave/plan boundaries:

| Wave | Plans | Rationale |
|------|-------|-----------|
| W0: Schema + New API | Plan 01: NoteEntry author + V012 migration + all construction site updates<br>Plan 02: `list_issue_comments` + `parse_ww_note_body` in issues_client | Blockers; everything downstream depends on these |
| W1: Notes Popover | Plan 03: `NoteDisplayData` struct + `view_model_to_card_data` notes field + relative timestamp<br>Plan 04: Slint notes popup (card.slint — PopupWindow, scrollable list, composer, Ctrl+Enter)<br>Plan 05: `on_notes_popover_opened` callback in main.rs (targeted GH refresh) | Write path first (save_note is already working), then read UI |
| W2: Card Face | Plan 06: Remove Row 5 from card.slint; remove `note-preview` from CardData/ViewModel/all consumers; grow Row 4 to 80px | Isolated change; validate card renders correctly |
| W3: Recipient Sidebar | Plan 07: `recipient-detail.slint` new file (layout only, static data)<br>Plan 08: Wire sidebar into dashboard.slint + option-grid shrink<br>Plan 09: Editing callbacks (Discord, Rx, Purpose) wired through sidebar<br>Plan 10: Delete summary-popup from card.slint | Sidebar is the largest new component |
| W4: Navigation | Plan 11: name-click-navigates-to-Recipients wiring in main.rs + RecipientCard new callback | Completes D-19 |

---

## Sources

### Primary (HIGH confidence)
- `crates/app/ui/card.slint` lines 1-667, 988-1280 — PopupWindow pattern, Row 5, Row 4, info-icon, name-area, summary-popup content, Phase 16.1 Discord edit pattern [VERIFIED: read in this session]
- `crates/app/ui/product-detail.slint` lines 1-250 — template for RecipientDetailPanel [VERIFIED]
- `crates/app/ui/dashboard.slint` lines 1-780 — CardData struct, RecipientCard wiring, tab/tile callbacks, ProductDetailPanel positioning pattern [VERIFIED]
- `crates/app/ui/option-grid.slint` — RecipientGrid structure, tile-clicked callback [VERIFIED]
- `crates/core/src/domain/note.rs` — NoteEntry struct (no author field) [VERIFIED]
- `crates/service/src/db/sqlite.rs` lines 1-100, 584-770 — save_note, read_notes, pending_edit methods [VERIFIED]
- `crates/service/src/db/migrations/V001__initial_schema.sql` — notes table schema [VERIFIED]
- `crates/integrations/src/github/issues_client.rs` lines 1-560 — create_issue_comment, format_note_comment, NO list_issue_comments [VERIFIED]
- `crates/app/src/live_client.rs` lines 346-422, 1551-1584 — save_note flow, backfill_card_notes [VERIFIED]
- `crates/app/src/dashboard/pending_edit_flusher.rs` — SaveNote flusher pattern [VERIFIED]
- `crates/app/src/dashboard/view_model.rs` — DashboardCardViewModel fields [VERIFIED]
- `crates/app/src/main.rs` lines 3257-3320 — on_tab_clicked, on_tile_clicked wiring [VERIFIED]
- `code_tips/SLINT_TIPS.md` — Flickable/VerticalLayout bottom-align bug, clipped Rectangle solution [VERIFIED]
- `code_tips/SQLITE_TIPS.md` — DELETE+re-INSERT pitfall for notes, CRLF migration warning [VERIFIED]

### Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all existing
- Architecture: HIGH — verified against source code
- Pitfalls: HIGH — two from code_tips (direct evidence), others from code patterns
- NoteEntry gap: HIGH — struct definition verified, no author field present
- list_issue_comments gap: HIGH — entire issues_client.rs scanned, method absent

**Research date:** 2026-04-15
**Valid until:** 2026-05-15 (stable codebase, no fast-moving dependencies)
