Archived
529 lines
31 KiB
Markdown
529 lines
31 KiB
Markdown
# 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.
|