feat(nexus): add GlobalMicButton scaffold for layout overhaul (phase 8)
Visual-only mic button for the top strip per docs/specs/2026-04-11-nexus-layout-overhaul.md §4.2. Renders three specified states (idle / listening / speaking) but Phase 8 only wires the idle state functionally. Phase 14 will toggle the state prop from the voice pipeline without changing this component's signature. Uses text-primary/bg-primary for volt (already migrated in phases 1-3) and literal #166534 / #a0a0a0 for forest and silver, which MIGRATION-PLAN.md §3 proposes as new semantic tokens that have not yet shipped. Part of Phase 8 of the Nexus layout overhaul (task 4 of 7). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c1647e70d7
commit
bfcdf1f598
2 changed files with 130 additions and 0 deletions
58
ui/src/components/frame/GlobalMicButton.test.tsx
Normal file
58
ui/src/components/frame/GlobalMicButton.test.tsx
Normal file
|
|
@ -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<typeof createRoot> | 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(<GlobalMicButton state={state} />);
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
72
ui/src/components/frame/GlobalMicButton.tsx
Normal file
72
ui/src/components/frame/GlobalMicButton.tsx
Normal file
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label="Voice"
|
||||
title="Voice (Phase 14 — not yet wired)"
|
||||
data-state={state}
|
||||
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="h-2 w-2 rounded-full 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue