# Phase 8: Discord Enrichment and External Jumps - Research

**Researched:** 2026-03-12
**Domain:** Slint UI enrichment, Rust URL/clipboard system APIs, Discord CDN, external protocol launch
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Discord identity on cards:**
- Small circular avatar (24-28px) to the left of the recipient name on the card face
- When Discord avatar is unavailable, show initials circle (first letter(s) of recipient name in a colored circle)
- Discord username displayed as secondary text below recipient name (e.g., `@alice_discord`)
- When no Discord username exists: fall back to email address sourced from GitHub or Shopify; if neither exists, show "No contact info"
- Discord username + avatar should also appear on the By Recipient discovery mode option grid, not just the card view

**DM launch:**
- "Open Discord DM" button lives in the existing three-dots/ellipsis menu (not hover-reveal, not on card face)
- Uses `discord://` protocol deep link to open Discord desktop app directly to user profile (`discord://discord.com/users/{user_id}`)
  - Note: Correct format from verified source is `discord://-/users/{user_id}` (see Architecture Patterns)
- DM menu item is hidden when recipient has no `discord_user_id` (not shown disabled)
- "Copy Email" menu item also added to ellipsis menu when email is available; copies to clipboard

**Shopify links:**
- "View Customer" and "View Order" as items in the ellipsis menu, grouped below Discord/email actions with a separator
- Links open in default system browser
- Hidden when URL data is unavailable (no Shopify customer linked = no "View Customer"; no order = no "View Order")
- One card = one Shopify order; "View Order" is always a single link, no sub-menu needed

**Ellipsis menu structure:**
1. Communication: Open Discord DM, Copy Email (conditional on data availability)
2. External links: View Customer, View Order (conditional on data availability)
3. Card actions: Archive/Unarchive/Archive Now (existing from Phase 7)
- Only show items where data exists; hide unavailable actions entirely

**External jump feedback:**
- Silent launch on success (no toast, no indicator) — OS handles browser/app switch
- Discord deep link failure: fallback to browser URL (`https://discord.com/users/{user_id}`), then error toast if browser also fails
- All external link errors logged (not just shown in UI)
- "Copy Email" shows brief "Copied to clipboard" toast (~1.5 seconds)

### Claude's Discretion
- Exact avatar circle styling (border, shadow, color generation for initials)
- Exact ellipsis menu item icons and styling
- External URL opening implementation approach (std::process::Command vs crate)
- How to source email from GitHub/Shopify data (field mapping)

### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope.
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| CARD-01 | Card shows recipient Discord username. | `discord_username` already in service `RecipientSnapshot`; needs to flow into `RecipientCardSnapshot` → `CardData` → card face secondary text |
| CARD-02 | Card shows recipient Discord avatar/icon when available. | Discord CDN URL `https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=64`; Slint `Image` via `Image::load_from_path` or pixel buffer; requires avatar hash storage |
| CARD-08 | Card action opens Shopify customer profile in default browser. | Shopify admin URL pattern `https://admin.shopify.com/stores/{store}/customers/{id}`; `open` crate v5 on Windows via `open::that(url)` |
| CARD-09 | Card action opens Shopify order in default browser. | Shopify admin URL pattern `https://admin.shopify.com/stores/{store}/orders/{id}`; same `open` crate approach |
| DSC-03 | System fetches/displays Discord avatar where available. | Fetch avatar bytes from Discord CDN; display in circular Slint Rectangle; fallback to initials when no avatar hash |
| DSC-04 | User can open DM context with one click from recipient/card UI. | Protocol: `discord://-/users/{user_id}`; fallback `https://discord.com/users/{user_id}`; via `open` crate |
</phase_requirements>

---

## Summary

Phase 8 enriches the existing card and option-grid UI with Discord identity data (avatar circle + username) and wires up outbound navigation from the ellipsis menu. The work is entirely additive: new fields flow through the established data pipeline (service `RecipientSnapshot` → `RecipientCardSnapshot` → `DashboardCardViewModel` → `CardData`), new menu items are added to the existing `card-menu` PopupWindow, and two new OS-level capabilities (URL opening and clipboard write) are introduced via lightweight Rust crates.

The most significant technical decisions are: (1) how to display the Discord avatar — Slint cannot load URLs at runtime natively, so the avatar must either be fetched to bytes in Rust and passed as a pixel buffer, or the avatar URL can be stored as a string and fetched on first render via a background task; and (2) which crate to use for URL opening and clipboard access, where `open` (v5.3.3) and `arboard` (v3.6.1) are the current standard choices for Windows-only v1.

The Shopify admin URL format requires a store domain (the `{store}.myshopify.com` slug). This is not currently stored anywhere in the data model, so Phase 8 must either hard-code the store URL from config or store the full admin URL at link time. This is a gap that must be addressed in the plan.

**Primary recommendation:** Add `open` and `arboard` to `app/Cargo.toml`. Pass Discord/Shopify data as strings through the existing pipeline. Implement avatar display as a Slint Rectangle initials-circle for Phase 8 (no real avatar image loading needed yet since `discord_avatar_hash` is not stored in the domain model — display is initials-only until the domain model gains avatar hash storage, which is not in scope for Phase 8).

---

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `open` | 5.3.3 | Open URLs and file paths in default system application | Handles Windows `ShellExecute` correctly; supports custom protocols including `discord://`; well-maintained |
| `arboard` | 3.6.1 | Read/write system clipboard | Maintained by 1Password; Windows clipboard-win backend; straightforward text API |
| Slint | 1.x (existing) | UI — avatar circle, secondary text, menu items | Already in use; Rectangle + Text + conditional `if` blocks cover all needs |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `std::process::Command` | stdlib | Fallback URL launch if `open` crate unavailable | Use only as fallback; `open` preferred |
| `log` | existing | Error logging for failed external opens | Already used in service layer; bring into app crate |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `open` | `webbrowser` crate | webbrowser is also good; open is simpler API |
| `open` | `std::process::Command("cmd /C start")` | std approach is fragile on some Windows versions; open crate handles edge cases |
| `arboard` | `copypasta` | copypasta 0.10.2 also works; arboard better maintained as of 2025 |
| Initials-circle | Fetched avatar image from Discord CDN | Avatar fetching requires async + `discord_avatar_hash` field not currently in domain model; initials are safe fallback for Phase 8 |

**Installation:**
```bash
# In crates/app/Cargo.toml [dependencies]
open = "5"
arboard = "3"
```

---

## Architecture Patterns

### Data Flow — Adding New Fields

Phase 8 follows the established data contract chain. New fields must be added at each layer:

```
Recipient (core domain)
  └─ discord_user_id: Option<String>    ← already exists
  └─ discord_username: Option<String>   ← already exists
  └─ shopify_customer_id: Option<String> ← already exists
  └─ [email: not currently stored — see gap below]

RecipientSnapshot (service API: crates/service/src/api/recipients.rs)
  └─ add: discord_user_id: Option<String>
  └─ discord_username already present
  └─ add: shopify_customer_url: Option<String>
  └─ add: shopify_order_url: Option<String>
  └─ add: email: Option<String>   ← sourced from github_profile_url or Shopify data

RecipientCardSnapshot (app service client: crates/app/src/service_client.rs)
  └─ add: discord_username: Option<String>
  └─ add: discord_user_id: Option<String>
  └─ add: shopify_customer_url: Option<String>
  └─ add: shopify_order_url: Option<String>
  └─ add: contact_secondary: Option<String>  ← discord username, or email, or None

DashboardCardViewModel (crates/app/src/dashboard/view_model.rs)
  └─ add: discord_user_id: Option<String>
  └─ add: discord_username: Option<String>
  └─ add: shopify_customer_url: Option<String>
  └─ add: shopify_order_url: Option<String>
  └─ add: contact_secondary: Option<String>

CardData struct (crates/app/ui/dashboard.slint)
  └─ add: discord-user-id: string         ← empty string = no Discord DM
  └─ add: discord-username: string        ← empty = no Discord identity
  └─ add: contact-secondary: string       ← displayed below name; empty = "No contact info"
  └─ add: shopify-customer-url: string    ← empty = hide "View Customer"
  └─ add: shopify-order-url: string       ← empty = hide "View Order"

RecipientCard (crates/app/ui/card.slint)
  └─ add matching in-properties
  └─ add callbacks: open-discord-dm(), copy-email(), open-shopify-customer(), open-shopify-order()

RecipientTileData (crates/app/ui/option-grid.slint)
  └─ add: discord-username: string        ← displayed below tile name
  └─ add: contact-secondary: string       ← fallback when no discord username

DashboardWindow (crates/app/ui/dashboard.slint)
  └─ add callbacks: card-open-discord-dm(int), card-copy-email(int),
                    card-open-shopify-customer(int), card-open-shopify-order(int)

main.rs
  └─ wire all 4 new callbacks
  └─ update seed_cards() with test Discord/Shopify data
  └─ update build_recipient_tiles() to include discord_username
```

### Pattern 1: Discord Deep Link with Fallback

**What:** Try `discord://` protocol first; fall back to HTTPS profile URL if the OS call fails; show error toast only if both fail.

**When to use:** Whenever user clicks "Open Discord DM" in the ellipsis menu.

**Discord protocol format (HIGH confidence — verified from community gist):**
```
Primary:  discord://-/users/{user_id}
Fallback: https://discord.com/users/{user_id}
```

**Example:**
```rust
// Source: open crate docs.rs + discord protocol gist
fn open_discord_dm(user_id: &str) -> Result<(), String> {
    let primary = format!("discord://-/users/{}", user_id);
    if open::that(&primary).is_ok() {
        return Ok(());
    }
    // Fallback to browser
    let browser_url = format!("https://discord.com/users/{}", user_id);
    open::that(&browser_url).map_err(|e| {
        log::error!("Failed to open Discord DM for user {}: {}", user_id, e);
        format!("Could not open Discord for user {}", user_id)
    })
}
```

### Pattern 2: Shopify Admin URL Construction

**What:** Build admin URL from numeric customer/order ID plus store slug.

**Gap identified:** The store domain (e.g., `mystore.myshopify.com`) is not currently stored in the data model. Phase 8 must resolve this. Options:
1. Store the full admin URL strings in `CardData` (constructed at service/snapshot layer using a configured store slug).
2. Store only the numeric Shopify IDs and construct URLs at the `CardData` → Slint binding layer using a stored config value.

**Recommended approach:** Construct full admin URLs at the `RecipientSnapshot` level using a store slug from config. Pass as opaque URL strings through the pipeline. This keeps URL logic in one place and avoids Slint needing any URL construction logic.

**URL patterns (MEDIUM confidence — standard Shopify admin URL structure):**
```
Customer: https://admin.shopify.com/stores/{store-slug}/customers/{customer_id}
Order:    https://admin.shopify.com/stores/{store-slug}/orders/{order_id}
```

**Example:**
```rust
// In RecipientSnapshot construction
let shopify_customer_url = recipient.shopify_customer_id.as_ref().map(|id| {
    format!("https://admin.shopify.com/stores/{}/customers/{}", store_slug, id)
});
```

### Pattern 3: Clipboard Write

**What:** Write email string to system clipboard via `arboard`.

**When to use:** When user clicks "Copy Email" in ellipsis menu.

**Example:**
```rust
// Source: arboard docs (v3.6.1)
fn copy_to_clipboard(text: &str) -> Result<(), String> {
    let mut clipboard = arboard::Clipboard::new()
        .map_err(|e| format!("Clipboard unavailable: {}", e))?;
    clipboard.set_text(text)
        .map_err(|e| {
            log::error!("Clipboard write failed: {}", e);
            format!("Could not copy to clipboard")
        })
}
```

### Pattern 4: Avatar Circle (Initials Fallback)

**What:** Render a circular colored Rectangle with text initial when no Discord avatar image is available.

**When to use:** Phase 8 scope — `discord_avatar_hash` is NOT currently stored in the domain model. All avatars will use the initials pattern in Phase 8. The `discord_user_id` + CDN URL approach is available for a future phase.

**Avatar CDN format (for future reference, HIGH confidence):**
```
https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=64
```

**Phase 8 implementation (initials only):**
```slint
// In card.slint — avatar circle left of recipient name
// Source: matches existing option-grid.slint pattern (lines 62-79)
Rectangle {
    x: 14px;
    y: 10px;
    width: 26px;
    height: 26px;
    border-radius: 13px;
    background: #2a3560;  // or color derived from name hash

    Text {
        text: root.recipient-initial;  // first char of name
        font-size: 11px;
        font-weight: 700;
        color: #7ea8ff;
        horizontal-alignment: center;
        vertical-alignment: center;
    }
}
```

**Color generation for initials (Claude's discretion — recommended):**
Since Slint does not support runtime color computation from string hashes, use a fixed palette of 6-8 colors indexed by `(name.bytes().sum()) % palette.len()`. The index mapping must be computed in Rust and passed as an int property to Slint, which selects a hardcoded color via conditional.

Simpler: use the single `#2a3560` color from the existing option-grid tiles. This is consistent and requires no new fields.

### Pattern 5: Secondary Contact Text on Card

**What:** Show `@discord_username` below recipient name; fall back to email; fall back to "No contact info".

**Computed in Rust at `CardData` population time:**
```rust
fn contact_secondary(discord_username: Option<&str>, email: Option<&str>) -> String {
    if let Some(u) = discord_username {
        format!("@{}", u)
    } else if let Some(e) = email {
        e.to_string()
    } else {
        "No contact info".to_string()
    }
}
```

### Pattern 6: Conditional Ellipsis Menu Items

**What:** Show/hide menu items based on data availability using Slint `if` blocks.

**Constraint:** Slint `if` blocks on items inside `VerticalLayout` work correctly — previously established pattern in card.slint (lines 360-379 `if root.show-refresh`). Use same approach for Discord DM, Copy Email, View Customer, View Order items.

**Separator implementation:** Slint has no built-in divider element. Use a thin `Rectangle` with `height: 1px; background: #4a5578` between sections.

**Example menu structure:**
```slint
card-menu := PopupWindow {
    // width needs to grow to accommodate longer items
    width: 140px;

    VerticalLayout {
        padding: 4px;

        // Section 1: Communication
        if root.discord-user-id != "" : Rectangle { /* Open Discord DM */ }
        if root.has-email : Rectangle { /* Copy Email */ }

        // Separator (only when section 1 AND section 2 both have items)
        if (root.discord-user-id != "" || root.has-email)
            && (root.shopify-customer-url != "" || root.shopify-order-url != "") : Rectangle {
            height: 1px;
            background: #4a5578;
            margin: 2px;
        }

        // Section 2: External links
        if root.shopify-customer-url != "" : Rectangle { /* View Customer */ }
        if root.shopify-order-url != "" : Rectangle { /* View Order */ }

        // Separator before section 3 (card actions always present)
        Rectangle { height: 1px; background: #4a5578; }

        // Section 3: Card actions (existing)
        Rectangle { /* Archive / Unarchive / Archive Now */ }
    }
}
```

**Note:** Slint conditional separators using compound boolean expressions — the `&&` and `||` operators are supported in `.slint` property expressions. However, multi-condition `if` expressions may need to be simplified to separate bool properties computed in Rust if the expression is complex. Test this during implementation.

### Recommended Project Structure Changes

```
crates/app/
├── Cargo.toml          ← add open = "5", arboard = "3"
├── src/
│   ├── main.rs         ← wire 4 new callbacks; update seed_cards()
│   └── dashboard/
│       ├── view_model.rs   ← add 5 new fields
│       ├── external.rs     ← NEW: open_discord_dm(), open_url(), copy_to_clipboard()
│       └── projection.rs   ← pass Discord/Shopify fields through project_snapshot()
├── ui/
│   ├── dashboard.slint ← add CardData fields; add 4 callbacks; pass to RecipientCard
│   ├── card.slint      ← add properties; avatar circle; secondary text; menu items
│   └── option-grid.slint ← add discord-username to RecipientTileData; show below name
crates/app/src/service_client.rs ← add fields to RecipientCardSnapshot
crates/service/src/api/recipients.rs ← add discord_user_id, shopify URLs, email to RecipientSnapshot
```

### Anti-Patterns to Avoid

- **Constructing Shopify URLs in Slint:** URL construction logic belongs in Rust. Pass complete URL strings to Slint.
- **Blocking the UI thread with URL open:** `open::that()` is synchronous. On Windows it delegates to `ShellExecuteW` which is fast for protocol launches. Acceptable for v1, but wrap in error handling.
- **Fetching Discord avatars over HTTP in Phase 8:** Avatar hash is not in the domain model. Do not introduce network fetching in Phase 8. Use initials.
- **Using `std::process::Command("cmd /C start ...")` directly:** The `open` crate handles Windows edge cases correctly. Use it.
- **Slint Image with @image-url for runtime URLs:** `@image-url` requires compile-time-known paths. Not usable for Discord CDN at runtime.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Open URL in browser | Custom ShellExecute wrapper | `open` crate v5 | Handles Windows edge cases, escaping, multiple browser configs |
| Write to clipboard | Win32 OpenClipboard/SetClipboardData wrapper | `arboard` v3 | Thread safety, Unicode handling, error recovery already solved |
| Discord protocol fallback | Custom process spawn with detection | `open::that()` return value + retry | open crate returns Err when launch fails; no subprocess needed |

**Key insight:** Both `open` and `arboard` are single-purpose crates with minimal transitive dependencies. The Windows-specific clipboard and shell APIs have many edge cases (Unicode paths, locked clipboard, default browser not set) that these crates have already addressed.

---

## Common Pitfalls

### Pitfall 1: Discord Protocol URL Format Is Undocumented

**What goes wrong:** Using `discord://discord.com/users/{id}` (as mentioned in CONTEXT.md) instead of the actual format `discord://-/users/{id}`.
**Why it happens:** Discord does not officially document their URI scheme. Community sources vary.
**How to avoid:** The format `discord://-/users/{user_id}` is verified from the community-maintained gist of Discord app protocol routes. Use this. The fallback to `https://discord.com/users/{user_id}` makes this resilient.
**Warning signs:** `open::that("discord://discord.com/users/...")` returns Ok but Discord doesn't open — the URL was malformed but ShellExecute accepted it. Test with a known user ID during 08-03 polish.

### Pitfall 2: Store Slug Not Available

**What goes wrong:** Phase 8 tries to construct Shopify admin URLs but the store domain (e.g., `mystore.myshopify.com` or `mystore`) is not stored anywhere in the current data model.
**Why it happens:** The Shopify integration stores `shopify_customer_id` (the numeric ID) but not the store's slug or admin URL prefix.
**How to avoid:** The plan must include a task to either (a) add a store slug to the service config/credential store so it can be used at snapshot time, or (b) store full admin URLs in the recipient record at link time. Option (a) is preferred since the store slug is a global config value.
**Warning signs:** `shopify_customer_url` is always an empty string in seed data — caught during plan review.

### Pitfall 3: Ellipsis Menu Width Too Narrow

**What goes wrong:** Existing menu is `width: 110px` (card.slint line 348). "Open Discord DM" and "View Customer" are longer strings that will overflow.
**Why it happens:** Phase 7 only needed "Archive / Unarchive / Archive Now" — shorter labels.
**How to avoid:** Increase `card-menu` PopupWindow width to ~140px in 08-01. Verify longest label fits.

### Pitfall 4: CardData Field Count Growth

**What goes wrong:** `CardData` struct in `dashboard.slint` grows with 5 new fields. All `for card-data[card-index] in root.cards` bindings must include the new fields. Missing a field binding causes a Slint compile error (not a runtime error) — this is actually helpful.
**Why it happens:** Slint structs require all fields to be bound when instantiated.
**How to avoid:** After adding fields to `CardData`, the compiler will flag every site that creates a `CardData` literal. Follow the compiler errors. The seed data in `main.rs` `seed_cards()` and `card_data_to_filter_model()` will both need updating.

### Pitfall 5: Separator Visibility Logic in Slint

**What goes wrong:** Complex boolean conditions in Slint `if` for separator visibility (e.g., "show separator only when both adjacent sections have at least one visible item") may not compile as inline expressions.
**Why it happens:** Slint property expressions support basic operators but complex multi-property expressions may require intermediate computed properties.
**How to avoid:** Compute a `has-communication-items: bool` and `has-shopify-items: bool` property in Slint using simple conditions (`self.discord-user-id != "" || self.has-email`). Use those bools for separator visibility.

### Pitfall 6: arboard Clipboard Requires Short Lifetime

**What goes wrong:** `arboard::Clipboard` must be dropped (not held open) between operations on Windows. Holding a `Clipboard` instance as a field on a struct causes subsequent clipboard access attempts to fail.
**Why it happens:** Windows clipboard is a shared system resource; holding it open blocks other apps.
**How to avoid:** Create `arboard::Clipboard::new()` inside the callback, use it, and let it drop. Do not store it.

---

## Code Examples

Verified patterns from official sources:

### Open a URL on Windows (open crate v5)
```rust
// Source: https://docs.rs/open/latest/open/
// Returns Ok(()) on success, Err on failure
fn open_url(url: &str) -> Result<(), String> {
    open::that(url).map_err(|e| {
        log::error!("Failed to open URL {}: {}", url, e);
        format!("Could not open: {}", url)
    })
}
```

### Clipboard write (arboard v3.6.1)
```rust
// Source: https://docs.rs/arboard/latest/arboard/
fn copy_text(text: &str) -> Result<(), String> {
    arboard::Clipboard::new()
        .and_then(|mut cb| cb.set_text(text))
        .map_err(|e| {
            log::error!("Clipboard error: {}", e);
            "Clipboard unavailable".to_string()
        })
}
```

### Discord DM deep link with fallback
```rust
fn open_discord_dm(user_id: &str) -> Result<(), String> {
    let primary = format!("discord://-/users/{}", user_id);
    if open::that(&primary).is_ok() {
        return Ok(());
    }
    let fallback = format!("https://discord.com/users/{}", user_id);
    open::that(&fallback).map_err(|e| {
        log::error!("Discord DM open failed for {}: {}", user_id, e);
        format!("Could not open Discord DM")
    })
}
```

### Slint conditional separator
```slint
// Separator between communication and external links sections
if root.has-communication-items && root.has-shopify-items : Rectangle {
    height: 1px;
    margin-top: 2px;
    margin-bottom: 2px;
    background: #4a5578;
}
```

### Avatar circle with initial (matches existing option-grid pattern)
```slint
// Source: option-grid.slint lines 62-79 — already established pattern
Rectangle {
    x: 14px;
    y: 9px;
    width: 26px;
    height: 26px;
    border-radius: 13px;
    background: #2a3560;

    Text {
        text: root.recipient-initial;
        font-size: 11px;
        font-weight: 700;
        color: #7ea8ff;
        horizontal-alignment: center;
        vertical-alignment: center;
    }
}
```

### RecipientCard name row shift (to accommodate avatar)
The current name text is at `x: 14px, y: 14px`. With a 26px avatar at `x: 14px`, the name text must shift right to `x: 48px` (14 + 26 + 8 gap). The secondary contact line goes at `x: 48px, y: 28px` in a smaller font. This shifts the name area touch target and the summary info icon glyph accordingly.

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `webbrowser` crate for URL open | `open` crate v5 | open v5 released 2023 | Simpler API, no dependency on xdg-open on Linux |
| `clipboard` crate (abandoned) | `arboard` v3 | 2022-2023 | Active maintenance by 1Password; Windows + Linux + macOS |
| Discord `discord://` undocumented | `discord://-/users/{id}` community-verified | Present | `-/` prefix is required; without it the URL is ignored on desktop |
| Shopify `/admin/customers/{id}` (legacy) | `https://admin.shopify.com/stores/{slug}/customers/{id}` | 2022 | New unified admin URL; old URLs redirect but may not deep-link correctly |

**Deprecated/outdated:**
- `clipboard` crate: unmaintained, do not use.
- `discord://discord.com/users/{id}`: wrong format, use `discord://-/users/{id}`.
- Old Shopify admin URL `/{shop}.myshopify.com/admin/customers/{id}`: still works via redirect but the new `admin.shopify.com/stores/{slug}` format is canonical.

---

## Open Questions

1. **Store slug source for Shopify URLs**
   - What we know: `shopify_customer_id` stores the numeric Shopify customer ID. No store slug is stored.
   - What's unclear: Where does the store slug come from? It would need to be a configured credential or extracted from the Shopify API token scope.
   - Recommendation: Add a `shopify_store_slug` field to the credential/config store (or derive it from the existing Shopify admin API base URL). The plan for 08-02 should include a task to expose this. For Phase 8 seed data, hardcode a placeholder.

2. **Email source field**
   - What we know: `github_profile_url` exists on `Recipient`. Shopify customers have email in the Shopify API response.
   - What's unclear: Is the recipient's email currently stored anywhere in the domain model or DB? Examining `crates/service/src/db/schema.rs` was not done — may already exist.
   - Recommendation: Read `schema.rs` at plan time to confirm whether email is persisted. If not, the fallback chain can use `github_profile_url` as a "contact" (less useful) or skip email entirely for Phase 8.

3. **Shopify order ID availability**
   - What we know: `Package` has `package_id` but it is a WITwhat-internal ID. Shopify order IDs from the API sync may or may not be stored.
   - What's unclear: Is there a `shopify_order_id` in `Package` or the DB schema?
   - Recommendation: Check `schema.rs` and `package.rs` for `shopify_order_id`. If absent, "View Order" cannot be implemented in Phase 8 without schema additions.

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in (`cargo test`) |
| Config file | none — standard Cargo test discovery |
| Quick run command | `cargo test -p app 2>&1 \| tail -20` |
| Full suite command | `cargo test --workspace 2>&1 \| tail -40` |

### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CARD-01 | Discord username flows from snapshot to CardData `contact-secondary` field | unit | `cargo test -p app discord_username` | ❌ Wave 0 |
| CARD-02 | Avatar initials field populated; no panic when discord_user_id absent | unit | `cargo test -p app avatar_initial` | ❌ Wave 0 |
| CARD-08 | Shopify customer URL constructed correctly from store slug + customer ID | unit | `cargo test -p app shopify_customer_url` | ❌ Wave 0 |
| CARD-09 | Shopify order URL constructed correctly from store slug + order ID | unit | `cargo test -p app shopify_order_url` | ❌ Wave 0 |
| DSC-03 | contact_secondary returns `@username` when discord_username set | unit | `cargo test -p app contact_secondary` | ❌ Wave 0 |
| DSC-04 | open_discord_dm formats correct primary and fallback URLs | unit | `cargo test -p app open_discord_dm` | ❌ Wave 0 |

**Note:** `open_discord_dm` and clipboard functions perform OS calls; test with mock or test only the URL-formatting logic. External launch is manual-verified during 08-03.

### Sampling Rate
- **Per task commit:** `cargo test -p app 2>&1 | tail -20`
- **Per wave merge:** `cargo test --workspace 2>&1 | tail -40`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `crates/app/tests/discord_enrichment_tests.rs` — covers CARD-01, CARD-02, DSC-03, DSC-04
- [ ] `crates/app/tests/shopify_url_tests.rs` — covers CARD-08, CARD-09
- [ ] `crates/app/src/dashboard/external.rs` — new module for URL open + clipboard; must exist before tests can import it

---

## Sources

### Primary (HIGH confidence)
- Slint docs (docs.slint.dev) — Image component, @image-url compile-time requirement, conditional `if` in VerticalLayout
- `open` crate docs.rs v5.3.3 — URL open API, Windows support
- `arboard` docs.rs v3.6.1 — Clipboard API, Windows clipboard-win backend
- Existing codebase (`card.slint`, `option-grid.slint`, `dashboard.slint`, `main.rs`, `view_model.rs`, `service_client.rs`, `recipients.rs`) — all data structures and patterns verified by direct read

### Secondary (MEDIUM confidence)
- [ghostrider-05 Discord protocol gist](https://gist.github.com/ghostrider-05/8f1a0bfc27c7c4509b4ea4e8ce718af0) — `discord://-/users/{id}` format; community-maintained, not official
- Discord CDN avatar URL format `https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=N` — consistent across multiple community sources
- Shopify admin URL format `https://admin.shopify.com/stores/{slug}/customers/{id}` — from Shopify developer forum discussions

### Tertiary (LOW confidence)
- Shopify order URL format (same pattern with `/orders/`) — inferred from customer URL pattern; not directly verified from official Shopify Admin API docs

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — `open` and `arboard` verified from docs.rs; Slint patterns verified from codebase
- Architecture: HIGH — pipeline chain verified by reading all 10+ source files; new fields follow exact established pattern
- Pitfalls: HIGH for Slint/UI pitfalls (from codebase read); MEDIUM for Discord protocol format (community source); MEDIUM for Shopify URL format (forum sources)
- Open questions: 3 gaps identified that plan must address before implementation of 08-02 and 08-03

**Research date:** 2026-03-12
**Valid until:** 2026-04-12 (stable ecosystem; Slint 1.x API stable; open/arboard have stable APIs)
