---
phase: 16.1
slug: discord-username-inline-editing-in-summary-popover-with-gh-project-write-back-discord-user-profile-image-fetching-and-avatar-caching
status: draft
shadcn_initialized: false
preset: none
created: 2026-03-23
---

# Phase 16.1 — UI Design Contract

> Visual and interaction contract for Phase 16.1: Discord username inline editing, Discord avatar fetching, and avatar caching.
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none — Slint UI framework, no shadcn |
| Preset | not applicable |
| Component library | Slint built-ins (Rectangle, Text, TextInput, Image, PopupWindow, TouchArea, FocusScope, Timer) |
| Icon library | Unicode glyphs rendered as Slint Text elements |
| Font | System default (Slint runtime default) |

> Source: codebase scan — `components.json` not found; all UI is `.slint` files. Design tokens live in `crates/app/ui/tokens.slint` as exported globals `Colors` and `Typography`.

---

## Spacing Scale

Declared values (must be multiples of 4):

| Token | Value | Usage |
|-------|-------|-------|
| xs | 4px | Icon-to-label gap, inline pencil icon gap |
| sm | 8px | Popover field row spacing, label-to-value gap |
| md | 16px | Popover outer padding, settings section padding |
| lg | 24px | Popover section separation |
| xl | 32px | Settings modal section spacing |
| 2xl | 48px | Not used in this phase |
| 3xl | 64px | Not used in this phase |

Exceptions:
- Popover `VerticalLayout` spacing: 12px (layout property inherited from Phase 16 baseline — preserve as-is to maintain visual continuity with existing popover rows)
- TextInput edit field height: 26px (existing pattern from Vision Rx edit fields in card.slint line ~965)
- Avatar ring outer: 30px diameter; inner avatar circle: 26px diameter (existing values — do not change)

> Source: `card.slint` popover section (line 836+), Vision Rx edit blocks (lines 940–1027).

---

## Typography

All sizes reference `Typography` global from `tokens.slint`. No new sizes introduced.

| Role | Token | Size | Weight | Line Height |
|------|-------|------|--------|-------------|
| Body | `Typography.size-sm` | 12px | 400 | 1.5 |
| Label / section heading | `Typography.size-md` | 13px | 600 | 1.2 |
| Small / secondary | `Typography.size-xs` | 11px | 400 | 1.5 |
| Large heading | `Typography.size-lg` | 18px | 600 | 1.2 |

Usage in this phase:
- Popover section labels (Purpose, Email, Discord Username, Products in Possession, Last Activity, Last Status Update, Vision Rx): `size-md` weight 600
- Popover field values and display text: `size-sm` weight 400
- "Saved" confirmation text: `size-xs` weight 400 in `Colors.success`
- Pencil icon glyph: `size-sm` weight 400 in `Colors.text-muted`; on hover: `Colors.text-secondary`
- Settings modal labels and inputs: `size-md` weight 400 (matching existing Shopify token section)
- Error toast text: `size-sm` weight 400
- Contact-secondary on card face: `size-xs` weight 400 in `Colors.text-dim` (unchanged)

> Source: `tokens.slint` (complete scale), `card.slint` popover section, `settings-modal.slint`.

---

## Color

All values reference `Colors` global from `tokens.slint`.

| Role | Token | Hex | Usage |
|------|-------|-----|-------|
| Dominant (60%) | `Colors.background` | `#1a1e2a` | App background, TextInput backgrounds during edit |
| Secondary (30%) | `Colors.surface` / `Colors.surface-popup` | `#242838` / `#2d3348` | Cards, settings modal, summary popover container |
| Accent (10%) | `Colors.accent` | `#4a7cff` | Reserved — see list below |
| Destructive | `Colors.error` | `#e05050` | Error toast background tint, settings clear-confirm state |

Accent (`#4a7cff`) reserved for:
1. TextInput border when a field is in active edit mode (Discord username, Vision Rx OD/OS)
2. Unsaved draft value text (field value differs from persisted value)
3. Focused option in Purpose selector dropdown

Additional semantic colors in use:
- `Colors.success` (`#4caf50`): "Saved" confirmation text after GH write-back
- `Colors.warning` (`#f0a030`): Error toast background (toast-is-warning), unassigned card name
- `Colors.text-muted` (`#8a92a8`): Empty/placeholder field values, pencil icon default state
- `Colors.text-dim` (`#6b7590`): Contact-secondary on card face (Discord username or "No contact info")
- `Colors.avatar-bg` (`#2a3560`): Avatar circle background (initials fallback, and behind real image)
- `Colors.avatar-text` (`#7ea8ff`): Initials text inside avatar circle

> Source: `tokens.slint`, `card.slint` avatar section (lines 251–276), Vision Rx edit fields (lines 965–1026).

---

## Component Inventory

### 1. Popover — Redesigned Field Order

Existing `summary-popup` PopupWindow in `card.slint` (line 836). Width stays 280px. `max-height: 420px` with Flickable scroll. Outer padding 16px. Inner `VerticalLayout` spacing 12px.

Primary focal point: the Discord Username row — the pencil affordance and accent edit border draw the eye to the new editable field.

New section order (top to bottom):

1. **Purpose** — existing inline selector, no changes to interaction
2. **Email** — new display-only field (moved from card face). Label: "Email" at `size-md` weight 600. Value: email string at `size-sm` `Colors.text-muted`, or em-dash `\u{2014}` if empty
3. **Discord Username** — inline editable with pencil icon (see component spec below)
4. **Products in Possession** — renamed from "Items in Possession" / "Recipient Products (GH)". Label: "Products in Possession" at `size-md` weight 600. Value: newline-separated product names at `size-sm` `Colors.text-muted`, or "None on record" if empty
5. **Last Activity** — replaces "Last Shipment Date" and "Last Received" sections. Label: "Last Activity" at `size-md` weight 600. Value format: see Last Activity spec below
6. **Last Status Update** — existing section, no change. Label unchanged, value unchanged
7. **Vision Rx** — existing section with added pencil icons on OD and OS rows (see spec below)

> Source: CONTEXT.md `## Decisions > ### Popover layout redesign`.

### 2. Pencil Edit Affordance

Used on: Discord Username field, Vision Rx OD row, Vision Rx OS row.

- Layout: `HorizontalLayout` with `spacing: 4px` containing value `Text` + pencil glyph `Text`
- Pencil glyph: `\u{270F}` (PENCIL, Unicode 270F). **Must be verified to render in Slint before implementation.** If `\u{270F}` fails, fallback to `\u{0065}\u{0064}` text label "ed" is unacceptable — use `\u{2022}` (bullet) as degraded affordance and file a note.
- Pencil `Text`: `font-size: Typography.size-xs`, `color: Colors.text-muted`
- On `TouchArea.has-hover` over the HorizontalLayout row: pencil color transitions to `Colors.text-secondary`
- Clicking anywhere on the row (pencil or value) enters edit mode — `TouchArea` covers full row
- Do NOT add pencil icon to Purpose — Purpose uses dropdown selector, not TextInput

> Source: CONTEXT.md `### Discord username inline editing`, `### Vision Rx edit affordance update`. Known constraint from MEMORY.md: `\u{22EE}` (vertical ellipsis) does NOT render in Slint.

### 3. Discord Username Inline Edit Field

Display state:
- `HorizontalLayout` spacing 4px: value `Text` + pencil glyph `Text`
- Value text: `font-size: Typography.size-sm`, color `Colors.text-muted` when matches persisted value; `Colors.accent` when draft differs
- If no Discord username stored: display `\u{2014}` (em-dash) in `Colors.text-muted`

Edit state (activated by click):
- `Rectangle` height 26px, `border-radius: 4px`, `background: Colors.background`, `border-width: 1px`, `border-color: Colors.accent`
- `TextInput` inside: `x: 6px, y: 4px, width: parent.width - 12px, height: 18px`, `font-size: Typography.size-sm`, `color: Colors.text-primary`
- Exit edit: Enter key or blur — triggers save callback
- Save sequence: GH Project write-back fires on background thread, then Discord API resolution fires on background thread
- After successful save: show "Saved" `Text` (`size-xs`, `Colors.success`) for 2 seconds using existing `popover-saved-timer` pattern

> Source: CONTEXT.md `### Discord username inline editing`; existing Vision Rx edit pattern in `card.slint` lines 965–1027.

### 4. Avatar Display (Card Face)

- Avatar container: 30px outer ring (purpose-colored), 26px inner circle (`Colors.avatar-bg`)
- When no cached avatar image: display recipient initials `Text` (`size-xs`, weight 600, `Colors.avatar-text`) — weight 600 matches section label weight and provides sufficient contrast against `Colors.avatar-bg`
- When cached avatar image exists: display `Image` element filling the 26px inner circle with `border-radius: 13px` clip. Image loaded via `slint::Image::load_from_path()` at runtime from `%APPDATA%/WITwhat/avatars/{discord_user_id}.png`
- Transition: instantaneous swap from initials to image — no animation, no fade
- When no bot token configured: initials circle only, no fetch attempted

> Source: CONTEXT.md `### Avatar display on cards`, `### Avatar caching (fallback path)`.

### 5. Card Face — Contact Secondary

- Current behavior: shows email as fallback when no Discord username
- New behavior: show Discord username when available; show "No contact info" when neither available
- Email removed entirely from card face
- Styling unchanged: `font-size: Typography.size-xs`, `color: Colors.text-dim`
- "No contact info" string: `Colors.text-dim` (same as normal contact-secondary; not a warning state)

> Source: CONTEXT.md `### Email removal from card face`.

### 6. Last Activity Field (Popover)

Format when data exists:
```
{Status label} — {date string}
{product name 1}
{product name 2}
...
```
- Status label on first line: `font-size: Typography.size-sm`, weight 600, `Colors.text-primary`
- Date on first line (after em-dash): `font-size: Typography.size-sm`, weight 400, `Colors.text-muted`
- Product names on subsequent lines: `font-size: Typography.size-xs`, `Colors.text-muted`

Status label mapping (exact strings — no deviation):
- `InTransit` enum variant → "Shipped"
- `Delivered` enum variant → "Delivered"
- `ReturnInTransit` enum variant → "Returning"
- `Returned` enum variant → "Returned"
- Other statuses (`NotShipped`, `LabelCreated`): excluded from Last Activity — show "None on record" if no qualifying card exists

Format when no qualifying card: single `Text` "None on record" at `size-sm` `Colors.text-muted`

> Source: CONTEXT.md `### Last Activity field logic`. "In Transit" label is **explicitly forbidden**.

### 7. Settings Modal — Discord Bot Token Section

New section added below the Shopify token section in `settings-modal.slint`. Follows identical layout pattern as the Shopify section.

States:

**Token not configured:**
- Label: "Discord Bot Token" at `size-md` weight 600 `Colors.text-primary`
- Hint text: "Add a Discord bot token to enable real avatar images." at `size-xs` `Colors.text-muted`
- Button: "Add Token" — `Rectangle` with `Colors.surface-elevated` background, `border-color: Colors.border-default`, `Text` "Add Token" at `size-sm` `Colors.text-secondary`
- Click "Add Token" → enters token-changing state

**Token configured:**
- Label: "Discord Bot Token" at `size-md` weight 600 `Colors.text-primary`
- Status text: "Token configured" at `size-xs` `Colors.success`
- Buttons: "Change Token" and "Clear Token" side by side — same styling as Shopify section equivalents

**Token-changing state:**
- `TextInput` field for token entry: height 32px, `border-color: Colors.accent`, `font-size: Typography.size-sm`
- Placeholder: "Paste bot token..." (rendered as default text, cleared on focus)
- "Save Token" button: `Colors.accent` background, white text
- "Discard Changes" button: `Colors.surface-elevated` background, `Colors.text-secondary` text

**Clear confirm state:**
- Confirmation text: "Remove Discord bot token from credential storage?" at `size-sm` `Colors.text-secondary`
- "Confirm Clear" button: `Colors.error` background, white text
- "Keep Token" button: `Colors.surface-elevated` background, `Colors.text-secondary` text

> Source: CONTEXT.md `### Discord bot token storage`; existing Shopify token section in `settings-modal.slint` as direct pattern model.

---

## Copywriting Contract

| Element | Copy |
|---------|------|
| Primary CTA (Discord save) | "Save" — implicit via Enter/blur; no visible button |
| Primary CTA (Settings token save) | "Save Token" |
| Saved confirmation | "Saved" |
| Contact-secondary empty state | "No contact info" |
| Email field empty state | `\u{2014}` (em-dash) |
| Discord username empty state | `\u{2014}` (em-dash) |
| Last Activity empty state | "None on record" |
| Products in Possession empty state | "None on record" |
| Last Status Update empty state | "None on record" |
| Discord API error toast | "Could not resolve Discord username. User ID unchanged." |
| GH write-back error (logged only) | stderr log only — no user-visible message |
| Settings hint (no token) | "Add a Discord bot token to enable real avatar images." |
| Settings token configured | "Token configured" |
| Settings clear confirm | "Remove Discord bot token from credential storage?" |
| Settings clear button | "Confirm Clear" |
| Settings change token button | "Change Token" |
| Settings clear pending button | "Clear Token" |
| Settings add button | "Add Token" |
| Settings cancel (discard in-progress token entry) | "Discard Changes" |
| Settings cancel (abort clear confirmation) | "Keep Token" |
| Last Activity status: InTransit | "Shipped" |
| Last Activity status: Delivered | "Delivered" |
| Last Activity status: ReturnInTransit | "Returning" |
| Last Activity status: Returned | "Returned" |

Destructive actions:
- **Discord bot token clear**: confirmation via in-modal two-step (click "Clear Token" → confirm text + "Confirm Clear" button). No separate dialog — inline state within settings modal, matching Shopify token pattern.

> Source: CONTEXT.md decisions throughout; "In Transit" is explicitly forbidden by CONTEXT.md.

---

## Interaction States

### Popover Edit Fields (Discord Username, Vision Rx OD, Vision Rx OS)

| State | Visual |
|-------|--------|
| Display, value saved | Value text `Colors.text-muted` + pencil glyph `Colors.text-muted` |
| Display, hover | Pencil glyph `Colors.text-secondary`; cursor: pointer |
| Display, draft differs from saved | Value text `Colors.accent` |
| Edit mode | TextInput in Rectangle with `Colors.accent` border |
| Saving | Edit mode exits; "Saved" text appears for 2s |
| Save error (Discord API only) | Error toast displayed; field reverts to prior value |

### Avatar Display

| State | Visual |
|-------|--------|
| No avatar data | Initials in `Colors.avatar-text` on `Colors.avatar-bg` |
| Avatar cached on disk | `Image` element filling 26px circle |
| Avatar fetch in progress | Initials shown until fetch completes (no spinner) |
| No bot token | Initials only, permanent |

### Card Face Contact Secondary

| State | Copy | Color |
|-------|------|-------|
| Discord username available | Discord username string | `Colors.text-dim` |
| No Discord username | "No contact info" | `Colors.text-dim` |

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| Not applicable | — | Project uses Slint, not React/shadcn. No component registry. |

---

## 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
