# Phase 16: GH Project New Columns Ingestion - Research

**Researched:** 2026-03-22
**Domain:** GitHub Project GraphQL ingestion, SQLite schema migration, Slint UI extension
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- Purpose is a GH Project Single Select field with 6 known values: Tester, VIP, Big Team, Marketing, Hardware Dev, Demo Event
- Colors discovered from GH Project API (single-select option colors), not hardcoded
- Every card gets a ring: white fallback when Purpose is empty or unassigned
- Purpose text appears as tooltip on avatar hover AND as a labeled field in the recipient summary popover
- Purpose values appear as colored filter chips on the existing chip bar alongside other chips
- Chips use GH-derived color; multi-select toggle: default all off (show everything); clicking activates to filter TO that Purpose; multiple chips can be active simultaneously (additive filtering)
- Vision Rx OD and OS shown in recipient summary popover only (not on card face)
- Inline editable: click to edit, save writes back to GH Project via GraphQL mutation (reuse existing `update_field_text` infrastructure)
- "Copy Rx" button copies both values to clipboard: `OD {value} / OS {value}`
- Columns: `product_names`, `product_shopify_urls` (comma-separated text fields in GH Project)
- `product_serials` column deferred entirely to Phase 19
- Product parallel-arrays are permanent recipient-level fields stored on recipients table in SQLite
- Displayed in recipient summary popover as product list
- Independent from card-level `product_names` (which comes from Shopify order line items)
- 2px colored ring around avatar circle; color from GH Project Purpose option color; white ring when Purpose is empty or card is unassigned
- Purpose tooltip triggers on avatar hover only (not entire header)

### Claude's Discretion

- Fallback color strategy if GH API color can't be read or doesn't map well to dark theme
- Exact clipboard formatting details for Copy Rx
- How to handle comma-separated parsing edge cases (trailing commas, whitespace)
- Avatar ring implementation approach within Slint constraints (no per-corner border-radius)

### Deferred Ideas (OUT OF SCOPE)

- `product_serials` column ingestion and display — Phase 19
- Product parallel-array write-back to GH Project — future phase
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| GHCOL-01 | System ingests `Purpose` column from GH Project and displays colored avatar border on cards | GraphQL query already has `ProjectV2ItemFieldSingleSelectValue`; needs `color` sub-field added; avatar ring via nested Rectangle in Slint |
| GHCOL-02 | Purpose value shows as tooltip on card header hover and in recipient summary popover | Slint tooltip via conditional Text overlay on avatar `has-hover`; popover already has VerticalLayout sections |
| GHCOL-03 | System ingests `Vision Rx OD` and `Vision Rx OS` columns from GH Project | Both are text fields; follow same pattern as existing `text` field extraction in `parse_gh_response` |
| GHCOL-04 | Vision Rx values display in recipient summary popover with inline editing | TextInput pattern already established in card note editing; write-back via existing `update_field_text` + `fetch_field_id` |
| GHCOL-05 | System ingests product parallel-array columns (`product_names`, `product_shopify_urls`, `product_serials`) from GH Project | Comma-split text fields; store on recipients table; display in popover; `product_serials` deferred |
</phase_requirements>

---

## Summary

Phase 16 extends the existing GH Project ingestion pipeline with five new fields (Purpose, Vision Rx OD, Vision Rx OS, product_names, product_shopify_urls) and surfaces them in the Slint UI. The pipeline is well-established: `GhCliProjectClient.fetch_rows()` pulls GraphQL data, `project_mapping.rs::map_rows()` transforms it into `GithubMappedRecipient`, which flows through `merge_recipient()` into the `Recipient` domain model, and then to `RecipientCardSnapshot` for display. Every layer needs to be extended with the new fields.

The biggest new technical challenge is fetching Purpose single-select **option colors** from the GH Project API — the current GraphQL query does not request them. The `ProjectV2ItemFieldSingleSelectValue` fragment currently extracts only `name`; it must be extended to also fetch `color` from the option object, which requires a deeper fragment on `ProjectV2SingleSelectField`. This color then flows all the way through to `CardData` in Slint as a color string.

The SQLite `recipients` table must gain five new columns via a `V002` refinery migration. Since recipients are currently not persisted to SQLite (the `card_row_to_snapshot()` function has a TODO noting this), this phase requires writing recipient data to SQLite for the first time, or threading recipient fields through the card snapshot. The practical short-term path — consistent with the existing "TODO Phase 17" comments — is to carry recipient Purpose/Rx fields on `RecipientCardSnapshot` directly, mirroring how `discord_user_id` is already carried, and also persist them to the recipients table per DATA-FLOW RULE-03.

**Primary recommendation:** Extend `GithubMappedRecipient` with the five new fields, update `map_rows()` to read them, propagate through `RecipientCardSnapshot`, add the recipients SQLite table columns via migration, write recipients on sync, and extend the Slint pipeline to render Purpose ring, Purpose chips, and the enriched summary popover.

---

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| rusqlite | 0.32 (bundled) | SQLite migrations and queries | Already in use (Phase 15); V002 migration follows established refinery pattern |
| refinery | 0.8 | Embedded SQL migrations | Already in use; V002__add_recipient_columns.sql follows V001 pattern |
| serde_json | (already in Cargo) | Parse GH GraphQL JSON responses | Already used in `parse_gh_response` |
| slint | (already in Cargo) | UI rendering for ring, chips, popover | Existing framework |
| arboard / clipboard | — | Clipboard write for Copy Rx | See below |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| arboard | 3.x | Cross-platform clipboard | For "Copy Rx" button — `arboard::Clipboard::new().and_then(|mut c| c.set_text(...))` |

**Version verification note:** The project already has `arboard` in scope via Slint's clipboard integration. Check `Cargo.toml` for whether `arboard` is a direct dependency before adding it. Slint may provide a clipboard API internally (`slint::ClipboardAction`) — verify before adding a new dependency.

**Installation (only if arboard not already present):**
```bash
cargo add arboard --no-default-features --features windows
```

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| arboard | Slint built-in clipboard | Slint clipboard is component-level; arboard gives direct Rust control |
| String color field on CardData | Slint `color` type passed from Rust | Color type is harder to construct dynamically from API strings; string hex is simpler |

---

## Architecture Patterns

### Data Flow (all 5 new fields follow this chain)

```
GH Project GraphQL
  └─> parse_gh_response() [gh_cli_client.rs]
        └─> GithubProjectRow.fields HashMap<String, String>
              └─> map_rows() [project_mapping.rs]
                    └─> GithubMappedRecipient  (+ 5 new fields)
                          └─> merge_recipient() [merge.rs]
                                └─> Recipient domain model  (+ 5 new fields)
                                      └─> run_sync_cycle() [live_client.rs]
                                            └─> RecipientCardSnapshot  (+ purpose_color, purpose_label, rx_od, rx_os, recipient_product_names, recipient_product_shopify_urls)
                                                  └─> SQLite recipients table  (V002 migration)
                                                        └─> project_snapshot() [projection.rs]
                                                              └─> DashboardCardViewModel  (+ purpose_color, purpose_label)
                                                                    └─> CardData (Slint)  (+ purpose-color: color, purpose-label: string)
                                                                          └─> RecipientCard avatar ring + tooltip
```

### Recommended Project Structure Extensions

```
crates/integrations/src/github/
├── project_client.rs        # Add fetch_purpose_colors() to trait (or extend fetch_rows)
├── gh_cli_client.rs         # Extend GraphQL query to include color on SingleSelectValue
└── project_mapping.rs       # Extend map_rows() to read Purpose/Rx/product arrays

crates/core/src/domain/
└── recipient.rs             # Add purpose, vision_rx_od, vision_rx_os, product_names, product_shopify_urls

crates/service/src/
├── sync/github_ingest.rs    # compute_ingest_diff must compare new fields
├── sync/merge.rs            # merge_recipient propagates new fields
└── db/
    ├── migrations/
    │   └── V002__add_recipient_columns.sql   # NEW: 5 columns on recipients table
    └── sqlite.rs            # upsert_recipient() + read_recipients() (new methods)

crates/app/src/
├── service_client.rs        # RecipientCardSnapshot + 6 new fields
├── live_client.rs           # card_row_to_snapshot, run_sync_cycle, snapshot_to_card_row
└── dashboard/
    ├── view_model.rs        # DashboardCardViewModel + purpose_color, purpose_label
    ├── projection.rs        # project_snapshot maps purpose/rx fields
    ├── discovery.rs         # add Purpose filter alongside status filter
    └── mod.rs               # build_card_data includes purpose-color, purpose-label

crates/app/ui/
├── dashboard.slint          # CardData struct gets purpose-color, purpose-label
├── card.slint               # Avatar ring + tooltip; popover sections for Rx, Purpose, products
└── chip-bar.slint           # ChipData may need color field; OR use two separate chip lists
```

### Pattern 1: Extending GithubMappedRecipient

Current `map_rows()` reads fields directly from `row.fields.get("ColumnName")`. The pattern is identical for all 5 new fields:

```rust
// Source: crates/integrations/src/github/project_mapping.rs (established pattern)
pub struct GithubMappedRecipient {
    // ... existing fields ...
    pub purpose: Option<String>,
    pub purpose_color: Option<String>,    // hex color from GH API option
    pub vision_rx_od: Option<String>,
    pub vision_rx_os: Option<String>,
    pub product_names: Vec<String>,        // split from comma-separated text
    pub product_shopify_urls: Vec<String>, // split from comma-separated text
}

// In map_rows():
purpose: row.fields.get("Purpose").filter(|s| !s.trim().is_empty()).cloned(),
purpose_color: row.fields.get("Purpose__color").filter(|s| !s.trim().is_empty()).cloned(),
vision_rx_od: row.fields.get("Vision Rx - OD").filter(|s| !s.trim().is_empty()).cloned(),
vision_rx_os: row.fields.get("Vision Rx - OS").filter(|s| !s.trim().is_empty()).cloned(),
product_names: parse_comma_array(row.fields.get("product_names")),
product_shopify_urls: parse_comma_array(row.fields.get("product_shopify_urls")),
```

**Key decision:** The Purpose color comes from the GH Project single-select option definition, not per-row. See Pattern 2 for how to fetch it.

### Pattern 2: Fetching Single-Select Option Colors

The current GraphQL query for `ProjectV2ItemFieldSingleSelectValue` only extracts `name`. To get colors, the query must be extended to also return the `color` field from the option:

```graphql
# Extend in gh_cli_client.rs fetch_rows() query:
... on ProjectV2ItemFieldSingleSelectValue {
  field { ... on ProjectV2Field { name } }
  name
  color   # ADD THIS — returns a color string like "BLUE", "GREEN", "GRAY", etc.
}
```

**GH Project API color values are COLOR NAME ENUMS, not hex strings.** Values are: `BLUE`, `GREEN`, `YELLOW`, `ORANGE`, `RED`, `PINK`, `PURPLE`, `GRAY`. These must be mapped to hex colors suitable for the dark theme.

**Approach for parsing:** In `parse_gh_response()`, for `ProjectV2ItemFieldSingleSelectValue` nodes, extract both `name` and `color`. Store the color as a synthetic field with key `"{field_name}__color"`:

```rust
// In parse_gh_response, for SingleSelectValue nodes:
if let Some(name_val) = fv.get("name").and_then(|v| v.as_str()) {
    fields.insert(field_name.clone(), name_val.to_string());
    if let Some(color_val) = fv.get("color").and_then(|v| v.as_str()) {
        fields.insert(format!("{}_color", field_name), color_val.to_string());
    }
}
```

**GH API color enum → hex mapping (for dark theme):**
```rust
fn gh_color_to_hex(gh_color: &str) -> &'static str {
    match gh_color {
        "BLUE"   => "#4a7cff",  // accent (matches existing Colors.accent)
        "GREEN"  => "#4caf50",  // success
        "YELLOW" => "#f0c030",
        "ORANGE" => "#f0a030",  // warning
        "RED"    => "#e05050",  // error
        "PINK"   => "#e060a0",
        "PURPLE" => "#9060e0",
        "GRAY"   => "#8a92a8",  // text-muted (already used, safe)
        _        => "#ffffff",  // white fallback (matches no-Purpose rule)
    }
}
```

**Confidence:** MEDIUM — GH Project GraphQL color enum values are documented; exact hex mapping is at Claude's discretion (locked by CONTEXT.md).

### Pattern 3: Avatar Ring in Slint (no per-corner border-radius)

Slint does not support per-corner border-radius. A 2px ring around a circle is achieved with a wrapping Rectangle of slightly larger size, filled with the ring color, clipped off by the same border-radius:

```slint
// In card.slint, replace the bare avatar Rectangle:
// Outer ring wrapper — larger by 4px (2px each side), filled with purpose color
Rectangle {
    width: 30px;
    height: 30px;
    border-radius: 15px;
    background: root.purpose-color != "" ? Colors.parse-color(root.purpose-color) : #ffffff;

    // Inner avatar circle — centered, 26x26
    Rectangle {
        x: 2px;
        y: 2px;
        width: 26px;
        height: 26px;
        border-radius: 13px;
        background: Colors.avatar-bg;
        Text {
            text: root.recipient-initial;
            // ...
        }
    }
}
```

**Constraint:** `Colors.parse-color()` requires a compile-time string literal in Slint. Dynamic colors from API strings cannot be passed as Slint `color` type at runtime via `parse-color`. Instead, carry the color as a Slint `color` property (not a string) and set it from Rust using the `slint::Color::from_argb_u8()` API when building `CardData`.

**Implementation:** Add `purpose-color: color` to `CardData` struct. Set it in Rust from the hex string:
```rust
// In mod.rs where CardData is built:
let purpose_color = vm.purpose_color.as_deref()
    .and_then(parse_hex_color)   // returns slint::Color
    .unwrap_or(slint::Color::from_argb_u8(255, 255, 255, 255)); // white
```

### Pattern 4: ChipBar Extension for Purpose Chips

`ChipData` currently has only `label: string` and `selected: bool`. Purpose chips need a color. Two approaches:

**Option A (recommended):** Extend `ChipData` to add an optional `chip-color: color` field. When set, use it instead of the default `#4a7cff` selected color and `#2d3348` unselected color.

```slint
// In chip-bar.slint:
export struct ChipData {
    label: string,
    selected: bool,
    chip-color: color,   // ADD: zero-value color = default blue behavior
}
```

The chip rendering then: `background: chip.selected ? (chip.chip-color.alpha > 0 ? chip.chip-color : #4a7cff) : #2d3348`.

**Option B:** Keep `ChipData` unchanged, use a separate `purpose-chips` property on `DashboardWindow` and render a second `ChipBar`-like component. This avoids changing existing `ChipData` struct but duplicates chip rendering logic.

**Recommendation:** Option A. The struct change is minimal and `ChipData` is only used in one place (`chip-bar.slint`).

### Pattern 5: Comma-Array Parsing

Product parallel-arrays are stored as comma-separated text in GH Project. Parsing function:

```rust
fn parse_comma_array(value: Option<&String>) -> Vec<String> {
    match value {
        None => vec![],
        Some(s) => s
            .split(',')
            .map(|part| part.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect(),
    }
}
```

This handles: trailing commas, leading/trailing whitespace per item, empty string inputs.

### Pattern 6: Vision Rx Write-Back

`update_field_text` already exists and is used for Shopify Profile URL write-back. Vision Rx inline editing reuses this path:

1. On save: call `update_field_text(project_id, github_item_id, field_id, new_value)`
2. `field_id` must be fetched once via `fetch_field_id(project_id, "Vision Rx - OD")` and cached
3. Write-back call can go through the existing `DashboardDataClient` trait by adding `update_recipient_field` method, OR through a direct call in the UI action handler in `live_client.rs`

**Existing write-back infrastructure in `live_client.rs`:** The Shopify Profile URL write-back is implemented as part of `pick_recipient` flow. Vision Rx write-back follows the same pattern but triggered from the summary popover's inline edit save action.

### Anti-Patterns to Avoid

- **Do NOT carry purpose_color as a String in Slint CardData.** Slint `color` properties must be set as `slint::Color` from Rust, not parsed at runtime. Use `color` type in the struct.
- **Do NOT add `github_profile_url` to any struct.** RULE-01: this field is prohibited everywhere.
- **Do NOT read from in-memory Repository for recipient data.** RULE-03: SQLite is the single read source. New recipient fields must go through `SqliteStore`.
- **Do NOT add new fields without updating DATA-FLOW.md in the same commit.** RULE-06.
- **Do NOT create `product_serials` column ingestion.** Deferred to Phase 19.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| SQLite schema changes | Manual ALTER TABLE | refinery V002 migration file | Atomic, versioned, already established in V001 |
| GH API color name → hex | Switch embedded in mapping | `gh_color_to_hex()` function in project_mapping.rs | Centralized, testable |
| Clipboard write | Win32 API calls | arboard or Slint built-in | Cross-thread safety, error handling |
| Comma splitting | Custom parser | `str.split(',').map(str::trim).filter(!empty)` | The pattern handles all edge cases |
| Purpose filter logic | New filter architecture | Extend `ModeState.selected_status_filters: HashSet<String>` | Same multi-select pattern as status filters |

**Key insight:** All five infrastructure patterns (field extraction, struct extension, migration, filter logic, write-back) have direct working precedents in the codebase. This phase is primarily extension work, not new architecture.

---

## Common Pitfalls

### Pitfall 1: fieldValues(first: 20) Limit

**What goes wrong:** The current GraphQL query fetches `fieldValues(first: 20)`. Adding Purpose, Vision Rx OD, Vision Rx OS, product_names, product_shopify_urls adds 5 more fields. If a project item has more than 20 field values total, some will be silently dropped.

**Why it happens:** GH Project GraphQL paginates field values per item. The `first: 20` limit was set when fewer columns existed.

**How to avoid:** Increase to `fieldValues(first: 30)` or higher. Count actual columns in the GH Project to confirm safe limit.

**Warning signs:** Random missing field values on items with many columns.

---

### Pitfall 2: Purpose Color is an ENUM, Not a Hex String

**What goes wrong:** GH Project API returns `color: "BLUE"` (enum name), not `"#4a7cff"`. Code that tries to use the raw API value as a CSS color will render nothing or produce an error.

**Why it happens:** The GitHub GraphQL API uses `ProjectV2SingleSelectFieldOption` color enum values: `BLUE`, `GREEN`, `YELLOW`, `ORANGE`, `RED`, `PINK`, `PURPLE`, `GRAY`.

**How to avoid:** Always pass the API color string through `gh_color_to_hex()` before storing or using in UI.

**Warning signs:** Avatar rings rendering as transparent or causing parse errors in Slint color properties.

---

### Pitfall 3: Recipients Table Not Persisted From Sync Cycle

**What goes wrong:** The current `run_sync_cycle` writes cards to SQLite but does NOT write recipient rows. The `card_row_to_snapshot()` function has explicit `TODO Phase 17: join recipients table` comments for `discord_username` and `discord_user_id`. If this phase adds recipient fields to `RecipientCardSnapshot` but doesn't also write recipients to SQLite, the data won't survive app restart.

**Why it happens:** Recipients table exists in schema (V001) but no upsert path was built in Phase 15 (recipients were considered identity-only).

**How to avoid:** Phase 16 must add `SqliteStore::upsert_recipient()` and call it from `run_sync_cycle()` for each GH recipient. Then `card_row_to_snapshot()` must JOIN the recipients table (or carry the fields from a parallel lookup). The simplest approach: carry purpose/rx fields directly on `RecipientCardSnapshot` (same as `discord_user_id`) and upsert them to both `cards` and `recipients` tables.

**Warning signs:** Purpose rings and Vision Rx values disappear on app restart.

---

### Pitfall 4: Slint `color` Type Cannot Be Constructed from a Runtime String

**What goes wrong:** Slint's `color` type in `.slint` files cannot be set from a runtime hex string using `Colors.parse-color()` — that function requires a compile-time string literal. If `purpose-color` is declared as `color` in `CardData`, the value must be constructed in Rust as `slint::Color`.

**Why it happens:** Slint's type system separates compile-time color literals from runtime color values.

**How to avoid:** In Rust, parse the hex color string to `slint::Color::from_argb_u8(255, r, g, b)` before setting on the Slint model. Use a helper function `parse_hex_to_slint_color(hex: &str) -> slint::Color`.

**Warning signs:** Compiler errors when trying to assign a `String` to a `color` property in generated Slint bindings.

---

### Pitfall 5: Purpose Filter Interaction with Existing Archive/Status Filters

**What goes wrong:** Purpose chips add a new filter dimension. If the filtering logic `apply_filters` applies status AND archive AND Purpose filters independently, a card must pass ALL active filters to be visible. This is additive within Purpose (OR across selected Purpose values) but conjunctive with other filter types (AND with archive, AND with status).

**Why it happens:** Multi-dimensional filtering requires careful boolean logic: `(no_purpose_selected OR card_purpose IN selected_purposes) AND (status passes) AND (archive passes)`.

**How to avoid:** In `discovery.rs`, add Purpose filter as a separate function: `filter_cards_by_purpose(cards, &selected_purposes)` where empty `selected_purposes` passes all cards through. Compose with existing filters.

---

### Pitfall 6: Summary Popover Inline Edit State Management

**What goes wrong:** The summary popover is a `PopupWindow` in Slint. `PopupWindow` has limited state management — properties set inside it may not survive re-opening, and callbacks defined inside it have constraints on capturing outer state.

**Why it happens:** Slint `PopupWindow` components are re-initialized on each `show()` call.

**How to avoid:** Keep Vision Rx edit state (`editing-rx-od`, `editing-rx-os`, draft text) on the parent `RecipientCard` component as `in-out` properties, mirroring the existing `editing-note: bool` and `note-draft: string` pattern. The popover reads and writes these parent-level properties.

---

## Code Examples

### Current GraphQL Query That Must Be Extended

```graphql
// Source: crates/integrations/src/github/gh_cli_client.rs fetch_rows()
// CURRENT — missing color:
... on ProjectV2ItemFieldSingleSelectValue {
  field { ... on ProjectV2Field { name } }
  name
}
// REQUIRED — add color field:
... on ProjectV2ItemFieldSingleSelectValue {
  field { ... on ProjectV2Field { name } }
  name
  color
}
```

### Refinery Migration V002

```sql
-- crates/service/src/db/migrations/V002__add_recipient_columns.sql
ALTER TABLE recipients ADD COLUMN purpose             TEXT;
ALTER TABLE recipients ADD COLUMN purpose_color       TEXT;
ALTER TABLE recipients ADD COLUMN vision_rx_od        TEXT;
ALTER TABLE recipients ADD COLUMN vision_rx_os        TEXT;
ALTER TABLE recipients ADD COLUMN product_names       TEXT;  -- comma-separated
ALTER TABLE recipients ADD COLUMN product_shopify_urls TEXT; -- comma-separated
```

Note: SQLite allows `ALTER TABLE ADD COLUMN` for nullable columns without data migration.

### Extending RecipientCardSnapshot

```rust
// Source pattern: crates/app/src/service_client.rs
pub struct RecipientCardSnapshot {
    // ... existing fields ...
    pub purpose: Option<String>,
    pub purpose_color: Option<String>,        // hex string e.g. "#4a7cff"
    pub vision_rx_od: Option<String>,
    pub vision_rx_os: Option<String>,
    pub recipient_product_names: Vec<String>,     // from GH Project (recipient-level)
    pub recipient_product_shopify_urls: Vec<String>,
}
```

Note the field name prefix `recipient_` disambiguates from the card-level `product_names` (from Shopify order line items) that already exists on the struct.

### CardData Extension in dashboard.slint

```slint
// Source: crates/app/ui/dashboard.slint
struct CardData {
    // ... existing fields ...
    purpose-color: color,    // slint::Color from Rust
    purpose-label: string,   // raw Purpose string e.g. "Tester"
}
```

### Avatar Ring Pattern in Slint

```slint
// In card.slint, Row 1 avatar — outer ring wraps inner circle
Rectangle {
    width: 30px;   // 26 + 2 + 2
    height: 30px;
    border-radius: 15px;
    background: root.purpose-color;   // white (#ffffff) as fallback set from Rust

    // Tooltip on avatar hover
    if avatar-touch.has-hover && root.purpose-label != "" : Text {
        text: root.purpose-label;
        // position above avatar, styled as tooltip
    }

    avatar-touch := TouchArea { /* captures hover */ }

    // Inner avatar
    Rectangle {
        x: 2px; y: 2px;
        width: 26px; height: 26px;
        border-radius: 13px;
        background: Colors.avatar-bg;
        Text {
            text: root.recipient-initial;
            // ...
        }
    }
}
```

---

## State of the Art

| Old Approach | Current Approach | Notes |
|--------------|------------------|-------|
| Recipients not in SQLite (Phase 15 TODO) | Phase 16 must write recipients to SQLite | First time recipient rows are actually persisted |
| Purpose colors hardcoded | Purpose colors fetched from GH API | API returns enum names (BLUE, GREEN, etc.), not hex |
| No recipient-level product arrays | `product_names`/`product_shopify_urls` on recipients table | Parallel-array convention from DATA-FLOW.md |
| `discord_username`/`discord_user_id` not joined from recipients table | Phase 16 can fix this as part of the recipients write path | `card_row_to_snapshot` has existing TODO for this |

**Deprecated/outdated:**
- The `TODO Phase 17: join recipients table` comments in `card_row_to_snapshot` are partially addressable in Phase 16 (discord fields can now be carried through if recipients are written to SQLite). Planner should decide whether to fix these TODOs as in-scope cleanup or keep them for Phase 17.

---

## Open Questions

1. **Should Phase 16 also resolve the `discord_username`/`discord_user_id` recipients-join TODO?**
   - What we know: `card_row_to_snapshot()` has explicit `TODO Phase 17` comments for these fields; they are already in `GithubMappedRecipient` and `RecipientCardSnapshot` but not being written to/read from the recipients table
   - What's unclear: Phase 16 must write recipients to SQLite anyway; fixing these TODOs would be trivial incremental work
   - Recommendation: Fix as opportunistic cleanup in Phase 16; if it causes scope creep concerns, defer explicitly

2. **What is the actual GH Project column name for product parallel-arrays?**
   - What we know: DATA-FLOW.md says "Product names column" and "Product Shopify URLs column" with notation "(column to be added)" — these columns may not yet exist in the GH Project
   - What's unclear: Whether these columns need to be created in the GH Project before ingestion can be tested
   - Recommendation: Planner should note that ingestion code can be written against expected column names; data will appear when GH Project columns are added

3. **Clipboard API: arboard vs Slint native?**
   - What we know: Slint has some clipboard integration; arboard 3.x is a direct option; the project uses Slint for UI
   - What's unclear: Whether Slint exposes a clipboard API accessible from Rust callback code
   - Recommendation: Check `Cargo.toml` for existing arboard dependency; if absent, use arboard directly in the save/copy Rust callback

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust `cargo test` (built-in) |
| Config file | none — standard Cargo workspace |
| Quick run command | `cargo test -p integrations -- --test-thread=1 2>&1` |
| Full suite command | `cargo test --workspace 2>&1` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| GHCOL-01 | `parse_gh_response` extracts `color` from SingleSelectValue | unit | `cargo test -p integrations parse_gh_response_extracts_color` | ❌ Wave 0 |
| GHCOL-01 | `map_rows()` reads Purpose and purpose_color into GithubMappedRecipient | unit | `cargo test -p integrations map_rows_reads_purpose_color` | ❌ Wave 0 |
| GHCOL-01 | `gh_color_to_hex()` maps all enum values | unit | `cargo test -p integrations gh_color_to_hex_maps_all_values` | ❌ Wave 0 |
| GHCOL-02 | Purpose label flows to CardData as `purpose-label` string | unit | `cargo test -p app project_snapshot_carries_purpose_label` | ❌ Wave 0 |
| GHCOL-03 | `map_rows()` reads Vision Rx OD and OS | unit | `cargo test -p integrations map_rows_reads_vision_rx` | ❌ Wave 0 |
| GHCOL-04 | `update_field_text` reused for Vision Rx save | integration | `cargo test -p integrations update_field_text_vision_rx` | ❌ Wave 0 |
| GHCOL-05 | `parse_comma_array` handles trailing commas and whitespace | unit | `cargo test -p integrations parse_comma_array_edge_cases` | ❌ Wave 0 |
| GHCOL-05 | `map_rows()` parses product_names and product_shopify_urls | unit | `cargo test -p integrations map_rows_reads_product_arrays` | ❌ Wave 0 |
| GHCOL-01,03,05 | SQLite V002 migration adds all 6 new columns on recipients table | unit | `cargo test -p service sqlite_v002_migration_adds_recipient_columns` | ❌ Wave 0 |
| GHCOL-01,03,05 | `compute_ingest_diff` detects changes in new fields | unit | `cargo test -p service ingest_diff_detects_purpose_change` | ❌ Wave 0 |

### Sampling Rate
- **Per task commit:** `cargo test -p integrations -- --test-thread=1 2>&1 | tail -20`
- **Per wave merge:** `cargo test --workspace 2>&1 | tail -30`
- **Phase gate:** Full workspace test suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] Tests for `parse_gh_response_extracts_color` — covers GHCOL-01 GraphQL parsing
- [ ] Tests for `map_rows_reads_purpose_color`, `map_rows_reads_vision_rx`, `map_rows_reads_product_arrays` — covers all 5 new fields in mapping layer
- [ ] Tests for `gh_color_to_hex_maps_all_values` — covers color translation
- [ ] Tests for `parse_comma_array_edge_cases` — covers comma parsing robustness
- [ ] Tests for `sqlite_v002_migration_adds_recipient_columns` — covers schema migration
- [ ] Tests for `project_snapshot_carries_purpose_label` — covers projection layer
- [ ] `V002__add_recipient_columns.sql` migration file itself

---

## Sources

### Primary (HIGH confidence)
- Direct code inspection: `crates/integrations/src/github/gh_cli_client.rs` — GraphQL query structure, existing parse functions
- Direct code inspection: `crates/integrations/src/github/project_mapping.rs` — map_rows() field extraction pattern
- Direct code inspection: `crates/service/src/db/sqlite.rs` + `V001__initial_schema.sql` — migration pattern, recipients table schema
- Direct code inspection: `crates/app/src/live_client.rs` — run_sync_cycle(), card_row_to_snapshot() TODOs
- Direct code inspection: `crates/app/ui/card.slint` — avatar circle structure, popover layout
- Direct code inspection: `crates/app/ui/chip-bar.slint` — ChipData struct, chip rendering
- Direct code inspection: `crates/app/ui/tokens.slint` — Colors global, existing hex values
- Direct code inspection: `crates/app/src/dashboard/projection.rs` — project_snapshot() pipeline
- `.planning/DATA-FLOW.md` Layer 1 field table — purpose, vision_rx_od, vision_rx_os, product_names, product_shopify_urls all documented as NEW fields with GH Project column sources

### Secondary (MEDIUM confidence)
- GitHub GraphQL API documentation (training knowledge): `ProjectV2ItemFieldSingleSelectValue` includes `color` field returning enum; `ProjectV2SingleSelectFieldOption.color` is documented
- GitHub GraphQL color enum values: BLUE, GREEN, YELLOW, ORANGE, RED, PINK, PURPLE, GRAY — consistent with known GH Projects API behavior

### Tertiary (LOW confidence)
- Slint `color` type runtime construction via `slint::Color::from_argb_u8()` — based on training knowledge; verify against Slint crate docs if behavior is unexpected
- arboard 3.x clipboard API — verify current version from crates.io if adding as new dependency

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all core libraries already in use; only arboard is potentially new
- Architecture: HIGH — all extension patterns directly verified from source code
- GH API color enum values: MEDIUM — consistent with documented API but not live-verified
- Pitfalls: HIGH — all identified from direct code inspection of existing TODOs and constraints

**Research date:** 2026-03-22
**Valid until:** 2026-04-22 (stable domain; SQLite/Slint APIs don't change rapidly)
