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 <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-02 22:41:30 +02:00 committed by GitHub
parent 4c619b25b9
commit 0beed6ad98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 496 additions and 457 deletions

View File

@ -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`).

View File

@ -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);

View File

@ -243,7 +243,11 @@ export function OoStage({
{subtitle}
</p>
{onStartVoice ? (
<StageVoiceEntry voiceReady={voiceReady} onStartVoice={onStartVoice} />
<StageVoiceEntry
voiceReady={voiceReady}
botColor={botColor}
onStartVoice={onStartVoice}
/>
) : null}
</>
) : (
@ -550,7 +554,11 @@ export function OoGroupStage({
)}
{onStartVoice && !hasMessages ? (
<StageVoiceEntry voiceReady={voiceReady} onStartVoice={onStartVoice} />
<StageVoiceEntry
voiceReady={voiceReady}
botColor={speaker?.color}
onStartVoice={onStartVoice}
/>
) : null}
</div>
</section>

View File

@ -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 ? (
<VoiceAuroraRim botColor={botColor} className="inset-x-[12%] h-0.5 blur-[3px]" />
) : null}
<Mic strokeWidth={2} className="size-icon-lg shrink-0" aria-hidden="true" />
{voiceReady ? <Trans>Sprechen starten</Trans> : <Trans>Sprache einrichten</Trans>}
</Button>

View File

@ -0,0 +1,88 @@
import { botColors } from "@rakazo/ui-tokens";
import type { CSSProperties } from "react";
import { useMemo } from "react";
import { auroraSpectrum, voiceAuroraBlobs } from "../../lib/voice-aurora";
import type { VoicePhase } from "./VoiceStage";
/** Iridescent glow rising from the bottom edge; reacts to phase and mic energy. */
export function VoiceAurora({
botColor,
phase,
energy,
reducedMotion,
}: {
botColor: string;
phase: VoicePhase;
energy: number;
reducedMotion: boolean;
}) {
const blobs = useMemo(() => voiceAuroraBlobs(botColor, phase, energy), [botColor, phase, energy]);
const motion = reducedMotion
? ""
: phase === "thinking"
? "voice-aurora-drift"
: "voice-aurora-breathe";
const transition = reducedMotion ? undefined : "height 180ms ease-out, opacity 240ms ease-out";
return (
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
{blobs.map((blob, index) => (
<div
key={blob.x}
className="absolute bottom-0 w-[62%] -translate-x-1/2 translate-y-1/2 mix-blend-screen"
style={{
left: `${blob.x}%`,
height: `${blob.rise * 2}%`,
opacity: blob.opacity,
transition,
}}
>
<div
className={`size-full rounded-full blur-[36px] ${motion}`}
style={{
background: `radial-gradient(closest-side, ${blob.color}, ${blob.color} 18%, transparent)`,
animationDelay: `${index * -0.7}s`,
}}
/>
</div>
))}
<VoiceAuroraRim
botColor={botColor}
className="inset-x-0 h-24 translate-y-1/2 blur-[26px] mix-blend-screen"
style={{ opacity: blobs[0]?.opacity, transition }}
/>
<div
className="absolute bottom-0 left-1/2 h-12 w-[56%] -translate-x-1/2 translate-y-1/2 rounded-full bg-foreground blur-[20px]"
style={{ opacity: (blobs[0]?.opacity ?? 0) * 0.32, transition }}
/>
<VoiceAuroraRim
botColor={botColor}
className="inset-x-[6%] h-[3px] blur-[2px] mix-blend-screen"
style={{ opacity: Math.min(1, (blobs[0]?.opacity ?? 0) + 0.2), transition }}
/>
</div>
);
}
/** Thin iridescent line along a bottom edge, brightest in the bot's own color. */
export function VoiceAuroraRim({
botColor = botColors[0],
className = "",
style,
}: {
botColor?: string;
className?: string;
style?: CSSProperties;
}) {
const [center, left, right, farLeft, farRight] = auroraSpectrum(botColor);
return (
<span
aria-hidden
className={`pointer-events-none absolute bottom-0 rounded-full ${className}`}
style={{
background: `linear-gradient(90deg, transparent, ${farLeft}, ${left}, ${center}, ${right}, ${farRight}, transparent)`,
...style,
}}
/>
);
}

View File

@ -1,111 +0,0 @@
import { useMemo } from "react";
export type VoicePhase = "listening" | "thinking" | "speaking";
const PHASE_PALETTE: Record<
VoicePhase,
{ primary: string; secondary: string; accent: string; glow: string }
> = {
listening: {
primary: "rgba(6,182,212,0.55)",
secondary: "rgba(14,165,233,0.35)",
accent: "rgba(34,211,238,0.22)",
glow: "voice-bloom-cyan",
},
speaking: {
primary: "rgba(16,185,129,0.58)",
secondary: "rgba(52,211,153,0.38)",
accent: "rgba(110,231,183,0.24)",
glow: "voice-bloom-emerald",
},
thinking: {
primary: "rgba(139,92,246,0.52)",
secondary: "rgba(167,139,250,0.34)",
accent: "rgba(196,181,253,0.22)",
glow: "voice-bloom-violet",
},
};
const ACCENT_TINT: Record<string, string> = {
cyan: "rgba(6,182,212,0.42)",
emerald: "rgba(16,185,129,0.42)",
violet: "rgba(139,92,246,0.42)",
amber: "rgba(245,158,11,0.38)",
rose: "rgba(244,63,94,0.36)",
};
export function VoiceCanvas({
phase,
energy = 0,
reducedMotion = false,
personaAccent,
}: {
phase: VoicePhase;
energy?: number;
reducedMotion?: boolean;
personaAccent?: keyof typeof ACCENT_TINT;
}) {
const palette = PHASE_PALETTE[phase];
const personaTint = personaAccent ? ACCENT_TINT[personaAccent] : null;
const bloomScale = useMemo(() => 1 + energy * 0.28, [energy]);
const ringOpacity = useMemo(() => 0.35 + energy * 0.45, [energy]);
return (
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden bg-[#030508]">
{/* Deep field */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_120%_80%_at_50%_100%,rgba(8,12,20,0.95)_0%,#030508_55%)]" />
{/* Dual-mind bloom orbs */}
<div
className={`absolute -left-[18%] top-[8%] h-[62vmin] w-[62vmin] rounded-full blur-[80px] ${reducedMotion ? "" : palette.glow}`}
style={{
background: `radial-gradient(circle, ${palette.primary} 0%, transparent 68%)`,
opacity: reducedMotion ? 0.55 : 0.75,
transform: `scale(${bloomScale})`,
transition: reducedMotion ? undefined : "transform 120ms ease-out",
}}
/>
<div
className={`absolute -right-[14%] top-[22%] h-[54vmin] w-[54vmin] rounded-full blur-[72px] ${reducedMotion ? "" : "voice-bloom-secondary"}`}
style={{
background: personaTint
? `radial-gradient(circle, ${personaTint} 0%, ${palette.secondary} 45%, transparent 70%)`
: `radial-gradient(circle, ${palette.secondary} 0%, transparent 70%)`,
opacity: reducedMotion ? 0.45 : 0.65,
animationDelay: "-1.4s",
}}
/>
<div
className={`absolute left-1/2 top-[38%] h-[48vmin] w-[48vmin] -translate-x-1/2 rounded-full blur-[96px] ${reducedMotion ? "" : palette.glow}`}
style={{
background: `radial-gradient(circle, ${palette.accent} 0%, transparent 72%)`,
opacity: 0.5 + energy * 0.25,
transform: `translateX(-50%) scale(${0.92 + energy * 0.18})`,
transition: reducedMotion
? undefined
: "transform 140ms ease-out, opacity 140ms ease-out",
}}
/>
{/* Liquid glass rings */}
<div className="absolute left-1/2 top-[42%] size-[min(72vmin,520px)] -translate-x-1/2 -translate-y-1/2">
<div
className={`size-full rounded-full border border-white/[0.07] ${reducedMotion ? "" : "voice-glass-ring"}`}
style={{ opacity: ringOpacity }}
/>
</div>
<div className="absolute left-1/2 top-[42%] size-[min(58vmin,420px)] -translate-x-1/2 -translate-y-1/2">
<div
className={`size-full rounded-full border border-white/[0.05] ${reducedMotion ? "" : "voice-glass-ring-inner"}`}
style={{ opacity: ringOpacity * 0.85 }}
/>
</div>
{/* Fine grain for depth */}
<div className="absolute inset-0 opacity-[0.035] mix-blend-overlay voice-grain" />
{/* Horizon line */}
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
</div>
);
}

View File

@ -0,0 +1,30 @@
import { useRef } from "react";
import { captionWords, continuesCaption } from "../../lib/voice-aurora";
/** Live caption whose new words fade in as they are heard or spoken. */
export function VoiceLiveText({ text, reducedMotion }: { text: string; reducedMotion: boolean }) {
const shown = useRef({ text: "", utterance: 0 });
if (shown.current.text !== text) {
shown.current = {
text,
utterance: continuesCaption(shown.current.text, text)
? shown.current.utterance
: shown.current.utterance + 1,
};
}
const { utterance } = shown.current;
return (
<p
dir="auto"
className="text-balance text-center text-ui-title font-medium leading-snug text-foreground"
>
{captionWords(text).map((word, index) => (
<span key={`${utterance}-${index}`} className={reducedMotion ? undefined : "voice-word-in"}>
{index ? " " : ""}
{word}
</span>
))}
</p>
);
}

View File

@ -1,13 +1,15 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { BotAvatar, Button } from "@rakazo/ui-web";
import { PhoneOff, ScrollText } from "lucide-react";
import { Mic, ScrollText, X } from "lucide-react";
import { useEffect, useId, useState } from "react";
import type { BotPersonaProfile } from "../../lib/bot-persona";
import { cadencePulseMs, personaMascotPose } from "../../lib/bot-persona";
import type { BotPersonaProfile, VoiceAwarenessEvent } from "../../lib/bot-persona";
import { personaMascotPose } from "../../lib/bot-persona";
import { VoiceAurora } from "./VoiceAurora";
import { VoiceAwarenessStrip } from "./VoiceAwarenessStrip";
import { VoiceCanvas, type VoicePhase } from "./VoiceCanvas";
import { VoiceLiveText } from "./VoiceLiveText";
import "./voice-aurora.css";
export type { VoicePhase };
export type VoicePhase = "listening" | "thinking" | "speaking";
export interface VoiceTranscriptLine {
role: "user" | "bot";
@ -15,6 +17,9 @@ export interface VoiceTranscriptLine {
speakerName?: string;
}
const ROUND_CONTROL =
"tactile-press size-control-md shrink-0 rounded-full bg-foreground/10 p-0 text-foreground backdrop-blur-md hover:bg-foreground/15";
export function VoiceStage({
phase,
phaseLabel,
@ -45,7 +50,7 @@ export function VoiceStage({
error?: string | null;
energy?: number;
awarenessLabel?: string | null;
awarenessEvent?: import("../../lib/bot-persona").VoiceAwarenessEvent | null;
awarenessEvent?: VoiceAwarenessEvent | null;
transcriptLines?: VoiceTranscriptLine[];
onInterrupt: () => void;
onHangUp: () => void;
@ -84,71 +89,22 @@ export function VoiceStage({
return () => window.removeEventListener("keydown", onKey);
}, []);
const cadenceMs = cadencePulseMs(persona.cadence);
return (
<div
data-testid="call-view"
data-theme="dark"
data-phase={phase}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed inset-0 z-40 flex flex-col text-white"
>
<VoiceCanvas
phase={phase}
energy={energy}
reducedMotion={reducedMotion}
personaAccent={persona.bloomAccent}
/>
<div className="relative z-10 flex min-h-0 flex-1 flex-col">
<header className="flex items-center justify-between gap-4 px-5 pt-[max(1rem,env(safe-area-inset-top))] pb-3 md:px-8">
<div className="min-w-0">
<div
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-ui-label font-semibold uppercase tracking-wider text-zinc-200 backdrop-blur-md"
style={
reducedMotion
? undefined
: { animation: `voicePrimaryPulse ${cadenceMs}ms ease-in-out infinite` }
}
>
<span
className={`size-2 rounded-full ${
phase === "listening"
? "bg-cyan-400 shadow-[0_0_10px_#22d3ee]"
: phase === "speaking"
? "bg-emerald-400 shadow-[0_0_10px_#34d399]"
: "bg-violet-400 shadow-[0_0_10px_#a78bfa]"
} ${reducedMotion ? "" : "animate-pulse"}`}
/>
<span>{phaseLabel}</span>
</div>
<h1
id={titleId}
className="mt-2 truncate text-ui-title font-semibold tracking-tight text-white"
className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-3 overflow-y-auto bg-background px-4 pt-[max(1rem,env(safe-area-inset-top))] pb-[max(1rem,env(safe-area-inset-bottom))] text-foreground"
>
<h1 id={titleId} className="sr-only">
{botName}
</h1>
<p className="truncate text-ui-body-sm text-zinc-400">{botTitle}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setTranscriptOpen((open) => !open)}
aria-expanded={transcriptOpen}
aria-controls="voice-transcript-panel"
className="tactile-press h-control-sm gap-2 rounded-full border-white/12 bg-white/[0.06] px-4 text-ui-body text-zinc-100 hover:bg-white/[0.12]"
>
<ScrollText strokeWidth={1.8} className="size-icon-md" />
<span className="hidden sm:inline">
<Trans>Transcript</Trans>
</span>
</Button>
</header>
{awarenessLabel && awarenessEvent ? (
<div className="px-5 pb-2 md:px-8">
<div className="w-full max-w-md">
<VoiceAwarenessStrip
event={awarenessEvent}
label={awarenessLabel}
@ -158,96 +114,97 @@ export function VoiceStage({
</div>
) : null}
<div className="flex flex-1 flex-col items-center justify-center px-6 pb-4">
<div className="relative flex flex-col items-center">
{phase === "thinking" && !reducedMotion ? (
<div
className="absolute size-[min(52vmin,380px)] rounded-full border border-violet-400/25 border-t-violet-300/70"
style={{ animation: "orbitalSpin 2.8s linear infinite" }}
/>
) : null}
<div
className="relative flex size-[min(44vmin,320px)] items-center justify-center rounded-full border border-white/18 bg-slate-950/55 shadow-[0_24px_80px_rgba(0,0,0,0.65),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl"
style={
reducedMotion
? undefined
: {
boxShadow: `0 24px 80px rgba(0,0,0,0.65), inset 0 1px 0 rgba(255,255,255,0.14), 0 0 ${Math.round(24 + energy * 40)}px ${personaGlow(persona.bloomAccent, energy)}`,
}
}
<section
data-testid="voice-panel"
className={`relative isolate flex min-h-[min(68vh,30rem)] w-full max-w-md flex-col overflow-hidden rounded-[2rem] border border-border bg-card shadow-2xl ${reducedMotion ? "" : "voice-panel-in"}`}
>
<VoiceAurora
botColor={botColor}
phase={phase}
energy={energy}
reducedMotion={reducedMotion}
/>
<div className="relative flex flex-1 flex-col items-center justify-center gap-6 px-7 pt-10 pb-4">
<div title={`${botName} · ${botTitle}`}>
<BotAvatar
color={botColor}
identity={botId}
status={botStatus}
pose={mascotPose}
size={128}
className="drop-shadow-[0_8px_32px_rgba(0,0,0,0.45)]"
size={88}
/>
</div>
<div className="mt-8 flex h-10 items-end justify-center gap-1.5" aria-hidden>
{Array.from({ length: 9 }, (_, index) => {
const barEnergy =
phase === "thinking"
? 0.35 + ((index % 3) + 1) * 0.08
: 0.25 + energy * 0.75 * (0.6 + (index % 5) * 0.1);
const height = `${Math.round(18 + barEnergy * 28)}px`;
const color =
phase === "listening"
? "bg-cyan-400/90"
: phase === "speaking"
? "bg-emerald-400/90"
: "bg-violet-400/70";
return (
<div
key={index}
className={`w-1.5 rounded-full ${color} ${reducedMotion ? "" : `wave-bar-${(index % 7) + 1}`}`}
style={{
height: reducedMotion ? "24px" : height,
opacity: 0.55 + barEnergy * 0.45,
transition: reducedMotion ? undefined : "height 90ms ease-out",
animationDuration: reducedMotion ? undefined : `${cadenceMs / 1000}s`,
}}
/>
);
})}
</div>
</div>
</div>
<div className="px-6 pb-2 md:px-10">
<div className="mx-auto max-w-2xl rounded-[24px] border border-white/10 bg-black/35 px-5 py-4 text-center backdrop-blur-xl">
<p
key={subtitle}
className={`text-ui-reading font-medium leading-relaxed text-zinc-50 ${reducedMotion ? "" : "animate-blur-in"}`}
>
{subtitle}
</p>
<VoiceLiveText text={subtitle} reducedMotion={reducedMotion} />
{error ? (
<p className="mt-2 text-ui-body-sm font-medium text-rose-400">{error}</p>
<p role="alert" className="text-center text-ui-body-sm font-medium text-destructive">
{error}
</p>
) : null}
</div>
<div className="relative flex items-center gap-2 px-4 pb-4">
<span
aria-live="polite"
className="mr-auto truncate rounded-full bg-foreground/10 px-3.5 py-2 text-ui-body-sm font-medium text-foreground/85 backdrop-blur-md"
>
{phaseLabel}
</span>
<Button
type="button"
variant="ghost"
onClick={() => setTranscriptOpen((open) => !open)}
aria-label={t`Transcript`}
title={t`Transcript`}
aria-expanded={transcriptOpen}
aria-controls="voice-transcript-panel"
aria-keyshortcuts="T"
className={ROUND_CONTROL}
>
<ScrollText strokeWidth={1.8} className="size-icon-md" />
</Button>
<Button
type="button"
variant="ghost"
onClick={onInterrupt}
aria-label={t`Interrupt`}
title={t`Interrupt`}
aria-keyshortcuts="Space"
className={`${ROUND_CONTROL} ${phase === "listening" ? "bg-foreground/20" : ""}`}
>
<Mic strokeWidth={1.9} className="size-icon-md" />
</Button>
<Button
type="button"
variant="ghost"
onClick={onHangUp}
aria-label={t`Hang up`}
title={t`Hang up`}
aria-keyshortcuts="Escape"
className={ROUND_CONTROL}
>
<X strokeWidth={2} className="size-icon-md" />
</Button>
</div>
</section>
<div
id="voice-transcript-panel"
className={`mx-auto w-full max-w-2xl overflow-hidden px-6 transition-[max-height,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] md:px-10 ${
transcriptOpen ? "max-h-48 opacity-100 pb-2" : "max-h-0 opacity-0"
className={`w-full max-w-md overflow-hidden transition-[max-height,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
transcriptOpen ? "max-h-56 opacity-100" : "max-h-0 opacity-0"
}`}
>
<div className="apple-glass-card max-h-44 overflow-y-auto rounded-2xl px-4 py-3 text-start rk-scroll">
<div className="rk-scroll max-h-52 overflow-y-auto rounded-3xl border border-border bg-card px-5 py-4 text-start">
{transcriptLines.length ? (
<ul className="space-y-2">
<ul className="space-y-2.5">
{transcriptLines.map((line, index) => (
<li
key={`${line.role}-${index}`}
className={`text-ui-body-sm leading-relaxed ${
line.role === "user" ? "text-cyan-100/90" : "text-zinc-300"
line.role === "user" ? "text-foreground" : "text-muted-foreground"
}`}
>
<span className="text-ui-label uppercase tracking-wide text-muted-foreground">
<span className="text-ui-label text-muted-foreground">
{line.role === "user" ? t`You` : (line.speakerName ?? botName)}
</span>
<span dir="auto" className="mt-0.5 block">
@ -263,53 +220,6 @@ export function VoiceStage({
)}
</div>
</div>
<footer className="flex flex-col items-center gap-3 px-6 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-3 md:px-10">
<div className="apple-island flex items-center gap-3 rounded-full px-3 py-2">
<Button
type="button"
variant="outline"
onClick={onInterrupt}
className="tactile-press h-control-md rounded-full border-white/15 bg-white/10 px-6 text-ui-body font-medium text-white hover:bg-white/18"
>
<Trans>Interrupt</Trans>
</Button>
<Button
type="button"
variant="destructive"
onClick={onHangUp}
aria-label={t`Hang up`}
title={t`Hang up`}
className="tactile-press size-control-lg rounded-full bg-rose-500 p-0 text-white shadow-[0_0_24px_rgba(244,63,94,0.45)] hover:bg-rose-600"
>
<PhoneOff strokeWidth={1.8} className="size-icon-lg" />
</Button>
</div>
<p className="flex items-center gap-1 text-ui-caption text-zinc-400">
<Trans>Space interrupts</Trans>
<span aria-hidden>·</span>
<Trans>Esc hangs up</Trans>
<span aria-hidden>·</span>
<Trans>T transcript</Trans>
</p>
</footer>
</div>
</div>
);
}
function personaGlow(accent: BotPersonaProfile["bloomAccent"], energy: number): string {
const alpha = 0.15 + energy * 0.35;
switch (accent) {
case "violet":
return `rgba(167,139,250,${alpha})`;
case "amber":
return `rgba(251,191,36,${alpha})`;
case "rose":
return `rgba(251,113,133,${alpha})`;
case "cyan":
return `rgba(34,211,238,${alpha})`;
default:
return `rgba(52,211,153,${alpha})`;
}
}

View File

@ -0,0 +1,71 @@
/* Voice Stage — aurora rising from the panel's bottom edge.
Positioning lives on the wrapper; these animations only touch the inner
layer so they never fight the energy-driven size. */
@keyframes voice-aurora-breathe {
0%,
100% {
transform: scale(1, 1);
}
50% {
transform: scale(1.08, 1.14);
}
}
@keyframes voice-aurora-drift {
0%,
100% {
transform: translateX(-9%) scale(0.96);
}
50% {
transform: translateX(9%) scale(1.04);
}
}
@keyframes voice-panel-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes voice-word-in {
from {
opacity: 0;
filter: blur(6px);
color: var(--muted-foreground);
}
to {
opacity: 1;
filter: blur(0);
}
}
.voice-aurora-breathe {
animation: voice-aurora-breathe 3.2s ease-in-out infinite;
}
.voice-aurora-drift {
animation: voice-aurora-drift 4.8s ease-in-out infinite;
}
.voice-panel-in {
animation: voice-panel-in 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.voice-word-in {
animation: voice-word-in 420ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.voice-aurora-breathe,
.voice-aurora-drift,
.voice-panel-in,
.voice-word-in {
animation: none;
}
}

View File

@ -0,0 +1,63 @@
import { botColors } from "@rakazo/ui-tokens";
import { describe, expect, it } from "vitest";
import { auroraSpectrum, captionWords, continuesCaption, voiceAuroraBlobs } from "./voice-aurora";
describe("auroraSpectrum", () => {
it("leads with the bot's own color and keeps the whole palette", () => {
const color = botColors[3];
const spectrum = auroraSpectrum(color.toLowerCase());
expect(spectrum[0]).toBe(color);
expect([...spectrum].sort()).toEqual([...botColors].sort());
});
it("puts an unknown color first, ahead of the palette", () => {
expect(auroraSpectrum("#123456")).toEqual(["#123456", ...botColors]);
});
});
describe("voiceAuroraBlobs", () => {
it("centers the bot color and fans out symmetrically", () => {
const blobs = voiceAuroraBlobs(botColors[0], "speaking", 0);
expect(blobs.map((blob) => blob.x)).toEqual([50, 32, 68, 14, 86]);
expect(blobs[0]?.color).toBe(botColors[0]);
expect(blobs[1]?.rise).toBe(blobs[2]?.rise);
expect(blobs[0]?.rise).toBeGreaterThan(blobs[3]?.rise ?? 0);
});
it("rises with mic energy while listening", () => {
const quiet = voiceAuroraBlobs(botColors[0], "listening", 0)[0];
const loud = voiceAuroraBlobs(botColors[0], "listening", 1)[0];
expect(loud?.rise).toBeGreaterThan(quiet?.rise ?? 0);
expect(loud?.opacity).toBeGreaterThan(quiet?.opacity ?? 0);
});
it("ignores energy outside listening and clamps bad input", () => {
expect(voiceAuroraBlobs(botColors[0], "thinking", 1)).toEqual(
voiceAuroraBlobs(botColors[0], "thinking", 0),
);
expect(voiceAuroraBlobs(botColors[0], "listening", 5)).toEqual(
voiceAuroraBlobs(botColors[0], "listening", 1),
);
expect(voiceAuroraBlobs(botColors[0], "listening", Number.NaN)).toEqual(
voiceAuroraBlobs(botColors[0], "listening", 0),
);
});
});
describe("live caption", () => {
it("splits on any whitespace", () => {
expect(captionWords(" Set a\ttimer \n")).toEqual(["Set", "a", "timer"]);
expect(captionWords(" ")).toEqual([]);
});
it("treats appended words and a completing last word as a continuation", () => {
expect(continuesCaption("Set a", "Set a timer for ten")).toBe(true);
expect(continuesCaption("remind me to wat", "remind me to water the plants")).toBe(true);
});
it("restarts for a new utterance", () => {
expect(continuesCaption("", "Hello")).toBe(false);
expect(continuesCaption("Set a timer", "Set a")).toBe(false);
expect(continuesCaption("Set a timer", "Get a timer now")).toBe(false);
});
});

View File

@ -0,0 +1,74 @@
import { botColors } from "@rakazo/ui-tokens";
import type { VoicePhase } from "../components/voice/VoiceStage";
/** One blurred light source rising from the bottom edge of the voice panel. */
export type AuroraBlob = {
color: string;
/** Horizontal center, percent of the panel width. */
x: number;
/** Height the glow reaches up the panel, percent. */
rise: number;
opacity: number;
};
// Center first, then alternating outward, so the speaking Oo's color sits in
// the middle of the arc and the rest of the bot palette fans out around it.
const SLOTS = [
{ x: 50, weight: 1 },
{ x: 32, weight: 0.86 },
{ x: 68, weight: 0.86 },
{ x: 14, weight: 0.64 },
{ x: 86, weight: 0.64 },
] as const;
/** The bot palette rotated so `botColor` leads. Unknown colors lead the full palette. */
export function auroraSpectrum(botColor: string): string[] {
const palette: string[] = [...botColors];
const index = palette.findIndex((color) => color.toLowerCase() === botColor.toLowerCase());
if (index < 0) return [botColor, ...palette];
return [...palette.slice(index), ...palette.slice(0, index)];
}
/**
* Listening follows the mic, thinking settles low while the arc drifts,
* speaking holds a full bloom. Energy is clamped to 0–1.
*/
export function voiceAuroraBlobs(
botColor: string,
phase: VoicePhase,
energy: number,
): AuroraBlob[] {
const level = Math.min(1, Math.max(0, Number.isFinite(energy) ? energy : 0));
const base =
phase === "listening"
? { rise: 44 + level * 40, opacity: 0.72 + level * 0.28 }
: phase === "thinking"
? { rise: 36, opacity: 0.6 }
: { rise: 66, opacity: 0.95 };
const spectrum = auroraSpectrum(botColor);
return SLOTS.map((slot, index) => ({
color: spectrum[index % spectrum.length] ?? botColor,
x: slot.x,
rise: Math.round(base.rise * slot.weight),
opacity: Number((base.opacity * slot.weight).toFixed(3)),
}));
}
/** Words of the live caption; whitespace-only input yields none. */
export function captionWords(text: string): string[] {
return text.trim().split(/\s+/).filter(Boolean);
}
/**
* True when `next` extends `previous` word by word, so the words already on
* screen stay put and only the new tail animates in.
*/
export function continuesCaption(previous: string, next: string): boolean {
const before = captionWords(previous);
const after = captionWords(next);
if (!before.length || after.length < before.length) return false;
// The last spoken word may still be completing ("wat" -> "water").
return before.every((word, index) =>
index === before.length - 1 ? after[index]?.startsWith(word) : after[index] === word,
);
}

View File

@ -173,6 +173,7 @@ import { SkillDraftCard } from "../components/teach/SkillDraftCard";
import { TeachCaptureOverlay } from "../components/teach/TeachCaptureOverlay";
import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOverlay";
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
import { VoiceAuroraRim } from "../components/voice/VoiceAurora";
import { useSidebarResize } from "../hooks/useSidebarResize";
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
@ -4626,6 +4627,7 @@ export function ShellPage() {
: undefined
}
voiceReady={Boolean(voiceStatus?.ready)}
voiceColor={voiceCallBot?.color}
replyTarget={activeReplyTarget}
replyQuote={activeReplyQuote}
replyTargetName={replyTargetName}
@ -6362,6 +6364,7 @@ const Composer = memo(function Composer({
onStop,
onVoice,
voiceReady = false,
voiceColor,
replyTarget,
replyQuote,
replyTargetName,
@ -6389,6 +6392,7 @@ const Composer = memo(function Composer({
onStop: () => Promise<void>;
onVoice?: () => void;
voiceReady?: boolean;
voiceColor?: string;
replyTarget?: ThreadMessage | null;
replyQuote?: string | null;
replyTargetName?: string;
@ -7015,10 +7019,13 @@ const Composer = memo(function Composer({
data-testid="composer-voice-primary"
className={`tactile-press h-control-lg shrink-0 gap-2 rounded-full px-4 sm:px-5 flex items-center justify-center cursor-pointer transition-all ${
voiceReady
? "border-cyan-400/35 bg-gradient-to-b from-cyan-500/20 to-cyan-950/40 text-cyan-50 shadow-[0_0_28px_rgba(34,211,238,0.22),inset_0_1px_0_rgba(255,255,255,0.16)] hover:border-cyan-300/50 hover:from-cyan-400/28 hover:to-cyan-950/50 voice-primary-ready"
: "border-amber-400/25 bg-white/[0.06] text-foreground/85 hover:bg-white/[0.12] hover:text-white"
? "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 ? (
<VoiceAuroraRim botColor={voiceColor} className="inset-x-[14%] h-0.5 blur-[3px]" />
) : null}
<Mic strokeWidth={1.9} className="size-icon-lg shrink-0" />
<span className="hidden text-ui-body font-semibold sm:inline">
<Trans>Voice</Trans>

View File

@ -261,49 +261,6 @@ body,
}
}
/* Voice Living Aura Animations */
@keyframes voiceAuraCyan {
0%,
100% {
transform: scale(1);
opacity: 0.45;
filter: blur(32px);
}
50% {
transform: scale(1.18);
opacity: 0.85;
filter: blur(44px);
}
}
@keyframes voiceAuraViolet {
0%,
100% {
transform: scale(0.95) rotate(0deg);
opacity: 0.5;
filter: blur(34px);
}
50% {
transform: scale(1.12) rotate(180deg);
opacity: 0.85;
filter: blur(48px);
}
}
@keyframes voiceAuraEmerald {
0%,
100% {
transform: scale(1);
opacity: 0.5;
filter: blur(32px);
}
50% {
transform: scale(1.22);
opacity: 0.9;
filter: blur(46px);
}
}
@keyframes orbitalSpin {
0% {
transform: rotate(0deg);
@ -369,7 +326,7 @@ body,
}
}
/* Voice Stage — dual-mind bloom field */
/* Text stage bloom */
@keyframes voiceBloomDrift {
0%,
100% {
@ -382,18 +339,6 @@ body,
}
}
@keyframes voiceBloomSecondary {
0%,
100% {
transform: scale(0.96) translate(0, 0);
opacity: 0.55;
}
50% {
transform: scale(1.1) translate(-2%, 1%);
opacity: 0.82;
}
}
@keyframes onboarding-oo-pop {
0% {
transform: scale(0.92);
@ -409,30 +354,6 @@ body,
}
}
@keyframes voiceGlassRing {
0%,
100% {
transform: scale(1);
opacity: 0.28;
}
50% {
transform: scale(1.04);
opacity: 0.42;
}
}
@keyframes voiceGlassRingInner {
0%,
100% {
transform: scale(1) rotate(0deg);
opacity: 0.22;
}
50% {
transform: scale(0.97) rotate(8deg);
opacity: 0.36;
}
}
@keyframes voicePrimaryPulse {
0%,
100% {
@ -447,46 +368,12 @@ body,
}
}
.voice-primary-ready {
animation: voicePrimaryPulse 3.6s ease-in-out infinite;
}
.voice-bloom-cyan {
animation: voiceBloomDrift 5.2s ease-in-out infinite;
}
.voice-bloom-emerald {
animation: voiceBloomDrift 4.4s ease-in-out infinite;
}
.voice-bloom-violet {
animation: voiceBloomDrift 6s ease-in-out infinite;
}
.voice-bloom-secondary {
animation: voiceBloomSecondary 7.2s ease-in-out infinite;
}
.voice-glass-ring {
animation: voiceGlassRing 8s ease-in-out infinite;
}
.voice-glass-ring-inner {
animation: voiceGlassRingInner 11s ease-in-out infinite;
}
.voice-grain {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
@media (prefers-reduced-motion: reduce) {
.voice-bloom-cyan,
.voice-bloom-emerald,
.voice-bloom-violet,
.voice-bloom-secondary,
.voice-glass-ring,
.voice-glass-ring-inner,
.voice-primary-ready {
.voice-bloom-emerald {
animation: none !important;
}
}