"use client";
import * as React from "react";
import { AsciiOrb, type AsciiOrbState, type AsciiOrbCharset } from "@/components/ui/ascii-voice-orb";
// Each preset has a light tint for dark backgrounds and a deep tone for light ones.
const COLORS: Record<string, { dark: string; light: string }> = {
Ice: { dark: "#a3d6ff", light: "#195b91" },
Amber: { dark: "#ffc775", light: "#975700" },
Mint: { dark: "#93f0ca", light: "#0b7652" },
Lilac: { dark: "#c9b6ff", light: "#5a3dc2" },
Graphite: { dark: "#eceff3", light: "#1a1d23" },
};
const SEG_BTN =
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-sm";
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="w-20 shrink-0 text-xs text-muted-foreground">{label}</span>
{children}
</div>
);
}
function Segmented<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: { value: T; label: React.ReactNode }[];
onChange: (v: T) => void;
}) {
return (
<Row label={label}>
<div role="group" aria-label={label} className="flex flex-wrap gap-0.5 rounded-lg bg-muted p-0.5">
{options.map((o) => (
<button key={o.value} type="button" aria-pressed={value === o.value} onClick={() => onChange(o.value)} className={SEG_BTN}>
{o.label}
</button>
))}
</div>
</Row>
);
}
function Slider({
label,
value,
min,
max,
step,
onChange,
format = (v) => v.toFixed(2),
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (v: number) => void;
format?: (v: number) => string;
}) {
return (
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-20 shrink-0">{label}</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-32 max-w-[180px] flex-1 accent-primary"
/>
<span className="w-10 font-mono tabular-nums">{format(value)}</span>
</label>
);
}
function Group({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex min-w-0 flex-col gap-2.5">
<h3 className="mb-0.5 border-b border-border pb-1.5 font-mono text-[11px] font-semibold uppercase tracking-[.12em] text-muted-foreground">{title}</h3>
{children}
</section>
);
}
export default function AsciiOrbDemo() {
const [state, setState] = React.useState<AsciiOrbState>("speaking");
const [theme, setTheme] = React.useState<"dark" | "light">("dark");
const [preset, setPreset] = React.useState("Ice");
const [charset, setCharset] = React.useState<AsciiOrbCharset>("classic");
const [fontSize, setFontSize] = React.useState(11);
const [speed, setSpeed] = React.useState(1);
const [level, setLevel] = React.useState(0);
const [simulate, setSimulate] = React.useState(true);
const [open, setOpen] = React.useState(true);
// Fake voice envelope: phrases made of syllables. In a real app, feed `level` from an AnalyserNode.
const active = state === "listening" || state === "speaking";
React.useEffect(() => {
if (!simulate || !active) return;
let raf = 0;
const start = performance.now();
const tick = (now: number) => {
const t = (now - start) / 1000;
const phrase = (Math.sin(t * 0.7) + 1) / 2;
const syl = Math.max(0, Math.sin(t * 6.2)) * 0.7 + Math.max(0, Math.sin(t * 9.1 + 1)) * 0.3;
setLevel(Math.round(phrase * syl * 100) / 100);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [simulate, active]);
const dark = theme === "dark";
const color = dark ? COLORS[preset].dark : COLORS[preset].light;
return (
<div className="flex h-full min-h-[600px] w-full flex-col overflow-hidden bg-background text-foreground">
{/* Stage */}
<div
className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6 transition-colors duration-300"
style={{ background: dark ? "#0b0d11" : "#ffffff" }}
>
<AsciiOrb
state={state}
level={level}
color={color}
charset={charset}
fontSize={fontSize}
speed={speed}
size="min(380px, 70vw, 46vh)"
glow={dark ? 0.5 : 0}
/>
</div>
{/* Controls */}
<div className="max-h-[60vh] shrink-0 overflow-y-auto border-t border-border px-4 pb-3 pt-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-bold">
ASCII Voice Orb <span className="ml-2 font-mono text-[11px] font-normal text-muted-foreground">demo controls</span>
</div>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="rounded-lg border border-border bg-muted px-2.5 py-1.5 text-xs font-semibold transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{open ? "Hide controls" : "Show controls"}
</button>
</div>
{open && (
<div className="grid grid-cols-1 gap-x-7 gap-y-4 md:grid-cols-3">
<Group title="Preview">
<Segmented
label="State"
value={state}
onChange={setState}
options={[
{ value: "idle", label: "Idle" },
{ value: "listening", label: "Listening" },
{ value: "thinking", label: "Thinking" },
{ value: "speaking", label: "Speaking" },
]}
/>
<Slider
label="Voice level"
value={level}
min={0}
max={1}
step={0.01}
onChange={(v) => {
setSimulate(false);
setLevel(v);
}}
/>
<label className="flex items-center gap-1.5 pl-[5.5rem] text-xs text-muted-foreground">
<input type="checkbox" checked={simulate} onChange={(e) => setSimulate(e.target.checked)} className="accent-primary" />
Simulate speech
</label>
<Segmented
label="Theme"
value={theme}
onChange={setTheme}
options={[
{ value: "dark", label: <><span className="size-3 rounded-full border border-border" style={{ background: "#0b0d11" }} />Dark</> },
{ value: "light", label: <><span className="size-3 rounded-full border border-border" style={{ background: "#ffffff" }} />Light</> },
]}
/>
</Group>
<Group title="Appearance">
<Segmented
label="Color"
value={preset}
onChange={setPreset}
options={Object.entries(COLORS).map(([name, c]) => ({
value: name,
label: (
<span
role="img"
aria-label={name}
title={name}
className="block size-3.5 rounded-full border border-border"
style={{ background: dark ? c.dark : c.light }}
/>
),
}))}
/>
<Segmented
label="Chars"
value={charset}
onChange={setCharset}
options={[
{ value: "classic", label: "Classic" },
{ value: "dense", label: "Dense" },
{ value: "binary", label: "Binary" },
]}
/>
<Slider label="Char size" value={fontSize} min={7} max={14} step={1} onChange={setFontSize} format={(v) => `${v}px`} />
</Group>
<Group title="Motion">
<Slider label="Speed" value={speed} min={0.3} max={2} step={0.1} onChange={setSpeed} format={(v) => v.toFixed(1) + "×"} />
</Group>
</div>
)}
</div>
</div>
);
}