GlobalMicButton drops the state and onClick scaffolding props from phase 8 and now consumes VoiceContext directly — click calls toggleListening(). It also reflects hasQueuedVoice via the idle dot color and an updated aria-label so the assistant-bound queue is observable from any route. CmdKButton replaces the synthetic Meta+K keydown shim with a direct setOpen(true) call on the CommandPaletteContext. The shim comment block is gone; no more document dispatch side effects on click. Tests updated to render inside their respective providers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
80 lines
2.5 KiB
TypeScript
80 lines
2.5 KiB
TypeScript
import { cn } from "@/lib/utils";
|
|
import { useVoice } from "../../context/VoiceContext";
|
|
|
|
/**
|
|
* GlobalMicButton — Phase 14 wiring of the top-strip voice affordance.
|
|
*
|
|
* Consumes `VoiceContext` directly; the `state` and `onClick` scaffolding
|
|
* props from Phase 8 are gone. Click cycles through
|
|
* idle → listening → (on stop) speaking → idle
|
|
* via `VoiceContext.toggleListening()`.
|
|
*
|
|
* Per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2 the three visual
|
|
* states are:
|
|
* - idle: forest-green dot, no animation
|
|
* - listening: volt fill, 1.5s pulse loop + expanding volt ring
|
|
* - speaking: silver fill, no pulse
|
|
*
|
|
* Literal hex `#166534` (forest) and `#a0a0a0` (silver) are used because
|
|
* MIGRATION-PLAN.md §3 proposes these as new CSS variables but has not yet
|
|
* shipped them. Volt uses the `text-primary`/`bg-primary` semantic token.
|
|
*/
|
|
export function GlobalMicButton() {
|
|
const { state, toggleListening, hasQueuedVoice } = useVoice();
|
|
|
|
const label =
|
|
state === "listening"
|
|
? "Voice — listening"
|
|
: state === "speaking"
|
|
? "Voice — processing"
|
|
: hasQueuedVoice
|
|
? "Voice (queued)"
|
|
: "Voice";
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void toggleListening();
|
|
}}
|
|
aria-label={label}
|
|
title={label}
|
|
data-state={state}
|
|
data-queued={hasQueuedVoice ? "true" : undefined}
|
|
className={cn(
|
|
"relative inline-flex h-8 w-8 items-center justify-center rounded-[8px]",
|
|
"border border-border bg-card transition-colors",
|
|
"hover:border-primary",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
)}
|
|
>
|
|
{state === "idle" && (
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"h-2 w-2 rounded-full",
|
|
hasQueuedVoice ? "bg-primary" : "bg-[#166534]",
|
|
)}
|
|
/>
|
|
)}
|
|
{state === "listening" && (
|
|
<>
|
|
<span
|
|
aria-hidden="true"
|
|
className="h-2 w-2 rounded-full bg-primary animate-pulse"
|
|
/>
|
|
<span
|
|
aria-hidden="true"
|
|
className="absolute inset-0 rounded-[8px] border-2 border-primary opacity-60 animate-ping"
|
|
/>
|
|
</>
|
|
)}
|
|
{state === "speaking" && (
|
|
<span
|
|
aria-hidden="true"
|
|
className="h-2 w-2 rounded-full bg-[#a0a0a0]"
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|