diff --git a/ui/src/components/frame/GlobalMicButton.test.tsx b/ui/src/components/frame/GlobalMicButton.test.tsx new file mode 100644 index 00000000..21329dd8 --- /dev/null +++ b/ui/src/components/frame/GlobalMicButton.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { GlobalMicButton } from "./GlobalMicButton"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("GlobalMicButton", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { root!.unmount(); }); + root = null; + } + if (container.parentNode) container.remove(); + }); + + function render(state: "idle" | "listening" | "speaking" = "idle") { + root = createRoot(container); + act(() => { + root!.render(); + }); + return { + getButton: () => container.querySelector("button[aria-label='Voice']") as HTMLButtonElement, + getDataState: () => container.querySelector("button[aria-label='Voice']")?.getAttribute("data-state"), + }; + } + + it("renders a button with aria-label 'Voice' by default", () => { + const { getButton } = render(); + expect(getButton()).not.toBeNull(); + }); + + it("reflects the state prop via data-state", () => { + expect(render("idle").getDataState()).toBe("idle"); + expect(render("listening").getDataState()).toBe("listening"); + expect(render("speaking").getDataState()).toBe("speaking"); + }); + + it("is a no-op on click in Phase 8 (does not throw)", () => { + const { getButton } = render(); + act(() => { + getButton().click(); + }); + expect(getButton()).not.toBeNull(); + }); +}); diff --git a/ui/src/components/frame/GlobalMicButton.tsx b/ui/src/components/frame/GlobalMicButton.tsx new file mode 100644 index 00000000..fd537f80 --- /dev/null +++ b/ui/src/components/frame/GlobalMicButton.tsx @@ -0,0 +1,72 @@ +import { cn } from "@/lib/utils"; + +export type GlobalMicState = "idle" | "listening" | "speaking"; + +interface GlobalMicButtonProps { + state?: GlobalMicState; + /** + * Phase 14 will wire this to the voice pipeline. In Phase 8 it's a no-op + * by default; callers can override for manual testing. + */ + onClick?: () => void; +} + +/** + * GlobalMicButton — Phase 8 visual-only mic button for the top strip. + * + * Per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2, three states are + * specified: + * - idle: forest-green dot, no animation + * - listening: volt fill, 1.5s pulse loop + expanding volt ring + * - speaking: silver fill, no pulse + * + * Phase 8 renders all three but only `idle` is wired up functionally. The + * listening / speaking visuals are scaffolded so Phase 14 can toggle the + * state prop without changing this component's signature. + * + * 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({ state = "idle", onClick }: GlobalMicButtonProps) { + return ( + + ); +}