Merge origin/main into claude/phase1-main-agent

Brings in the aurora voice stage so the Phase 1 workstreams integrate on current main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-02 20:59:40 +00:00
commit 8cfbca51b2
No known key found for this signature in database
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;
}
}