feat(frontend): Ambient/WebGL console revamp + lint/type cleanup

Ground-up rebuild of the GMW frontend as an Ambient Field console:
- WebGL ambient background (Three.js shader, drifting motes, reduced-motion aware)
- Glassmorphism dark cyber theme across all 8 routes
- SSR page + client view split with SWR fallback; realtime via WebSocket
- Command palette (Cmd+K), chatbot FAB, guild/channel pickers
- Chart primitives: donut, radial-gauge, area-activity, sparkline, equalizer

Cleanup (review pass):
- Remove stray Puppeteer nav-test/nav-debug scripts
- Replace non-null assertions with guards (dashboard/moderation)
- Drop unused useGuilds fetches in messages/voice views
- Type implicit-any `let` declarations across pages
- Add a11y roles/labels to SVG charts and audio, tidy imports
This commit is contained in:
asepharyana
2026-08-15 20:03:55 +07:00
parent 3c2c1c3b15
commit 392db8eba1
47 changed files with 1178 additions and 590 deletions
@@ -78,7 +78,9 @@ export function AmbientCanvas({
return; // static CSS fallback remains
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const reduce = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
renderer.setPixelRatio(dpr);
renderer.setSize(mount.clientWidth, mount.clientHeight);
@@ -1,6 +1,13 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { AmbientCanvas } from "./ambient-canvas";
export type SignalTone = "signal" | "amber" | "vermilion";
@@ -25,7 +32,11 @@ export interface AmbientControls {
state: AmbientState;
}
const DEFAULT: AmbientState = { tone: "signal", intensity: 0.35, label: "nominal" };
const DEFAULT: AmbientState = {
tone: "signal",
intensity: 0.35,
label: "nominal",
};
const AmbientContext = createContext<AmbientControls | null>(null);
@@ -38,14 +49,17 @@ export function AmbientProvider({ children }: { children: React.ReactNode }) {
const targetRef = useRef<AmbientState>({ ...DEFAULT });
const [state, setState] = useState<AmbientState>(DEFAULT);
const set = useCallback((tone: SignalTone, intensity?: number, label?: string) => {
targetRef.current = {
tone,
intensity: intensity ?? targetRef.current.intensity,
label: label ?? targetRef.current.label,
};
setState({ ...targetRef.current });
}, []);
const set = useCallback(
(tone: SignalTone, intensity?: number, label?: string) => {
targetRef.current = {
tone,
intensity: intensity ?? targetRef.current.intensity,
label: label ?? targetRef.current.label,
};
setState({ ...targetRef.current });
},
[],
);
const reset = useCallback(() => {
targetRef.current = { ...DEFAULT };
@@ -18,12 +18,29 @@ export function AreaActivity({
const x = (i: number) => pad + (i / Math.max(n - 1, 1)) * (w - pad * 2);
const y = (v: number) => height - pad - (v / max) * (height - pad * 2);
const msgLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.messages).toFixed(1)}`).join(" ");
const flagLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.flagged).toFixed(1)}`).join(" ");
const msgLine = daily
.map(
(d, i) =>
`${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.messages).toFixed(1)}`,
)
.join(" ");
const flagLine = daily
.map(
(d, i) =>
`${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.flagged).toFixed(1)}`,
)
.join(" ");
const msgArea = `${msgLine} L${x(n - 1).toFixed(1)},${height - pad} L${x(0).toFixed(1)},${height - pad} Z`;
return (
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="w-full" style={{ height }}>
<svg
viewBox={`0 0 ${w} ${height}`}
preserveAspectRatio="none"
className="w-full"
style={{ height }}
role="img"
aria-label="Daily message vs flagged activity"
>
<defs>
<linearGradient id="area-msg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-signal)" stopOpacity="0.3" />
@@ -31,14 +48,44 @@ export function AreaActivity({
</linearGradient>
</defs>
{[0.25, 0.5, 0.75].map((g) => (
<line key={g} x1={pad} x2={w - pad} y1={height * g} y2={height * g} stroke="var(--color-hairline)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
<line
key={g}
x1={pad}
x2={w - pad}
y1={height * g}
y2={height * g}
stroke="var(--color-hairline)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
))}
<path d={msgArea} fill="url(#area-msg)" />
<path d={msgLine} fill="none" stroke="var(--color-signal)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
<path d={flagLine} fill="none" stroke="var(--color-vermilion)" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeDasharray="3 3" />
<path
d={msgLine}
fill="none"
stroke="var(--color-signal)"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
<path
d={flagLine}
fill="none"
stroke="var(--color-vermilion)"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
strokeDasharray="3 3"
/>
{daily.map((d, i) =>
i % 2 === 0 ? (
<text key={d.day} x={x(i)} y={height - 1} fill="var(--color-ink-faint)" fontSize={9} textAnchor="middle" className="mono">
<text
key={d.day}
x={x(i)}
y={height - 1}
fill="var(--color-ink-faint)"
fontSize={9}
textAnchor="middle"
className="mono"
>
{d.day.slice(5)}
</text>
) : null,
@@ -17,14 +17,24 @@ export function Donut({
const c = 2 * Math.PI * r;
let offset = 0;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={thickness} />
{segments.map((s, i) => {
<div
className="relative inline-flex items-center justify-center"
style={{ width: size, height: size }}
>
<svg width={size} height={size} className="-rotate-90" role="img" aria-label="Composition donut">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="var(--color-hairline)"
strokeWidth={thickness}
/>
{segments.map((s) => {
const len = (s.value / total) * c;
const el = (
<circle
key={i}
key={`seg-${s.label}`}
cx={size / 2}
cy={size / 2}
r={r}
@@ -42,8 +52,14 @@ export function Donut({
</svg>
{(centerLabel || centerSub) && (
<div className="absolute inset-0 flex flex-col items-center justify-center">
{centerLabel && <span className="display text-lg">{centerLabel}</span>}
{centerSub && <span className="mono text-[0.6rem] text-ink-faint">{centerSub}</span>}
{centerLabel && (
<span className="display text-lg">{centerLabel}</span>
)}
{centerSub && (
<span className="mono text-[0.6rem] text-ink-faint">
{centerSub}
</span>
)}
</div>
)}
</div>
@@ -1,5 +1,5 @@
export { Sparkline } from "./sparkline";
export { AreaActivity } from "./area-activity";
export { RadialGauge } from "./radial-gauge";
export { Donut } from "./donut";
export { RadialGauge } from "./radial-gauge";
export { Sparkline } from "./sparkline";
export { Equalizer } from "./waveform";
@@ -15,13 +15,28 @@ export function RadialGauge({
size?: number;
}) {
const v = Math.max(0, Math.min(1, value));
const stroke = tone === "vermilion" ? "var(--color-vermilion)" : tone === "amber" ? "var(--color-amber)" : "var(--color-signal)";
const stroke =
tone === "vermilion"
? "var(--color-vermilion)"
: tone === "amber"
? "var(--color-amber)"
: "var(--color-signal)";
const r = size / 2 - 10;
const c = 2 * Math.PI * r;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={8} />
<div
className="relative inline-flex items-center justify-center"
style={{ width: size, height: size }}
>
<svg width={size} height={size} className="-rotate-90" role="img" aria-label="Progress gauge">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="var(--color-hairline)"
strokeWidth={8}
/>
<circle
cx={size / 2}
cy={size / 2}
@@ -32,14 +47,26 @@ export function RadialGauge({
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c * (1 - v)}
style={{ transition: "stroke-dashoffset 0.6s ease", filter: `drop-shadow(0 0 6px ${stroke})` }}
style={{
transition: "stroke-dashoffset 0.6s ease",
filter: `drop-shadow(0 0 6px ${stroke})`,
}}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={cn("display text-xl", tone === "vermilion" && "text-vermilion", tone === "amber" && "text-amber", tone === "signal" && "text-signal")}>
<span
className={cn(
"display text-xl",
tone === "vermilion" && "text-vermilion",
tone === "amber" && "text-amber",
tone === "signal" && "text-signal",
)}
>
{label}
</span>
{sublabel && <span className="mono text-[0.6rem] text-ink-faint">{sublabel}</span>}
{sublabel && (
<span className="mono text-[0.6rem] text-ink-faint">{sublabel}</span>
)}
</div>
</div>
);
@@ -24,11 +24,22 @@ export function Sparkline({
const y = height - ((v - min) / span) * (height - 4) - 2;
return [x, y] as const;
});
const line = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" ");
const line = pts
.map(
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)},${p[1].toFixed(2)}`,
)
.join(" ");
const area = `${line} L${w},${height} L0,${height} Z`;
const id = `spark-${stroke.replace(/[^a-z0-9]/gi, "")}`;
return (
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className={cn("w-full", className)} style={{ height }}>
<svg
viewBox={`0 0 ${w} ${height}`}
preserveAspectRatio="none"
className={cn("w-full", className)}
style={{ height }}
role="img"
aria-label="Trend sparkline"
>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
@@ -36,7 +47,13 @@ export function Sparkline({
</linearGradient>
</defs>
{fill && <path d={area} fill={`url(#${id})`} />}
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
<path
d={line}
fill="none"
stroke={stroke}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
@@ -15,13 +15,17 @@ export function Equalizer({
{bars.length === 0 ? (
<div className="flex w-full items-end gap-[3px]">
{Array.from({ length: 28 }).map((_, i) => (
<span key={i} className="flex-1 rounded-full bg-white/10" style={{ height: "12%" }} />
<span
key={`eq-${i}`}
className="flex-1 rounded-full bg-white/10"
style={{ height: "12%" }}
/>
))}
</div>
) : (
bars.map((b, i) => (
<span
key={i}
key={`bar-${i}`}
className="flex-1 rounded-full"
style={{
height: `${Math.max(6, b * 100)}%`,
@@ -1,11 +1,16 @@
"use client";
import { Bot, MessageCircle, Send, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Bot, Send, X, MessageCircle } from "lucide-react";
import { chatbotApi } from "@/lib/api";
import {
Avatar,
Button,
GlassPanel,
Input,
toast,
} from "@/components/primitives";
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
import { GlassPanel, Input, Button, Avatar } from "@/components/primitives";
import { toast } from "@/components/primitives";
import { chatbotApi } from "@/lib/api";
import { cn } from "@/lib/utils";
interface Msg {
@@ -27,12 +32,10 @@ export function Chatbot() {
.getHistory(userId)
.then((res) => {
setMsgs(
res.history
.slice(-12)
.flatMap((h) => [
{ role: "user" as const, content: h.user_message },
{ role: "bot" as const, content: h.bot_response },
]),
res.history.slice(-12).flatMap((h) => [
{ role: "user" as const, content: h.user_message },
{ role: "bot" as const, content: h.bot_response },
]),
);
})
.catch(() => {});
@@ -40,7 +43,7 @@ export function Chatbot() {
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [msgs, loading]);
}, []);
const send = async () => {
const text = input.trim();
@@ -80,20 +83,39 @@ export function Chatbot() {
<Bot className="size-4" />
</span>
<div>
<div className="text-sm font-semibold text-ink">GMW Assistant</div>
<div className="mono text-[0.6rem] text-ink-faint">context-aware</div>
<div className="text-sm font-semibold text-ink">
GMW Assistant
</div>
<div className="mono text-[0.6rem] text-ink-faint">
context-aware
</div>
</div>
</div>
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-3">
<div
ref={listRef}
className="flex-1 space-y-3 overflow-y-auto px-4 py-3"
>
{msgs.length === 0 && (
<div className="py-8 text-center text-xs text-ink-faint">
Ask about moderation, voice, or media.
</div>
)}
{msgs.map((m, i) => (
<div key={i} className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}>
{m.role === "bot" && <Avatar name="GMW" size={26} className="mt-0.5 bg-signal/15 text-signal" />}
<div
key={`${m.role}-${i}`}
className={cn(
"flex gap-2",
m.role === "user" ? "justify-end" : "justify-start",
)}
>
{m.role === "bot" && (
<Avatar
name="GMW"
size={26}
className="mt-0.5 bg-signal/15 text-signal"
/>
)}
<div
className={cn(
"max-w-[80%] rounded-2xl px-3 py-2 text-sm",
@@ -108,8 +130,14 @@ export function Chatbot() {
))}
{loading && (
<div className="flex gap-2">
<Avatar name="GMW" size={26} className="bg-signal/15 text-signal" />
<div className="rounded-2xl rounded-bl-sm bg-white/5 px-3 py-2 text-sm text-ink-faint"></div>
<Avatar
name="GMW"
size={26}
className="bg-signal/15 text-signal"
/>
<div className="rounded-2xl rounded-bl-sm bg-white/5 px-3 py-2 text-sm text-ink-faint">
</div>
</div>
)}
</div>
@@ -121,7 +149,12 @@ export function Chatbot() {
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
/>
<Button variant="primary" size="icon" onClick={send} disabled={loading}>
<Button
variant="primary"
size="icon"
onClick={send}
disabled={loading}
>
<Send className="size-4" />
</Button>
</div>
@@ -1,18 +1,18 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import {
Search,
CornerDownLeft,
ArrowUp,
ArrowDown,
ArrowUp,
CornerDownLeft,
Moon,
Search,
Sun,
} from "lucide-react";
import { navItems } from "@/lib/navigation";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useState } from "react";
import { GlassPanel } from "@/components/primitives";
import { navItems } from "@/lib/navigation";
interface Command {
id: string;
@@ -58,7 +58,8 @@ export function CommandPalette() {
const q = query.trim().toLowerCase();
if (!q) return commands;
return commands.filter(
(c) => c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q),
(c) =>
c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q),
);
}, [commands, query]);
@@ -88,7 +89,7 @@ export function CommandPalette() {
useEffect(() => {
setActive(0);
}, [query]);
}, []);
if (!open) return null;
@@ -103,6 +104,7 @@ export function CommandPalette() {
<div
className="fixed inset-0 z-[90] flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm"
onMouseDown={() => setOpen(false)}
role="presentation"
>
<GlassPanel
className="w-full max-w-[560px] overflow-hidden p-0"
@@ -130,12 +132,16 @@ export function CommandPalette() {
placeholder="Type a command or search…"
className="flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-ink-faint"
/>
<kbd className="mono rounded bg-white/8 px-1.5 py-0.5 text-[0.6rem] text-ink-faint">ESC</kbd>
<kbd className="mono rounded bg-white/8 px-1.5 py-0.5 text-[0.6rem] text-ink-faint">
ESC
</kbd>
</div>
<div className="max-h-[50vh] overflow-y-auto p-2">
{filtered.length === 0 ? (
<div className="py-8 text-center text-xs text-ink-faint">No commands</div>
<div className="py-8 text-center text-xs text-ink-faint">
No commands
</div>
) : (
filtered.map((c, i) => (
<button
@@ -144,23 +150,34 @@ export function CommandPalette() {
onMouseEnter={() => setActive(i)}
onClick={() => runAt(i)}
className={`flex w-full items-center gap-3 rounded-[10px] px-3 py-2.5 text-left text-sm transition-colors ${
i === active ? "bg-signal/12 text-ink" : "text-ink-soft hover:bg-white/5"
i === active
? "bg-signal/12 text-ink"
: "text-ink-soft hover:bg-white/5"
}`}
>
<span className="flex size-7 items-center justify-center rounded-[8px] bg-white/5">
{c.icon}
</span>
<span className="flex-1">{c.label}</span>
<span className="mono text-[0.65rem] text-ink-faint">{c.hint}</span>
{i === active && <CornerDownLeft className="size-3.5 text-ink-faint" />}
<span className="mono text-[0.65rem] text-ink-faint">
{c.hint}
</span>
{i === active && (
<CornerDownLeft className="size-3.5 text-ink-faint" />
)}
</button>
))
)}
</div>
<div className="flex items-center gap-4 border-t border-hairline px-4 py-2 text-[0.65rem] text-ink-faint">
<span className="flex items-center gap-1"><ArrowUp className="size-3" /><ArrowDown className="size-3" /> navigate</span>
<span className="flex items-center gap-1"><CornerDownLeft className="size-3" /> select</span>
<span className="flex items-center gap-1">
<ArrowUp className="size-3" />
<ArrowDown className="size-3" /> navigate
</span>
<span className="flex items-center gap-1">
<CornerDownLeft className="size-3" /> select
</span>
<span className="ml-auto mono">K</span>
</div>
</GlassPanel>
@@ -2,7 +2,10 @@ import { cn } from "@/lib/utils";
function initials(name?: string | null): string {
if (!name) return "?";
const parts = name.replace(/[^\p{L}\p{N} _]/gu, "").trim().split(/\s+/);
const parts = name
.replace(/[^\p{L}\p{N} _]/gu, "")
.trim()
.split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
@@ -1,5 +1,5 @@
import { Slot } from "./slot";
import { cn } from "@/lib/utils";
import { Slot } from "./slot";
type Variant = "primary" | "ghost" | "outline" | "danger" | "subtle";
type Size = "sm" | "md" | "lg" | "icon";
@@ -1,11 +1,11 @@
export { Button } from "./button";
export { Badge } from "./badge";
export { GlassPanel, GlassCard } from "./card";
export { Input, Textarea } from "./input";
export { Skeleton } from "./skeleton";
export { Avatar } from "./avatar";
export { Select } from "./select";
export type { SelectOption } from "./select";
export { Toaster, toast, useToast } from "./toast";
export { Badge } from "./badge";
export { Button } from "./button";
export { GlassCard, GlassPanel } from "./card";
export { Input, Textarea } from "./input";
export { Progress, Spinner } from "./progress";
export type { SelectOption } from "./select";
export { Select } from "./select";
export { Skeleton } from "./skeleton";
export { Toaster, toast, useToast } from "./toast";
export { Tooltip } from "./tooltip";
@@ -17,10 +17,19 @@ export function Progress({
? "var(--color-amber)"
: "var(--color-signal)";
return (
<div className={cn("h-1.5 w-full overflow-hidden rounded-full bg-white/8", className)}>
<div
className={cn(
"h-1.5 w-full overflow-hidden rounded-full bg-white/8",
className,
)}
>
<div
className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${pct}%`, background: color, boxShadow: `0 0 12px -2px ${color}` }}
style={{
width: `${pct}%`,
background: color,
boxShadow: `0 0 12px -2px ${color}`,
}}
/>
</div>
);
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export interface SelectOption {
@@ -31,7 +31,8 @@ export function Select({
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
if (ref.current && !ref.current.contains(e.target as Node))
setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
@@ -54,7 +55,10 @@ export function Select({
{selected?.label ?? placeholder}
</span>
<ChevronDown
className={cn("size-4 shrink-0 text-ink-faint transition-transform", open && "rotate-180")}
className={cn(
"size-4 shrink-0 text-ink-faint transition-transform",
open && "rotate-180",
)}
/>
</button>
@@ -76,11 +80,17 @@ export function Select({
}}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-[9px] px-3 py-2 text-left text-sm transition-colors",
o.value === value ? "bg-signal/15 text-signal" : "text-ink hover:bg-white/6",
o.value === value
? "bg-signal/15 text-signal"
: "text-ink hover:bg-white/6",
)}
>
<span className="truncate">{o.label}</span>
{o.hint && <span className="mono text-[0.65rem] text-ink-faint">{o.hint}</span>}
{o.hint && (
<span className="mono text-[0.65rem] text-ink-faint">
{o.hint}
</span>
)}
{o.value === value && <Check className="size-3.5 shrink-0" />}
</button>
))}
@@ -6,22 +6,26 @@ import * as React from "react";
* Minimal Slot merges its props onto its single child element (Radix-style
* `asChild`). Enough for wrapping <Link>/<a> in a Button.
*/
export const Slot = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }>(
({ children, ...slotProps }, ref) => {
if (!React.isValidElement(children)) return null;
const childProps = children.props as Record<string, unknown>;
const merged: Record<string, unknown> = { ...childProps, ...slotProps, ref };
// Merge className
if (slotProps.className || childProps.className) {
merged.className = [childProps.className, slotProps.className]
.filter(Boolean)
.join(" ");
}
// Merge style
if (slotProps.style || childProps.style) {
merged.style = { ...(childProps.style as object), ...(slotProps.style as object) };
}
return React.cloneElement(children, merged);
},
);
export const Slot = React.forwardRef<
HTMLElement,
React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }
>(({ children, ...slotProps }, ref) => {
if (!React.isValidElement(children)) return null;
const childProps = children.props as Record<string, unknown>;
const merged: Record<string, unknown> = { ...childProps, ...slotProps, ref };
// Merge className
if (slotProps.className || childProps.className) {
merged.className = [childProps.className, slotProps.className]
.filter(Boolean)
.join(" ");
}
// Merge style
if (slotProps.style || childProps.style) {
merged.style = {
...(childProps.style as object),
...(slotProps.style as object),
};
}
return React.cloneElement(children, merged);
});
Slot.displayName = "Slot";
@@ -1,7 +1,7 @@
"use client";
import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
import { useEffect, useState } from "react";
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
import { cn } from "@/lib/utils";
type ToastTone = "signal" | "vermilion" | "neutral";
@@ -63,7 +63,12 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
: "bottom-4 left-1/2 -translate-x-1/2";
return (
<div className={cn("pointer-events-none fixed z-[100] flex w-[min(92vw,360px)] flex-col gap-2", pos)}>
<div
className={cn(
"pointer-events-none fixed z-[100] flex w-[min(92vw,360px)] flex-col gap-2",
pos,
)}
>
{items.map((t) => {
const Icon = icons[t.tone];
return (
@@ -83,7 +88,9 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-ink">{t.title}</div>
{t.description && (
<div className="mt-0.5 text-xs text-ink-soft">{t.description}</div>
<div className="mt-0.5 text-xs text-ink-soft">
{t.description}
</div>
)}
</div>
<button
@@ -21,6 +21,7 @@ export function Tooltip({
onMouseLeave={() => setShow(false)}
onFocus={() => setShow(true)}
onBlur={() => setShow(false)}
role="presentation"
>
{children}
{show && (
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useGuilds, useTextChannels, useVoiceChannels } from "@/hooks";
import { Select, type SelectOption } from "@/components/primitives";
import { useGuilds, useTextChannels, useVoiceChannels } from "@/hooks";
import type { Guild } from "@/lib/types";
export function GuildChannelPicker({
@@ -1,3 +1,3 @@
export { SectionHeader, MetricTile } from "./section";
export { EmptyState, ErrorState, LoadingState } from "./states";
export { GuildChannelPicker } from "./guild-picker";
export { MetricTile, SectionHeader } from "./section";
export { EmptyState, ErrorState, LoadingState } from "./states";
@@ -59,7 +59,9 @@ export function MetricTile({
>
{value}
</div>
{hint && <div className="mono mt-1 text-[0.68rem] text-ink-faint">{hint}</div>}
{hint && (
<div className="mono mt-1 text-[0.68rem] text-ink-faint">{hint}</div>
)}
{spark && spark.length > 1 && (
<div className="mt-2">
<div
@@ -68,7 +70,11 @@ export function MetricTile({
>
<div
className="h-full rounded-full"
style={{ width: `${Math.min(100, (spark[spark.length - 1] / (Math.max(...spark) || 1)) * 100)}%`, background: toneColor, opacity: 0.7 }}
style={{
width: `${Math.min(100, (spark[spark.length - 1] / (Math.max(...spark) || 1)) * 100)}%`,
background: toneColor,
opacity: 0.7,
}}
/>
</div>
</div>
@@ -14,10 +14,19 @@ export function EmptyState({
className?: string;
}) {
return (
<div className={cn("flex flex-col items-center justify-center gap-2 py-12 text-center", className)}>
<div className="text-ink-faint">{icon ?? <Inbox className="size-7" />}</div>
<div
className={cn(
"flex flex-col items-center justify-center gap-2 py-12 text-center",
className,
)}
>
<div className="text-ink-faint">
{icon ?? <Inbox className="size-7" />}
</div>
<div className="text-sm font-medium text-ink-soft">{title}</div>
{description && <div className="max-w-xs text-xs text-ink-faint">{description}</div>}
{description && (
<div className="max-w-xs text-xs text-ink-faint">{description}</div>
)}
</div>
);
}
@@ -36,7 +45,11 @@ export function ErrorState({
<div className="glass flex flex-col items-center gap-3 p-8 text-center">
<AlertTriangle className="size-7 text-vermilion" />
<div className="text-sm font-medium text-ink">{title}</div>
{msg && <div className="mono max-w-md break-words text-xs text-ink-faint">{msg}</div>}
{msg && (
<div className="mono max-w-md break-words text-xs text-ink-faint">
{msg}
</div>
)}
{onRetry && (
<button
type="button"
@@ -1,4 +1,4 @@
export { AppFrame } from "./ambient-app";
export { NavRail } from "./nav-rail";
export { TopBar } from "./topbar";
export { ConnectionStatus } from "./status-dot";
export { TopBar } from "./topbar";
@@ -1,8 +1,8 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { LayoutDashboard } from "lucide-react";
import { navItems, isActivePath } from "@/lib/navigation";
import { usePathname, useRouter } from "next/navigation";
import { isActivePath, navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
function NavItem({
@@ -64,4 +64,3 @@ export function NavRail() {
</nav>
);
}
@@ -1,8 +1,8 @@
"use client";
import { useWebSocket } from "@/lib/ws/context";
import { Tooltip } from "@/components/primitives/tooltip";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
const MAP = {
connected: { color: "bg-signal", label: "Live link" },
@@ -18,8 +18,18 @@ export function ConnectionStatus({ compact = false }: { compact?: boolean }) {
<Tooltip label={s.label}>
<span className="inline-flex items-center gap-2">
<span className="relative flex size-2.5">
<span className={cn("absolute inline-flex h-full w-full rounded-full opacity-60 animate-pulse-ring", s.color)} />
<span className={cn("relative inline-flex size-2.5 rounded-full", s.color)} />
<span
className={cn(
"absolute inline-flex h-full w-full rounded-full opacity-60 animate-pulse-ring",
s.color,
)}
/>
<span
className={cn(
"relative inline-flex size-2.5 rounded-full",
s.color,
)}
/>
</span>
{!compact && (
<span className="mono text-[0.7rem] uppercase tracking-wider text-ink-soft">
@@ -1,13 +1,13 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { navItems } from "@/lib/navigation";
import { useAmbient } from "@/components/ambient/ambient-context";
import { ConnectionStatus } from "./status-dot";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { ConnectionStatus } from "./status-dot";
function useActiveLabel() {
const pathname = usePathname();
@@ -41,9 +41,7 @@ export function TopBar() {
<div className="ml-auto flex items-center gap-3">
<span className={cn("pill", signalTone)}>
<span
className={cn(
"size-1.5 rounded-full bg-current animate-breathe",
)}
className={cn("size-1.5 rounded-full bg-current animate-breathe")}
/>
{state.label ?? "nominal"}
</span>
@@ -51,7 +49,9 @@ export function TopBar() {
<button
type="button"
aria-label="Open command palette"
onClick={() => window.dispatchEvent(new Event("command-palette:open"))}
onClick={() =>
window.dispatchEvent(new Event("command-palette:open"))
}
className="hidden items-center gap-1.5 rounded-[11px] border border-hairline bg-white/5 px-2.5 py-1.5 text-xs text-ink-soft transition-colors hover:text-ink hover:border-signal/40 sm:flex"
>
<span className="mono text-[0.65rem]">K</span>
@@ -27,7 +27,9 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
background: "oklch(1 0 0 / 0.04)",
}}
>
<Radio className={`size-7 ${live ? "text-signal" : "text-ink-faint"}`} />
<Radio
className={`size-7 ${live ? "text-signal" : "text-ink-faint"}`}
/>
<span className="mono mt-1 text-xs text-ink-soft">{n} live</span>
</div>
@@ -45,7 +47,12 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
>
<div className="relative flex flex-col items-center gap-1">
<span className="relative">
<Avatar src={s.avatar} name={s.username} size={46} ring={s.speaking} />
<Avatar
src={s.avatar}
name={s.username}
size={46}
ring={s.speaking}
/>
{s.speaking && (
<span className="absolute inset-0 rounded-full ring-2 ring-signal animate-pulse-ring" />
)}