---
phase: 20.1.1.1.1
slug: phase-20-1-1-round-2-gap-closure-note-dedup-sidebar-nav-role
status: draft
shadcn_initialized: false
preset: none
created: 2026-04-15
---

# Phase 20.1.1.1.1 — UI Design Contract

> Visual and interaction contract for this bug-fix / gap-closure phase.
> Generated by gsd-ui-researcher. This phase corrects defects discovered in UAT round 2 —
> it does not introduce new UI surfaces. Each section below describes the TARGET visual state
> for the defect(s) that affect that component.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none (native Slint, in-tree tokens.slint) |
| Preset | not applicable |
| Component library | Slint 1.x standard library (PopupWindow, TouchArea, FocusScope, TextInput) |
| Icon library | Unicode codepoints inline (pencil U+270F, em-dash U+2014) |
| Font | Slint default (system sans-serif) |

**shadcn gate:** Not applicable. This is a native Rust/Slint desktop application (Windows, PLAT-01).
No web stack, no shadcn, no npm dependencies.

All design tokens are defined in `crates/app/ui/tokens.slint` (`Colors` global and `Typography` global).
No new tokens are introduced by this phase.

**Icon accessibility:** The pencil icon (U+270F) is used as a purely decorative affordance alongside
editable fields. It is rendered inside a non-interactive `Text` element with no attached `TouchArea`.
It carries no semantic action and requires no `accessible-label`. Any clickable edit affordance in
this phase uses a `TouchArea` with an explicit `accessible-label` property.

---

## Spacing Scale

Declared values (existing in codebase — multiples of 4):

| Token | Value | Usage in this phase |
|-------|-------|---------------------|
| xs | 4px | Icon gaps, pill text padding, dropdown row horizontal padding, popup gap below pill |
| sm | 8px | Compact element spacing, pill-to-name vertical gap |
| md | 12px | Sidebar outer padding (existing: `padding: 12px`) |
| lg | 16px | Section spacing inside sidebar VerticalLayout (`spacing: 10px` preserved) |
| xl | 24px | Pill height (existing), dropdown row height (28px — preserved from card-menu pattern) |
| 2xl | 44px | Touch target minimum (product image square, `+` button circle) |
| 3xl | 80px | Product row height (Row 4 in card.slint — existing, not changed) |

Exceptions:
- Sidebar outer padding is 12px (existing — preserving it, not rounding to 16px to avoid layout shift)
- Product image inner square: 44×44px inner, 46px outer wrapper (existing stride 52px) — D-11 scales the inner square; see D-11 section below

---

## Typography

All sizes come from `Typography` global in `tokens.slint`. No new sizes are added.

Declared font weights: **400** (regular) and **700** (bold) — exactly two weights.

| Role | Token | Size | Weight | Usage in this phase |
|------|-------|------|--------|---------------------|
| Display | size-lg | 18px | 700 | Recipient name in sidebar header |
| Body | size-md | 13px | 400 | Card initials, product square text |
| Label | size-sm | 12px | 400 | Sidebar field values (Discord, Rx), dropdown option text |
| Caption | size-xs | 11px | 400 | Field labels (muted), SN labels; pill text uses 700 (see D-02) |

Line heights: Slint manages line height automatically; no explicit override needed for this phase.

**D-11 SN label change:** SN label font-size grows from 9px (hardcoded) to `Typography.size-xs` (11px).
This is the only typography change in this phase.

---

## Color

All values come from `Colors` global in `tokens.slint`. No new colors are added.

| Role | Token | Hex | Usage |
|------|-------|-----|-------|
| Dominant (60%) | Colors.background | #1a1e2a | App background, card backgrounds |
| Secondary (30%) | Colors.surface | #242838 | Sidebar panel (RecipientDetailPanel), surface-elevated for popup |
| Popup surface | Colors.surface-popup | #2d3348 | Purpose dropdown PopupWindow background |
| Accent (10%) | Colors.accent | #4a7cff | Focused TextInput border only |
| Purpose color | root.purpose-color | dynamic | Pill outline border-color (2px) and pill text color |
| Destructive | Colors.error | #e05050 | Not used in this phase |

**Accent reserved for:** Active TextInput focus border only (when editing Rx OD, Rx OS, Discord username fields). NOT used for pill border — pill border uses `purpose-color`.

**Pill color change (D-02):**
- Before: `background: root.purpose-color` (filled pill)
- After: `background: transparent`, `border-width: 2px`, `border-color: root.purpose-color`, text `color: root.purpose-color`
- The hollow outline preserves color identity without the filled background that was visually heavy.

**Dropdown hover state (D-01):**
- Row background on hover: `Colors.border-default` (#3a4060) — same pattern as existing card-menu PopupWindow
- Row background at rest: `transparent`

---

## Defect Visual Contracts

This section is the primary output for this phase. Each defect with a UI component is specified to the pixel.

---

### D-01: Purpose Pill — Dropdown Edit Mode

**Trigger:** User clicks the purpose pill. `editing-purpose` becomes `true`.

**Before (broken):** A `TextInput` appears inside the pill (freeform text entry).

**After (target):** A `PopupWindow` opens below the pill showing a vertical list of role options.

**PopupWindow spec:**
- `x: 0px` (left-aligned with pill)
- `y: parent.height + 4px` (4px gap below pill bottom edge)
- `width: 160px`
- Background: `Colors.surface-popup` (#2d3348)
- `border-radius: 6px`
- `border-width: 1px`
- `border-color: Colors.border-muted` (#4a5578)
- `padding: 4px` (inner VerticalLayout)

**Each option row:**
- `height: 28px`
- `border-radius: 4px`
- Background: `Colors.border-default` (#3a4060) on hover, `transparent` at rest
- Text: `font-size: Typography.size-sm` (12px), `color: Colors.text-primary`, `x: 10px`, vertically centered
- `mouse-cursor: pointer`
- On click: sets `root.purpose-draft = role`, `root.editing-purpose = false`, calls `root.save-purpose(role)`, closes popup

**Role option list:** Hardcode the known GH Project Purpose field values. Use the same list as the original summary-popup (sourced from `GhCliProjectClient::list_project_field_options("Purpose")`). If the exact values are unavailable at implementation time, use a `purpose-options` property of type `[string]` populated from Rust at startup so the list stays maintainable without a Slint recompile.

**Closing the dropdown without selecting:** Esc in the popup closes it without saving (`editing-purpose = false`, `purpose-draft` restored to `root.purpose`).

---

### D-02: Purpose Pill — Position and Style

**Before (broken):** Pill rendered in a separate `HorizontalLayout` block BELOW the header row (avatar + name), taking the full sidebar width.

**After (target):** Pill rendered INSIDE the `VerticalLayout` that wraps the recipient name (the second column of the header `HorizontalLayout`). It appears as a second row directly beneath the name text.

**Pill display-mode spec (when `editing-purpose == false`):**
- `height: 24px`
- `width: purpose-pill-text.preferred-width + 20px` (min 70px)
- `border-radius: 12px`
- `background: transparent` (hollow — no fill)
- `border-width: 2px`
- `border-color: root.purpose-color`
- Text: `font-size: Typography.size-xs` (11px), `font-weight: 700`, `color: root.purpose-color` (matches border), centered
- `mouse-cursor: pointer`

**What does NOT change:** The pill's TouchArea click handler and `save-purpose` callback. Only the position (moved into header VerticalLayout) and the fill style (filled → hollow) change.

---

### D-05: Sidebar — Esc Key Behavior

**Before (broken):** When no inline edit is active, pressing Esc in the sidebar calls `root.close-clicked()` and returns `accept`, closing the sidebar.

**After (target):** When no inline edit is active, pressing Esc in the sidebar returns `reject` (no action, event propagates to global Esc handler in dashboard.slint).

**When an inline edit IS active:** Esc still closes the edit mode (clears `editing-purpose`, `editing-rx-od`, `editing-rx-os`, `editing-discord-username`) and returns `accept`. This behavior is unchanged.

**Visual implication:** The sidebar has no visible close affordance (no X button). The only dismissal paths are: tab-switch (D-03/D-04 navigation), breadcrumb-back (D-06), or programmatic dismiss from Rust.

---

### D-06: Breadcrumb-Back — Sidebar Dismissal

**Before (broken):** Navigating breadcrumb-back to the tile grid leaves the sidebar visible alongside the tile grid.

**After (target):** Sidebar is invisible when the tile grid is shown (`show-recipient-grid == true`, `recipient-detail-visible == false`). The sidebar must not be visible on the option-grid view.

**Visual implication:** No sidebar panel is rendered when the user is viewing the recipient tile grid. The card-flickable takes the full width. No layout gap or phantom panel is visible.

---

### D-03/D-04: Sidebar — Filtered Card View Coexistence

**Before (broken):**
- D-03: Tile-click shows sidebar overlaid on tile grid; does NOT navigate to filtered card view.
- D-04: Tab-restore shows filtered card view but sidebar disappears.

**After (target):** The sidebar is visible alongside the filtered card view (not the tile grid).

**Layout spec (filtered card view + sidebar visible):**
- Sidebar: `width: 320px`, right-anchored within the main content area
- Card flickable: `width: parent.width - 320px` (reduced by sidebar width when `recipient-detail-visible == true`)
- The sidebar mount condition in `dashboard.slint` changes from `show-recipient-grid && recipient-detail-visible` to `recipient-detail-visible` only

**Visual state machine:**

| State | Tile Grid Visible | Sidebar Visible | Card Flickable Width |
|-------|-------------------|-----------------|----------------------|
| Tab home (no selection) | yes | no | full width |
| Tile-clicked | no | yes | parent.width - 320px |
| Tab-restored (had selection) | no | yes | parent.width - 320px |
| Breadcrumb-back | yes | no | full width |

---

### D-08: Product Add Button — Hover Flicker Fix

**Before (broken):** Moving the cursor from the card body onto the `+` button causes the button to rapidly appear/disappear (hover-zone feedback loop).

**After (target):** The `+` button remains stably visible once the cursor enters the card area. No flicker.

**Implementation contract:** Add a persistent passive `btn-zone := TouchArea` at the product-row level (always present when the card is rendered, not inside the `if card-hover-zone.has-hover` conditional). Condition the `+` button's VISIBILITY on `card-hover-zone.has-hover || btn-zone.has-hover`. The `btn-zone` has no `clicked` handler (passive). It is declared BEFORE the inner button content so inner button click handlers take precedence (Slint last-child wins on events).

**Positioning of btn-zone:**
- `x`: same as the `+` button circle's x position (to the right of the last item square)
- `y: 0px`
- `width: 44px`
- `height: 80px` (full row height)

**Code tip:** This fix must be documented in `code_tips/SLINT_TIPS.md` as "Hover-zone flicker fix: use a persistent passive TouchArea alongside the conditional element" per D-08 locked decision.

---

### D-09: Product Add Button — Vertical Alignment

**Before (broken):** The `+` button inner circle sits at `y: 18px` (mathematically centered in 80px row), but the product image squares have their inner Rectangle at `y: 0px`. The button appears lower than the squares.

**After (target):** The `+` button inner circle is visually aligned with the product image squares.

**Fix:** Change the inner image square `Rectangle y: 0px` to `y: 18px` to center it in the 80px outer row height. This aligns both the image squares and the `+` button at `y: 18px` from the outer row top.

**Resulting spec:**
- Outer per-square Rectangle: `height: 80px`, `y: 0px` (unchanged)
- Inner image/initials Rectangle: `y: 18px`, `height: 44px`, `width: 44px` (was `y: 0px`)
- `+` button inner circle: `y: 18px`, `height: 44px`, `width: 44px` (unchanged — already correct)

---

### D-11: Product Icon and SN Label Scaling

**Before (broken):** Row 4 height grew to 80px in Phase 20.1.1. The inner product image squares remained 44×44px and the SN label remained 9px. They look small in the taller row.

**After (target):** Inner squares scale up; SN label uses the Typography token.

**Inner square spec:**
- Inner Rectangle: `width: 52px`, `height: 52px` (was 44×44)
- Inner Rectangle `x: 0px` (outer wrapper is 46px — wait: outer wrapper must widen to accommodate)
- Outer per-square wrapper Rectangle: `width: 58px` (was 46px), stride: `x: sq-index * 64px` (was 52px)
- Inner Rectangle `y: 14px` (centers 52px in 80px row: `(80-52)/2 = 14`)
- `border-radius: 7px` (was 6px — proportional to larger square)

**SN label spec:**
- `font-size: Typography.size-xs` (11px, was hardcoded 9px)
- `y: 52px + 14px + 2px = 68px` (bottom of inner square + 2px gap)
- `width: 58px` (matches outer wrapper)

**Note:** Stride increase from 52px to 64px requires verification that the card width (which is fixed) can accommodate the larger squares without overflow. If the card width cannot fit, fall back to 56px inner square with 60px stride (`x: sq-index * 60px`). The executor should measure rendered output and use the largest size that fits within the card width for 4–5 squares.

---

## Copywriting Contract

This phase is a bug-fix phase. There are no new user-facing copy strings. The existing strings that are corrected:

| Element | Before (broken) | After (target) | Defect |
|---------|-----------------|----------------|--------|
| Purpose pill placeholder | "Purpose" (shown when purpose is empty) | "Purpose" (unchanged — preserved) | D-01/D-02 |
| Dropdown option text | n/a (was TextInput freeform) | Role names from GH Project field options (e.g. "Standard", "Demo", "Developer" — exact values TBD at runtime) | D-01 |
| Item display label | "No items added" shown incorrectly when items exist | Correct item count label (e.g. "3 items") shown when items exist | D-13 |
| Return status | "Return Created" shown when should be "Return Underway" | "Return Underway" shown when tracking exists | D-12 |
| Connection status | "Connecting..." stuck indefinitely | Resolves to "Connected" or "Connected (no Shopify)" after first successful sync | D-14 |

**Error states (no new copy needed):** Existing connection-status strings in `dashboard.slint` cover the error states. No new error copy is introduced.

**Destructive actions:** None in this phase.

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| n/a — Slint native, no npm | none | not applicable |

No web component registries. This is a Rust/Slint native desktop application. No third-party UI block vetting is required.

---

## Interaction State Machine (Summary)

For the executor's reference — the complete sidebar state machine for this phase:

```
[Tile Grid]
    user clicks tile
        -> navigate to Filtered Card View
        -> populate sidebar with tile data
        -> sidebar visible = true
        -> card-flickable width = parent.width - 320px

[Filtered Card View + Sidebar]
    user presses Esc (no edit active)
        -> no action (sidebar stays)
    user presses Esc (edit active)
        -> close edit mode only
    user clicks breadcrumb-back
        -> sidebar visible = false
        -> navigate to Tile Grid
        -> card-flickable width = full
    user switches tab and returns
        -> restore Filtered Card View
        -> sidebar visible = true (selected_tile restored)
    user clicks purpose pill
        -> editing-purpose = true
        -> PopupWindow dropdown opens below pill
    user selects role from dropdown
        -> purpose-draft = role
        -> editing-purpose = false
        -> save-purpose(role) callback fired
        -> popup closes
```

---

## 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
