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.
* 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";
export default async function MessagesPage({
@@ -19,7 +19,7 @@ export default async function MessagesPage({
? (sp.tab as "all" | "images" | "review")
: "all";
let initialPage;
let initialPage: MessagePageResult | undefined;
if (guild) {
initialPage = await getMessages(guild, channel || undefined).catch(
() => undefined,
@@ -149,7 +149,13 @@ function PreviewDialog({
<div className="display text-lg text-[var(--color-signal)]">
{recording.filename}
</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)]">
{(recording.size_bytes / 1024).toFixed(0)} KB ·{" "}
{recording.upload_status}
@@ -43,6 +43,8 @@ export function RadialGauge({
height={size}
viewBox={`0 0 ${size} ${size}`}
className="-rotate-90"
role="img"
aria-label={`${Math.round(pct * 100)}% ${label ?? "gauge"}`}
>
<circle
cx={size / 2}
@@ -21,7 +21,15 @@ export function Sparkline({
}: SparklineProps) {
const id = useId().replace(/:/g, "");
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 max = Math.max(...data);
@@ -46,6 +54,8 @@ export function Sparkline({
viewBox={`0 0 ${width} ${height}`}
className={className}
preserveAspectRatio="none"
role="img"
aria-label="Trend sparkline"
>
<defs>
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
@@ -52,15 +52,15 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
};
return (
<div className="flex flex-col h-full">
<div className="flex h-full flex-col">
{/* Chat messages */}
<div
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 ? (
<div className="flex flex-col justify-center h-full gap-3 px-3 text-center">
<p className="text-[11px] text-text-secondary/50">
<div className="flex h-full flex-col justify-center gap-3 px-3 text-center">
<p className="text-[11px] text-[var(--color-ink-soft)]">
Halo! 👋 Aku tau soal server ini pesan, flag, dan aktivitas.
</p>
<div className="flex flex-wrap justify-center gap-1.5">
@@ -70,9 +70,9 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
type="button"
onClick={() => handleSuggestion(s)}
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}
</button>
))}
@@ -85,15 +85,15 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`}
>
<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"
? "bg-primary/20 text-text-primary rounded-br-sm"
: "glass text-text-secondary rounded-bl-sm"
? "rounded-br-sm bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
: "rounded-bl-sm bg-[var(--color-surface-2)] text-[var(--color-ink)]"
}`}
>
{msg.content}
</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)}
</span>
</div>
@@ -101,18 +101,18 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
)}
{isTyping && (
<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="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" }}
/>
<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" }}
/>
<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" }}
/>
</span>
@@ -124,13 +124,13 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
{/* Input bar */}
<form
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
ref={inputRef}
type="text"
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}
autoComplete="off"
/>
@@ -138,22 +138,22 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
<button
type="button"
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}
aria-label="Hapus riwayat chat"
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
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}
aria-label="Kirim pesan"
title="Kirim"
>
<Send className="size-3.5 text-primary" />
<Send className="size-3.5" />
</button>
</form>
</div>
@@ -47,50 +47,49 @@ export function ChatbotContainer() {
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Main chatbot bubble — minimized FAB opens the full chat directly */}
<div
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
minimized ? "w-14 h-14 cursor-pointer" : "w-[320px] h-[460px]"
className={`surface-2 overflow-hidden shadow-2xl transition-all duration-200 ${
minimized ? "h-14 w-14 cursor-pointer" : "h-[460px] w-[320px]"
}`}
>
{minimized ? (
<button
type="button"
onClick={() => setMinimized(false)}
className="w-full h-full flex items-center justify-center"
className="flex size-full items-center justify-center"
onMouseDown={handleMouseDown}
aria-label="Buka chatbot"
title="Buka chatbot"
>
<Bot className="size-6 text-primary" />
<Bot className="size-6 text-[var(--color-signal)]" />
</button>
) : (
<div className="flex flex-col h-full">
<div className="flex h-full flex-col">
{/* Drag handle + controls */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */}
<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}
>
<span className="flex items-center gap-1.5 text-[10px] font-semibold text-text-secondary tracking-wide uppercase">
<Bot className="size-3.5 text-primary" />
<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-[var(--color-signal)]" />
Chatbot
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
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"
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>
</div>
</div>
{/* 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} />
</div>
</div>
@@ -39,11 +39,12 @@ export function MessageEntry({
const status = msg.ai_status ?? null;
return (
<div
<button
type="button"
data-selected={selected}
onClick={onSelect}
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)]",
selected && "bg-[var(--color-signal)]/6",
)}
@@ -81,6 +82,6 @@ export function MessageEntry({
)}
</div>
</div>
</div>
</button>
);
}
@@ -25,6 +25,7 @@ export function Tooltip({
}: TooltipProps) {
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)}
@@ -21,7 +21,7 @@ export function LoadingSkeleton({
<div
key={i}
className={cn(
"glass rounded-[var(--radius-card)] overflow-hidden",
"surface-2 overflow-hidden",
height,
width,
className,
@@ -33,6 +33,7 @@ export function StaticFallback({
aria-hidden
preserveAspectRatio="xMidYMid slice"
>
<title>Speaker orbs</title>
<rect width="200" height="200" fill="oklch(0.18 0.02 70)" />
{orbs.map((o, i) => (
<g
@@ -71,6 +72,7 @@ export function StaticFallback({
aria-hidden
preserveAspectRatio="xMidYMid slice"
>
<title>Signal dots</title>
<rect width="200" height="200" fill="oklch(0.18 0.02 70)" />
{dots.map((d, i) => (
<circle