---
phase: 16
slug: gh-project-new-columns-ingestion
status: draft
shadcn_initialized: false
preset: none
created: 2026-03-22
---

# Phase 16 — UI Design Contract

> Visual and interaction contract for frontend phases. Generated by gsd-ui-researcher, verified by gsd-ui-checker.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none (Slint desktop framework) |
| Preset | not applicable |
| Component library | Slint built-ins (Rectangle, Text, TextInput, TouchArea, PopupWindow, HorizontalLayout, VerticalLayout) |
| Icon library | none — ASCII/Unicode glyphs inline (per MEMORY.md: vertical ellipsis does not render, use ASCII alternatives) |
| Font | Slint default system font |

**shadcn gate:** Not applicable. This is a Rust/Slint Windows desktop app, not a React/Next.js/Vite project. No `components.json` exists or is expected.

All design tokens are defined in `crates/app/ui/tokens.slint` via the `Colors` and `Typography` globals. Every new element in this phase MUST reference those globals, not hardcoded hex literals.

---

## Spacing Scale

Declared values (multiples of 4 only). Source: existing `card.slint` and `chip-bar.slint` patterns.

| Token | Value | Usage |
|-------|-------|-------|
| xs | 4px | Avatar ring gap (2px each side), inline icon gaps |
| sm | 8px | Chip bar padding, element inner padding |
| md | 12px | Card body left/right padding (established-pattern exception — see below) |
| lg | 16px | Summary popover padding (established in card.slint) |
| xl | 24px | Popover section spacing |
| 2xl | 32px | Not used in this phase |
| 3xl | 64px | Not used in this phase |

Exceptions:

- **Avatar ring (2px):** Avatar ring is achieved via a 30px outer Rectangle wrapping a 26px inner Rectangle — the 2px gap on each side is the ring thickness, not a spacing token.
- **6px chip inter-chip gap (established-pattern exception):** The existing `chip-bar.slint` uses a 6px gap between chips. This value is not a multiple of 4 but is a carry-over from the existing chip bar component. Changing it would break the established visual rhythm of the chip bar without any functional benefit. This exception is intentional and must not be altered in Phase 16.
- **12px card body padding (established-pattern exception):** The existing `card.slint` uses 12px left/right body padding. This value is not in the standard spacing set (4, 8, 16, 24, 32, 48, 64) but is a carry-over from the established card layout. Changing it would shift all card content alignment and break visual consistency with existing cards. This exception is intentional and must not be altered in Phase 16.

---

## Typography

Source: `crates/app/ui/tokens.slint` — `Typography` global. No new sizes are introduced in this phase; use the canonical 4-size scale exclusively.

| Role | Size | Weight | Line Height |
|------|------|--------|-------------|
| Body | 13px (`Typography.size-md`) | 400 (regular) | 1.5 (Slint default) |
| Label / secondary | 12px (`Typography.size-sm`) | 400 (regular) | 1.5 |
| Caption / badge | 11px (`Typography.size-xs`) | 400 (regular) | 1.3 |
| Section heading | 18px (`Typography.size-lg`) | 600 (semibold) | 1.2 |

**Size-interval differentiation note:** The 11px, 12px, and 13px sizes are close together. Visual distinction at these tight intervals is maintained by weight differentiation: 11px and 12px labels that need to stand out from adjacent body copy must use weight 600 (semibold) rather than 400. See specific assignments below — any element assigned weight 600 at these sizes relies on weight, not size alone, for its hierarchy signal.

New elements this phase and their required typography:
- **Purpose tooltip text:** 11px (`Typography.size-xs`), weight 400, `Colors.text-primary` on dark overlay background
- **Purpose label in popover:** 12px (`Typography.size-sm`), weight 400, `Colors.text-muted` as value; 13px (`Typography.size-md`), weight 600 as section heading
- **Vision Rx OD / OS headings:** 13px (`Typography.size-md`), weight 600, `Colors.text-primary`
- **Vision Rx values (read mode):** 12px (`Typography.size-sm`), weight 400, `Colors.text-muted`
- **Vision Rx TextInput (edit mode):** 12px (`Typography.size-sm`), weight 400, `Colors.text-primary`
- **Copy Rx button text:** 11px (`Typography.size-xs`), weight 400, `Colors.accent`
- **Product list items in popover:** 12px (`Typography.size-sm`), weight 400, `Colors.text-muted`
- **Purpose chip labels:** 11px (`Typography.size-xs`), weight 400, `#ffffff` when selected / `Colors.text-muted` when unselected (matches existing chip-bar pattern)

---

## Color

Source: `crates/app/ui/tokens.slint` — `Colors` global. No new tokens are introduced for surfaces or text. Purpose colors are dynamic (from GH API) and represented as `color` properties in Slint, not as new static tokens.

| Role | Value | Usage |
|------|-------|-------|
| Dominant (60%) | `#1a1e2a` (`Colors.background`) | App background, chip bar background |
| Secondary (30%) | `#242838` (`Colors.surface`) / `#2a3050` (`Colors.surface-elevated`) / `#2d3348` (`Colors.surface-popup`) | Cards, popover panels, chip unselected state |
| Accent (10%) | `#4a7cff` (`Colors.accent`) | Copy Rx button text, Close link in popover, Save Rx button fill on Rx inline edit |
| Destructive | `#e05050` (`Colors.error`) | Not used in this phase |

Accent reserved for: Copy Rx button text, Close link in summary popover, Save Rx button fill on Vision Rx inline edit. NOT used for Purpose ring colors (those come from GH API).

**Focal point:** The primary visual anchor on each card face is the 2px Purpose-colored avatar ring. It is the only element on the card face that carries dynamic color derived from recipient data.

### Purpose Color Mapping (GH API Enum → Hex)

GH Project API returns color as an enum name, not a hex value. The mapping below is used in `gh_color_to_hex()` in `project_mapping.rs`. These hex values are chosen for legibility on the dark theme (`Colors.background = #1a1e2a`):

| GH API Color Enum | Hex Value | Token Reference |
|-------------------|-----------|-----------------|
| `BLUE` | `#4a7cff` | `Colors.accent` (reuse) |
| `GREEN` | `#4caf50` | `Colors.success` (reuse) |
| `YELLOW` | `#f0c030` | (no existing token — use inline) |
| `ORANGE` | `#f0a030` | `Colors.warning` (reuse) |
| `RED` | `#e05050` | `Colors.error` (reuse) |
| `PINK` | `#e060a0` | (no existing token — use inline) |
| `PURPLE` | `#9060e0` | (no existing token — use inline) |
| `GRAY` | `#8a92a8` | `Colors.text-muted` (reuse) |
| fallback (empty / unrecognized) | `#ffffff` | white — MUST NOT use gray; gray is a valid Purpose color per CONTEXT.md |

**Fallback rationale (Claude's discretion):** White is the no-Purpose fallback because gray is already in use as a GH Project Purpose color. A gray ring would be visually indistinguishable from "no Purpose assigned." White provides a clear distinction. On the dark background the white ring is visible but neutral.

### Purpose Chip Color Behavior

When a Purpose chip is unselected: background `Colors.surface-popup` (`#2d3348`), text `Colors.text-muted`.
When a Purpose chip is selected: background = the Purpose hex color for that value; text `#ffffff`.
This requires extending `ChipData` with a `chip-color: color` field (Option A from RESEARCH.md).

---

## Component Inventory

New and modified Slint components this phase:

### 1. Avatar ring wrapper (card.slint)

Replace the bare 26x26 avatar `Rectangle` with a 30x30 outer ring wrapper + 26x26 inner circle:

```
Outer ring: 30x30px, border-radius 15px, background = purpose-color (Slint `color` property)
  Inner circle: x=2px y=2px, 26x26px, border-radius 13px, background = Colors.avatar-bg
    Initial text: Typography.size-xs, Colors.avatar-text, centered
```

- `purpose-color` is a `color` property on `RecipientCard`, set from Rust as `slint::Color`
- White fallback (`slint::Color::from_argb_u8(255, 255, 255, 255)`) when Purpose is empty or unassigned

### 2. Purpose tooltip (card.slint)

Conditional `Text` overlay triggered by `avatar-touch.has-hover` AND `root.purpose-label != ""`:

```
Rectangle (tooltip bubble):
  background: Colors.surface-popup
  border-radius: 4px
  border-width: 1px
  border-color: Colors.border-muted
  positioned: above avatar, y = -22px, x = 0px (clamped to card bounds)
  Text:
    text: root.purpose-label
    font-size: Typography.size-xs (11px)
    color: Colors.text-primary
    padding: 4px 6px
```

- Tooltip appears on avatar hover only, not entire card header (per CONTEXT.md)
- `purpose-label` is a `string` property on `RecipientCard` (the raw Purpose text e.g. "Tester")

### 3. ChipData struct extension (chip-bar.slint)

```slint
export struct ChipData {
    label: string,
    selected: bool,
    chip-color: color,   // zero-value color = use default accent blue behavior
}
```

Chip rendering logic:
- Selected state background: `chip.selected ? (chip.chip-color.brighter(0) != transparent ? chip.chip-color : Colors.accent) : Colors.surface-popup`
- Simpler Rust-side approach: pass `Colors.accent` as default for non-Purpose chips so the conditional is always `chip.selected ? chip.chip-color : Colors.surface-popup`

**Note:** The Slint `color` zero-value is `transparent` (alpha=0). All non-Purpose chips must be initialized with `chip-color: Colors.accent` (or equivalent `slint::Color` from Rust) to preserve existing behavior. The Rust chip-building code must be updated when this struct changes.

### 4. Vision Rx section in summary popover (card.slint)

New section appended to `summary-popup` `VerticalLayout` after existing sections:

```
Section heading: Text "Vision Rx"
  font-size: Typography.size-md (13px), font-weight: 600, color: Colors.text-primary

OD row (read mode):
  HorizontalLayout spacing=8px:
    Text "OD" — size-sm, color: Colors.text-muted, width=24px
    Text root.vision-rx-od (or "—" if empty) — size-sm, color: Colors.text-secondary
    TouchArea (edit trigger — sets editing-rx-od=true)

OD row (edit mode, editing-rx-od=true):
  HorizontalLayout spacing=8px:
    Text "OD" — size-sm, color: Colors.text-muted, width=24px
    TextInput <=> rx-od-draft — size-sm, color: Colors.text-primary, background: Colors.background
    Save Rx button: 44x16px, border-radius=4px, background: Colors.accent, Text "Save Rx" size-xs white
    Discard button: 48x16px, border-radius=4px, background: Colors.surface-popup, Text "Discard" size-xs Colors.text-muted

OS row: identical pattern to OD row, using vision-rx-os / editing-rx-os / rx-os-draft

Copy Rx button:
  Rectangle: height=24px, border-radius=4px, background: transparent
  Text "Copy Rx" — size-xs (11px), color: Colors.accent
  TouchArea: mouse-cursor pointer, triggers copy-rx callback
  Visible only when both rx-od and rx-os are non-empty
```

State properties on `RecipientCard` (parent-level, not inside PopupWindow):
- `in-out property <bool> editing-rx-od: false`
- `in-out property <bool> editing-rx-os: false`
- `in-out property <string> rx-od-draft: ""`
- `in-out property <string> rx-os-draft: ""`
- `in property <string> vision-rx-od: ""`
- `in property <string> vision-rx-os: ""`
- `callback save-rx-od(string)` — triggers GH Project write-back
- `callback save-rx-os(string)` — triggers GH Project write-back
- `callback copy-rx()` — copies `OD {od_value} / OS {os_value}` to clipboard

### 5. Purpose label section in summary popover (card.slint)

New section added to `summary-popup` `VerticalLayout` after Vision Rx:

```
Section heading: Text "Purpose"
  font-size: Typography.size-md (13px), font-weight: 600, color: Colors.text-primary

Value: Text root.purpose-label (or "Unassigned" if empty)
  font-size: Typography.size-sm (12px), color: Colors.text-muted
```

### 6. Recipient products section in summary popover (card.slint)

New section added after Purpose:

```
Section heading: Text "Recipient Products (GH)"
  font-size: Typography.size-md (13px), font-weight: 600, color: Colors.text-primary
  Subtitle annotation: "(assigned via GH Project)" — size-xs, Colors.text-dim

Product list (for each item in recipient-product-names):
  Text — size-sm, color: Colors.text-muted, wrap: word-wrap

Empty state:
  Text "None on record" — size-sm, color: Colors.text-muted
```

- `in property <[string]> recipient-product-names: []` on `RecipientCard`
- This is distinct from the card-level item squares (which come from Shopify order line items)

---

## Interaction Contracts

### Avatar ring hover → tooltip

- Trigger: `has-hover` on a `TouchArea` layered over the avatar ring
- Show condition: `root.purpose-label != ""`
- Dismiss: automatically on hover exit (conditional element disappears)
- Tooltip does NOT appear on the rest of the card header

### Purpose chip multi-select toggle

- Default state: all Purpose chips unselected (show all cards)
- Click to activate: chip turns to Purpose color, cards filter to show only selected Purpose(s)
- Click again: deactivates (returns to show-all for that Purpose)
- Multiple Purpose chips selected simultaneously: additive OR — card passes if its Purpose matches any selected chip
- Purpose filter is conjunctive with existing archive filter (AND relationship)
- Cards with empty Purpose: visible when no Purpose chip is active; hidden when any Purpose chip is active
- Filter logic in `discovery.rs`: `filter_cards_by_purpose(cards, &selected_purposes)` where empty set passes all through

### Vision Rx inline edit

- Click on OD or OS value text: activates edit mode for that field only (independent edit state)
- Edit mode renders a `TextInput` with Save Rx / Discard controls
- Save Rx: writes draft value, calls `save-rx-od(draft)` or `save-rx-os(draft)` callback, exits edit mode
- Discard: abandons draft without writing, exits edit mode
- Both fields can be in edit mode simultaneously (independent `editing-rx-od` and `editing-rx-os` flags)
- Pressing Escape in the popover closes the entire popover (existing behavior preserved), discarding unsaved Rx drafts

### Copy Rx button

- Visible only when both `vision-rx-od` and `vision-rx-os` are non-empty
- Click: fires `copy-rx()` callback which writes to system clipboard
- Clipboard format: `OD {od_value} / OS {os_value}` — single line, slash-separated
- Example: `OD -2.50 +1.00 x180 / OS -3.00 +0.75 x175`
- No confirmation UI — silent clipboard write
- No visual feedback beyond the button's hover state

---

## Copywriting Contract

| Element | Copy |
|---------|------|
| Primary CTA (Purpose chips activate) | "Tester" / "VIP" / "Big Team" / "Marketing" / "Hardware Dev" / "Demo Event" (chip labels derived from GH Project values — no fixed CTA label) |
| Copy Rx button | "Copy Rx" |
| Vision Rx section heading | "Vision Rx" |
| Vision Rx OD label | "OD" |
| Vision Rx OS label | "OS" |
| Vision Rx empty state (each field) | "—" (em dash, not the word "empty") |
| Purpose section heading in popover | "Purpose" |
| Purpose empty state in popover | "Unassigned" |
| Recipient products section heading | "Recipient Products (GH)" |
| Recipient products empty state | "None on record" |
| Save button (Rx inline edit) | "Save Rx" |
| Discard button (Rx inline edit) | "Discard" |
| Error state (Rx save fails) | No inline UI error — silent failure; the value reverts to prior state on next sync. Executor note: log error to Rust stderr; do not surface an error toast for this operation in Phase 16. |
| Destructive actions | None in this phase |

---

## States and Data Contracts

### New `CardData` fields (dashboard.slint)

```slint
struct CardData {
    // ... existing fields ...
    purpose-color: color,    // slint::Color from Rust; white (#ffffff) as fallback
    purpose-label: string,   // raw Purpose value e.g. "Tester"; "" when unassigned
    vision-rx-od: string,    // raw text from GH Project; "" when absent
    vision-rx-os: string,    // raw text from GH Project; "" when absent
    recipient-product-names: [string],   // GH Project product_names, split from comma-separated
}
```

`recipient-product-shopify-urls` is NOT surfaced in the Slint layer in Phase 16 — it is stored in SQLite for Phase 17 consumption but not displayed in the popover UI (URLs with no product entity to link to are not useful yet).

### New `RecipientCard` properties

```
in property <color> purpose-color: #ffffff
in property <string> purpose-label: ""
in property <string> vision-rx-od: ""
in property <string> vision-rx-os: ""
in property <[string]> recipient-product-names: []
in-out property <bool> editing-rx-od: false
in-out property <bool> editing-rx-os: false
in-out property <string> rx-od-draft: ""
in-out property <string> rx-os-draft: ""
callback save-rx-od(string)
callback save-rx-os(string)
callback copy-rx()
```

### Empty states

| Context | Trigger | Display |
|---------|---------|---------|
| No Purpose assigned | `purpose-label == ""` | White ring (2px `#ffffff` border) on avatar; "Unassigned" in popover Purpose section; no tooltip on hover |
| Vision Rx OD absent | `vision-rx-od == ""` | Display "—" in read mode |
| Vision Rx OS absent | `vision-rx-os == ""` | Display "—" in read mode |
| Copy Rx button hidden | Either Rx field is empty | Button not rendered (conditional element) |
| No recipient products | `recipient-product-names.length == 0` | "None on record" in popover section |
| No Purpose chips active | All chips unselected | All cards visible (no Purpose filter active) |

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none | not applicable (Slint desktop app, not web) |
| Third-party | none | not applicable |

No external component registries are used. All UI is built with the existing Slint component system and the project's established patterns in `crates/app/ui/`.

---

## Implementation Notes for Executor

1. **Do NOT use `Colors.parse-color()` at runtime.** Purpose color must be set as `slint::Color::from_argb_u8(255, r, g, b)` from Rust when building `CardData`. Parse the hex string using a `parse_hex_to_slint_color()` helper.

2. **Avatar ring uses a wrapper Rectangle, not border.** Slint has no per-corner border-radius and no border-image. The ring is the background of a 30x30 outer Rectangle; the inner 26x26 Rectangle sits at x=2, y=2 on top.

3. **ChipData struct change is a breaking change for existing callers.** When `chip-color: color` is added, all Rust code that builds `ChipData` values must be updated to provide a value. Use `Colors.accent` equivalent (`slint::Color::from_argb_u8(255, 74, 124, 255)`) as the default for all non-Purpose chips.

4. **Vision Rx edit state lives on RecipientCard, not inside PopupWindow.** PopupWindow re-initializes on each `show()`. Properties that must survive are declared as `in-out` on the parent `RecipientCard`.

5. **Popover is 280px wide** (established in existing `summary-popup`). New sections are appended to the existing `VerticalLayout` at the end, before the Close button. The popover height grows with content automatically.

6. **Purpose chips integrate into the existing `ChipBar` component** with the extended `ChipData` struct. The Rust side manages a combined chip list: existing chips (e.g. "Show archived") plus Purpose chips appended after. Purpose chips are identified by a non-default `chip-color`.

7. **Save Rx / Discard button widths** in the component inventory above are indicative. Slint sizes these to the label text width by default; use `min-width` if explicit sizing is required. "Save Rx" is wider than the former "Save" label — account for this in the OD/OS row HorizontalLayout.

---

## Checker Sign-Off

- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS

**Approval:** pending
