From 0beed6ad98525ea318ee41a844a3bb22e9785b3e Mon Sep 17 00:00:00 2001
From: cynarAI <155274210+cynarAI@users.noreply.github.com>
Date: Fri, 2 Oct 2026 22:41:30 +0200
Subject: [PATCH] feat(web): aurora voice stage with live word-by-word caption
(#100)
Replace the full-bleed cyan/violet/emerald bloom field with one dark glass
panel: the Oo, a live caption whose new words fade in, and a compact control
row (phase label, transcript, interrupt, hang up). An iridescent aurora rises
from the panel's bottom edge, follows mic energy while listening, drifts while
thinking and blooms while speaking. Its spectrum is the shared bot palette
rotated so the active Oo's color sits in the middle, so no new hex values.
Voice entry buttons drop the hardcoded cyan/amber styling for neutral tokens
plus a thin aurora rim when voice is ready. Dead voice CSS is removed and the
voice e2e now captures the stage.
Claude-Session: https://claude.ai/code/session_012G3dUMVDCDL3PwQE4WTQxL
Co-authored-by: Claude
---
VOICE.md | 23 +-
apps/web/e2e/voice.spec.ts | 3 +
apps/web/src/components/stage/OoStage.tsx | 12 +-
.../src/components/stage/StageVoiceEntry.tsx | 10 +-
apps/web/src/components/voice/VoiceAurora.tsx | 88 +++++
apps/web/src/components/voice/VoiceCanvas.tsx | 111 ------
.../src/components/voice/VoiceLiveText.tsx | 30 ++
apps/web/src/components/voice/VoiceStage.tsx | 340 +++++++-----------
.../web/src/components/voice/voice-aurora.css | 71 ++++
apps/web/src/lib/voice-aurora.test.ts | 63 ++++
apps/web/src/lib/voice-aurora.ts | 74 ++++
apps/web/src/pages/Shell.tsx | 11 +-
apps/web/src/styles.css | 117 +-----
13 files changed, 496 insertions(+), 457 deletions(-)
create mode 100644 apps/web/src/components/voice/VoiceAurora.tsx
delete mode 100644 apps/web/src/components/voice/VoiceCanvas.tsx
create mode 100644 apps/web/src/components/voice/VoiceLiveText.tsx
create mode 100644 apps/web/src/components/voice/voice-aurora.css
create mode 100644 apps/web/src/lib/voice-aurora.test.ts
create mode 100644 apps/web/src/lib/voice-aurora.ts
diff --git a/VOICE.md b/VOICE.md
index af5ad12..a96c536 100644
--- a/VOICE.md
+++ b/VOICE.md
@@ -42,11 +42,11 @@ Voice is the **immersive primary mode** for 1:1 bot conversations. Text chat use
| Composer **Voice** pill | Opens Voice Stage when a provider is connected; otherwise opens Voice settings |
| Transcript “Speak this reply” | Reads one message aloud (text-first affordance, unchanged) |
-The composer Voice control uses `h-control-lg`, cyan bloom when ready, and amber styling when setup is needed.
+The composer and stage Voice controls use `h-control-lg` in neutral tokens; when voice is ready an iridescent aurora rim (`VoiceAuroraRim`) runs along the bottom edge.
## Voice Stage
-Full-bleed immersive layer (`VoiceStage` + `VoiceCanvas`) replaces the modal call dialog.
+Full-screen dark layer with one glass panel (`VoiceStage`): the Oo, the live caption, and a control row. An iridescent aurora (`VoiceAurora`) rises from the panel's bottom edge. Its spectrum is the bot palette (`botColors` from `@rakazo/ui-tokens`) rotated so the active Oo's own color sits in the center.
### Persona & consciousness layer
@@ -72,14 +72,16 @@ Phase labels, idle prompts, and thinking lines are **character-specific** (DE/EN
| Phase | Visual | Audio |
|-------|--------|-------|
-| **Listening** | Cyan dual-mind bloom, mic-reactive energy bars | Dictation active; silence endpoint sends |
-| **Thinking** | Violet orbital ring, calm bars | TTS idle; awaiting bot reply |
-| **Speaking** | Emerald bloom, conductor mascot pose | TTS playback; caption shown live |
+| **Listening** | Aurora rises and brightens with mic energy, breathing | Dictation active; silence endpoint sends |
+| **Thinking** | Aurora settles low and drifts side to side | TTS idle; awaiting bot reply |
+| **Speaking** | Full aurora bloom, conductor mascot pose | TTS playback; caption shown live |
-Presence visuals sync to `dictation.energy` (0–1) while listening. Mascot uses `EvooMascot` poses: idle / thinking / wave.
+Aurora height and opacity follow `dictation.energy` (0–1) while listening (`voiceAuroraBlobs` in `apps/web/src/lib/voice-aurora.ts`). The caption (`VoiceLiveText`) fades each new word in; words already on screen stay put while an utterance grows and the caption restarts for a new one. Mascot uses `EvooMascot` poses: idle / thinking / wave.
### Controls
+The control row holds the persona phase label (live region), Transcript, Interrupt (mic), and Hang up (×).
+
| Input | Action |
|-------|--------|
| **Space** | Interrupt (stop TTS or cancel dictation, resume listening) |
@@ -92,18 +94,19 @@ Collapsible panel shows the last messages from the thread plus the live utteranc
## Accessibility
-- `prefers-reduced-motion: reduce` disables bloom drift, ring spin, and composer pulse; static gradients remain.
+- `prefers-reduced-motion: reduce` disables aurora breathing/drift, the panel entrance, and word fade-in; the static aurora remains.
- Dialog semantics: `role="dialog"`, `aria-modal`, labelled bot name.
- Hang up exposes `aria-label="Hang up"`.
-- Keyboard exit paths documented in-stage.
+- Shortcuts are exposed through `aria-keyshortcuts` on the matching controls instead of on-screen hint text.
## Architecture
```
Shell (composer Voice)
└─ CallView (orchestration: dictation + speaker + thread send)
- └─ VoiceStage (layout, controls, transcript drawer)
- └─ VoiceCanvas (ambient bloom field)
+ └─ VoiceStage (panel, controls, transcript drawer)
+ ├─ VoiceAurora (bottom-edge glow, phase + energy)
+ └─ VoiceLiveText (word-by-word caption)
```
Shared singletons: `dictation` (`apps/web/src/lib/dictation.ts`), `speaker` (`apps/web/src/lib/tts.ts`).
diff --git a/apps/web/e2e/voice.spec.ts b/apps/web/e2e/voice.spec.ts
index c00acec..ee2295f 100644
--- a/apps/web/e2e/voice.spec.ts
+++ b/apps/web/e2e/voice.spec.ts
@@ -79,7 +79,10 @@ test("voice settings connect a key, speak a reply, and open a call", async ({ pa
.getByRole("button", { name: "Voice", exact: true })
.click();
await expect(page.getByTestId("call-view")).toBeVisible();
+ await expect(page.getByTestId("voice-panel")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Interrupt" })).toBeVisible();
await expect(page.getByRole("button", { name: "Hang up" })).toBeVisible();
+ await captureScreenshot(page, testInfo, "voice-stage");
await page.getByRole("button", { name: "Hang up" }).click();
await expect(page.getByTestId("call-view")).toHaveCount(0);
diff --git a/apps/web/src/components/stage/OoStage.tsx b/apps/web/src/components/stage/OoStage.tsx
index 7f4e0d6..1fa1f26 100644
--- a/apps/web/src/components/stage/OoStage.tsx
+++ b/apps/web/src/components/stage/OoStage.tsx
@@ -243,7 +243,11 @@ export function OoStage({
{subtitle}