diff --git a/services/frontend/src/app/(dashboard)/analysis/view.tsx b/services/frontend/src/app/(dashboard)/analysis/view.tsx
index 8cad2c8..1b6dc1c 100644
--- a/services/frontend/src/app/(dashboard)/analysis/view.tsx
+++ b/services/frontend/src/app/(dashboard)/analysis/view.tsx
@@ -4,7 +4,7 @@ import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
-import { EmptyState, LoadingState, SectionHeader } from "@/components/shared";
+import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
import { aiTone } from "@/lib/ai-status";
import {
@@ -70,7 +70,7 @@ export function AnalysisView() {
}
/>
{query.trim().length >= 2 && search.isLoading && (
-
+
)}
{(search.data ?? []).length === 0 ? (
;
- if (!stats && isLoading) return ;
+ if (!stats && isLoading)
+ return (
+
diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx
index 637f530..3e406a8 100644
--- a/services/frontend/src/app/(dashboard)/messages/view.tsx
+++ b/services/frontend/src/app/(dashboard)/messages/view.tsx
@@ -22,8 +22,8 @@ import {
import {
EmptyState,
ErrorState,
- LoadingState,
SectionHeader,
+ SkeletonRows,
} from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker";
import {
@@ -185,7 +185,7 @@ export function MessagesView({
{error && !messages ? (
) : isLoading && !messages ? (
-
+
) : list.length === 0 ? (
}
diff --git a/services/frontend/src/app/(dashboard)/moderation/view.tsx b/services/frontend/src/app/(dashboard)/moderation/view.tsx
index b93796e..59707d2 100644
--- a/services/frontend/src/app/(dashboard)/moderation/view.tsx
+++ b/services/frontend/src/app/(dashboard)/moderation/view.tsx
@@ -24,9 +24,11 @@ import {
} from "@/components/primitives";
import {
ErrorState,
- LoadingState,
MetricTile,
SectionHeader,
+ SkeletonMetricRow,
+ SkeletonPanel,
+ SkeletonRows,
} from "@/components/shared";
import { useModerationActions, useModerationStats } from "@/hooks";
import { formatNumber, formatRelativeTime } from "@/lib/format";
@@ -92,7 +94,14 @@ export function ModerationView({
}, [failedRate, ambient]);
if (error && !stats) return
;
- if (!stats && isLoading) return
;
+ if (!stats && isLoading)
+ return (
+
+
+
+
+
+ );
if (!stats) return
;
const statusOpts: SelectOption[] = [
diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx
index 0e48061..557c875 100644
--- a/services/frontend/src/app/(dashboard)/recordings/view.tsx
+++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx
@@ -9,14 +9,10 @@ import {
Button,
GlassCard,
GlassPanel,
+ Skeleton,
toast,
} from "@/components/primitives";
-import {
- EmptyState,
- ErrorState,
- LoadingState,
- SectionHeader,
-} from "@/components/shared";
+import { EmptyState, ErrorState, SectionHeader } from "@/components/shared";
import {
useDeleteRecording,
useRecordings,
@@ -55,7 +51,29 @@ export function RecordingsView({
};
if (error && !items) return
;
- if (!items && isLoading) return
;
+ if (!items && isLoading)
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+ );
return (
diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx
index ae5767e..5238224 100644
--- a/services/frontend/src/app/(dashboard)/voice/view.tsx
+++ b/services/frontend/src/app/(dashboard)/voice/view.tsx
@@ -16,8 +16,8 @@ import { Button, GlassPanel, toast } from "@/components/primitives";
import {
EmptyState,
ErrorState,
- LoadingState,
SectionHeader,
+ SkeletonPanel,
} from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker";
import { VoiceStage } from "@/components/voice/voice-stage";
@@ -67,7 +67,16 @@ export function VoiceView({
}, [status?.connected, ambient]);
if (error && !status) return ;
- if (!status && isLoading) return ;
+ if (!status && isLoading)
+ return (
+
+ );
const connected = status?.connected ?? false;
const listenBars = Array.from(listen.levels.values()).slice(0, 32);
diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css
index d2483ef..6960f4f 100644
--- a/services/frontend/src/app/globals.css
+++ b/services/frontend/src/app/globals.css
@@ -53,6 +53,7 @@
/* ── Light theme overrides ── */
.light {
+ color-scheme: light;
--color-canvas: oklch(0.96 0.012 80);
--color-canvas-2: oklch(0.92 0.014 80);
--color-surface: oklch(1 0 0 / 0.7);
@@ -65,6 +66,18 @@
--color-hairline: oklch(0.22 0.02 70 / 0.12);
}
+/* Light-theme scrollbar + selection tuned for pale canvas */
+.light ::-webkit-scrollbar-thumb {
+ background: oklch(0.22 0.02 70 / 0.2);
+}
+.light ::-webkit-scrollbar-thumb:hover {
+ background: oklch(0.22 0.02 70 / 0.34);
+}
+.light ::selection {
+ background: var(--color-signal-glow);
+ color: var(--color-ink);
+}
+
@layer base {
* {
@apply border-transparent;
diff --git a/services/frontend/src/components/shared/index.ts b/services/frontend/src/components/shared/index.ts
index b57da76..cdac96c 100644
--- a/services/frontend/src/components/shared/index.ts
+++ b/services/frontend/src/components/shared/index.ts
@@ -1,4 +1,12 @@
export { GuildChannelPicker } from "./guild-picker";
export { MarkdownLite } from "./markdown";
export { MetricTile, SectionHeader } from "./section";
-export { EmptyState, ErrorState, LoadingState } from "./states";
+export {
+ EmptyState,
+ ErrorState,
+ LoadingState,
+ SkeletonHero,
+ SkeletonMetricRow,
+ SkeletonPanel,
+ SkeletonRows,
+} from "./states";
diff --git a/services/frontend/src/components/shared/states.tsx b/services/frontend/src/components/shared/states.tsx
index 3aa3b93..520004f 100644
--- a/services/frontend/src/components/shared/states.tsx
+++ b/services/frontend/src/components/shared/states.tsx
@@ -1,7 +1,97 @@
-import { AlertTriangle, Inbox, Loader2 } from "lucide-react";
-import { Spinner } from "@/components/primitives";
+import { AlertTriangle, Loader2 } from "lucide-react";
+import { GlassPanel, Skeleton, Spinner } from "@/components/primitives";
import { cn } from "@/lib/utils";
+/** A panel-shaped loading placeholder. */
+export function SkeletonPanel({
+ rows = 3,
+ className,
+}: {
+ rows?: number;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+/** A 2×2 (or 4-col) grid of metric-tile placeholders. */
+export function SkeletonMetricRow({ cols = 4 }: { cols?: number }) {
+ return (
+
+ {Array.from({ length: cols }).map((_, i) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+/** A stacked list of row placeholders (message/moderation/queue style). */
+export function SkeletonRows({
+ rows = 6,
+ className,
+}: {
+ rows?: number;
+ className?: string;
+}) {
+ return (
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+/** Hero block placeholder (dashboard / media "now playing"). */
+export function SkeletonHero({ className }: { className?: string }) {
+ return (
+
+
+
+
+ );
+}
+
export function EmptyState({
title = "Nothing here yet",
description,
@@ -16,13 +106,15 @@ export function EmptyState({
return (
-
- {icon ?? }
-
+ {icon && (
+
+ {icon}
+
+ )}
{title}
{description && (
{description}