Files
hope-voice-api/.artifacts/20260831-105824-plan-code-ui-audit/ui-ux-spec.md
T
2026-08-31 19:50:46 -04:00

529 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Hope Voice iOS — UI/UX Audit & Redesign Specification
- Task: 20260831-105824-plan-code-ui-audit
- Agent: coder (UI/UX research pass)
- Model: litellm-lan/qwen3.8-27b
- Date: 2026-08-31
- Status: complete (research + specification only; no product code changed)
---
## 1. Scope & Method
This document audits the current iOS UI in `/Users/shane/dev/voice-ios` against the
canonical Hope visual language found in the reference projects, and specifies a
coherent redesign. All findings cite file paths and line numbers so an implementer can
act without this conversation's context.
Sources inspected:
| Path | What it provided |
|---|---|
| `hope-voice-api/web/style.css` (1607 lines) | Full design-token system (`:root`, lines 1–26), LED-wall avatar (lines 316–429), brand glyph (lines 125–166), message bubbles (lines 702–764), buttons/controls (line 431+), task status dots (lines 1120–1150), keyframes (`think-pulse` L427, `message-in` L709, `status-pulse` L1133) |
| `hope-voice-api/web/app.js` | LED-wall build/render implementation (lines 265–341): 21×21 grid, radial falloff, per-dot phase ripple, audio-reactive sizing, state palettes (line 389 sets `data-state`) |
| `hope-voice-api/web/index.html` | ARIA roles: `led-wall` is `aria-hidden="true"` (L61), `orb-labels` is `aria-live="polite"` (L62), transcript `aria-live="polite"` (L102) |
| `hope-voice-api/web/favicon.svg` | Square Hope glyph: 64×64 rounded rect (`rx=16`) dark plate `#08111f`, iris circle + tilted orbit ellipse + warm dot — the canonical "square Hope" mark |
| `hope_companion/scripts/gen-icons.mjs` | SDF-generated app icon: 1024² grid, rounded-rect plate (`sdRoundedRect … 512,512, r=228`) with indigo→violet diagonal gradient (43,46,122)→(124,58,237), white mark of two nodes + arc + status ring |
| `hope_companion/resources/icon.png` | 1024×1024 RGBA output of the above generator (verified via `sips`: hasAlpha, RGB) |
| `voice-ios/Sources/**` (all SwiftUI sources) | Current implementation audited below |
| `voice-ios/project.yml`, `Resources/Assets.xcassets/AppIcon.appiconset/Contents.json` | Build config; app icon set is **empty** (no image assigned) |
### The "square Hope element" — resolved
The square Hope visual element is the **LED-wall avatar**: a 320×320 px square
container (`border-radius: 26px`) holding a 21×21 dot matrix that blooms with audio.
Evidence: `style.css` L331–346 (`.led-wall { width: 320px; height: 320px; … grid-template-columns: repeat(21,1fr); border-radius: 26px }`),
L349–359 (glass panel backing), L361–372 (`.led-dot`), L376–392 (state color palette),
and `app.js` L265–341 (build + per-frame render). It is the single most prominent
Hope visual in the web UI and sits at the center of the voice console.
Two related but distinct marks also exist and are relevant to the iOS redesign:
1. **Favicon glyph** (`favicon.svg`): 64×64 rounded square, dark plate `#08111f`,
iris circle `#8ba7ff` with tilted orbit ellipse `#526cae` and warm accent dot
`#ffb38e`. This is the compact "Hope" identity mark.
2. **Companion app icon** (`hope_companion/resources/icon.png`, generated by
`gen-icons.mjs`): 1024² rounded square (corner radius 228/1024 ≈ 22.3%) with an
indigo→violet diagonal gradient and a white two-node + arc + ring mark.
The current iOS app uses **none** of these: `OrbView.swift` renders a plain 120 pt
`Circle()` filled with a system-color gradient (lines 45–47), and the app icon set is
empty. This is the central brand gap this spec addresses.
---
## 2. Asset Provenance
| Asset | Source | Format | Notes |
|---|---|---|---|
| LED-wall avatar | `hope-voice-api/web/style.css` L316–429 + `app.js` L265–341 | CSS grid + JS per-frame render | Not an image asset; must be re-implemented in SwiftUI (Canvas/TimelineView) |
| Square Hope glyph (favicon) | `hope-voice-api/web/favicon.svg` | SVG 64×64 | Portable to iOS as a vector asset or SF Symbol-style shape |
| Companion icon | `hope_companion/resources/icon.png` | PNG 1024² RGBA, generated by `scripts/gen-icons.mjs` (SDF) | Candidate base for the iOS AppIcon; generator is dependency-free Node |
| Design tokens | `style.css` `:root` L1–26 | CSS custom properties | Full palette + easing below (§5) |
| State palettes | `style.css` L376–392 | CSS `data-state` selectors | idle/connecting/thinking/listening/speaking colors |
No binary image assets currently exist in `voice-ios/Resources/Assets.xcassets` beyond
an empty `AppIcon.appiconset`. The redesign should add: (a) an `AppIcon` derived from
the companion mark, (b) a `HopeGlyph` vector asset from the favicon, and (c) no raster
assets for the LED wall (procedural rendering only).
---
## 3. Current State Audit (voice-ios)
### 3.1 Screen inventory
| Screen | File | Navigation | Notes |
|---|---|---|---|
| Conversation (root) | `Sources/Features/Conversation/ConversationView.swift` | `NavigationStack`, `.navigationTitle("Hope Voice")` | Single screen; everything else is pushed or sheeted from the toolbar |
| Orb + status header | `ConversationView.statusHeader` L63–125, `OrbView.swift` | — | 120 pt circle, system colors |
| Transcript list | `OrbView.swift` (TranscriptListView) L64–146 | Inline in root VStack | Plain rows, role name + timestamp, no bubbles/avatars |
| Composer | `ConversationView.composer` L161–191 | — | Start button / ProgressView / TextField+Send+End |
| Settings | `Sources/Features/Settings/SettingsView.swift` | Pushed from toolbar gear | Form: Server URL, Voice, Model, Text mode, Connection test; voice/model pickers as sheets |
| Connection test | `Sources/Features/Settings/ConnectionTestView.swift` | Pushed from Settings | Token endpoint probe |
| Tasks panel | `Sources/Features/Tasks/TaskPanelView.swift` | Pushed from toolbar tray icon (badge) | Cards with status dot, steps, result/error blocks; DEBUG "Sample" button |
| Media panel | `Sources/Features/Media/MediaPanelView.swift` | Pushed from toolbar music icon | Now-playing card, transport, seek, volume, pending jobs, choices, search, library, add-stream |
### 3.2 Navigation & hierarchy findings
- **N1 — Flat toolbar with three unrelated destinations.** Tasks, Media, and Settings
are all `.topBarTrailing` pushes (ConversationView L193–229). There is no tab bar or
grouped navigation; the web UI keeps these as side panels/sheets. Acceptable on iOS,
but the icon set (`tray.full`, `music.note`, `gearshape`) carries no brand and the
order puts Settings last while it is the most-used config surface.
- **N2 — No persistent identity.** The web header shows the brand glyph + "Hope Voice"
+ presence dot + profile chip (index.html L18–39). iOS shows only a plain nav title;
no Hope mark anywhere in the UI.
- **N3 — Session lifecycle is not visually distinct.** Idle, connecting, waitingAgent,
ready, and failed all render in the same layout with only text changes
(ConversationView L148–163, L248–263). The web UI has a dedicated console-intro
state (style.css L290–314) before connecting.
### 3.3 Component & state findings
- **C1 — OrbView is not the Hope element.** `OrbView.swift` L44–56: a `Circle()` with
`.blue/.green/.orange/.red` system gradients and a uniform `scaleEffect` pulse.
It matches none of the web LED-wall behavior (radial bloom, per-dot phase ripple,
audio-reactive dot sizing) and uses colors outside the Hope palette (§5).
- **C2 — State palette mismatch.** OrbView maps listening→green, speaking/thinking→orange
(L17–24), while the web uses cool blue for listening (`#6f8ff7`), warm amber for
thinking/connecting (`#d99a6c`), and light periwinkle for speaking (`#9dc2ff`)
(style.css L376–392). The iOS colors contradict the brand's "cool while it listens,
warm toward the center as it speaks" intent.
- **C3 — No audio-reactive rendering.** `ConversationStateModel` exposes smoothed
`micLevel`/`agentLevel` (ConversationState.swift L18–19) but nothing consumes them for
visuals except a 4 pt mic meter bar (ConversationView L127–138). The web drives the
entire avatar from these levels every frame (app.js L300–341).
- **C4 — Transcript rows are not chat bubbles.** `TranscriptListView.transcriptRow`
(OrbView.swift L111–137) renders full-width stacked cards with a role-name caption.
The web uses aligned bubbles: user right-aligned `#1b3150` with bottom-right corner
cut, agent left-aligned `#14243a` with bottom-left cut, 27 px avatar dot for the
agent, partials at opacity 0.58 (style.css L714–756). Partial/final distinction on
iOS is only an opacity change on plain text (L117, L130).
- **C5 — No message entrance motion.** Web rows animate in with `message-in`
(translateY 5px + fade, style.css L709–712); iOS rows appear instantly.
- **C6 — Thinking chip is plain.** A capsule with SF Symbol "ellipsis"
(ConversationView L68–74) vs the web's amber pulsing dot + text (`think-pulse`,
style.css L409–429).
- **C7 — Task status dots lack motion.** `TaskPanelView` uses static colored circles;
the web's running dot pulses (`status-pulse`, style.css L1125–1135).
- **C8 — Composer states are abrupt.** Idle shows a Start button, connecting shows a
bare `ProgressView` (L169–170), ready shows a disabled TextField in voice mode with
no explanation beyond placeholder text. The web's composer is always present and the
mic meter sits directly above it (index.html L85–107).
- **C9 — Empty app icon.** `Resources/Assets.xcassets/AppIcon.appiconset/Contents.json`
has no image entries; the app ships with the default iOS placeholder.
- **C10 — No Dynamic Type / reduced-motion handling anywhere.** No
`@Environment(\.dynamicTypeSize)`, no `accessibilityReduceMotion` checks in any view.
- **C11 — Accessibility is label-only.** Phase 8 added labels/values (OrbView L58–60,
toolbar buttons L211/219/227), but the LED wall equivalent on web is `aria-hidden`
with a separate `aria-live="polite"` status region (index.html L61–62). iOS's
OrbView combines label+value on one element, which is fine, but transcript updates are
not announced (`TranscriptListView` has no `accessibilityLiveRegion` equivalent —
SwiftUI: needs `.accessibilityLabel` on rows + possibly UIAccessibility notifications
for new finals).
### 3.4 What is already good (keep)
- State machine port is faithful: EMA 0.6/0.4, thresholds 0.035/0.02, thinking chip
after 2 s (ConversationState.swift L12–89) — matches app.js exactly.
- Stale-room guards, audio interruption handling, error banners (Phase 8).
- Media panel structure (now-playing → transport → seek → volume → jobs → choices →
search → library → add-stream) is a sensible vertical flow for iPhone.
- Task card information design (status dot + description + role-styled steps + result/
error blocks) mirrors the web well.
---
## 4. Navigation & Hierarchy Specification
### Target structure
```
Root: ConversationScreen (NavigationStack)
├── Header (inline, not nav-title-only)
│ ├── HopeGlyph (28×28, favicon mark) + "Hope Voice" title
│ └── Toolbar: [Tasks (badge)] [Media] [Settings]
├── PresenceStage (replaces statusHeader)
│ ├── HopeLedWall (160–200 pt square, procedural)
│ ├── StateLabel (aria-live equivalent)
│ ├── ThinkingChip / BargeInHint
│ ├── MicMeter (voice mode)
│ └── Voice·Model caption + Agent caption
├── TranscriptList (bubbles, §6.3)
└── ComposerBar (always visible; content by session state, §6.4)
Pushed: TaskPanelView, MediaPanelView, SettingsView (unchanged destinations)
Sheets: VoicePickerView, ModelPickerView (unchanged)
```
Changes vs current:
1. **Move mode toggles out of the header.** The mic/text-mode toggles currently live in
the status header (ConversationView L93–105), making it tall and busy. Move them into
the ComposerBar as a segmented control (Voice | Text) — matching the web's
`mode-switch` group (index.html L26–34). The header keeps at most: wall, label, chip,
meter, one caption line.
2. **Add an intro/landing state.** When `.idle`, render a compact console-intro block
(brand glyph large + "Hope is ready when you are" + Start button) instead of the
full transcript layout — mirrors web `.console-intro` (style.css L290–314). This
makes the first-run experience branded rather than blank.
3. **Keep three toolbar items** but reorder: Tasks, Media, Settings is fine; ensure the
tasks badge uses `--danger`/red only for *failed* and iris-blue for *active* (see §6.5).
---
## 5. Visual Language & Tokens
### 5.1 Color tokens (from style.css L1–26, ported to SwiftUI)
```swift
enum HopeColor {
static let canvas = Color(hex: 0x08111F) // page background
static let canvasDeep = Color(hex: 0x050B14)
static let surface = Color(hex: 0x0D1929)
static let surfaceRaised = Color(hex: 0x132238)
static let surfaceSoft = Color(hex: 0x172941)
static let line = Color(hex: 0xB7CCEB, opacity: 0.13)
static let lineStrong = Color(hex: 0xB7CCEB, opacity: 0.22)
static let ink = Color(hex: 0xF4F7FB)
static let inkSoft = Color(hex: 0xC5D1E2)
static let inkMuted = Color(hex: 0x8395AD)
static let iris = Color(hex: 0x8BA7FF) // primary accent
static let irisStrong = Color(hex: 0x6F8FF7)
static let irisDeep = Color(hex: 0x3C5FC9)
static let warm = Color(hex: 0xFFB38E) // secondary accent
static let mint = Color(hex: 0x76D9B2) // success
static let danger = Color(hex: 0xFF806F) // error
static let userBubble = Color(hex: 0x1B3150)
static let agentBubble = Color(hex: 0x14243A)
static let focus = Color(hex: 0xB5C5FF)
}
```
LED-wall state colors (style.css L376–392):
| State | Web color | SwiftUI |
|---|---|---|
| idle | `#56739E` | `Color(hex: 0x56739E)` |
| connecting / thinking | `#D99A6C` | `Color(hex: 0xD99A6C)` |
| listening | `#6F8FF7` | `HopeColor.irisStrong` |
| speaking | `#9DC2FF` + drop-shadow `rgba(111,143,247,0.35)` @14px | `Color(hex: 0x9DC2FF)` + `.shadow(color: .iris.opacity(0.35), radius: 14)` |
**Rule:** replace all system `.blue/.green/.orange/.red` in OrbView, mic meter, chips,
and banners with the tokens above. Semantic mapping: success=mint, warning/danger=
danger (not system red), thinking=warm, listening=irisStrong, speaking=#9DC2FF.
### 5.2 Shape & surface tokens
- Corner radii: panels/cards 14 pt (matches current TaskCardView), LED-wall plate
26 pt at 320 px web → scale to ~13–18 pt at 160–200 pt iOS size (keep the 26/320 ≈ 8%
ratio). Bubbles 12 pt with one corner cut to 3 pt (user: bottom-right; agent:
bottom-left) — style.css L732–751.
- Backing surfaces: LED-wall glass panel = radial gradient
`rgba(111,143,247,0.10)` center → transparent 62%, over `rgba(6,12,24,0.4)`, with a
1 pt border `rgba(139,167,255,0.14)` (style.css L349–359).
- Easing: `cubic-bezier(0.22, 1, 0.36, 1)` (`--ease-out`) for all state transitions;
dot-level changes use 90 ms linear (style.css L371); wall filter transition 220 ms ease (L345).
### 5.3 Typography
Keep SwiftUI defaults but align scale with web: status label = `.headline` (web 0.83rem
semibold), captions `.caption`/`.caption2`, transcript body `.body` at line-height 1.55
(web L737–738). No custom font is used by the web (system stack) — do not introduce one.
---
## 6. Screen & Component Specifications
### 6.1 HopeLedWall (replaces OrbView)
**Geometry:** square container, size `min(200, availableWidth - 32)` pt, corner radius
≈ 8% of side. Inside: a 21×21 dot grid with 2 pt gap and 12 pt padding (web L337–342).
At 180 pt the dots are ~5 pt base size — same as web's `--sz: 5px` (L362).
**Implementation:** SwiftUI `Canvas` inside a `TimelineView(.animation)` (or
`.periodic(from:by: 1/30)` to cap cost; web renders every rAF but iOS should cap at
30 fps for battery). For each of the 441 dots compute, porting app.js L298–341 verbatim:
```
rad = sqrt(dx² + dy²) // 0 center → ~1.41 corner; web normalizes by (COLS/2)
phase = hash(r,c) * 2π // deterministic per-dot phase (app.js L290 formula)
level = speaking ? agentLevel : listening ? micLevel : 0 // from ConversationStateModel
idle: vis = 0.16 - rad*0.08 ; size = 1
signal: lvl = min(1, level*1.3)
falloff = exp(-rad² * (2.0 - lvl*1.1))
ripple = 0.62 + 0.38*sin(t*0.32 + phase)
vis = falloff * (0.35 + lvl) * ripple * (speaking ? 1 : 0.55)
size = 1 + (1 - rad) * 1.4 * lvl
no-signal: breathe = 0.5 + 0.5*sin(t*0.12 + phase)
vis = 0.14 - rad*0.06 + 0.04*breathe ; size = 1 + (1-rad)*0.3
```
Dot color = state palette (§5.1); opacity clamped [0.02, 1]; dot diameter = `2*size` pt
base 5 pt → up to ~9 pt at center on strong signal. The wall itself gets the glass
backing (§5.2) and, in speaking state, a soft outer glow (shadow radius 14, iris 35%).
**Performance notes:**
- Precompute `rad` and `phase` once into a `[Dot]` array; per-frame work is only the
trig/exp math on 441 entries — trivial for Canvas.
- Pause the TimelineView when phase == .idle (static matrix) or scene backgrounded.
- Respect `accessibilityReduceMotion`: render the static idle matrix + state color only,
no ripple/breathe; keep opacity changes but drop size animation.
**Accessibility:** mark the wall `.accessibilityHidden(true)` and expose a separate
status element with `.accessibilityLabel("Conversation status")` +
`.accessibilityValue(stateText)` — exactly mirroring web's `aria-hidden` wall +
`aria-live="polite"` label region (index.html L61–62). Announce phase changes via
`UIAccessibility.post(notification: .announcement, …)` only on meaningful transitions
(speaking start/end), not every tick.
### 6.2 PresenceStage layout (replaces statusHeader)
```
VStack(spacing: 12) {
HopeLedWall(state:, level:) // §6.1
StateLabel(text: statusText) // .headline, inkSoft
ThinkingChip() // when isThinkingChipVisible
BargeInHint() // when textMode && ready
MicMeter(level:) // voice mode only; 4 pt capsule, iris fill
CaptionLine(voice · model) // .caption, inkMuted — one line, not two
}
```
- Thinking chip: amber dot (5 pt, `warm`) with `think-pulse` equivalent
(opacity 1→0.28, scale 1→0.75, 1.2 s ease-in-out infinite) + "Thinking…" caption —
replaces the current ellipsis capsule (ConversationView L68–74).
- Mic meter: fill color `irisStrong` (not system green), width = level × width × 2.5
(keep existing scaling, ConversationView L134).
- Error banners keep their current placement but use `danger` token at 0.1 opacity
background; reconnecting banner uses `warm`.
### 6.3 TranscriptList (bubble redesign)
Port web bubble semantics (style.css L714–756):
- **User row:** right-aligned, max width 82%, background `userBubble` (#1B3150),
text #E8EFFF, corner radius 12 with bottom-right 3 pt. No avatar dot.
- **Agent row:** left-aligned, max width 82%, background `agentBubble` (#14243A),
text `inkSoft`, 27 px→24 pt avatar dot (iris ring + iris center, style.css L714–730),
corner radius 12 with bottom-left 3 pt.
- **Partial rows:** opacity 0.58 on the whole row (L753–756) — keep current behavior but
apply to bubble background too.
- **System rows:** centered, transparent, `inkMuted`, caption size (L702–707).
- **Entrance animation:** `message-in` equivalent — opacity 0→1 + y +5→0 over ~180 ms
ease-out on insert only (L709–712). Use `.transition(.opacity.combined(with: .offset(y: 5)))`
with `withAnimation(.easeOut(duration: 0.18))` in the existing scroll-to-bottom path
(OrbView.swift L102–106).
- **Timestamps:** keep per-row timestamps but style them as web does — hidden by default,
revealed on row focus/long-press (`message-row::after`, L758–764). Simplest iOS: show
timestamp in caption2 `inkMuted` under the bubble (current behavior) — acceptable;
long-press-to-reveal is a later-phase nicety.
- **Accessibility:** each row gets `.accessibilityElement(children: .combine)` with label
"\(roleName): \(text)" so VoiceOver reads one element per message; post an
announcement on new *final* agent messages only.
### 6.4 ComposerBar (always visible)
Replace the current state-swapped HStack (ConversationView L161–191) with a persistent
bar:
| Session state | Content |
|---|---|
| idle | Intro card above; bar shows prominent "Start conversation" button (iris fill, white text, 42 pt min height — web `.btn` metrics L444–453) |
| connecting / waitingAgent | Bar disabled, shows ProgressView + "Connecting…" caption |
| ready, voice mode | Segmented [Voice | Text] control; TextField hidden (placeholder area shows "Barge in anytime — speak or switch to text"); End button (danger outline) |
| ready, text mode | Same segmented control; TextField enabled (rounded, surfaceRaised bg); Send button (iris) disabled when empty; End button |
The Voice/Text segmented control replaces both current toggles (L95–104) and the
Settings "Text mode" toggle stays in sync via `SettingsStore.textMode`.
### 6.5 TaskPanelView adjustments
- Status dot: running = iris with `status-pulse` (opacity 1→0.3, 1.4 s infinite);
completed = mint; failed = danger; pending = inkMuted (style.css L1120–1131).
- Step role colors already mirror web (thinking=blue→iris, tool_call=indigo, etc.) —
keep, but swap system blues for `iris`/`irisDeep`.
- Toolbar badge on the tray icon: iris-blue background for active count; reserve red
(`danger`) for a failed-task indicator if one is added later. Current red-always badge
(ConversationView L206) reads as "error" — change to `iris`.
### 6.6 MediaPanelView adjustments
- Keep layout; swap system colors: now-playing card bg `surfaceRaised`, transport
accent iris, error banner danger/0.1, seek slider tint iris.
- Now-playing art placeholder: use HopeGlyph at low opacity instead of a blank box.
### 6.7 Settings & pickers
- Form sections are fine; add the HopeGlyph + "Hope Voice" header row at top for brand
continuity (mirrors web header, index.html L18–39).
- Connection test result colors: success mint, failure danger.
---
## 7. Motion Specification
| Element | Trigger | Animation | Source |
|---|---|---|---|
| LED wall dots | every frame (≤30 fps) while active | opacity + size per §6.1 formulas; 90 ms linear feel via per-frame lerp | app.js L300–341, style.css L371 |
| Wall state color | phase change | cross-fade 220 ms ease (filter transition) | style.css L345, L376–392 |
| Speaking glow | entering .speaking | shadow radius 0→14 iris/35% over 220 ms | style.css L389–392 |
| Thinking chip dot | visible | `think-pulse`: opacity 1→0.28, scale 1→0.75, 1.2 s ease-in-out ∞ | style.css L415–429 |
| Message row insert | new event | fade + translateY(5→0), 180 ms ease-out | style.css L709–712 |
| Task running dot | status running | `status-pulse`: opacity 1→0.3, 1.4 s ∞ | style.css L1125–1135 |
| Buttons | tap/press | transform scale or color 180 ms `--ease-out` | style.css L453 |
| State label text | phase change | no animation (instant) — keep text updates jank-free | — |
**Reduce Motion:** when `accessibilityReduceMotion` is on: wall renders static per-state
matrix (no ripple/breathe/size), thinking chip loses pulse (static dot), message rows
appear without offset (fade only or instant), task dots static. All state *information*
remains visible — motion is decorative.
---
## 8. Accessibility Specification
1. **Status announcements:** separate live status element (label "Conversation status",
value = state text) distinct from the hidden wall (§6.1). Announce only on phase
transitions to speaking/listening/failed, throttled to ≥2 s apart.
2. **Transcript:** combined elements per row (§6.3); announce new final agent messages;
partials are not announced (they churn too fast — matches web `aria-live="polite"`
on the container which coalesces).
3. **Reduce Motion** handled per §7.
4. **Dynamic Type:** all text uses semantic fonts (`.headline`, `.body`, `.caption`) —
already true; verify bubble max-widths don't clip at accessibility sizes by allowing
`maxWidth: 82%` to grow to 92% above `.accessibility1`. Wall size is fixed (decorative).
5. **Contrast:** token palette on canvas (#08111F): ink #F4F7FB ≈ 16:1, inkSoft #C5D1E2
≈ 10:1, inkMuted #8395AD ≈ 4.6:1 (passes AA for large text; captions using it are
≥ caption size — flag as a watch-item), iris #8BA7FF on canvas ≈ 6.5:1. All pass WCAG
AA except muted-on-canvas at small sizes, which should be reserved for secondary
metadata only (timestamps, agent name).
6. **Touch targets:** all interactive controls ≥44 pt (web `.btn` min-height 42 px; iOS
standard). Composer buttons and toolbar icons already comply via SF Symbol sizing —
verify segmented control height ≥44.
7. **VoiceOver order:** header status → transcript (oldest→newest) → composer, matching
visual order; the wall is hidden from VO (§6.1).
---
## 9. App Icon & Branding Assets
1. **AppIcon:** generate from `hope_companion/scripts/gen-icons.mjs` output
(`resources/icon.png`, 1024²) — it already encodes the square Hope plate + mark and
is dependency-free to regenerate. Add all required iOS sizes (60/58/40/29 @1x–3x,
marketing 1024) to `AppIcon.appiconset`. Fallback if regeneration is undesirable:
use the existing PNG directly; it has alpha and correct dimensions (verified via sips).
2. **HopeGlyph asset:** port `favicon.svg` into the asset catalog as a vector PDF/SVG or
implement as a small SwiftUI shape view (rounded rect + circle + rotated ellipse + dot)
for use in the header, empty states, and media placeholder. The SVG is 6 lines —
trivial to hand-port with `Path`.
3. **No raster assets** for the LED wall — procedural only (§6.1).
---
## 10. Phased Implementation Plan
### Phase A — Brand foundation (low risk, high impact)
- [ ] Add `HopeColor` token enum + `Color(hex:)` helper; replace system colors in OrbView,
mic meter, chips, banners, task dots, media panel.
- [ ] Add AppIcon (all sizes) from companion generator output.
- [ ] Add HopeGlyph shape view; use in header row of Conversation and Settings.
- **Exit:** app visually reads as "Hope" at a glance; zero system-green/orange/red left
in conversation surfaces.
### Phase B — LED wall avatar (the core element)
- [ ] Implement `HopeLedWall` (Canvas + TimelineView ≤30 fps) with the exact formulas
from §6.1, driven by `ConversationStateModel.micLevel/agentLevel`.
- [ ] Glass backing + speaking glow; state palette per §5.1.
- [ ] Pause rendering when idle/backgrounded; reduce-motion fallback (§7).
- [ ] Replace OrbView usage in ConversationView; keep the old file for one release as a
`#if`-guarded fallback, then delete.
- **Exit:** wall blooms with agent audio (visible in sim via text-mode TTS-driven state —
note: sim mic is dead per PLAN.md Phase 2, so listening-side reactivity must be
verified on device; speaking-side can be verified in simulator).
### Phase C — Transcript bubbles & composer
- [ ] Bubble redesign per §6.3 (alignment, colors, corner cuts, avatar dot, entrance
animation, combined VO elements).
- [ ] Persistent ComposerBar with Voice/Text segmented control per §6.4; remove toggles
from header.
- [ ] Intro/landing state for `.idle` per §4.3.
- **Exit:** transcript visually matches web bubbles; mode switch is one tap in the bar.
### Phase D — Polish pass
- [ ] Thinking chip pulse, task running-dot pulse, button press easing (§7).
- [ ] Toolbar badge color → iris (§6.5); media panel token swap (§6.6).
- [ ] Dynamic Type audit at `.accessibility3`; contrast fixes for muted captions if needed.
- **Exit:** side-by-side with web UI on same deployment shows consistent language; no
motion under reduce-motion; VO walkthrough clean.
Sequencing rationale: A is pure token/asset work (no behavior change). B is the
signature element and isolates the only new rendering code. C changes information layout
(transcript/composer) but reuses existing data flow. D is small deltas. Each phase is
independently reviewable and revertible.
---
## 11. Acceptance Criteria (for the redesign implementation)
1. **Brand:** app icon set populated; HopeGlyph visible in conversation + settings
headers; no system palette colors on conversation surfaces (grep for `.blue`,
`.green`, `.orange`, `.red` in `Sources/Features` returns only token-enum internals).
2. **LED wall:** renders 21×21 grid; dot opacity/size respond to `agentLevel` while
speaking (verifiable in simulator via TTS) and to `micLevel` while listening
(device-only); idle shows static dim matrix; state colors match §5.1 table exactly.
3. **Performance:** wall animation holds 30 fps on iPhone 15-class hardware with no
main-thread stalls (Instruments template: Time Profiler + SwiftUI view body count);
TimelineView paused when `.idle` or backgrounded.
4. **Transcript:** user rows right-aligned #1B3150, agent rows left-aligned #14243A with
avatar dot; partials at 0.58 opacity; new rows animate in (fade+5 pt rise); VO reads
one element per message.
5. **Composer:** always visible; Voice/Text segmented control switches mode and syncs
with `SettingsStore.textMode`; Start/End states per §6.4 table.
6. **Accessibility:** reduce-motion yields static wall + no pulses; status announcements
fire on speaking/listening/failed transitions only; all interactive targets ≥44 pt.
7. **Build:** zero warnings; simulator build/run clean (XcodeBuildMCP); existing
connect/disconnect/reconnect matrix from PLAN.md Phase 8 still passes.
8. **No regressions:** media reconciliation, task events, TLS pinning, persistence all
unchanged (these phases touch only presentation layer).
---
## 12. Risks & Open Questions
- **R1 — Sim mic limitation.** Listening-side wall reactivity cannot be verified in the
simulator (CoreAudio -4010, PLAN.md Phase 2). Device testing required for that half of
§6.1; speaking-side is verifiable in sim via agent TTS.
- **R2 — Canvas performance on older devices.** 441 dots × per-frame trig at 30 fps is
cheap on A15+ but should be profiled on the oldest supported device (iOS 17 floor =
A14-class). Mitigation: drop to 15 fps or 17×17 grid if profiling shows headroom loss.
- **R3 — Color(hex:) helper.** No such helper exists in the codebase; it is a new ~10-line
utility. Trivial but must be added before Phase A color swaps.
- **R4 — Web parity scope.** This spec deliberately does not port web-only features
(keyboard shortcuts, drag-to-seek waveform) — consistent with PLAN.md's accepted gaps.
- **Open question:** should the LED wall size scale with device width (e.g., larger on
iPad-class or landscape)? Spec assumes iPhone portrait only per UPDATE_PATH.md scope;
recommend fixed 180 pt for Phase B and revisit later.