# Phase 03 — UI Review

**Audited:** 2026-03-22
**Baseline:** Abstract 6-pillar standards (no UI-SPEC.md exists)
**Screenshots:** Not captured — desktop Slint app, no HTTP dev server

---

## Pillar Scores

| Pillar | Score | Key Finding |
|--------|-------|-------------|
| 1. Copywriting | 3/4 | "Save"/"Cancel" are generic but contextually defensible; "Missing" fallback label is intentionally blunt |
| 2. Visuals | 3/4 | Strong hierarchy within cards; tab strip uses single-letter mnemonics with no fallback when tooltips are unavailable |
| 3. Color | 2/4 | 49 unique hardcoded hex values with at least 5 near-duplicate drift pairs — no token system exists |
| 4. Typography | 3/4 | 9 distinct sizes in use (10–20px) — two sizes above the 4-scale guideline; weights are clean (600/700 only) |
| 5. Spacing | 3/4 | Consistent 4/8/12px rhythm for padding and gaps; isolated magic pixel values exist for absolute positioning |
| 6. Experience Design | 3/4 | Refresh, error, retry, disabled, stale, and archive states all handled; no empty-state UI when card grid is zero-length |

**Overall: 17/24**

---

## Top 3 Priority Fixes

1. **No empty-state view when card list is zero** — Users who open the app before any recipients are synced see a blank dark rectangle with no guidance — change the card flickable to conditionally render a centered message (e.g., "No recipients yet — connect to GitHub to sync") when `root.cards.length == 0`. File: `crates/app/ui/dashboard.slint` line 463.

2. **49 hardcoded hex values with near-duplicate drift pairs** — Color regressions are undetectable because there is no single source of truth; `#3a3f52` (search-bar.slint:27, option-grid.slint:148) and `#3a3f55` (settings-modal.slint:94, 143, 213, 271, 353) are used for the same semantic purpose (unfocused border) but differ by 3 hex digits. Extract a color constant block at the top of each `.slint` file or into a shared `tokens.slint`, then replace all raw literals with named bindings.

3. **Tab strip single-letter labels ("S", "D", "R", "P") with tooltip-only disambiguation** — Users who have never hovered the tabs must guess that "S" = "Status Updated", "D" = "Ship Date", etc. Tooltips fix this on hover but do nothing for first-time orientation. Replace single letters with 2–3 character abbreviations ("St", "Dt", "Rc", "Pd") or add a 1-line label below each letter that is always visible given the 48px width.

---

## Detailed Findings

### Pillar 1: Copywriting (3/4)

**Passing items:**
- "Refresh all" button label is action-specific (dashboard.slint:51).
- Status pill, stale badge, and error retry chip all receive programmatic strings from the Rust projection layer — no hardcoded "Unknown" leaks into the grid.
- Search result count uses conditional singular/plural: `"1 result"` vs `"\{root.result-count} results"` (search-bar.slint:65–66). Good pattern.
- Pending-edit indicator uses natural language: `"1 edit pending"` / `"N edits pending"` (dashboard.slint:276).
- Toast undo label "Undo" is short and idiomatic for a transient action (card.slint:660).

**Issues:**
- `card.slint:375` — `"Save"` and `card.slint:401` — `"Cancel"` are generic but appear only inside note inline-edit mode. In this narrow context they are defensible; however, "Save note" / "Discard" would eliminate any ambiguity about what is being saved. Minor.
- `view_model.rs:56` — `contact_secondary` defaults to `"No contact info"`. This literal is user-visible when a card has no Discord or email. It is technically accurate but cold; consider `"—"` or leave blank and suppress the row.
- `state.rs:13–16` — All three non-Ready `CardMissingState` variants produce `"Missing"` as their label. The label does not differentiate between a missing image vs missing note vs partial data, leaving the user without a signal about what to fix. Scoped labels like `"No image"` and `"No note"` would improve actionability.

---

### Pillar 2: Visuals (3/4)

**Passing items:**
- Card hierarchy is well-ordered: avatar + name row → status pill + date → item squares → note preview. Scanability is high.
- Summary popup (card.slint:716–780) uses `no-auto-close` policy plus an Escape key handler — prevents accidental dismissal.
- Stale badge (card.slint:428–444) is right-aligned on the status row, visually separated from the pill. Orange-on-dark-background coloring creates appropriate urgency without being harsh.
- Hover affordances are present on all interactive elements (three-dots, item squares, add button, breadcrumb back button) with `mouse-cursor: pointer`.
- Tab tooltips (tab-strip.slint:150–228) are present and correctly rendered last for z-order.
- Toast animation (dashboard.slint:626) uses `ease-out` opacity — appropriate for ephemeral feedback.

**Issues:**
- **Tab strip mnemonics** (tab-strip.slint:31, 65, 99, 133): Single-letter labels "S", "D", "R", "P" are distinguishable only via hover tooltip. There is no persistent visual label explaining the letter's meaning. On first use, the user must discover the function of each tab by hovering or by trial.
- **Info icon glyph positioning** (card.slint:144–151): The `(i)` info glyph is placed at `x: 48px + 8px; y: 10px` — the same y-coordinate as the recipient name text. This means the icon and name text overlap at the same baseline. If the name is short, the icon appears inline with the name and looks like a decoration or stray character rather than a button affordance. The icon should be right-aligned on the name row or explicitly offset to avoid visual collision.
- **No empty state visual** when `root.cards.length == 0` (dashboard.slint:463). The Flickable computes its viewport height from `cards.length` but shows nothing when the array is empty. The dark content rectangle renders blank with no instructional content.

---

### Pillar 3: Color (2/4)

**Color inventory from all `.slint` files: 49 unique hardcoded hex values.**

Top recurring values (used 20+ times):
- `#8a92a8` — muted gray text (32 uses)
- `#4a7cff` — primary accent (26 uses)
- `#e0e4ef` — primary text (25 uses)
- `#2d3348` — surface elevation (23 uses)
- `#3a4060` — hover state surface (21 uses)
- `#6b7590` — secondary text (16 uses)

**Issues:**

- **Near-duplicate token drift — border color:** `#3a3f52` appears in `search-bar.slint:27` and `option-grid.slint:148`; `#3a3f55` appears in `settings-modal.slint:94, 143, 213, 271, 353`. These serve the identical semantic role (unfocused input border) but differ by 3 hex digits. One will drift further over time.

- **Near-duplicate token drift — text colors:** `#e0e4ef` vs `#e0e4f0` (`settings-modal.slint:74`); `#8a92a8` vs `#8a8fa8` (`settings-modal.slint:83, 132, 193`). Settings modal was likely written separately and these values were eyeballed rather than copied.

- **Near-duplicate accent shades:** `#c0c8da` (external link items in card menu, card.slint:589, 609) vs `#c0c6e0` (settings hover text, dashboard.slint:299) — both are muted blue-gray but neither matches the standard `#8a92a8` muted gray or `#e0e4ef` primary text, creating a third unlabeled tier of text hierarchy.

- **49 unique values with no token file** means any visual refactor requires global search-and-replace across all files, with high risk of missing the near-duplicate variants. The fix is to add a `tokens.slint` with named globals (e.g., `global Colors`) and replace inline literals.

- The core design intent — dark navy background, blue accent, layered gray text — is consistent and reads well. The issues are maintenance-tier, not perception-tier.

---

### Pillar 4: Typography (3/4)

**Sizes in use (by frequency):**
| Size | Uses |
|------|------|
| 12px | 34 |
| 13px | 31 |
| 11px | 24 |
| 16px | 8 |
| 14px | 7 |
| 18px | 4 |
| 10px | 4 |
| 20px | 1 |
| 9px  | 1 |

9 distinct sizes total — 5 more than the recommended maximum of 4 for a dense information display. However, the actual reading burden is lower than the count suggests: 11/12/13px form a tight cluster used for UI chrome at different density levels; 16px is reserved for tab icons and the info glyph; 18px is only the "+" add button; 20px and 9px appear once each.

**Weights in use:** `600` (7 uses) and `700` (9 uses) — exactly two weights, which meets the 2-weight guideline.

**Issues:**
- `20px` appears once (likely lookup modal or settings — not audited in full); `9px` appears once. These outlier sizes should be eliminated or promoted to the nearest standard size in the cluster.
- The 10/11/12/13px cluster creates four near-identical reading sizes. Collapsing to 11px (chrome) / 13px (body) / 16px (icon/display) would reduce cognitive load during future development.

---

### Pillar 5: Spacing (3/4)

**Dominant spacing values by frequency:**
| Value | Uses |
|-------|------|
| 0px | 75 (z-anchors) |
| 12px | 62 (primary grid gap, content padding) |
| 4px | 39 (micro padding, border-radius) |
| 24px | 38 (section padding) |
| 8px | 36 (secondary gap) |
| 36px | 33 (item square size) |
| 48px | 32 (tab strip width, column offset) |

The 4/8/12/24px scale is applied consistently throughout card layout, chip bar, and modal internals. This is a healthy 4px base rhythm.

**Issues:**
- Absolute positioning uses several magic values that are derived rather than from the scale: `60px`, `72px`, `96px`, `154px`, `196px`, `264px` etc. These are computed layout anchors (e.g., header height + tab strip offset) and are expected in Slint's absolute layout model — but they are replicated at multiple call sites (e.g., `y: 72px` appears in dashboard.slint at lines 426, 427, 428, 608 for the card grid, breadcrumb, and tab strip). A named constant or intermediate property would prevent drift if the header height changes.
- `card.slint` note-edit area uses `y: 148px` (view) vs `y: 144px` (edit mode) — a 4px vertical shift between read and edit modes creates a subtle layout jump on click. Both values should use the same anchor.

---

### Pillar 6: Experience Design (3/4)

**State coverage inventory:**
| State | Covered |
|-------|---------|
| Per-card refresh in progress | Yes — `refresh-disabled` disables control, scoped to active card |
| Per-card refresh error + retry | Yes — `show-error`, `retry-label`, `refresh-error` on three-dots background |
| Global refresh-all disabled | Yes — `refresh-all-disabled` dims button and changes cursor |
| F5 parity with refresh-all | Yes — `dashboard.slint:241–244` routes F5 to same callback |
| Stale data warning | Yes — stale badge visible on status row |
| Missing image fallback | Yes — `CardMissingState::MissingImage` renders "Missing" label |
| Missing note fallback | Yes — `CardMissingState::MissingNote` renders "Missing" label |
| Archive / unarchive with undo | Yes — toast with undo button, opacity dim on archived cards |
| Archive confirmation | Yes — "ToBeArchived" intermediate state before "Archive Now" |
| Connection status indicator | Yes — 5 states (unconfigured/connecting/connected/no-shopify/disconnected) |
| Pending edit sync counter | Yes — inline indicator with edit count |
| Search result count | Yes — result badge in search bar |
| Modal backdrop + Escape | Yes — all three modals (settings, lookup, recipient picker) |

**Issues:**
- **No empty-state UI when card list is zero-length.** The `card-flickable` computes `viewport-height` from `root.cards.length` (dashboard.slint:463) but renders nothing when the list is empty. The dark content rectangle shows as a featureless void. A first-time user or a user whose sync has failed will see no guidance. Add an `if root.cards.length == 0` branch with a centered message inside the content rectangle.
- **Retry TouchArea has no click handler** (card.slint:709–711): The retry error chip `TouchArea` has `mouse-cursor: pointer` but no `clicked` handler. The chip is visually present and looks interactive but does nothing when clicked. This is a broken affordance: users clicking "Retry" receive no response.
- **"No contact info" default** (view_model.rs:56): This string is displayed on every card that lacks Discord/email. It is technically an informational state, but it reads as an error. Suppressing the row when no secondary contact exists (conditionally hide via `contact-secondary != ""` guard already in card.slint:135) and ensuring the Rust projection emits an empty string rather than the literal `"No contact info"` would clean this up.

---

## Files Audited

**Slint UI files:**
- `crates/app/ui/dashboard.slint`
- `crates/app/ui/card.slint`
- `crates/app/ui/tab-strip.slint`
- `crates/app/ui/chip-bar.slint`
- `crates/app/ui/search-bar.slint`
- `crates/app/ui/settings-modal.slint` (partial — first 50 lines + grep)
- `crates/app/ui/option-grid.slint` (grep only)
- `crates/app/ui/lookup-modal.slint` (grep only)
- `crates/app/ui/recipient-picker.slint` (grep only)

**Rust source files:**
- `crates/app/src/dashboard/view_model.rs`
- `crates/app/src/dashboard/state.rs`
- `crates/app/src/dashboard/projection.rs`
- `crates/app/src/dashboard/actions.rs`

**Plan and summary files:**
- `.planning/phases/03-core-card-dashboard/03-01-PLAN.md`
- `.planning/phases/03-core-card-dashboard/03-02-PLAN.md`
- `.planning/phases/03-core-card-dashboard/03-03-PLAN.md`
- `.planning/phases/03-core-card-dashboard/03-01-SUMMARY.md`
- `.planning/phases/03-core-card-dashboard/03-02-SUMMARY.md`
- `.planning/phases/03-core-card-dashboard/03-03-SUMMARY.md`

**Registry audit:** No `components.json` found — shadcn not initialized. Registry audit skipped.
