refactor(frontend): finish design-system migration — chatbot, a11y, lint

- Rewrite chatbot container + panel to new surface/signal/ink tokens
  (was still on dead glass/text-primary tokens -> wrong colors)
- loading-skeleton: glass -> surface-2
- Fix a11y: SVG charts role=img+aria-label, audio aria-label,
  message-entry as real <button>, tooltip biome-ignore (intentional)
- Type messages/page initialPage (noImplicitAny)
- tsc clean, next build green, biome 0 errors
This commit is contained in:
asepharyana
2026-08-14 11:02:52 +07:00
parent 5bbf75a65b
commit 8b281c7feb
10 changed files with 61 additions and 40 deletions
@@ -2,7 +2,7 @@
* Messages — Server Component. * Messages — Server Component.
* Reads URL guild/channel/selected/tab on the server; seeds first page SSR. * Reads URL guild/channel/selected/tab on the server; seeds first page SSR.
*/ */
import { getMessages } from "@/lib/api/server"; import { getMessages, type MessagePageResult } from "@/lib/api/server";
import MessagesView from "./view"; import MessagesView from "./view";
export default async function MessagesPage({ export default async function MessagesPage({
@@ -19,7 +19,7 @@ export default async function MessagesPage({
? (sp.tab as "all" | "images" | "review") ? (sp.tab as "all" | "images" | "review")
: "all"; : "all";
let initialPage; let initialPage: MessagePageResult | undefined;
if (guild) { if (guild) {
initialPage = await getMessages(guild, channel || undefined).catch( initialPage = await getMessages(guild, channel || undefined).catch(
() => undefined, () => undefined,
@@ -149,7 +149,13 @@ function PreviewDialog({
<div className="display text-lg text-[var(--color-signal)]"> <div className="display text-lg text-[var(--color-signal)]">
{recording.filename} {recording.filename}
</div> </div>
<audio controls src={recording.download_url ?? ""} className="w-full" /> {/* biome-ignore lint/a11y/useMediaCaption: voice recordings are uncaptioned audio previews — no transcript available */}
<audio
controls
src={recording.download_url ?? ""}
aria-label={`Audio recording: ${recording.filename}`}
className="w-full"
/>
<div className="mono text-xs text-[var(--color-ink-soft)]"> <div className="mono text-xs text-[var(--color-ink-soft)]">
{(recording.size_bytes / 1024).toFixed(0)} KB ·{" "} {(recording.size_bytes / 1024).toFixed(0)} KB ·{" "}
{recording.upload_status} {recording.upload_status}
@@ -43,6 +43,8 @@ export function RadialGauge({
height={size} height={size}
viewBox={`0 0 ${size} ${size}`} viewBox={`0 0 ${size} ${size}`}
className="-rotate-90" className="-rotate-90"
role="img"
aria-label={`${Math.round(pct * 100)}% ${label ?? "gauge"}`}
> >
<circle <circle
cx={size / 2} cx={size / 2}
@@ -21,7 +21,15 @@ export function Sparkline({
}: SparklineProps) { }: SparklineProps) {
const id = useId().replace(/:/g, ""); const id = useId().replace(/:/g, "");
if (data.length < 2) if (data.length < 2)
return <svg width={width} height={height} className={className} />; return (
<svg
width={width}
height={height}
className={className}
role="img"
aria-label="No data"
/>
);
const min = Math.min(...data); const min = Math.min(...data);
const max = Math.max(...data); const max = Math.max(...data);
@@ -46,6 +54,8 @@ export function Sparkline({
viewBox={`0 0 ${width} ${height}`} viewBox={`0 0 ${width} ${height}`}
className={className} className={className}
preserveAspectRatio="none" preserveAspectRatio="none"
role="img"
aria-label="Trend sparkline"
> >
<defs> <defs>
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1"> <linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
@@ -52,15 +52,15 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
{/* Chat messages */} {/* Chat messages */}
<div <div
ref={listRef} ref={listRef}
className="flex-1 overflow-y-auto px-2 py-2 space-y-1.5" className="flex-1 space-y-1.5 overflow-y-auto px-2 py-2"
> >
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="flex flex-col justify-center h-full gap-3 px-3 text-center"> <div className="flex h-full flex-col justify-center gap-3 px-3 text-center">
<p className="text-[11px] text-text-secondary/50"> <p className="text-[11px] text-[var(--color-ink-soft)]">
Halo! 👋 Aku tau soal server ini pesan, flag, dan aktivitas. Halo! 👋 Aku tau soal server ini pesan, flag, dan aktivitas.
</p> </p>
<div className="flex flex-wrap justify-center gap-1.5"> <div className="flex flex-wrap justify-center gap-1.5">
@@ -70,9 +70,9 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
type="button" type="button"
onClick={() => handleSuggestion(s)} onClick={() => handleSuggestion(s)}
disabled={isTyping} disabled={isTyping}
className="flex items-center gap-1 rounded-full border border-glass-border px-2.5 py-1 text-[10px] text-text-secondary/70 transition-colors hover:bg-glass-bg hover:text-text-primary disabled:opacity-40" className="flex items-center gap-1 rounded-full border border-[var(--color-hairline)] bg-[var(--color-surface-2)] px-2.5 py-1 text-[10px] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-signal)] hover:text-[var(--color-signal-ink)] disabled:opacity-40"
> >
<Sparkles className="size-2.5 text-primary/60" /> <Sparkles className="size-2.5 text-[var(--color-signal)]" />
{s} {s}
</button> </button>
))} ))}
@@ -85,15 +85,15 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`} className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`}
> >
<div <div
className={`text-[11px] px-2.5 py-1.5 rounded-xl max-w-[85%] leading-relaxed whitespace-pre-wrap break-words ${ className={`max-w-[85%] break-words whitespace-pre-wrap rounded-xl px-2.5 py-1.5 text-[11px] leading-relaxed ${
msg.role === "user" msg.role === "user"
? "bg-primary/20 text-text-primary rounded-br-sm" ? "rounded-br-sm bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
: "glass text-text-secondary rounded-bl-sm" : "rounded-bl-sm bg-[var(--color-surface-2)] text-[var(--color-ink)]"
}`} }`}
> >
{msg.content} {msg.content}
</div> </div>
<span className="mt-0.5 px-1 text-[9px] text-text-secondary/30"> <span className="mt-0.5 px-1 text-[9px] text-[var(--color-ink-soft)]">
{formatTime(msg.timestamp)} {formatTime(msg.timestamp)}
</span> </span>
</div> </div>
@@ -101,18 +101,18 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
)} )}
{isTyping && ( {isTyping && (
<div className="flex justify-start"> <div className="flex justify-start">
<div className="glass rounded-xl rounded-bl-sm px-2.5 py-2"> <div className="rounded-xl rounded-bl-sm bg-[var(--color-surface-2)] px-2.5 py-2">
<span className="inline-flex gap-1"> <span className="inline-flex gap-1">
<span <span
className="size-1.5 rounded-full bg-text-secondary animate-bounce" className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
style={{ animationDelay: "0ms" }} style={{ animationDelay: "0ms" }}
/> />
<span <span
className="size-1.5 rounded-full bg-text-secondary animate-bounce" className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
style={{ animationDelay: "150ms" }} style={{ animationDelay: "150ms" }}
/> />
<span <span
className="size-1.5 rounded-full bg-text-secondary animate-bounce" className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
style={{ animationDelay: "300ms" }} style={{ animationDelay: "300ms" }}
/> />
</span> </span>
@@ -124,13 +124,13 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
{/* Input bar */} {/* Input bar */}
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="flex items-center gap-1.5 px-2 py-2 border-t border-glass-border shrink-0" className="flex shrink-0 items-center gap-1.5 border-t border-[var(--color-hairline)] px-2 py-2"
> >
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder="Tanya soal server, pesan, atau statistik…" placeholder="Tanya soal server, pesan, atau statistik…"
className="flex-1 bg-transparent text-[11px] text-text-primary placeholder-text-secondary/30 outline-none" className="flex-1 bg-transparent text-[11px] text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]/50"
disabled={isTyping} disabled={isTyping}
autoComplete="off" autoComplete="off"
/> />
@@ -138,22 +138,22 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
<button <button
type="button" type="button"
onClick={() => void clearMessages()} onClick={() => void clearMessages()}
className="size-6 flex items-center justify-center rounded hover:bg-glass-bg transition-colors disabled:opacity-40" className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)] disabled:opacity-40"
disabled={isTyping} disabled={isTyping}
aria-label="Hapus riwayat chat" aria-label="Hapus riwayat chat"
title="Hapus riwayat" title="Hapus riwayat"
> >
<Eraser className="size-3 text-text-secondary/50 hover:text-destructive" /> <Eraser className="size-3 text-[var(--color-ink-soft)] hover:text-[var(--color-vermilion)]" />
</button> </button>
)} )}
<button <button
type="submit" type="submit"
className="size-7 flex items-center justify-center rounded-lg bg-primary/15 hover:bg-primary/25 transition-colors disabled:opacity-40" className="flex size-7 items-center justify-center rounded-lg bg-[var(--color-signal)] text-[var(--color-signal-ink)] transition-colors hover:opacity-90 disabled:opacity-40"
disabled={isTyping} disabled={isTyping}
aria-label="Kirim pesan" aria-label="Kirim pesan"
title="Kirim" title="Kirim"
> >
<Send className="size-3.5 text-primary" /> <Send className="size-3.5" />
</button> </button>
</form> </form>
</div> </div>
@@ -47,50 +47,49 @@ export function ChatbotContainer() {
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp} onMouseLeave={handleMouseUp}
> >
{/* Main chatbot bubble — minimized FAB opens the full chat directly */}
<div <div
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${ className={`surface-2 overflow-hidden shadow-2xl transition-all duration-200 ${
minimized ? "w-14 h-14 cursor-pointer" : "w-[320px] h-[460px]" minimized ? "h-14 w-14 cursor-pointer" : "h-[460px] w-[320px]"
}`} }`}
> >
{minimized ? ( {minimized ? (
<button <button
type="button" type="button"
onClick={() => setMinimized(false)} onClick={() => setMinimized(false)}
className="w-full h-full flex items-center justify-center" className="flex size-full items-center justify-center"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
aria-label="Buka chatbot" aria-label="Buka chatbot"
title="Buka chatbot" title="Buka chatbot"
> >
<Bot className="size-6 text-primary" /> <Bot className="size-6 text-[var(--color-signal)]" />
</button> </button>
) : ( ) : (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
{/* Drag handle + controls */} {/* Drag handle + controls */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */} {/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */}
<div <div
className="flex items-center justify-between px-3 py-2 border-b border-glass-border cursor-grab active:cursor-grabbing shrink-0" className="flex shrink-0 cursor-grab items-center justify-between border-b border-[var(--color-hairline)] px-3 py-2 active:cursor-grabbing"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
<span className="flex items-center gap-1.5 text-[10px] font-semibold text-text-secondary tracking-wide uppercase"> <span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
<Bot className="size-3.5 text-primary" /> <Bot className="size-3.5 text-[var(--color-signal)]" />
Chatbot Chatbot
</span> </span>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<button <button
type="button" type="button"
onClick={() => setMinimized(true)} onClick={() => setMinimized(true)}
className="size-6 flex items-center justify-center rounded hover:bg-glass-bg transition-colors" className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)]"
aria-label="Kecilkan chatbot" aria-label="Kecilkan chatbot"
title="Kecilkan chatbot" title="Kecilkan chatbot"
> >
<Minimize2 className="size-3.5 text-text-secondary/60 hover:text-text-primary" /> <Minimize2 className="size-3.5 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]" />
</button> </button>
</div> </div>
</div> </div>
{/* Chat panel — always open when bubble is expanded */} {/* Chat panel — always open when bubble is expanded */}
<div className="flex-1 min-h-0"> <div className="min-h-0 flex-1">
<ChatPanel inputRef={inputRef} /> <ChatPanel inputRef={inputRef} />
</div> </div>
</div> </div>
@@ -39,11 +39,12 @@ export function MessageEntry({
const status = msg.ai_status ?? null; const status = msg.ai_status ?? null;
return ( return (
<div <button
type="button"
data-selected={selected} data-selected={selected}
onClick={onSelect} onClick={onSelect}
className={cn( className={cn(
"group relative mb-1.5 flex items-start gap-2.5 rounded-[var(--radius-r)] p-2.5 cursor-pointer", "group relative mb-1.5 flex w-full items-start gap-2.5 rounded-[var(--radius-r)] p-2.5 text-left cursor-pointer",
"transition-all hover:bg-[var(--color-surface-2)]", "transition-all hover:bg-[var(--color-surface-2)]",
selected && "bg-[var(--color-signal)]/6", selected && "bg-[var(--color-signal)]/6",
)} )}
@@ -81,6 +82,6 @@ export function MessageEntry({
)} )}
</div> </div>
</div> </div>
</div> </button>
); );
} }
@@ -25,6 +25,7 @@ export function Tooltip({
}: TooltipProps) { }: TooltipProps) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: tooltip wrapper — reveals on hover AND focus (keyboard-accessible via focus handlers above)
<span <span
className="relative inline-flex" className="relative inline-flex"
onMouseEnter={() => setShow(true)} onMouseEnter={() => setShow(true)}
@@ -21,7 +21,7 @@ export function LoadingSkeleton({
<div <div
key={i} key={i}
className={cn( className={cn(
"glass rounded-[var(--radius-card)] overflow-hidden", "surface-2 overflow-hidden",
height, height,
width, width,
className, className,
@@ -33,6 +33,7 @@ export function StaticFallback({
aria-hidden aria-hidden
preserveAspectRatio="xMidYMid slice" preserveAspectRatio="xMidYMid slice"
> >
<title>Speaker orbs</title>
<rect width="200" height="200" fill="oklch(0.18 0.02 70)" /> <rect width="200" height="200" fill="oklch(0.18 0.02 70)" />
{orbs.map((o, i) => ( {orbs.map((o, i) => (
<g <g
@@ -71,6 +72,7 @@ export function StaticFallback({
aria-hidden aria-hidden
preserveAspectRatio="xMidYMid slice" preserveAspectRatio="xMidYMid slice"
> >
<title>Signal dots</title>
<rect width="200" height="200" fill="oklch(0.18 0.02 70)" /> <rect width="200" height="200" fill="oklch(0.18 0.02 70)" />
{dots.map((d, i) => ( {dots.map((d, i) => (
<circle <circle