+
+
- ) : null}
-
-
-
- {phase === "thinking" && !reducedMotion ? (
-
- ) : null}
-
-
-
-
-
-
- {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 (
-
- );
- })}
-
-
-
-
-
-
-
- {subtitle}
+
+ {error ? (
+
+ {error}
- {error ? (
-
{error}
- ) : null}
-
+ ) : null}
-
-
- {transcriptLines.length ? (
-
- {transcriptLines.map((line, index) => (
- -
-
- {line.role === "user" ? t`You` : (line.speakerName ?? botName)}
-
-
- {line.text}
-
-
- ))}
-
- ) : (
-
- Your conversation will appear here.
-
- )}
-
+
+
+ {phaseLabel}
+
+
+
+
+
-
+
+
+ {transcriptLines.length ? (
+
+ {transcriptLines.map((line, index) => (
+ -
+
+ {line.role === "user" ? t`You` : (line.speakerName ?? botName)}
+
+
+ {line.text}
+
+
+ ))}
+
+ ) : (
+
+ Your conversation will appear here.
+
+ )}
+
);
}
-
-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})`;
- }
-}
diff --git a/apps/web/src/components/voice/voice-aurora.css b/apps/web/src/components/voice/voice-aurora.css
new file mode 100644
index 0000000..a13b019
--- /dev/null
+++ b/apps/web/src/components/voice/voice-aurora.css
@@ -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;
+ }
+}
diff --git a/apps/web/src/lib/voice-aurora.test.ts b/apps/web/src/lib/voice-aurora.test.ts
new file mode 100644
index 0000000..dd641d8
--- /dev/null
+++ b/apps/web/src/lib/voice-aurora.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/lib/voice-aurora.ts b/apps/web/src/lib/voice-aurora.ts
new file mode 100644
index 0000000..a855719
--- /dev/null
+++ b/apps/web/src/lib/voice-aurora.ts
@@ -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,
+ );
+}
diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx
index 6d0ba02..ed00d18 100644
--- a/apps/web/src/pages/Shell.tsx
+++ b/apps/web/src/pages/Shell.tsx
@@ -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
;
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 ? (
+
+ ) : null}
Voice
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 564ba06..9190132 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -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;
}
}