feat(frontend): rebuild as Ambient/WebGL console with all pages + command palette

Ground-up rombak UI: hapus semua component/page lama, bangun ulang dengan
desain sistem Ambient (WebGL haze + drifting motes, signal-driven color)
di atas kontrak API/WS/type yang sudah ada.

- Design system: globals.css tokens + primitives (glass, button, badge,
  select, avatar, toast, chart SVG murni).
- Shell: nav rail, topbar (status WS + pill signal + theme), AppFrame.
- 8 halaman: dashboard, voice (orbital stage), media, messages (live feed +
  detail AI), moderation, analysis (search), recordings, + chatbot floating.
- Command palette (Cmd/Ctrl+K) untuk navigasi cepat.
- Server fetch di-page di-try/catch agar render graceful saat backend mati.

Verified: tsc clean, next build 8/8 halaman, semua route 200.
This commit is contained in:
asepharyana
2026-08-15 17:53:48 +07:00
parent b98101c576
commit 1b56212d1a
104 changed files with 2905 additions and 7048 deletions
@@ -1,38 +1,51 @@
import { cn } from "@/lib/utils";
export interface AvatarProps {
src?: string | null;
name?: string | null;
size?: number;
className?: string;
}
function initials(name?: string | null): string {
if (!name) return "?";
const parts = name.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();
}
export function Avatar({ src, name, size = 36, className }: AvatarProps) {
export function Avatar({
src,
name,
size = 36,
className,
ring,
}: {
src?: string | null;
name?: string | null;
size?: number;
className?: string;
ring?: boolean;
}) {
const dim = { width: size, height: size };
return (
<span
className={cn(
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full",
"bg-[var(--color-signal)]/20 text-[var(--color-signal)] font-semibold",
"bg-gradient-to-br from-white/10 to-white/[0.02] text-ink-soft",
ring && "ring-2 ring-signal/50",
className,
)}
style={{ width: size, height: size, fontSize: size * 0.38 }}
style={dim}
>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={name ?? ""}
className="size-full object-cover"
alt={name ?? "avatar"}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
initials(name)
<span
className="font-semibold"
style={{ fontSize: Math.max(10, size * 0.36) }}
>
{initials(name)}
</span>
)}
</span>
);
@@ -1,40 +1,38 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export type BadgeTone = "signal" | "amber" | "vermilion" | "neutral";
type Tone = "signal" | "amber" | "vermilion" | "neutral";
const toneClass: Record<BadgeTone, string> = {
signal: "bg-[var(--color-signal)]/15 text-[var(--color-signal)]",
amber: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
vermilion: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
neutral: "bg-[var(--color-hairline)] text-[var(--color-ink-soft)]",
const tones: Record<Tone, string> = {
signal: "bg-signal/12 text-signal border-signal/30",
amber: "bg-amber/12 text-amber border-amber/30",
vermilion: "bg-vermilion/12 text-vermilion border-vermilion/30",
neutral: "bg-white/6 text-ink-soft border-white/10",
};
export interface BadgeProps {
tone?: BadgeTone;
children: ReactNode;
className?: string;
dot?: boolean;
}
export function Badge({
tone = "neutral",
children,
className,
dot,
}: BadgeProps) {
className,
children,
}: {
tone?: Tone;
dot?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
<span className={cn("pill", toneClass[tone], className)}>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[0.7rem] font-semibold tracking-wide",
tones[tone],
className,
)}
>
{dot && (
<span
className={cn(
"size-1.5 rounded-full",
tone === "signal" && "bg-[var(--color-signal)]",
tone === "amber" && "bg-[var(--color-amber)]",
tone === "vermilion" && "bg-[var(--color-vermilion)]",
tone === "neutral" && "bg-[var(--color-ink-soft)]",
)}
/>
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-current opacity-60 animate-pulse-ring" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
)}
{children}
</span>
@@ -1,55 +1,52 @@
"use client";
import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react";
import { forwardRef } from "react";
import { Slot } from "./slot";
import { cn } from "@/lib/utils";
type Variant = "primary" | "ghost" | "danger" | "outline";
type Variant = "primary" | "ghost" | "outline" | "danger" | "subtle";
type Size = "sm" | "md" | "lg" | "icon";
const variantClass: Record<Variant, string> = {
const variants: Record<Variant, string> = {
primary:
"bg-[var(--color-signal)] text-[var(--color-signal-ink)] hover:opacity-90",
ghost:
"bg-transparent text-[var(--color-ink)] hover:bg-[var(--color-surface-2)]",
danger: "bg-[var(--color-vermilion)] text-white hover:opacity-90",
"bg-signal text-signal-ink hover:brightness-110 shadow-[0_8px_24px_-10px_var(--color-signal-glow)] font-semibold",
ghost: "text-ink-soft hover:text-ink hover:bg-white/5",
outline:
"bg-transparent text-[var(--color-ink)] border border-[var(--color-hairline)] hover:bg-[var(--color-surface-2)]",
"border border-hairline bg-white/0 text-ink hover:bg-white/5 hover:border-signal/40",
danger:
"bg-vermilion text-white hover:brightness-110 shadow-[0_8px_24px_-10px_var(--color-vermilion-glow)] font-semibold",
subtle: "bg-white/5 text-ink hover:bg-white/10",
};
const sizeClass: Record<Size, string> = {
sm: "h-8 px-3 text-xs rounded-[var(--radius-r-control)]",
md: "h-10 px-4 text-sm rounded-[var(--radius-r-control)]",
lg: "h-12 px-6 text-base rounded-[var(--radius-r)]",
icon: "size-9 rounded-[var(--radius-r-control)]",
const sizes: Record<Size, string> = {
sm: "h-8 px-3 text-xs rounded-[9px] gap-1.5",
md: "h-10 px-4 text-sm rounded-[11px] gap-2",
lg: "h-12 px-6 text-base rounded-[13px] gap-2",
icon: "h-10 w-10 rounded-[11px]",
};
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "ref"> {
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
asChild?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, variant = "primary", size = "md", children, ...props },
ref,
) => {
const reduce = useReducedMotion();
return (
<motion.button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 select-none cursor-pointer font-medium outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] disabled:opacity-50 disabled:pointer-events-none transition-colors duration-150",
variantClass[variant],
sizeClass[size],
className,
)}
whileTap={reduce ? undefined : { scale: 0.97 }}
{...props}
>
{children}
</motion.button>
);
},
);
Button.displayName = "Button";
export const Button = ({
className,
variant = "subtle",
size = "md",
asChild,
...props
}: ButtonProps) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
"inline-flex items-center justify-center whitespace-nowrap transition-all duration-150 select-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal/60 disabled:opacity-40 disabled:pointer-events-none",
variants[variant],
sizes[size],
className,
)}
{...props}
/>
);
};
@@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
/** Floating glass panel — primary container. */
export function GlassPanel({
className,
children,
glow,
...props
}: React.HTMLAttributes<HTMLDivElement> & { glow?: boolean }) {
return (
<div
className={cn(
"glass p-5",
glow && "shadow-[0_0_40px_-18px_var(--color-signal-glow)]",
className,
)}
{...props}
>
{children}
</div>
);
}
/** Smaller glass sub-panel. */
export function GlassCard({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("glass-2 p-4", className)} {...props}>
{children}
</div>
);
}
@@ -1,75 +0,0 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
export interface DialogProps {
open: boolean;
onClose: () => void;
children: ReactNode;
className?: string;
labelledBy?: string;
}
export function Dialog({
open,
onClose,
children,
className,
labelledBy,
}: DialogProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
<div
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<motion.div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
className={cn(
"relative z-10 w-full max-w-lg surface-2 shadow-2xl",
className,
)}
initial={
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }
}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,11 +1,11 @@
export { Avatar, type AvatarProps } from "./avatar";
export { Badge, type BadgeProps, type BadgeTone } from "./badge";
export { Button, type ButtonProps } from "./button";
export { Dialog, type DialogProps } from "./dialog";
export { Input, type InputProps } from "./input";
export { Progress, type ProgressProps } from "./progress";
export { Select, type SelectProps } from "./select";
export { Sheet, type SheetProps } from "./sheet";
export { Skeleton, type SkeletonProps } from "./skeleton";
export { Toaster, type ToasterProps, useToast } from "./toast";
export { Tooltip, type TooltipProps } from "./tooltip";
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 { Progress, Spinner } from "./progress";
export { Tooltip } from "./tooltip";
@@ -1,23 +1,35 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
mono?: boolean;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, mono, ...props }, ref) => (
export function Input({
className,
...props
}: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
ref={ref}
className={cn(
"w-full bg-[var(--color-surface-2)] text-[var(--color-ink)] placeholder:text-[var(--color-ink-soft)]/60",
"rounded-[var(--radius-r-control)] border border-[var(--color-hairline)] px-3 py-2 text-sm",
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] transition-colors",
mono && "font-mono tracking-tight",
"h-10 w-full rounded-[11px] bg-white/5 border border-hairline px-3.5 text-sm text-ink",
"placeholder:text-ink-faint transition-colors",
"focus:outline-none focus:border-signal/50 focus:bg-white/8",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
);
}
export function Textarea({
className,
...props
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
className={cn(
"w-full rounded-[11px] bg-white/5 border border-hairline px-3.5 py-2.5 text-sm text-ink",
"placeholder:text-ink-faint transition-colors resize-none",
"focus:outline-none focus:border-signal/50 focus:bg-white/8",
className,
)}
{...props}
/>
);
}
@@ -1,39 +1,38 @@
import { cn } from "@/lib/utils";
export interface ProgressProps {
value: number;
max?: number;
className?: string;
tone?: "signal" | "amber" | "vermilion";
showLabel?: boolean;
}
export function Progress({
value,
max = 100,
className,
tone = "signal",
showLabel,
}: ProgressProps) {
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const stroke = {
signal: "var(--color-signal)",
amber: "var(--color-amber)",
vermilion: "var(--color-vermilion)",
}[tone];
className,
}: {
value: number;
tone?: "signal" | "amber" | "vermilion";
className?: string;
}) {
const pct = Math.max(0, Math.min(100, value));
const color =
tone === "vermilion"
? "var(--color-vermilion)"
: tone === "amber"
? "var(--color-amber)"
: "var(--color-signal)";
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-hairline)]">
<div
className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-300"
style={{ width: `${pct}%`, background: stroke }}
/>
</div>
{showLabel && (
<span className="mono text-xs text-[var(--color-ink-soft)]">
{Math.round(pct)}%
</span>
)}
<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}` }}
/>
</div>
);
}
export function Spinner({ className }: { className?: string }) {
return (
<span
className={cn(
"inline-block size-4 animate-spin rounded-full border-2 border-white/20 border-t-signal",
className,
)}
/>
);
}
@@ -1,26 +1,91 @@
import { forwardRef, type SelectHTMLAttributes } from "react";
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
mono?: boolean;
export interface SelectOption {
value: string;
label: string;
hint?: string;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, mono, children, ...props }, ref) => (
<select
ref={ref}
className={cn(
"w-full bg-[var(--color-surface-2)] text-[var(--color-ink)] appearance-none cursor-pointer",
"rounded-[var(--radius-r-control)] border border-[var(--color-hairline)] px-3 py-2 text-sm",
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] transition-colors",
"bg-[url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 fill=%22none%22 stroke=%22%23aaa%22 stroke-width=%222%22><path d=%22M2 4l4 4 4-4%22/></svg>')] bg-[length:12px] bg-[right_0.75rem_center] bg-no-repeat pr-9",
mono && "font-mono tracking-tight",
className,
export function Select({
value,
onChange,
options,
placeholder = "Select…",
className,
size = "md",
}: {
value: string | null;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
className?: string;
size?: "sm" | "md";
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
const selected = options.find((o) => o.value === value);
return (
<div ref={ref} className={cn("relative", className)}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-[11px] border border-hairline bg-white/5 text-left text-ink transition-colors hover:border-signal/40",
"focus:outline-none focus:border-signal/60",
size === "sm" ? "h-9 px-3 text-xs" : "h-10 px-3.5 text-sm",
)}
>
<span className={cn("truncate", !selected && "text-ink-faint")}>
{selected?.label ?? placeholder}
</span>
<ChevronDown
className={cn("size-4 shrink-0 text-ink-faint transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<div
className="glass absolute z-50 mt-1.5 max-h-72 w-full overflow-auto p-1.5"
style={{ animation: "fade-up 0.14s ease" }}
>
{options.length === 0 && (
<div className="px-3 py-2 text-xs text-ink-faint">No options</div>
)}
{options.map((o) => (
<button
key={o.value}
type="button"
onClick={() => {
onChange(o.value);
setOpen(false);
}}
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",
)}
>
<span className="truncate">{o.label}</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>
))}
</div>
)}
{...props}
>
{children}
</select>
),
);
Select.displayName = "Select";
</div>
);
}
@@ -1,74 +0,0 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect } from "react";
import { cn } from "@/lib/utils";
export interface SheetProps {
open: boolean;
onClose: () => void;
children: ReactNode;
side?: "left" | "right" | "bottom";
className?: string;
}
export function Sheet({
open,
onClose,
children,
side = "left",
className,
}: SheetProps) {
const reduce = useReducedMotion();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
const dir =
side === "left"
? { initial: { x: "-100%" }, animate: { x: 0 } }
: side === "right"
? { initial: { x: "100%" }, animate: { x: 0 } }
: { initial: { y: "100%" }, animate: { y: 0 } };
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<motion.aside
className={cn(
"absolute bg-[var(--color-canvas)] shadow-2xl",
side === "left" &&
"left-0 top-0 h-full w-72 border-r border-[var(--color-hairline)]",
side === "right" &&
"right-0 top-0 h-full w-72 border-l border-[var(--color-hairline)]",
side === "bottom" &&
"bottom-0 left-0 w-full rounded-t-2xl border-t border-[var(--color-hairline)]",
className,
)}
initial={reduce ? { opacity: 0 } : dir.initial}
animate={reduce ? { opacity: 1 } : dir.animate}
exit={reduce ? { opacity: 0 } : dir.initial}
transition={{ type: "spring", stiffness: 320, damping: 32 }}
>
{children}
</motion.aside>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,20 +1,16 @@
import { cn } from "@/lib/utils";
export interface SkeletonProps {
className?: string;
rounded?: boolean;
}
export function Skeleton({ className, rounded }: SkeletonProps) {
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
aria-hidden
className={cn(
"animate-shimmer rounded-[var(--radius-r-control)]",
"bg-[var(--color-surface-2)]",
rounded && "rounded-full",
"rounded-[10px] bg-white/[0.06] animate-shimmer relative overflow-hidden",
className,
)}
{...props}
/>
);
}
@@ -0,0 +1,27 @@
"use client";
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);
},
);
Slot.displayName = "Slot";
@@ -1,147 +1,104 @@
"use client";
import { X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { useEffect, useState } from "react";
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
import { cn } from "@/lib/utils";
type ToastTone = "signal" | "amber" | "vermilion" | "neutral";
interface ToastItem {
type ToastTone = "signal" | "vermilion" | "neutral";
interface Toast {
id: number;
title?: string;
title: string;
description?: string;
tone: ToastTone;
}
interface ToastContextValue {
toast: (t: {
title?: string;
description?: string;
tone?: ToastTone;
}) => void;
let nextId = 1;
const listeners = new Set<(t: Toast[]) => void>();
let store: Toast[] = [];
function emit() {
store = [...store];
listeners.forEach((l) => l(store));
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function toast(t: {
title: string;
description?: string;
tone?: ToastTone;
}) {
const item: Toast = { id: nextId++, tone: t.tone ?? "neutral", ...t };
store = [...store, item];
listeners.forEach((l) => l(store));
setTimeout(() => {
store = store.filter((x) => x.id !== item.id);
emit();
}, 4200);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) {
return {
toast: (_: {
title?: string;
description?: string;
tone?: ToastTone;
}) => {},
};
}
return ctx;
return { toast };
}
const toneBar: Record<ToastTone, string> = {
signal: "bg-[var(--color-signal)]",
amber: "bg-[var(--color-amber)]",
vermilion: "bg-[var(--color-vermilion)]",
neutral: "bg-[var(--color-ink-soft)]",
const icons = {
signal: CheckCircle2,
vermilion: AlertTriangle,
neutral: Info,
};
export interface ToasterProps {
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
}
export function Toaster({ position = "bottom-right" }: ToasterProps) {
const [items, setItems] = useState<ToastItem[]>([]);
const reduce = useReducedMotion();
const toast = useCallback(
(t: { title?: string; description?: string; tone?: ToastTone }) => {
const id = Date.now() + Math.random();
const item: ToastItem = {
id,
tone: t.tone ?? "neutral",
title: t.title,
description: t.description,
};
setItems((prev) => [...prev, item]);
setTimeout(() => {
setItems((prev) => prev.filter((i) => i.id !== id));
}, 4500);
},
[],
);
export function Toaster({ position = "bottom-right" }: { position?: string }) {
const [items, setItems] = useState<Toast[]>([]);
useEffect(() => {
// expose a no-op provider only; actual provider wraps below
listeners.add(setItems);
setItems(store);
return () => {
listeners.delete(setItems);
};
}, []);
const posClass =
const pos =
position === "bottom-right"
? "bottom-4 right-4"
: position === "bottom-left"
? "bottom-4 left-4"
: position === "top-right"
? "top-4 right-4"
: "top-4 left-4";
: position === "top-right"
? "top-4 right-4"
: "bottom-4 left-1/2 -translate-x-1/2";
return (
<ToastContext.Provider value={{ toast }}>
<div
className={cn(
"fixed z-[60] flex w-[min(92vw,360px)] flex-col gap-2",
posClass,
)}
>
<AnimatePresence>
{items.map((it) => (
<motion.div
key={it.id}
layout
initial={
reduce ? { opacity: 0 } : { opacity: 0, x: 40, scale: 0.96 }
}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={
reduce ? { opacity: 0 } : { opacity: 0, x: 40, scale: 0.96 }
}
transition={{ type: "spring", stiffness: 360, damping: 30 }}
className="surface-2 relative flex gap-3 overflow-hidden p-3 pr-9 shadow-xl"
<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 (
<div
key={t.id}
className="glass pointer-events-auto flex items-start gap-3 p-3.5"
style={{ animation: "fade-up 0.18s ease" }}
>
<Icon
className={cn(
"mt-0.5 size-4 shrink-0",
t.tone === "signal" && "text-signal",
t.tone === "vermilion" && "text-vermilion",
t.tone === "neutral" && "text-ink-soft",
)}
/>
<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>
<button
type="button"
onClick={() => {
store = store.filter((x) => x.id !== t.id);
emit();
}}
className="text-ink-faint hover:text-ink"
>
<span
className={cn("w-1 shrink-0 rounded-full", toneBar[it.tone])}
/>
<div className="min-w-0 flex-1">
{it.title && (
<div className="text-sm font-semibold text-[var(--color-ink)]">
{it.title}
</div>
)}
{it.description && (
<div className="text-xs text-[var(--color-ink-soft)]">
{it.description}
</div>
)}
</div>
<button
type="button"
onClick={() =>
setItems((prev) => prev.filter((i) => i.id !== it.id))
}
className="absolute right-2 top-2 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
aria-label="Dismiss"
>
<X className="size-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
<X className="size-3.5" />
</button>
</div>
);
})}
</div>
);
}
@@ -1,31 +1,20 @@
"use client";
import { type ReactNode, useState } from "react";
import { useState } from "react";
import { cn } from "@/lib/utils";
export interface TooltipProps {
content: ReactNode;
children: ReactNode;
side?: "top" | "bottom" | "left" | "right";
className?: string;
}
const sidePos: Record<NonNullable<TooltipProps["side"]>, string> = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
/** Lightweight hover/focus tooltip. */
export function Tooltip({
content,
label,
children,
side = "top",
className,
}: TooltipProps) {
}: {
label: string;
children: React.ReactNode;
side?: "top" | "bottom";
}) {
const [show, setShow] = useState(false);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: tooltip wrapper — reveals on hover AND focus (keyboard-accessible via focus handlers above)
<span
className="relative inline-flex"
onMouseEnter={() => setShow(true)}
@@ -34,18 +23,18 @@ export function Tooltip({
onBlur={() => setShow(false)}
>
{children}
<span
role="tooltip"
className={cn(
"pointer-events-none absolute z-50 whitespace-nowrap rounded-[var(--radius-r-control)] px-2.5 py-1 text-xs font-medium",
"bg-[var(--color-ink)] text-[var(--color-canvas)] opacity-0 transition-opacity duration-150",
sidePos[side],
show && "opacity-100",
className,
)}
>
{content}
</span>
{show && (
<span
role="tooltip"
className={cn(
"glass pointer-events-none absolute left-1/2 z-50 -translate-x-1/2 whitespace-nowrap px-2.5 py-1 text-xs text-ink",
side === "top" ? "bottom-[calc(100%+6px)]" : "top-[calc(100%+6px)]",
)}
style={{ animation: "fade-up 0.12s ease" }}
>
{label}
</span>
)}
</span>
);
}