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}

{onStartVoice ? ( - + ) : null} ) : ( @@ -550,7 +554,11 @@ export function OoGroupStage({ )} {onStartVoice && !hasMessages ? ( - + ) : null} diff --git a/apps/web/src/components/stage/StageVoiceEntry.tsx b/apps/web/src/components/stage/StageVoiceEntry.tsx index c256fde..14c4088 100644 --- a/apps/web/src/components/stage/StageVoiceEntry.tsx +++ b/apps/web/src/components/stage/StageVoiceEntry.tsx @@ -1,12 +1,15 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { Button } from "@rakazo/ui-web"; import { Mic } from "lucide-react"; +import { VoiceAuroraRim } from "../voice/VoiceAurora"; export function StageVoiceEntry({ voiceReady, + botColor, onStartVoice, }: { voiceReady: boolean; + botColor?: string; onStartVoice: () => void; }) { const { t } = useLingui(); @@ -22,10 +25,13 @@ export function StageVoiceEntry({ title={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`} className={`tactile-press h-control-lg w-full max-w-sm gap-2.5 rounded-full px-6 text-ui-body font-semibold transition-all ${ voiceReady - ? "border-cyan-400/40 bg-gradient-to-b from-cyan-500/25 to-cyan-950/50 text-cyan-50 shadow-[0_0_32px_rgba(34,211,238,0.28),inset_0_1px_0_rgba(255,255,255,0.18)] hover:border-cyan-300/55 hover:from-cyan-400/32 hover:to-cyan-950/55 voice-primary-ready" - : "border-amber-400/30 bg-white/[0.06] text-foreground/90 hover:bg-white/[0.12]" + ? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10" + : "border-border bg-foreground/5 text-foreground/80 hover:bg-foreground/10" }`} > + {voiceReady ? ( + + ) : null}