diff --git a/services/frontend/package.json b/services/frontend/package.json
index 3d21ce6f..cb2d5957 100644
--- a/services/frontend/package.json
+++ b/services/frontend/package.json
@@ -12,7 +12,6 @@
"dependencies": {
"@orpc/client": "1.15.0",
"clsx": "^2.1.1",
- "d3-force": "^3.0.0",
"lucide-react": "^1.27.0",
"motion": "^12.0.0",
"next": "16.2.12",
@@ -27,8 +26,6 @@
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
- "@types/bun": "^1.4.0",
- "@types/d3-force": "^3.0.10",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/services/frontend/pnpm-lock.yaml b/services/frontend/pnpm-lock.yaml
index 0f8bf7fa..f13c6ae4 100644
--- a/services/frontend/pnpm-lock.yaml
+++ b/services/frontend/pnpm-lock.yaml
@@ -14,9 +14,6 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
- d3-force:
- specifier: ^3.0.0
- version: 3.0.0
lucide-react:
specifier: ^1.27.0
version: 1.28.0(react@19.2.4)
@@ -54,12 +51,6 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.3.3
- '@types/bun':
- specifier: ^1.4.0
- version: 1.4.0
- '@types/d3-force':
- specifier: ^3.0.10
- version: 3.0.10
'@types/node':
specifier: ^20
version: 20.19.43
@@ -576,12 +567,6 @@ packages:
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
- '@types/bun@1.4.0':
- resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==}
-
- '@types/d3-force@3.0.10':
- resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
-
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
@@ -626,9 +611,6 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
- bun-types@1.4.0:
- resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==}
-
caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
@@ -655,22 +637,6 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
- d3-dispatch@3.0.1:
- resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
- engines: {node: '>=12'}
-
- d3-force@3.0.0:
- resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
- engines: {node: '>=12'}
-
- d3-quadtree@3.0.1:
- resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
- engines: {node: '>=12'}
-
- d3-timer@3.0.1:
- resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
- engines: {node: '>=12'}
-
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1495,12 +1461,6 @@ snapshots:
'@tweenjs/tween.js@23.1.3': {}
- '@types/bun@1.4.0':
- dependencies:
- bun-types: 1.4.0
-
- '@types/d3-force@3.0.10': {}
-
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
@@ -1548,10 +1508,6 @@ snapshots:
update-browserslist-db: 1.2.3(browserslist@4.28.7)
optional: true
- bun-types@1.4.0:
- dependencies:
- '@types/node': 20.19.43
-
caniuse-lite@1.0.30001806: {}
chromium-bidi@17.0.2(devtools-protocol@0.0.1666840):
@@ -1575,18 +1531,6 @@ snapshots:
csstype@3.2.3: {}
- d3-dispatch@3.0.1: {}
-
- d3-force@3.0.0:
- dependencies:
- d3-dispatch: 3.0.1
- d3-quadtree: 3.0.1
- d3-timer: 3.0.1
-
- d3-quadtree@3.0.1: {}
-
- d3-timer@3.0.1: {}
-
debug@4.4.3:
dependencies:
ms: 2.1.3
diff --git a/services/frontend/src/app/(dashboard)/analysis/view.tsx b/services/frontend/src/app/(dashboard)/analysis/view.tsx
index ae1c3bb8..1b6dc1c2 100644
--- a/services/frontend/src/app/(dashboard)/analysis/view.tsx
+++ b/services/frontend/src/app/(dashboard)/analysis/view.tsx
@@ -1,72 +1,24 @@
"use client";
-/**
- * Analysis scene — semantic search lives in a floating console (left);
- * results stream into a translucent dossier (right). The stage shows an
- * analysis hub wired to the guild's top channels.
- */
-import { Hash, Search, Sparkles } from "lucide-react";
-import { useEffect, useMemo, useState } from "react";
+import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
+import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
-import { Avatar, Badge } from "@/components/primitives";
-import { EmptyState, SkeletonRows } from "@/components/shared";
-import { useScenePublish } from "@/components/shell/scene-graph-context";
-import { useChannels, useMessageSearch } from "@/hooks";
+import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
+import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
+import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
import { aiTone } from "@/lib/ai-status";
-import type { ConstellationGraph } from "@/lib/constellation/graph";
import {
formatDuration,
getMessageChannelLabel,
renderMessageContent,
} from "@/lib/format";
-function analysisGraph(
- channels:
- | {
- channel_id: string;
- channel_name?: string | null;
- total_messages: number;
- }[]
- | undefined,
-): ConstellationGraph {
- const rows = (channels ?? []).slice(0, 10);
- const maxMsg = rows.reduce((m, c) => Math.max(m, c.total_messages), 0);
- const clamp = (n: number) => (maxMsg <= 0 ? 0.3 : Math.max(0.15, n / maxMsg));
- return {
- nodes: [
- { id: "hub", label: "analysis", kind: "guild", value: 1 },
- ...rows.map((c) => ({
- id: `channel:${c.channel_id}`,
- label: c.channel_name ?? c.channel_id.slice(0, 8),
- kind: "channel" as const,
- href: "/channels/",
- value: clamp(c.total_messages),
- })),
- ],
- edges: rows.map((c) => ({
- source: "hub",
- target: `channel:${c.channel_id}`,
- })),
- };
-}
-
export function AnalysisView() {
const [query, setQuery] = useState("");
const search = useMessageSearch(query, query.trim().length >= 2);
+ const { data: reactors } = useTopReactors();
const { data: channels } = useChannels();
const ambient = useAmbient();
- const publish = useScenePublish();
-
- const graph = useMemo(() => analysisGraph(channels), [channels]);
-
- useEffect(() => {
- publish({ graph, focus: null });
- }, [graph, publish]);
-
- useEffect(
- () => () => publish({ graph: { nodes: [], edges: [] }, focus: null }),
- [publish],
- );
useEffect(() => {
ambient.set(
@@ -77,50 +29,52 @@ export function AnalysisView() {
}, [query, ambient]);
return (
-
- {/* Search console — left */}
-
-
- Search the archive
-
-
-
-
+
+
+
+
+
+
Semantic search
+
+ Search the archive
+
+
+
+
+
+ setQuery(e.target.value)}
- placeholder="Find messages, patterns, flags…"
autoFocus
- className="h-11 w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 pl-11 pr-4 text-sm text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]"
/>
- {query.trim().length > 0 && query.trim().length < 2 ? (
-
- minimal 2 karakter…
-
- ) : null}
-
+ {query.trim().length > 0 && query.trim().length < 2 && (
+
+ Type at least 2 characters…
+
+ )}
+
- {/* Results dossier — right */}
-
-
- matches
-
- {(search.data ?? []).length}
-
-
-
- {query.trim().length >= 2 && search.isLoading ? (
-
- ) : (search.data ?? []).length === 0 ? (
+
+
+
+ {(search.data ?? []).length}
+
+ }
+ />
+ {query.trim().length >= 2 && search.isLoading && (
+
+ )}
+ {(search.data ?? []).length === 0 ? (
}
+ icon={ }
title="No matches yet"
description="Run a search to surface messages across the guild."
/>
@@ -129,27 +83,27 @@ export function AnalysisView() {
{(search.data ?? []).map((m) => (
-
+
-
+
{m.username}
-
+
{getMessageChannelLabel(m)}
- {m.ai_status ? (
+ {m.ai_status && (
{m.ai_analysis_duration_ms &&
m.ai_analysis_duration_ms > 0
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
: m.ai_status}
- ) : null}
+ )}
-
+
{renderMessageContent(m.content, m.metadata) || "(embed)"}
@@ -157,14 +111,86 @@ export function AnalysisView() {
))}
)}
-
-
+
- {/* Channels hint — bottom-left */}
-
- {(channels ?? []).length} channels
- terhubung ke hub analitik — klik untuk membuka konstelasi channel
-
+
+
+
+ Top reactors
+
+ }
+ />
+
+ {(reactors ?? []).slice(0, 6).map((r, i) => {
+ const maxNet = reactors?.[0]?.net_count || 1;
+ const pct = Math.max(
+ 4,
+ Math.round((r.net_count / maxNet) * 100),
+ );
+ return (
+
+
{i + 1}
+
+ {r.username}
+
+
+
+ +{r.net_count}
+
+
+ );
+ })}
+ {(reactors ?? []).length === 0 && (
+
+ No data
+
+ )}
+
+
+
+
+
+ Top channels
+
+ }
+ />
+
+ {(channels ?? []).slice(0, 6).map((c) => (
+
+
+ {c.channel_name ?? c.channel_id.slice(0, 8)}
+
+
+ {c.total_messages}
+
+
+ ))}
+ {(channels ?? []).length === 0 && (
+
+ No data
+
+ )}
+
+
+
+
);
}
diff --git a/services/frontend/src/app/(dashboard)/channels/view.tsx b/services/frontend/src/app/(dashboard)/channels/view.tsx
index 11f0370e..9e33728e 100644
--- a/services/frontend/src/app/(dashboard)/channels/view.tsx
+++ b/services/frontend/src/app/(dashboard)/channels/view.tsx
@@ -1,18 +1,8 @@
"use client";
-/**
- * Channels scene — every channel is a star on the stage; clicking a star
- * opens its floating culture dossier here. Search filters the sky.
- */
-import { useEffect, useMemo, useState } from "react";
+import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary";
import { SkeletonPanel } from "@/components/shared";
-import {
- useSceneFocusSetter,
- useSceneGraph,
- useScenePublish,
-} from "@/components/shell/scene-graph-context";
import { useChannelCultures } from "@/hooks";
-import { culturesToGraph } from "@/lib/constellation/graph";
import type { ChannelCultureRow } from "@/lib/types";
export function ChannelsView({
@@ -21,92 +11,12 @@ export function ChannelsView({
initialCultures?: ChannelCultureRow[];
}) {
const { data: cultures } = useChannelCultures(100, initialCultures);
- const publish = useScenePublish();
- const setFocus = useSceneFocusSetter();
- const { state } = useSceneGraph();
- const [query, setQuery] = useState("");
-
- const rows = cultures ?? [];
-
- const filtered = useMemo(() => {
- const q = query.trim().toLowerCase();
- if (!q) return rows;
- return rows.filter(
- (r) =>
- (r.channel_name ?? r.channel_id).toLowerCase().includes(q) ||
- (r.culture_summary ?? "").toLowerCase().includes(q),
- );
- }, [rows, query]);
-
- const graph = useMemo(() => culturesToGraph(filtered), [filtered]);
-
- useEffect(() => {
- publish({ graph, focus: null });
- }, [graph, publish]);
-
- useEffect(() => () => setFocus(null), [setFocus]);
-
- const selectedId = state?.focus ?? null;
- const selectedRow = useMemo(
- () =>
- selectedId
- ? (rows.find((r) => `channel:${r.channel_id}` === selectedId) ?? null)
- : null,
- [rows, selectedId],
- );
-
return (
-
- {/* Search whisper — top-left */}
-
-
- {/* Culture dossier for the selected star */}
- {selectedRow ? (
-
- setFocus(null)}
- >
- esc
-
- channel culture
-
- {selectedRow.channel_name ?? selectedRow.channel_id}
-
-
- {selectedRow.culture_summary ?? "Belum dianalisis."}
-
-
- {selectedRow.last_analyzed_at
- ? `analyzed ${new Date(selectedRow.last_analyzed_at).toLocaleString()}`
- : "never analyzed"}
-
-
+
+ {cultures ? (
+
) : (
-
- klik sebuah bintang untuk membuka culture dossier
-
+
)}
);
diff --git a/services/frontend/src/app/(dashboard)/dashboard/view.tsx b/services/frontend/src/app/(dashboard)/dashboard/view.tsx
index b9288964..5e870ee7 100644
--- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx
+++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx
@@ -1,18 +1,32 @@
"use client";
-/**
- * Dashboard scene — guild star + channel orbit live on the stage canvas;
- * this overlay adds the metric whisper (top-left), activity ribbon
- * (bottom-left), and moderation gauge (right) as floating panels.
- */
-import { Activity, Flag, ShieldAlert } from "lucide-react";
-import { useEffect, useMemo } from "react";
+import {
+ Activity,
+ Flag,
+ MessageSquare,
+ Mic,
+ Radio,
+ ShieldAlert,
+ Users,
+} from "lucide-react";
+import { useEffect } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { AreaActivity, RadialGauge } from "@/components/charts";
-import { ErrorState, LoadingState } from "@/components/shared";
-import { useScenePublish } from "@/components/shell/scene-graph-context";
-import { useActivity, useStats } from "@/hooks";
-import { statsToGraph } from "@/lib/constellation/graph";
+import { GlassPanel } from "@/components/primitives";
+import {
+ ErrorState,
+ LoadingState,
+ SkeletonHero,
+ SkeletonMetricRow,
+ SkeletonPanel,
+} from "@/components/shared";
+import { MetricTile, SectionHeader } from "@/components/shared/section";
+import {
+ useActivity,
+ useStats,
+ useTopReactions,
+ useTopReactors,
+} from "@/hooks";
import { formatNumber } from "@/lib/format";
import type { DashboardStats } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
@@ -36,19 +50,11 @@ export function DashboardView({
initialStats?: DashboardStats;
initialActivity?: Awaited
>["data"];
}) {
- const { data: stats, error } = useStats(initialStats);
+ const { data: stats, isLoading, error } = useStats(initialStats);
const { data: activity } = useActivity(14, initialActivity as never);
+ const { data: reactors } = useTopReactors();
+ const { data: reactions } = useTopReactions();
const ambient = useAmbient();
- const publish = useScenePublish();
-
- const graph = useMemo(
- () => (stats ? statsToGraph(stats) : { nodes: [], edges: [] }),
- [stats],
- );
-
- useEffect(() => {
- publish({ graph, focus: "guild" });
- }, [graph, publish]);
useEffect(() => {
const s = deriveSignal(stats);
@@ -60,134 +66,303 @@ export function DashboardView({
}, [stats, ambient]);
if (error && !stats) return ;
- if (!stats) return ;
+ if (!stats && isLoading)
+ return (
+
+ );
+ if (!stats) return ;
const s = stats;
const total = s.total_flagged + s.total_clean || 1;
const cleanRatio = s.total_clean / total;
return (
-
- {/* Metric whisper — top-left under brand */}
-
- Operations · {deriveSignal(s).label}
-
-
+ {/* Hero */}
+
+
+
+
+
GMW · Operations Grid
+
+ Ambient Field
+
+
+ Real-time moderation, voice & media presence across the monitored
+ guild. {formatNumber(s.total_messages)} messages captured.
+
+
+
+
+
+ {deriveSignal(s).label}
+
+
+
+
+
+ }
+ className="animate-stagger"
+ style={staggerDelay(0)}
/>
- 0 ? "vermilion" : "ink"}
+ tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
hint={`${s.today_flagged} today`}
+ className="animate-stagger"
+ style={staggerDelay(1)}
/>
- }
+ className="animate-stagger"
+ style={staggerDelay(2)}
/>
- }
+ className="animate-stagger"
+ style={staggerDelay(3)}
/>
-
+
- {/* Activity ribbon — bottom-left */}
-
-
- 14-day signal
-
+ {/* Activity */}
+
+
+ Activity & moderation
+
+ }
+ action={
+
+
+ messages
+
+
+ flagged
+
+
+ }
+ />
{activity ? (
) : (
)}
-
+
- {/* Moderation gauge — right */}
-
-
- Trust
-
- 0.8
- ? "signal"
- : cleanRatio > 0.6
- ? "amber"
- : "vermilion"
- }
- label={`${Math.round(cleanRatio * 100)}%`}
- sublabel="clean"
- />
-
-
-
- flagged
-
- {formatNumber(s.total_flagged)}
-
-
- warned
- {formatNumber(s.total_warned)}
-
-
- pending
- {s.moderation_overview.pending}
-
-
- processing
- {s.moderation_overview.processing}
-
-
-
+ {/* Two-column: channels + moderation */}
+
+
+
+
+ {s.top_channels.slice(0, 7).map((c) => {
+ const pct =
+ (c.message_count / (s.top_channels[0]?.message_count || 1)) *
+ 100;
+ return (
+
+
+ {c.channel_name ?? c.channel_id.slice(0, 8)}
+
+
+
+ {formatNumber(c.message_count)}
+
+
+ );
+ })}
+
+
+
+
+
+
+
0.8
+ ? "signal"
+ : cleanRatio > 0.6
+ ? "amber"
+ : "vermilion"
+ }
+ label={`${Math.round(cleanRatio * 100)}%`}
+ sublabel="clean"
+ />
+
+
}
+ label="Clean"
+ value={formatNumber(s.total_clean)}
+ />
+
}
+ label="Flagged"
+ value={formatNumber(s.total_flagged)}
+ />
+
}
+ label="Warned"
+ value={formatNumber(s.total_warned)}
+ />
+
+
+
+
+
+
+
+
+
+
+ {/* Reactors + reactions */}
+
+
+
+
+ {(reactors ?? []).slice(0, 6).map((r, i) => {
+ const maxNet = reactors?.[0]?.net_count || 1;
+ const pct = Math.max(4, Math.round((r.net_count / maxNet) * 100));
+ return (
+
+
{i + 1}
+
+ {r.username}
+
+
+
+ +{formatNumber(r.net_count)}
+
+
+ );
+ })}
+ {(reactors ?? []).length === 0 &&
}
+
+
+
+
+
+
+ {(reactions ?? []).slice(0, 5).map((m) => (
+
+
+ {m.top_emojis.slice(0, 3).map((e, i) => (
+
+ {e.emoji}
+
+ ))}
+
+
+
+ {m.content || "(no text)"}
+
+
+ {m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}
+
+
+
+ {m.reaction_count}
+
+
+ ))}
+ {(reactions ?? []).length === 0 &&
}
+
+
+
);
}
-function WhisperRow({
+function Row({
+ icon,
+ label,
+ value,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+}) {
+ return (
+
+ {icon}
+ {label}
+ {value}
+
+ );
+}
+
+function Mini({
label,
value,
tone,
- hint,
}: {
label: string;
- value: string;
- tone: "ink" | "signal" | "vermilion";
- hint?: string;
+ value: number;
+ tone: "signal" | "amber" | "vermilion";
}) {
const color =
tone === "vermilion"
? "text-vermilion"
- : tone === "signal"
- ? "text-signal"
- : "text-ink";
+ : tone === "amber"
+ ? "text-amber"
+ : "text-signal";
return (
-
-
- {label}
-
- {value}
- {hint ? (
-
- {hint}
-
- ) : null}
-
+
+ );
+}
+
+function EmptyHint() {
+ return (
+
+ Awaiting data…
+
);
}
diff --git a/services/frontend/src/app/(dashboard)/glossary/view.tsx b/services/frontend/src/app/(dashboard)/glossary/view.tsx
index fe36d0c8..19098096 100644
--- a/services/frontend/src/app/(dashboard)/glossary/view.tsx
+++ b/services/frontend/src/app/(dashboard)/glossary/view.tsx
@@ -1,118 +1,15 @@
"use client";
-/**
- * Glossary scene — term KB as a satellite ring on the stage; the search
- * whisper filters nodes live, and a term's definition floats in a dossier.
- */
-import { useEffect, useMemo, useState } from "react";
import { SkeletonPanel } from "@/components/shared";
-import {
- useSceneFocusSetter,
- useSceneGraph,
- useScenePublish,
-} from "@/components/shell/scene-graph-context";
+import { TermGlossary } from "@/components/TermGlossary";
import { useGlossary } from "@/hooks";
-import type { ConstellationGraph } from "@/lib/constellation/graph";
import type { GlossaryRow } from "@/lib/types";
-/** Terms → satellite ring (deterministic positions come from layout). */
-function termsToGraph(rows: GlossaryRow[]): ConstellationGraph {
- return {
- nodes: rows.slice(0, 60).map((t) => ({
- id: `term:${t.term}`,
- label: t.term,
- kind: "term" as const,
- value: Math.min(1, 0.3 + t.definition.length / 400),
- })),
- edges: [],
- };
-}
-
export function GlossaryView({
initialTerms,
}: {
initialTerms?: GlossaryRow[];
}) {
const { data: terms } = useGlossary(100, initialTerms);
- const publish = useScenePublish();
- const setFocus = useSceneFocusSetter();
- const { state } = useSceneGraph();
- const [query, setQuery] = useState("");
-
- const rows = terms ?? [];
- const filtered = useMemo(() => {
- const q = query.trim().toLowerCase();
- if (!q) return rows;
- return rows.filter(
- (t) =>
- t.term.toLowerCase().includes(q) ||
- t.definition.toLowerCase().includes(q),
- );
- }, [rows, query]);
-
- const graph = useMemo(() => termsToGraph(filtered), [filtered]);
-
- useEffect(() => {
- publish({ graph, focus: null });
- }, [graph, publish]);
-
- useEffect(() => () => setFocus(null), [setFocus]);
-
- const selectedId = state?.focus ?? null;
- const selectedTerm = useMemo(
- () =>
- selectedId
- ? (rows.find((t) => `term:${t.term}` === selectedId) ?? null)
- : null,
- [rows, selectedId],
- );
-
- return (
-
-
-
- {selectedTerm ? (
-
- ) : (
-
- klik sebuah satelit untuk membuka definisi
-
- )}
-
- );
+ return terms ? : ;
}
diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx
index 4e504646..eaa0fbdf 100644
--- a/services/frontend/src/app/(dashboard)/layout.tsx
+++ b/services/frontend/src/app/(dashboard)/layout.tsx
@@ -1,7 +1,7 @@
import { AmbientProvider } from "@/components/ambient/ambient-context";
import { Chatbot } from "@/components/chatbot/chatbot";
import { CommandPalette } from "@/components/command/command-palette";
-import { ConstellationFrame } from "@/components/shell";
+import { AppFrame } from "@/components/shell";
import { WsProvider } from "@/lib/ws/context";
export default function DashboardLayout({
@@ -10,7 +10,7 @@ export default function DashboardLayout({
return (
- {children}
+ {children}
diff --git a/services/frontend/src/app/(dashboard)/media/view.tsx b/services/frontend/src/app/(dashboard)/media/view.tsx
index 1972387a..35598492 100644
--- a/services/frontend/src/app/(dashboard)/media/view.tsx
+++ b/services/frontend/src/app/(dashboard)/media/view.tsx
@@ -1,10 +1,5 @@
"use client";
-/**
- * Media scene — the queue becomes a spiral of nodes on the stage; the
- * player console (disc + transport + URL bar) floats bottom-left, and
- * the up-next list docks right.
- */
import {
ListMusic,
Play,
@@ -14,14 +9,15 @@ import {
Square,
Volume2,
} from "lucide-react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
-import { Button, Input, toast } from "@/components/primitives";
-import { ErrorState, SkeletonHero } from "@/components/shared";
+import { Button, GlassPanel, Input, toast } from "@/components/primitives";
import {
- useSceneFocusSetter,
- useScenePublish,
-} from "@/components/shell/scene-graph-context";
+ ErrorState,
+ SectionHeader,
+ SkeletonHero,
+ SkeletonPanel,
+} from "@/components/shared";
import {
useMediaLoop,
useMediaQueue,
@@ -30,37 +26,11 @@ import {
useMediaStop,
useMediaWsSync,
} from "@/hooks";
-import type { ConstellationGraph } from "@/lib/constellation/graph";
import { formatDuration } from "@/lib/format";
import type { MediaState } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
-function queueGraph(media: MediaState | undefined): ConstellationGraph {
- const items = (media?.queue ?? []).slice(0, 24);
- return {
- nodes: [
- {
- id: "now-playing",
- label: media?.current?.title ?? "media",
- kind: "guild",
- value: 1,
- },
- ...items.map((it, i) => ({
- id: `track:${i}:${it.source}`,
- label: it.title,
- kind: "media" as const,
- value: Math.max(0.2, 0.8 - i * 0.05),
- href: undefined,
- })),
- ],
- edges: items.map((it, i) => ({
- source: "now-playing",
- target: `track:${i}:${it.source}`,
- })),
- };
-}
-
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const ws = useWebSocket();
const { data: media, isLoading, error } = useMediaState(initialStatus);
@@ -70,8 +40,6 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const loop = useMediaLoop();
useMediaWsSync(ws);
const ambient = useAmbient();
- const publish = useScenePublish();
- const setFocus = useSceneFocusSetter();
const [url, setUrl] = useState("");
@@ -83,19 +51,6 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
0,
);
- const graph = useMemo(() => queueGraph(media), [media]);
- useEffect(() => {
- publish({ graph, focus: null });
- }, [graph, publish]);
-
- useEffect(
- () => () => {
- publish({ graph: { nodes: [], edges: [] }, focus: null });
- setFocus(null);
- },
- [publish, setFocus],
- );
-
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
useEffect(() => {
ambient.set(
@@ -125,105 +80,126 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
};
if (error && !media) return ;
- if (!media && isLoading) return ;
+ if (!media && isLoading)
+ return (
+
+
+
+
+ );
return (
-
- {/* Now playing — top-center whisper */}
-
-
+
+
+
+
- {current?.thumbnailUrl ? (
- // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
-
- ) : (
-
- )}
+
+ {current?.thumbnailUrl ? (
+ // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
+
+ ) : (
+
+ )}
+
-
-
- {playing ? (
-
- {[0, 1, 2].map((i) => (
-
- ))}
-
- ) : null}
- {playing ? "Now playing" : current ? "Paused" : "Nothing queued"}
-
-
- {current?.title ?? "Nothing queued"}
- {formatDuration(current?.durationMs)
- ? ` · ${formatDuration(current?.durationMs)}`
- : ""}
-
-
-
-
- {/* Console — bottom-left */}
-
-
-
setUrl(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && onPlay()}
- />
+
+
+ {playing ? (
+ <>
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ Now playing
+ >
+ ) : current ? (
+ "Paused"
+ ) : (
+ "Nothing queued"
+ )}
+
+
+ {current?.title ?? "Nothing queued"}
+
+
+ {current?.source && (
+ {current.source}
+ )}
+ {current?.mode && (
+ {current.mode}
+ )}
+ {formatDuration(current?.durationMs) && (
+
+ {formatDuration(current?.durationMs)}
+
+ )}
+
+
+
+ Queue & play
+
+
skip.mutate()}
+ disabled={skip.isPending}
+ >
+ Skip
+
+
stop.mutate()}
+ disabled={stop.isPending}
+ >
+ Stop
+
+
loop.mutate(!media?.loop)}
+ aria-pressed={!!media?.loop}
+ >
+ Loop
+
+
+
-
-
- Queue & play
-
-
skip.mutate()}
- disabled={skip.isPending}
- >
- Skip
-
-
stop.mutate()}
- disabled={stop.isPending}
- >
- Stop
-
-
loop.mutate(!media?.loop)}
- aria-pressed={!!media?.loop}
- >
- Loop
-
-
-
-
+
+
+
+ setUrl(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && onPlay()}
+ />
+
+
+
+
-
+
{Math.round((media?.musicVolume ?? 0) * 100)}%
-
+
- {/* Up next — right dock */}
-
-
- up next
-
- {queueList.length} track{queueList.length === 1 ? "" : "s"}
- {queueTotal ? ` · ${formatDuration(queueTotal)}` : ""}
-
-
-
- {queueList.length === 0 ? (
-
-
-
- Queue is empty
-
-
- Paste a URL to start playback.
-
+
+
+ {queueList.length} track{queueList.length === 1 ? "" : "s"}
+ {queueTotal && ` · ${formatDuration(queueTotal)}`}
+
+ }
+ />
+ {queueList.length === 0 ? (
+
+
+
Queue is empty
+
+ Paste a URL above to start playback.
- ) : (
-
- {queueList.map((item, i) => {
- const isNext = i === 0 && playing;
- return (
-
-
- {i + 1}
+
+ ) : (
+
+ {queueList.map((item, i) => {
+ const isNext = i === 0 && playing;
+ return (
+
+
{i + 1}
+ {item.thumbnailUrl ? (
+ // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
+
+ ) : (
+
+
- {item.thumbnailUrl ? (
- // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
-
- ) : (
-
-
-
- )}
-
-
- {item.title}
-
-
- {item.source}
-
+ )}
+
+
+ {item.title}
- {isNext ? (
-
- up next
-
- ) : null}
-
+
+ {item.source}
+
+
+ {isNext && (
+
+ up next
+
+ )}
+
+ {item.mode ?? "music"}
+
+ {formatDuration(item.durationMs) && (
+
{formatDuration(item.durationMs)}
-
- );
- })}
-
- )}
-
-
+ )}
+
+ );
+ })}
+
+ )}
+
);
}
diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx
index dacccbc9..093f1871 100644
--- a/services/frontend/src/app/(dashboard)/messages/view.tsx
+++ b/services/frontend/src/app/(dashboard)/messages/view.tsx
@@ -1,11 +1,5 @@
"use client";
-/**
- * Messages scene — the live feed becomes an orbital belt of nodes on the
- * stage; the functional console (picker/search/modes) floats top-left,
- * the stream itself is a translucent dossier column, and a selected
- * message opens its inspection dossier bottom-center.
- */
import {
AlertTriangle,
Calendar,
@@ -21,13 +15,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
import { useAmbient } from "@/components/ambient/ambient-context";
import { EditHistory } from "@/components/EditHistory";
-import { Avatar, Badge, Skeleton } from "@/components/primitives";
-import { EmptyState, ErrorState, SkeletonRows } from "@/components/shared";
-import { GuildChannelPicker } from "@/components/shared/guild-picker";
import {
- useSceneFocusSetter,
- useScenePublish,
-} from "@/components/shell/scene-graph-context";
+ Avatar,
+ Badge,
+ GlassPanel,
+ Input,
+ Skeleton,
+} from "@/components/primitives";
+import {
+ EmptyState,
+ ErrorState,
+ SectionHeader,
+ SkeletonRows,
+} from "@/components/shared";
+import { GuildChannelPicker } from "@/components/shared/guild-picker";
import {
useLoadMore,
useMessageActivity,
@@ -41,7 +42,6 @@ import {
useSemanticSearch,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
-import type { ConstellationGraph } from "@/lib/constellation/graph";
import {
formatBytes,
formatDuration,
@@ -59,24 +59,6 @@ import type {
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
-/** Recent messages → orbital belt (chained edges give the belt shape). */
-function beltGraph(list: MessageRecord[]): ConstellationGraph {
- const recent = list.slice(0, 36);
- const nodes = recent.map((m, i) => ({
- id: `msg:${m.id}`,
- label: m.username,
- kind:
- m.ai_status === "flagged" ? ("flagged" as const) : ("message" as const),
- value: Math.max(0.15, 1 - i / Math.max(1, recent.length)),
- href: undefined,
- }));
- const edges = nodes.slice(0, -1).map((n, i) => ({
- source: n.id,
- target: nodes[i + 1]?.id ?? n.id,
- }));
- return { nodes, edges };
-}
-
export function MessagesView({
initialGuilds,
initialGuildId,
@@ -98,11 +80,15 @@ export function MessagesView({
const [channelId, setChannelId] = useState
(null);
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState("");
+ // Search mode: "exact" (substring match over captured messages) or
+ // "semantic" (vector similarity over the persistent Qdrant archive).
const [semanticMode, setSemanticMode] = useState(false);
+ // feed | timeline: "timeline" groups messages into date-grouped cards.
const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed");
+ // Guard against loading the entire history on a long scroll: cap how many
+ // older pages we append. Each page is 50 messages (backend limit default).
const MAX_OLDER_PAGES = 10;
const [loadedPages, setLoadedPages] = useState(0);
- const [showIntel, setShowIntel] = useState(false);
const {
data: messages,
@@ -113,7 +99,11 @@ export function MessagesView({
channelId ?? undefined,
initialMessages ?? undefined,
);
+ // Stream history one message per WS frame (replaces the 50-row batched fetch).
+ // Drives snapshots into the SWR list above as they arrive; falls back to the
+ // SSR `initialMessages` seed if WS is unavailable.
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
+ // Cursor to the next (older) page + whether more history exists.
const { data: pageInfo } = useMessagesHasMore(
guildId ?? "",
channelId ?? undefined,
@@ -134,12 +124,10 @@ export function MessagesView({
const edits = useRecentEdits(50, undefined, initialEdits);
const detail = useMessageDetail(selected);
const ambient = useAmbient();
- const publish = useScenePublish();
- const setFocus = useSceneFocusSetter();
-
- const scrollRef = useRef(null);
- const nearBottomRef = useRef(true);
+ // Fetch the next (older) page via cursor and bump the loaded-page counter.
+ // Older messages prepend at the top, so preserve the viewport by offsetting
+ // scrollTop by the height added above (Discord keeps your place while loading).
const loadOlder = useCallback(async () => {
if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return;
const el = scrollRef.current;
@@ -164,32 +152,21 @@ export function MessagesView({
const searching = query.trim().length >= 2 && !semanticMode;
const semanticSearching = query.trim().length >= 2 && semanticMode;
const list = searching ? (search.data ?? []) : (messages ?? []);
+ // Discord-style order: oldest at the top, newest at the bottom. The backend
+ // returns DESC (newest first); reverse so the feed reads top→bottom like DC.
const display = useMemo(() => [...list].reverse(), [list]);
- const graph = useMemo(
- () => (searching ? { nodes: [], edges: [] } : beltGraph(list)),
- [list, searching],
- );
-
- useEffect(() => {
- publish({ graph, focus: selected ? `msg:${selected}` : null });
- }, [graph, selected, publish]);
-
- useEffect(
- () => () => {
- publish({ graph: { nodes: [], edges: [] }, focus: null });
- setFocus(null);
- },
- [publish, setFocus],
- );
-
+ // Timeline mode: inject date-separator headers above the first message of
+ // each day. Messages are sorted oldest→newest (display is reversed), so a
+ // date change means a new group. Produces an array of either "date" or "msg"
+ // nodes so the render loop can switch easily.
const timelineNodes = useMemo(() => {
if (viewMode !== "timeline") return null;
const out: Array<
| { type: "date"; label: string; iso: string }
| { type: "msg"; m: (typeof display)[number] }
> = [];
- let prevDate = "";
+ let prev = "";
for (const m of display) {
const d = new Date(m.created_at).toLocaleDateString(undefined, {
weekday: "short",
@@ -197,15 +174,24 @@ export function MessagesView({
day: "numeric",
});
const iso = new Date(m.created_at).toISOString().slice(0, 10);
- if (d !== prevDate) {
+ if (d !== prev) {
out.push({ type: "date", label: d, iso });
- prevDate = d;
+ prev = d;
}
out.push({ type: "msg", m });
}
return out;
}, [display, viewMode]);
+ // Ref to the scroll container so we can manage scroll position like Discord:
+ // open at the bottom (newest), keep the viewport stable when prepending older
+ // messages at the top, and follow new live messages only when already near
+ // the bottom.
+ const scrollRef = useRef(null);
+ const nearBottomRef = useRef(true);
+
+ // Scroll to the bottom on the first load / when switching guild-channel, so
+ // the newest messages are visible (Discord behaviour).
const firstLoadRef = useRef(true);
useEffect(() => {
if (firstLoadRef.current && display.length > 0) {
@@ -215,6 +201,8 @@ export function MessagesView({
}
}, [display.length]);
+ // When a new live message lands (list grows, still searching off), follow it
+ // to the bottom only if the user was already near the bottom.
const prevLen = useRef(list.length);
useEffect(() => {
if (searching) return;
@@ -227,12 +215,8 @@ export function MessagesView({
}, [list.length, searching]);
return (
-
- {/* Console whisper — top-left */}
-
+
+
-
-
-
-
setQuery(e.target.value)}
- placeholder="Search messages…"
- className="h-9 w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 pl-9 pr-3 font-mono text-xs text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]"
+
+
+ setQuery(e.target.value)}
+ />
+
+
setSemanticMode((v) => !v)}
+ className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
+ semanticMode
+ ? "border-signal/40 bg-signal/10 text-signal"
+ : "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
+ }`}
+ title="Toggle semantic (vector) search over the message archive"
+ >
+ {semanticMode ? "Semantic" : "Exact"}
+
+
+ setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
+ }
+ className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
+ viewMode === "timeline"
+ ? "border-signal/40 bg-signal/10 text-signal"
+ : "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
+ }`}
+ title="Toggle timeline (date-grouped) view"
+ >
+ {viewMode === "timeline" ? "Timeline" : "Feed"}
+
+
+
+
+ {semanticSearching && (
+
+
+ {semantic.data?.length ?? 0} matches
+
+ }
/>
-
-
setSemanticMode((v) => !v)}
- className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
- semanticMode
- ? "border-signal/40 bg-signal/10 text-signal"
- : "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
- }`}
- title="Toggle semantic (vector) search over the message archive"
- >
- {semanticMode ? "Semantic" : "Exact"}
-
-
- setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
+ {semantic.isLoading ? (
+
+ ) : semantic.data && semantic.data.length > 0 ? (
+
+ {semantic.data.map((r, i) => (
+
+
+
+
+ {(r.score * 100).toFixed(0)}%
+
+
+ {formatRelativeTime(r.created_at)}
+
+
+
+ {r.content}
+
+
+
+ ))}
+
+ ) : (
+ }
+ title="No semantic matches"
+ description="Try different wording — semantic search finds meaning, not exact text."
+ />
+ )}
+
+ )}
+
+
+
+ {list.length} shown
+
}
- className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
- viewMode === "timeline"
- ? "border-signal/40 bg-signal/10 text-signal"
- : "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
- }`}
- title="Toggle timeline (date-grouped) view"
- >
- {viewMode === "timeline" ? "Timeline" : "Feed"}
-
-
-
-
- {/* Stream dossier — right column */}
-
-
-
- {searching ? `“${query}”` : "live stream"}
-
-
- {list.length} shown
-
-
-
-
- {semanticSearching ? (
-
- ) : error && !messages ? (
+ />
+ {error && !messages ? (
) : isLoading && !messages ? (
) : list.length === 0 ? (
}
+ icon={
}
title="No messages"
description="Pick a guild to begin, or run a search."
/>
) : (
-
+
{!searching && (
-
+
{loadMore.isPending ? (
-
+
Loading older…
@@ -326,19 +348,19 @@ export function MessagesView({
↑ Load older messages
) : loadedPages >= MAX_OLDER_PAGES ? (
-
+
capped at {MAX_OLDER_PAGES} older pages · use search for
more
) : (
messages &&
messages.length > 0 && (
-
+
beginning of history
)
@@ -347,9 +369,11 @@ export function MessagesView({
)}
{
const el = e.currentTarget;
+ // Track whether the user is near the bottom (to follow live
+ // messages) and auto-load older messages when scrolled to top.
nearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (searching || !hasMore || loadMore.isPending) return;
@@ -360,11 +384,11 @@ export function MessagesView({
}}
>
{viewMode === "timeline" && timelineNodes
- ? timelineNodes.map((node) =>
+ ? timelineNodes.map((node, _i) =>
node.type === "date" ? (
{node.label}
@@ -378,7 +402,7 @@ export function MessagesView({
/>
),
)
- : display.map((m) => (
+ : display.map((m, _i) => (
)}
-
-
+
- {/* Mobile stream — full-width sheet under the console */}
-
- {error && !messages ? (
-
- ) : isLoading && !messages ? (
-
- ) : display.length === 0 ? (
-
- ) : (
- display.map((m) => (
-
+
+ {!selected ? (
+
- ))
- )}
-
-
- {/* Inspection dossier — bottom-center */}
- {selected ? (
-
- setSelected(null)}
- >
- esc
-
- {detail.loading ? (
+ ) : detail.loading ? (
-
-
+
+
) : detail.message ? (
)}
-
- ) : null}
-
- {/* Intel strip — bottom-left */}
-
-
setShowIntel((v) => !v)}
- className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
- showIntel
- ? "border-signal/40 bg-signal/10 text-signal"
- : "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
- }`}
- >
- intel {showIntel ? "▲" : "▼"}
-
- {showIntel ? (
-
- {activity.data && activity.data.length > 0 ? (
-
- ) : null}
- {edits.data ?
: null}
-
- ) : null}
+
-
- );
-}
-function SemanticResults({
- semantic,
-}: {
- semantic: ReturnType
;
-}) {
- if (semantic.isLoading) return ;
- if (!semantic.data || semantic.data.length === 0) {
- return (
- }
- title="No semantic matches"
- description="Try different wording — semantic search finds meaning, not exact text."
- />
- );
- }
- return (
-
- {semantic.data.map((r, i) => (
-
-
-
- {(r.score * 100).toFixed(0)}%
-
-
- {formatRelativeTime(r.created_at)}
-
-
-
- {r.content}
-
-
- ))}
+ {activity.data && activity.data.length > 0 && (
+
+ )}
+
+ {edits.data &&
}
);
}
@@ -556,12 +493,10 @@ function MessageDetail({
return (
-
+
-
- {m.username}
-
-
+
{m.username}
+
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
@@ -573,21 +508,21 @@ function MessageDetail({
-
+
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
"(no text)"}
- {m.ai_analysis ? (
+ {m.ai_analysis && (
AI analysis
-
- ) : null}
+ )}
- {flags.length > 0 || cats.length > 0 ? (
+ {(flags.length > 0 || cats.length > 0) && (
{flags.map((f) => (
@@ -600,9 +535,9 @@ function MessageDetail({
))}
- ) : null}
+ )}
- {attachments.length > 0 ? (
+ {attachments.length > 0 && (
Attachments ({attachments.length})
@@ -614,22 +549,23 @@ function MessageDetail({
href={a.discord_url ?? a.uploaded_url ?? "#"}
target="_blank"
rel="noreferrer"
- className="flex items-center gap-2 rounded-xl border border-[var(--color-hairline)] px-3 py-2 font-mono text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
+ className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink"
>
{a.filename}
-
+
{formatBytes(a.size)}
))}
- ) : null}
+ )}
);
}
+/** Single message card used by both the live feed and the date-grouped timeline. */
function MessageRow({
m,
selected,
@@ -641,32 +577,31 @@ function MessageRow({
}) {
return (
onSelect(m.id)}
- className={`animate-stagger flex w-full items-start gap-3 rounded-xl border p-2.5 text-left transition-colors ${
+ className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
selected === m.id
? "border-signal/40 bg-signal/8"
- : "border-[var(--color-hairline)] hover:bg-white/[0.04]"
+ : "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
}`}
>
-
+
-
+
{m.username}
-
+
{getMessageChannelLabel(m)}
-
+
{formatRelativeTime(m.created_at)}
-
+
{renderMessageContent(m.content, m.metadata) || (
-
- (empty / embed)
-
+ (empty / embed)
)}
diff --git a/services/frontend/src/app/(dashboard)/moderation/view.tsx b/services/frontend/src/app/(dashboard)/moderation/view.tsx
index 1e546639..80e4b542 100644
--- a/services/frontend/src/app/(dashboard)/moderation/view.tsx
+++ b/services/frontend/src/app/(dashboard)/moderation/view.tsx
@@ -1,34 +1,40 @@
"use client";
-/**
- * Moderation scene — flagged actions orbit a verdict hub on the stage
- * (vermilion stars); the overlay carries the metric whisper (left),
- * the live feed as a bottom ribbon, and an intel dossier (right) with
- * trends / coverage / domains / heatmap / drilldown.
- */
import {
AlertTriangle,
Ban,
+ CheckCircle2,
+ Clock,
Filter,
MessageSquareWarning,
MicOff,
ShieldAlert,
Trash2,
UserX,
+ XCircle,
} from "lucide-react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { CategoryDrilldown } from "@/components/CategoryDrilldown";
import { CoverageTiles } from "@/components/CoverageTiles";
import { Donut } from "@/components/charts";
+import { LiveModerationFeed } from "@/components/LiveModerationFeed";
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
-import { Badge, Select, type SelectOption } from "@/components/primitives";
-import { ScamDomains } from "@/components/ScamDomains";
-import { ErrorState } from "@/components/shared";
import {
- useSceneFocusSetter,
- useScenePublish,
-} from "@/components/shell/scene-graph-context";
+ Badge,
+ GlassPanel,
+ Select,
+ type SelectOption,
+} from "@/components/primitives";
+import { ScamDomains } from "@/components/ScamDomains";
+import {
+ ErrorState,
+ MetricTile,
+ SectionHeader,
+ SkeletonMetricRow,
+ SkeletonPanel,
+ SkeletonRows,
+} from "@/components/shared";
import { TopChannels } from "@/components/TopChannels";
import { TopicTrends } from "@/components/TopicTrends";
import {
@@ -43,7 +49,6 @@ import {
useTopFlaggedDomains,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
-import type { ConstellationGraph } from "@/lib/constellation/graph";
import { downloadCsv } from "@/lib/csv";
import { formatNumber, formatRelativeTime } from "@/lib/format";
import type {
@@ -51,6 +56,7 @@ import type {
ModerationActionType,
ModerationStats,
} from "@/lib/types";
+import { staggerDelay } from "@/lib/utils";
const ACTION_ICON: Record
= {
delete_message: ,
@@ -68,35 +74,6 @@ const ACTION_LABEL: Record = {
ban_user: "Ban",
};
-/** Flagged actions → vermilion satellites around a verdict hub. */
-function moderationGraph(
- liveActions: ModerationAction[],
- stats?: ModerationStats,
-): ConstellationGraph {
- const recent = liveActions.slice(0, 24);
- const nodes = [
- { id: "verdict", label: "verdict hub", kind: "guild" as const, value: 1 },
- ...recent.map((a) => ({
- id: `mod:${a.id}`,
- label: a.username ?? (a.user_id ?? "unknown").slice(0, 8),
- kind:
- a.status === "failed"
- ? ("flagged" as const)
- : a.status === "pending"
- ? ("message" as const)
- : ("channel" as const),
- value: a.status === "executed" ? 0.55 : 0.35,
- href: undefined,
- meta: { mod_status: a.status },
- })),
- ];
- void stats;
- return {
- nodes,
- edges: recent.map((a) => ({ source: "verdict", target: `mod:${a.id}` })),
- };
-}
-
export function ModerationView({
initialStats,
initialActions,
@@ -119,17 +96,13 @@ export function ModerationView({
const { data: hourly } = useHourlyModeration(30);
const { data: coverage } = useModerationCoverage(30);
const [drilldown, setDrilldown] = useState(null);
- const [intelOpen, setIntelOpen] = useState(false);
const { data: categoryActions, isValidating: categoryLoading } =
useModerationByCategory(drilldown ? 30 : 0, drilldown);
- const publish = useScenePublish();
- const setFocus = useSceneFocusSetter();
-
const failedRate = stats ? stats.failed_rate * 100 : 0;
const byAction = stats?.by_action ?? {};
- const segments = Object.entries(byAction).map(([k]) => ({
+ const segments = Object.entries(byAction).map(([k, _v]) => ({
value: 1,
color:
k === "ban_user" || k === "kick_user"
@@ -140,23 +113,6 @@ export function ModerationView({
label: k,
}));
- const graph = useMemo(
- () => moderationGraph(liveActions, stats),
- [liveActions, stats],
- );
-
- useEffect(() => {
- publish({ graph, focus: null });
- }, [graph, publish]);
-
- useEffect(
- () => () => {
- publish({ graph: { nodes: [], edges: [] }, focus: null });
- setFocus(null);
- },
- [publish, setFocus],
- );
-
const ambient = useAmbient();
useEffect(() => {
ambient.set(
@@ -169,9 +125,11 @@ export function ModerationView({
if (error && !stats) return ;
if (!stats && isLoading)
return (
-
- menghubungkan verdict hub…
-
+
+
+
+
+
);
if (!stats) return ;
@@ -190,204 +148,193 @@ export function ModerationView({
];
return (
-
- {/* Metric whisper — left */}
-
-
- Enforcement
-
-
-
-
- 0 ? "vermilion" : "ink"}
- />
- 0 ? "amber" : "ink"}
- />
-
-
-
-
- {Object.entries(byAction).map(([k, v]) => {
- const count = typeof v === "number" ? v : null;
- return (
-
- {ACTION_ICON[k as ModerationActionType]}
-
- {ACTION_LABEL[k as ModerationActionType] ?? k}
-
- {count !== null ? {count} : null}
-
- );
- })}
-
-
-
-
- {/* Intel dossier — right */}
-
-
setIntelOpen((v) => !v)}
- className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
- intelOpen
- ? "border-signal/40 bg-signal/10 text-signal"
- : "border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 text-[var(--color-ink-soft)] backdrop-blur-md hover:text-[var(--color-ink)]"
- }`}
- >
- intel {intelOpen ? "▲" : "▼"}
-
- {intelOpen ? (
-
- {trends ? : null}
- {coverage ? : null}
- {domains ? : null}
- {hourly ? : null}
- {channels ? : null}
-
-
- ) : null}
+
+
+ }
+ />
+ }
+ />
+ 0 ? "vermilion" : "neutral"}
+ icon={ }
+ />
+ 0 ? "amber" : "neutral"}
+ icon={ }
+ />
- {/* Live feed ribbon — bottom */}
-
-
-
-
- action log
-
-
-
-
-
- downloadCsv(
- "moderation-actions.csv",
- (actions ?? []).map((a) => ({
- id: a.id,
- user: a.username ?? a.user_id,
- action_type: a.action_type,
- status: a.status,
- severity: a.severity ?? "",
- categories: (a.categories ?? []).join("|"),
- reason: a.reason ?? "",
- created_at: a.created_at
- ? new Date(a.created_at).toISOString()
- : "",
- })),
- )
+
+
+ {trends ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {coverage ? (
+
+ ) : (
+
+ )}
+
+
+ {domains ? (
+
+ ) : (
+
+ )}
+
+
+ {hourly ? (
+
+ ) : (
+
+ )}
+
+
+ {channels ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
- CSV
-
+ centerLabel={`${Math.round(failedRate)}%`}
+ centerSub="fail rate"
+ />
+
+ {Object.entries(byAction).map(([k, v]) => {
+ const count = typeof v === "number" ? v : null;
+ return (
+
+
+ {ACTION_ICON[k as ModerationActionType]}
+
+
+ {ACTION_LABEL[k as ModerationActionType] ?? k}
+
+ {count !== null && (
+ {count}
+ )}
+
+ );
+ })}
+ {Object.keys(byAction).length === 0 && (
+
+ No actions recorded yet.
+
+ )}
+
-
-
-
-
- );
-}
+
-function ActionRows({
- actions,
- liveCount,
-}: {
- actions: ModerationAction[];
- liveCount: number;
-}) {
- if (actions.length === 0 && liveCount === 0) {
- return (
-
- No matching actions.
+
+
+
+
+
+
+ downloadCsv(
+ "moderation-actions.csv",
+ (actions ?? []).map((a) => ({
+ id: a.id,
+ user: a.username ?? a.user_id,
+ action_type: a.action_type,
+ status: a.status,
+ severity: a.severity ?? "",
+ categories: (a.categories ?? []).join("|"),
+ reason: a.reason ?? "",
+ created_at: a.created_at
+ ? new Date(a.created_at).toISOString()
+ : "",
+ })),
+ )
+ }
+ className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]"
+ title="Download moderation actions as CSV"
+ >
+ CSV
+
+
+ }
+ />
+
+ {(actions ?? []).map((a, i) => (
+
+ ))}
+ {(actions ?? []).length === 0 && (
+
+ No matching actions.
+
+ )}
+
+
- );
- }
- return (
-
- {actions.map((a, i) => (
-
- ))}
);
}
-function Whisper({
- label,
- value,
- tone,
-}: {
- label: string;
- value: string;
- tone: "ink" | "signal" | "vermilion" | "amber";
-}) {
- const color =
- tone === "vermilion"
- ? "text-vermilion"
- : tone === "amber"
- ? "text-amber"
- : tone === "signal"
- ? "text-signal"
- : "text-ink";
- return (
-
-
- {label}
-
- {value}
-
- );
-}
-
function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
const tone =
a.status === "executed"
@@ -398,6 +345,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
const icon = ACTION_ICON[a.action_type] ?? (
);
+ // Map moderation severity → design-system tone (reuse aiTone with a
+ // severity→status projection so "none" reads as clean/signal).
const severityTone =
a.severity == null
? null
@@ -410,8 +359,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
);
return (
-
+
{a.username ?? "unknown"}
{a.status}
-
+
{formatRelativeTime(a.created_at)}
- {a.reason ? (
-
- “{a.reason}”
-
- ) : null}
- {severityTone && a.severity ? (
+ {a.reason && (
+
“{a.reason}”
+ )}
+ {severityTone && a.severity && (
{a.severity}
- {a.confidence != null ? (
-
+ {a.confidence != null && (
+
conf {(a.confidence * 100).toFixed(0)}%
- ) : null}
+ )}
- ) : null}
+ )}
{a.flags?.length ? (
{a.flags.slice(0, 6).map((f) => (
@@ -453,24 +400,24 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
) : null}
{a.evidence?.length ? (
-
+
“{a.evidence[0]}”
) : null}
- {a.executed_by ? (
-
+ {a.executed_by && (
+
by {a.executed_by}
{a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
- ) : null}
- {a.content ? (
-
+ )}
+ {a.content && (
+
{a.content}
- ) : null}
- {a.error ? (
+ )}
+ {a.error && (
{a.error}
- ) : null}
+ )}
);
diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx
index 839a9602..4268b809 100644
--- a/services/frontend/src/app/(dashboard)/recordings/view.tsx
+++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx
@@ -1,25 +1,18 @@
"use client";
-/**
- * Recordings scene — voice clips orbit as a timeline ring on the stage;
- * the clip archive docks right with inline audio players, playing clip
- * highlighted both in the dock and on the sky.
- */
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import {
Avatar,
Badge,
Button,
+ GlassCard,
+ GlassPanel,
Skeleton,
toast,
} from "@/components/primitives";
-import { EmptyState, ErrorState } from "@/components/shared";
-import {
- useSceneFocusSetter,
- useScenePublish,
-} from "@/components/shell/scene-graph-context";
+import { EmptyState, ErrorState, SectionHeader } from "@/components/shared";
import {
NowPlayingChip,
RecordingAudioPlayer,
@@ -29,29 +22,11 @@ import {
useRecordings,
useRecordingsWsSync,
} from "@/hooks";
-import type { ConstellationGraph } from "@/lib/constellation/graph";
import { formatBytes, formatRelativeTime } from "@/lib/format";
import type { VoiceRecording } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
-function recordingsGraph(items: VoiceRecording[]): ConstellationGraph {
- const recent = items.slice(0, 28);
- return {
- nodes: [
- { id: "archive", label: "archive", kind: "guild", value: 1 },
- ...recent.map((r) => ({
- id: `rec:${r.id}`,
- label: r.username,
- kind: "media" as const,
- value: 0.5,
- href: undefined,
- })),
- ],
- edges: recent.map((r) => ({ source: "archive", target: `rec:${r.id}` })),
- };
-}
-
export function RecordingsView({
initialItems,
}: {
@@ -62,25 +37,8 @@ export function RecordingsView({
const del = useDeleteRecording();
useRecordingsWsSync(ws);
const ambient = useAmbient();
- const publish = useScenePublish();
- const setFocus = useSceneFocusSetter();
const [playingId, setPlayingId] = useState
(null);
- const list = items ?? [];
-
- const graph = useMemo(() => recordingsGraph(list), [list]);
- useEffect(() => {
- publish({ graph, focus: playingId ? `rec:${playingId}` : null });
- }, [graph, playingId, publish]);
-
- useEffect(
- () => () => {
- publish({ graph: { nodes: [], edges: [] }, focus: null });
- setFocus(null);
- },
- [publish, setFocus],
- );
-
useEffect(() => {
ambient.set("signal", 0.3, "recordings");
}, [ambient]);
@@ -101,17 +59,14 @@ export function RecordingsView({
if (error && !items) return ;
if (!items && isLoading)
return (
-
+
-
- {Array.from({ length: 5 }).map((_, i) => (
-
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
@@ -120,168 +75,118 @@ export function RecordingsView({
-
+
))}
-
+
);
return (
-
- {/* Archive dock — right */}
-
-
- voice captures
-
- {list.length} clips
+
+
+ {(items ?? []).length} clips
-
-
- {list.length === 0 ? (
-
}
- title="No recordings"
- description="Voice clips captured by the bot appear here."
- />
- ) : (
-
- {list.map((r, i) => {
- const up = uploadStatus(r);
- const isPlaying = playingId === r.id;
- return (
-
-
-
-
-
- {r.username}
-
-
-
-
- {r.channel_name ?? "voice"}
-
- ·
- {formatRelativeTime(r.created_at)}
-
-
- {isPlaying ?
: null}
- {up && !isPlaying ? (
-
{up.label}
- ) : null}
-
- {formatBytes(r.size_bytes)}
+ }
+ />
+ {(items ?? []).length === 0 ? (
+ }
+ title="No recordings"
+ description="Voice clips captured by the bot appear here."
+ />
+ ) : (
+
+ {(items ?? []).map((r, i) => {
+ const up = uploadStatus(r);
+ const isPlaying = playingId === r.id;
+ return (
+
+
+
+
+
+ {r.username}
+
+
+
+
+ {r.channel_name ?? "voice"}
+
+ ·
+
+ {formatRelativeTime(r.created_at)}
+
+ {isPlaying &&
}
+ {up && !isPlaying &&
{up.label} }
+
+ {formatBytes(r.size_bytes)}
+
+
- {r.download_url ? (
-
- setPlayingId((prev) => {
- if (active) return r.id;
- return prev === r.id ? null : prev;
- })
- }
- />
- ) : (
-
-
- {r.upload_status === "pending"
- ? "Upload pending…"
- : r.upload_error
- ? r.upload_error
- : "Processing…"}
-
- )}
+ {r.download_url ? (
+
+ setPlayingId((prev) => {
+ if (active) return r.id;
+ // Only clear if THIS card was the one playing.
+ return prev === r.id ? null : prev;
+ })
+ }
+ />
+ ) : (
+
+
+ {r.upload_status === "pending"
+ ? "Upload pending…"
+ : r.upload_error
+ ? r.upload_error
+ : "Processing…"}
+
+ )}
-
- {r.download_url ? (
-
- Download
-
- ) : null}
-
onDelete(r.id)}
- disabled={del.isPending}
- >
- Delete
-
-
-
- );
- })}
-
- )}
+
+ {r.download_url && (
+
+ Download
+
+ )}
+
onDelete(r.id)}
+ disabled={del.isPending}
+ >
+ Delete
+
+
+
+ );
+ })}
-
-
- {/* Mobile dock */}
-
- {list.length === 0 ? (
-
- ) : (
- list.map((r) => (
-
-
- {r.username} · {formatRelativeTime(r.created_at)}
-
- {r.download_url ? (
-
- setPlayingId((prev) =>
- active ? r.id : prev === r.id ? null : prev,
- )
- }
- />
- ) : (
-
- processing…
-
- )}
-
- ))
- )}
-
-
- {/* Hint */}
-
- setiap klip adalah simpel di cincin timeline — yang sedang play menyala
- di langit
-
-
+ )}
+
);
}
diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx
index efa99c69..789e59e0 100644
--- a/services/frontend/src/app/(dashboard)/voice/view.tsx
+++ b/services/frontend/src/app/(dashboard)/voice/view.tsx
@@ -1,10 +1,5 @@
"use client";
-/**
- * Voice scene — the stage node sits center; live speakers orbit it with
- * glow (published to the constellation). Controls float bottom-left,
- * connections whisper right, and the mic/listen meters stay docked.
- */
import {
Headphones,
Mic,
@@ -14,16 +9,17 @@ import {
Volume2,
Waves,
} from "lucide-react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { Equalizer } from "@/components/charts";
-import { Button, toast } from "@/components/primitives";
-import { EmptyState, ErrorState } from "@/components/shared";
-import { GuildChannelPicker } from "@/components/shared/guild-picker";
+import { Button, GlassPanel, toast } from "@/components/primitives";
import {
- useSceneFocusSetter,
- useScenePublish,
-} from "@/components/shell/scene-graph-context";
+ EmptyState,
+ ErrorState,
+ SectionHeader,
+ SkeletonPanel,
+} from "@/components/shared";
+import { GuildChannelPicker } from "@/components/shared/guild-picker";
import { VoiceStage } from "@/components/voice/voice-stage";
import {
useMicTransmit,
@@ -33,37 +29,9 @@ import {
useVoiceListen,
useVoiceStatus,
} from "@/hooks";
-import type { ConstellationGraph } from "@/lib/constellation/graph";
import type { Guild, VoiceStatus } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
-function speakerGraph(
- speakers: {
- userId: string;
- username: string;
- speaking: boolean;
- level?: number;
- }[],
-): ConstellationGraph {
- return {
- nodes: [
- { id: "stage", label: "voice stage", kind: "guild", value: 1 },
- ...speakers.map((sp) => ({
- id: `speaker:${sp.userId}`,
- label: sp.username,
- kind: "speaker" as const,
- value: sp.speaking ? 0.8 : 0.4,
- href: undefined,
- meta: { speaking: sp.speaking },
- })),
- ],
- edges: speakers.map((sp) => ({
- source: "stage",
- target: `speaker:${sp.userId}`,
- })),
- };
-}
-
export function VoiceView({
initialStatus,
initialGuilds,
@@ -79,8 +47,6 @@ export function VoiceView({
const listen = useVoiceListen(ws);
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
const ambient = useAmbient();
- const publish = useScenePublish();
- const setFocus = useSceneFocusSetter();
const [guildId, setGuildId] = useState
(
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
@@ -97,19 +63,6 @@ export function VoiceView({
return unsub;
}, [subscribe, ws]);
- const graph = useMemo(() => speakerGraph(speakers), [speakers]);
- useEffect(() => {
- publish({ graph, focus: null });
- }, [graph, publish]);
-
- useEffect(
- () => () => {
- publish({ graph: { nodes: [], edges: [] }, focus: null });
- setFocus(null);
- },
- [publish, setFocus],
- );
-
useEffect(() => {
if (status?.connected) ambient.set("signal", 0.55, "voice live");
else ambient.set("vermilion", 0.35, "voice idle");
@@ -118,9 +71,13 @@ export function VoiceView({
if (error && !status) return ;
if (!status && isLoading)
return (
-
- menyetel ulang stage…
-
+
);
const connected = status?.connected ?? false;
@@ -161,94 +118,43 @@ export function VoiceView({
};
return (
-
- {/* Stage presence — top-center whisper */}
-
-
- {speakers.length === 0 ? (
-
}
- title={connected ? "Silent right now" : "Not connected"}
- description={
- connected
- ? "Speakers appear as they talk."
- : "Connect to a voice channel to see presence."
- }
- />
- ) : (
-
- )}
+
+
+
+
{
+ setGuildId(g);
+ setChannelId(c);
+ }}
+ />
+
+ {connected ? (
+
disconnect.mutate()}
+ disabled={disconnect.isPending}
+ >
+ Disconnect
+
+ ) : (
+
+ Connect
+
+ )}
+
-
- {/* Connections — right */}
-
- links · {speakers.length} present
-
- {(status?.connections ?? []).map((c) => (
-
-
- {c.channelName}
-
- {new Date(c.connectedAt).toLocaleTimeString()}
-
-
- ))}
- {(status?.connections ?? []).length === 0 ? (
-
- no active links
-
- ) : null}
-
- channel: {status?.activeChannelName ?? "—"}
-
-
-
-
- {/* Console — bottom-left */}
-
- {
- setGuildId(g);
- setChannelId(c);
- }}
- />
-
- {connected ? (
-
disconnect.mutate()}
- disabled={disconnect.isPending}
- >
- Disconnect
-
- ) : (
-
- Connect
-
- )}
+
:
}
{micOn ? "Mic live" : "Push-to-talk"}
+ {micOn && (
+
+
+
+
+ )}
-
-
- {micOn ? (
-
-
-
+ {listen.active && (
+
+
+
+
+ )}
+
+
+
+ {
+ const v = Number(e.target.value);
+ setMicVol(v);
+ mic.setVolume(v);
+ }}
+ aria-label="Mic transmit volume"
+ className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
+ />
+ {micVol}%
+
+
+
+ {
+ const v = Number(e.target.value);
+ setListenVol(v);
+ listen.setVolume(v);
+ }}
+ aria-label="Listen volume"
+ className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
+ />
+ {listenVol}%
+
- ) : null}
- {listen.active ? (
-
-
-
-
- ) : null}
-
-
-
-
- {
- const v = Number(e.target.value);
- setMicVol(v);
- mic.setVolume(v);
- }}
- aria-label="Mic transmit volume"
- className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
- />
- {micVol}%
-
-
-
- {
- const v = Number(e.target.value);
- setListenVol(v);
- listen.setVolume(v);
- }}
- aria-label="Listen volume"
- className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
- />
- {listenVol}%
-
-
+
+
+
+
+
+ {speakers.length} present
+
+ }
+ />
+ {speakers.length === 0 ? (
+ }
+ title={connected ? "Silent right now" : "Not connected"}
+ description={
+ connected
+ ? "Speakers appear as they talk."
+ : "Connect to a voice channel to see presence."
+ }
+ />
+ ) : (
+ <>
+
+
+ {speakers.map((sp) => (
+
+
+ {sp.username}
+
+ ))}
+
+ >
+ )}
+
+
+
+
+
+ {(status?.connections ?? []).map((c) => (
+
+
+
+ {c.channelName}
+
+
+ {new Date(c.connectedAt).toLocaleTimeString()}
+
+
+ ))}
+ {(status?.connections ?? []).length === 0 && (
+
+ No active links
+
+ )}
+
+
+ channel {" "}
+ {status?.activeChannelName ?? "—"}
+
+
+
);
}
diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css
index 7baaa57a..e37ca79e 100644
--- a/services/frontend/src/app/globals.css
+++ b/services/frontend/src/app/globals.css
@@ -17,7 +17,7 @@
/* ── Surfaces (dark-first; light overrides below) ── */
--color-canvas: oklch(0.12 0.014 70);
--color-canvas-2: oklch(0.16 0.02 70);
- --color-surface: oklch(0.2 0.022 70 / 0.55);
+ --color-surface: oklch(0.2 0.022 70 / 0.45);
--color-surface-2: oklch(0.26 0.024 70 / 0.45);
/* ── Ink ── */
@@ -77,11 +77,6 @@
background: var(--color-signal-glow);
color: var(--color-ink);
}
-/* Constellation stage: soften glow layers so stars stay legible on pale sky */
-.light .glass-glow,
-.light [class*="shadow-[0_0"] {
- --tw-shadow-color: oklch(0.5 0.1 150 / 0.18);
-}
@layer base {
* {
diff --git a/services/frontend/src/components/shared/page-transition.tsx b/services/frontend/src/components/shared/page-transition.tsx
index 543ec1b1..216ca416 100644
--- a/services/frontend/src/components/shared/page-transition.tsx
+++ b/services/frontend/src/components/shared/page-transition.tsx
@@ -11,7 +11,5 @@ export function PageTransition({
children: React.ReactNode;
className?: string;
}) {
- return (
-
{children}
- );
+ return
{children}
;
}
diff --git a/services/frontend/src/components/shell/ambient-app.tsx b/services/frontend/src/components/shell/ambient-app.tsx
new file mode 100644
index 00000000..0704e1ef
--- /dev/null
+++ b/services/frontend/src/components/shell/ambient-app.tsx
@@ -0,0 +1,30 @@
+import { MiniPlayer } from "@/components/media/mini-player";
+import { MobileNav } from "./mobile-nav";
+import { NavRail } from "./nav-rail";
+import { TopBar } from "./topbar";
+
+/**
+ * App chrome: slim nav rail + sticky top bar + scrollable content region.
+ * Sits above the fixed AmbientCanvas. Providers (Ambient + WS) are mounted in
+ * the route layout so every page shares one live link and signal context.
+ *
+ * < md the side rail collapses (hidden) and a bottom tab bar (MobileNav)
+ * takes over navigation; the content region gains bottom padding so the last
+ * panel never hides behind the dock. A persistent MiniPlayer floats at the
+ * bottom-right whenever a media track is loaded outside /media.
+ */
+export function AppFrame({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/components/shell/constellation-frame.tsx b/services/frontend/src/components/shell/constellation-frame.tsx
deleted file mode 100644
index b59ec5a5..00000000
--- a/services/frontend/src/components/shell/constellation-frame.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-"use client";
-
-/**
- * ConstellationFrame — replaces the classic AppFrame chrome.
- * The stage canvas sits fixed behind everything; page content is an
- * overlay layer (no top bar / nav rail / scroll shell). Views publish
- * their live graph via SceneGraphProvider; the frame renders whatever
- * the active view published (fallback: route-scenes default builder).
- * Chatbot + CommandPalette keep mounting at the layout level.
- */
-import { usePathname } from "next/navigation";
-import { type ReactNode, useMemo } from "react";
-import { MiniPlayer } from "@/components/media/mini-player";
-import { ConstellationStage } from "@/components/shell/constellation-stage";
-import { FloatingChrome } from "@/components/shell/floating-chrome";
-import {
- buildDefaultGraph,
- resolveScene,
- type SceneSeed,
-} from "@/components/shell/route-scenes";
-import { SceneA11yMirror } from "@/components/shell/scene-a11y-mirror";
-import {
- SceneGraphProvider,
- useSceneGraph,
-} from "@/components/shell/scene-graph-context";
-
-function StageFromContext({ seed }: { seed?: SceneSeed }) {
- const pathname = usePathname() ?? "/";
- const { state, setFocus } = useSceneGraph();
- const onChannelsRoute = pathname.startsWith("/channels");
-
- const graph = useMemo(() => {
- if (state) return state.graph;
- const scene = resolveScene(pathname);
- return scene
- ? buildDefaultGraph(scene, seed ?? {})
- : { nodes: [], edges: [] };
- }, [state, pathname, seed]);
-
- return (
-
{
- // On /channels/ a click selects the star (opens its dossier).
- if (onChannelsRoute && id.startsWith("channel:")) {
- setFocus((prev) => (prev === id ? null : id));
- return;
- }
- const meta = state?.graph.nodes.find((n) => n.id === id);
- if (meta?.href) window.location.assign(meta.href);
- }}
- />
- );
-}
-
-export function ConstellationFrame({
- children,
- sceneSeed,
-}: ConstellationFrameProps_) {
- return (
-
-
-
-
-
-
- {/* Overlay content region — scenes place floating panels inside. */}
-
- {/* no pointer-events here: empty areas stay click-through to the sky */}
- {children}
-
-
-
- );
-}
-
-interface ConstellationFrameProps_ {
- children: ReactNode;
- /** Typed SSR seed for the route-scenes fallback builder. */
- sceneSeed?: SceneSeed;
-}
diff --git a/services/frontend/src/components/shell/constellation-stage.tsx b/services/frontend/src/components/shell/constellation-stage.tsx
deleted file mode 100644
index d8cac212..00000000
--- a/services/frontend/src/components/shell/constellation-stage.tsx
+++ /dev/null
@@ -1,435 +0,0 @@
-"use client";
-
-/**
- * ConstellationStage — full-bleed interactive star-field renderer.
- * Renders the route's graph as glowing nodes + hairline edges over a
- * three.js orthographic view. Pan = drag, zoom = wheel/pinch, click on
- * an href-bearing node navigates (plain location.assign — router.push
- * can no-op under standalone+trailingSlash builds).
- */
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import * as THREE from "three";
-import {
- type CameraState,
- easeInOutCubic,
- fitCamera,
- flyTo,
-} from "@/lib/constellation/camera";
-import type { ConstellationGraph } from "@/lib/constellation/graph";
-import { computeLayout, type LayoutNode } from "@/lib/constellation/layout";
-import { readPalette, type StagePalette } from "@/lib/constellation/palette";
-
-export interface ConstellationStageProps {
- graph: ConstellationGraph;
- seed?: number;
- /** Node id kept highlighted (scene selection). */
- selectedId?: string | null;
- onNodeClick?: (nodeId: string) => void;
-}
-
-const Z_MIN = 0.35;
-const Z_MAX = 3;
-
-export function ConstellationStage({
- graph,
- seed = 42,
- selectedId = null,
- onNodeClick,
-}: ConstellationStageProps) {
- const canvasRef = useRef(null);
- const labelsRef = useRef(null);
- const camRef = useRef({ x: 0, y: 0, z: 1 });
- const layoutRef = useRef([]);
- const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n])));
- const [size, setSize] = useState({ w: 0, h: 0 });
- const hoveredRef = useRef(null);
- const [reduced, setReduced] = useState(false);
-
- nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n]));
-
- // Viewport size + reduced-motion preference.
- useEffect(() => {
- const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
- const sync = () => setReduced(mq.matches);
- sync();
- mq.addEventListener("change", sync);
- const measure = () =>
- setSize({ w: window.innerWidth, h: window.innerHeight });
- measure();
- window.addEventListener("resize", measure);
- return () => {
- mq.removeEventListener("change", sync);
- window.removeEventListener("resize", measure);
- };
- }, []);
-
- // Deterministic layout whenever graph or viewport changes.
- // On scene change we FLY the camera to the new fit instead of snapping.
- const prevSceneRef = useRef("");
- const animRef = useRef<{
- from: CameraState;
- to: CameraState;
- t0: number;
- } | null>(null);
-
- useEffect(() => {
- if (size.w < 50 || size.h < 50) return;
- const layout = computeLayout(graph.nodes, graph.edges, {
- width: size.w,
- height: size.h,
- seed,
- reduced,
- });
- layoutRef.current = layout;
- const target = fitCamera(layout, size.w, size.h);
- const first = graph.nodes[0]?.id ?? "";
- const sceneKey = `${graph.nodes.length}:${first}`;
- if (!reduced && prevSceneRef.current && prevSceneRef.current !== sceneKey) {
- animRef.current = {
- from: { ...camRef.current },
- to: target,
- t0: performance.now(),
- };
- } else {
- camRef.current = target;
- }
- prevSceneRef.current = sceneKey;
- }, [graph, size.w, size.h, seed, reduced]);
-
- // Build + run the renderer.
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas || size.w < 50 || size.h < 50 || layoutRef.current.length === 0)
- return;
-
- let palette: StagePalette = readPalette();
- const renderer = new THREE.WebGLRenderer({
- canvas,
- antialias: true,
- alpha: true,
- });
- renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
- renderer.setSize(size.w, size.h, false);
-
- const scene = new THREE.Scene();
- const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -200, 200);
-
- const group = new THREE.Group();
- scene.add(group);
- const disposables: { dispose: () => void }[] = [];
- const track = void }>(d: T): T => {
- disposables.push(d);
- return d;
- };
-
- // Edges first (under nodes).
- const byId = new Map(layoutRef.current.map((n) => [n.id, n]));
- const edgeMat = track(
- new THREE.LineBasicMaterial({
- color: palette.inkFaint,
- transparent: true,
- opacity: 0.35,
- }),
- );
- for (const e of graph.edges) {
- const a = byId.get(e.source);
- const b = byId.get(e.target);
- if (!a || !b) continue;
- const geo = track(
- new THREE.BufferGeometry().setFromPoints([
- new THREE.Vector3(a.x, a.y, 0),
- new THREE.Vector3(b.x, b.y, 0),
- ]),
- );
- group.add(new THREE.Line(geo, edgeMat));
- }
-
- // Nodes: glow halo + core disc.
- interface NodeVisual {
- id: string;
- halo: THREE.Mesh;
- core: THREE.Mesh;
- r: number;
- x: number;
- y: number;
- }
- const visuals: NodeVisual[] = [];
- for (const n of layoutRef.current) {
- const meta = nodeByIdRef.current.get(n.id);
- const kind = meta?.kind ?? "message";
- const flagged =
- Number(meta?.meta?.flagged_count ?? 0) > 0 && kind === "channel";
- const color =
- kind === "guild"
- ? palette.signal
- : flagged
- ? palette.vermilion
- : palette.ink;
- const haloGeo = track(new THREE.CircleGeometry(n.r * 2.4, 32));
- const haloMat = track(
- new THREE.MeshBasicMaterial({
- color,
- transparent: true,
- opacity: kind === "guild" ? 0.14 : 0.08,
- }),
- );
- const coreGeo = track(new THREE.CircleGeometry(n.r, 32));
- const coreMat = track(
- new THREE.MeshBasicMaterial({
- color,
- transparent: true,
- opacity: 0.92,
- }),
- );
- const halo = new THREE.Mesh(haloGeo, haloMat);
- halo.position.set(n.x, n.y, -1);
- const core = new THREE.Mesh(coreGeo, coreMat);
- core.position.set(n.x, n.y, 0);
- group.add(halo, core);
- visuals.push({ id: n.id, halo, core, r: n.r, x: n.x, y: n.y });
- }
-
- const applyCamera = () => {
- const { x, y, z } = camRef.current;
- const hw = size.w / (2 * z);
- const hh = size.h / (2 * z);
- camera.left = -hw;
- camera.right = hw;
- camera.top = hh;
- camera.bottom = -hh;
- camera.position.set(x, y, 100);
- camera.updateProjectionMatrix();
- };
-
- // --- interaction state ---
- type PointerMode =
- | { t: "idle" }
- | { t: "drag"; sx: number; sy: number; cx: number; cy: number };
- let mode: PointerMode = { t: "idle" };
- let raf = 0;
- let disposed = false;
-
- const pointerToWorld = (clientX: number, clientY: number) => {
- const rect = canvas.getBoundingClientRect();
- const { x, y, z } = camRef.current;
- return {
- wx: x + (clientX - rect.left - size.w / 2) / z,
- wy: y - (clientY - rect.top - size.h / 2) / z,
- };
- };
-
- const pickNode = (clientX: number, clientY: number): NodeVisual | null => {
- const { wx, wy } = pointerToWorld(clientX, clientY);
- let best: NodeVisual | null = null;
- let bestD = Infinity;
- for (const v of visuals) {
- const d = Math.hypot(wx - v.x, wy - v.y);
- if (d <= v.r + 6 && d < bestD) {
- best = v;
- bestD = d;
- }
- }
- return best;
- };
-
- const onPointerDown = (ev: PointerEvent) => {
- mode = {
- t: "drag",
- sx: ev.clientX,
- sy: ev.clientY,
- cx: camRef.current.x,
- cy: camRef.current.y,
- };
- canvas.style.cursor = "grabbing";
- };
- const onPointerMove = (ev: PointerEvent) => {
- if (mode.t === "drag") {
- const z = camRef.current.z;
- camRef.current.x = mode.cx - (ev.clientX - mode.sx) / z;
- camRef.current.y = mode.cy + (ev.clientY - mode.sy) / z;
- return;
- }
- const hit = pickNode(ev.clientX, ev.clientY);
- hoveredRef.current = hit ? hit.id : null;
- canvas.style.cursor = hit ? "pointer" : "grab";
- };
- const onPointerUp = (ev: PointerEvent) => {
- const wasDrag =
- mode.t === "drag" &&
- Math.hypot(ev.clientX - mode.sx, ev.clientY - mode.sy) > 4;
- mode = { t: "idle" };
- canvas.style.cursor = "grab";
- if (wasDrag) return;
- const hit = pickNode(ev.clientX, ev.clientY);
- if (!hit) return;
- const meta = nodeByIdRef.current.get(hit.id);
- onNodeClick?.(hit.id);
- if (meta?.href) window.location.assign(meta.href);
- };
- const onWheel = (ev: WheelEvent) => {
- ev.preventDefault();
- const old = camRef.current.z;
- const next = Math.max(
- Z_MIN,
- Math.min(Z_MAX, old * Math.exp(-ev.deltaY * 0.0012)),
- );
- camRef.current.z = next;
- };
-
- canvas.style.cursor = "grab";
- canvas.addEventListener("pointerdown", onPointerDown);
- window.addEventListener("pointermove", onPointerMove);
- window.addEventListener("pointerup", onPointerUp);
- canvas.addEventListener("wheel", onWheel, { passive: false });
-
- // Labels (HTML, imperative transforms — cheap for ≤16 nodes).
- const labelHost = labelsRef.current;
- const labelEls = new Map();
- const labeled = [...layoutRef.current]
- .sort((a, b) => b.r - a.r)
- .slice(0, 14);
- if (labelHost) {
- for (const n of labeled) {
- const el = document.createElement("span");
- el.textContent = nodeByIdRef.current.get(n.id)?.label ?? n.id;
- el.dataset.nodeLabel = n.id;
- el.className =
- "pointer-events-none absolute whitespace-nowrap font-mono text-[11px] tracking-wide text-[var(--color-ink-soft)] transition-colors";
- labelHost.appendChild(el);
- labelEls.set(n.id, el);
- }
- }
-
- // Theme sync — tokens flip with the .light class.
- const themeObserver = new MutationObserver(() => {
- palette = readPalette();
- for (const v of visuals) {
- const meta = nodeByIdRef.current.get(v.id);
- const flagged =
- Number(meta?.meta?.flagged_count ?? 0) > 0 &&
- (meta?.kind ?? "") === "channel";
- const c =
- meta?.kind === "guild"
- ? palette.signal
- : flagged
- ? palette.vermilion
- : palette.ink;
- v.core.material.color.setHex(c);
- v.halo.material.color.setHex(c);
- }
- edgeMat.color.setHex(palette.inkFaint);
- });
- themeObserver.observe(document.documentElement, {
- attributes: true,
- attributeFilter: ["class"],
- });
-
- let hidden = document.hidden;
- const onVis = () => {
- hidden = document.hidden;
- };
- document.addEventListener("visibilitychange", onVis);
-
- const start = performance.now();
- const frame = (now: number) => {
- if (disposed) return;
- raf = requestAnimationFrame(frame);
- if (hidden) return;
-
- // Camera fly-to (scene transitions): eased interpolation over ~900ms.
- const anim = animRef.current;
- if (anim) {
- const raw = (now - anim.t0) / 900;
- if (raw >= 1) {
- camRef.current = anim.to;
- animRef.current = null;
- } else if (raw > 0) {
- camRef.current = flyTo(anim.from, anim.to, raw, easeInOutCubic);
- }
- }
-
- applyCamera();
-
- // Gentle breathing glow (visual only — hit positions stay baked).
- const t = (now - start) / 1000;
- let i = 0;
- for (const v of visuals) {
- if (!reduced) {
- const pulse = 0.5 + 0.5 * Math.sin(t * 1.4 + i * 0.7);
- v.halo.material.opacity =
- (v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse);
- }
- const hov = v.id === hoveredRef.current || v.id === selectedId;
- v.core.scale.setScalar(hov ? 1.25 : 1);
- i += 1;
- }
-
- renderer.render(scene, camera);
-
- if (labelHost) {
- const { x, y, z } = camRef.current;
- for (const [id, el] of labelEls) {
- const v = visuals.find((s) => s.id === id);
- if (!v) continue;
- const sx = (v.x - x) * z + size.w / 2;
- const sy = -(v.y - y) * z + size.h / 2 + v.r + 14;
- el.style.transform = `translate(${sx}px, ${sy}px) translateX(-50%)`;
- el.style.color =
- id === hoveredRef.current || id === selectedId
- ? "var(--color-signal)"
- : "var(--color-ink-soft)";
- }
- }
- };
- raf = requestAnimationFrame(frame);
-
- return () => {
- disposed = true;
- cancelAnimationFrame(raf);
- document.removeEventListener("visibilitychange", onVis);
- canvas.removeEventListener("pointerdown", onPointerDown);
- window.removeEventListener("pointermove", onPointerMove);
- window.removeEventListener("pointerup", onPointerUp);
- canvas.removeEventListener("wheel", onWheel);
- themeObserver.disconnect();
- for (const el of labelEls.values()) el.remove();
- for (const d of disposables) d.dispose();
- scene.clear();
- renderer.dispose();
- };
- }, [graph, size.w, size.h, reduced, selectedId, onNodeClick]);
-
- const emptyGraph = graph.nodes.length === 0;
- const hint = useMemo(
- () => (emptyGraph ? "Menunggu data scene…" : null),
- [emptyGraph],
- );
-
- const handleReset = useCallback(() => {
- if (layoutRef.current.length > 0 && size.w > 0) {
- camRef.current = fitCamera(layoutRef.current, size.w, size.h);
- }
- }, [size.w, size.h]);
-
- return (
- <>
-
-
- {hint ? (
-
- {hint}
-
- ) : null}
-
- reset view
-
- >
- );
-}
diff --git a/services/frontend/src/components/shell/floating-chrome.tsx b/services/frontend/src/components/shell/floating-chrome.tsx
deleted file mode 100644
index 899bcdf3..00000000
--- a/services/frontend/src/components/shell/floating-chrome.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-"use client";
-
-/**
- * FloatingChrome — the only persistent UI over the constellation.
- * No top bar, no nav rail: brand + live link (top-left), theme toggle
- * and palette hint (top-right), route switcher (bottom-center).
- * All internal navigation uses plain — trailingSlash builds.
- */
-import { Menu, Moon, Sun, X } from "lucide-react";
-import { usePathname } from "next/navigation";
-import { useTheme } from "next-themes";
-import { useEffect, useState } from "react";
-import { navItems } from "@/lib/navigation";
-import { ConnectionStatus } from "./status-dot";
-
-function ThemeToggle() {
- const { theme, setTheme } = useTheme();
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
- if (!mounted) {
- return ;
- }
- return (
- setTheme(theme === "light" ? "dark" : "light")}
- >
- {theme === "light" ? (
-
- ) : (
-
- )}
-
- );
-}
-
-export function FloatingChrome() {
- const pathname = usePathname();
- const [menuOpen, setMenuOpen] = useState(false);
-
- return (
- <>
- {/* Brand + live link */}
-
-
- GMW
-
-
- constellation
-
-
-
-
- {/* Theme + palette hint */}
-
-
- window.dispatchEvent(new Event("command-palette:open"))
- }
- >
- ⌘K
-
-
-
-
- {/* Route switcher */}
-
-
- {navItems.map((item) => {
- const active =
- pathname === item.matchPrefix ||
- pathname.startsWith(item.matchPrefix);
- return (
-
- {item.label}
-
- );
- })}
-
-
-
- {/* Mobile: sheet menu instead of a bottom tab bar */}
- setMenuOpen((o) => !o)}
- >
- {menuOpen ? (
-
- ) : (
-
- )}
-
- {menuOpen ? (
-
- {navItems.map((item) => {
- const active =
- pathname === item.matchPrefix ||
- pathname.startsWith(item.matchPrefix);
- return (
-
- {item.label}
-
- );
- })}
-
- ) : null}
- >
- );
-}
diff --git a/services/frontend/src/components/shell/index.ts b/services/frontend/src/components/shell/index.ts
index 727ed4b9..c3522b56 100644
--- a/services/frontend/src/components/shell/index.ts
+++ b/services/frontend/src/components/shell/index.ts
@@ -1,2 +1,5 @@
-export { ConstellationFrame } from "./constellation-frame";
+export { AppFrame } from "./ambient-app";
+export { MobileNav } from "./mobile-nav";
+export { NavRail } from "./nav-rail";
export { ConnectionStatus } from "./status-dot";
+export { TopBar } from "./topbar";
diff --git a/services/frontend/src/components/shell/mobile-nav.tsx b/services/frontend/src/components/shell/mobile-nav.tsx
new file mode 100644
index 00000000..04ba5b4e
--- /dev/null
+++ b/services/frontend/src/components/shell/mobile-nav.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { isActivePath, mobileNavItems } from "@/lib/navigation";
+import { cn } from "@/lib/utils";
+
+/**
+ * Mobile bottom tab bar. Shown only < md (the side NavRail is hidden there).
+ * Mirrors the desktop nav items but as a thumb-friendly dock with labels and a
+ * top active indicator. Safe-area aware for notched devices.
+ */
+export function MobileNav() {
+ const path = usePathname() ?? "/";
+
+ return (
+
+ {mobileNavItems.map((item) => {
+ const active = isActivePath(path, item.matchPrefix);
+ return (
+
+ {active && (
+
+ )}
+
+ {item.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/services/frontend/src/components/shell/nav-rail.tsx b/services/frontend/src/components/shell/nav-rail.tsx
new file mode 100644
index 00000000..59cf9bc8
--- /dev/null
+++ b/services/frontend/src/components/shell/nav-rail.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { isActivePath, navItems } from "@/lib/navigation";
+import { cn } from "@/lib/utils";
+
+function NavItem({
+ href,
+ label,
+ active,
+ Icon,
+}: {
+ href: string;
+ label: string;
+ active: boolean;
+ Icon: React.ComponentType>;
+}) {
+ return (
+
+ {active && (
+
+ )}
+
+ {/* hover tooltip — labels are hidden in the rail, so surface on hover */}
+
+ {label}
+
+
+ );
+}
+
+export function NavRail() {
+ const pathname = usePathname();
+ const path = pathname ?? "/";
+
+ return (
+
+
+ {navItems.map((item) => (
+
+ ))}
+
+
+ );
+}
diff --git a/services/frontend/src/components/shell/route-scenes.ts b/services/frontend/src/components/shell/route-scenes.ts
deleted file mode 100644
index d64bea3a..00000000
--- a/services/frontend/src/components/shell/route-scenes.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * Scene config: maps pathname → constellation scene definition.
- * Pure data; the stage reads it to build graphs and place overlays.
- */
-
-import type { ConstellationGraph } from "@/lib/constellation/graph";
-import {
- channelsToGraph,
- culturesToGraph,
- statsToGraph,
-} from "@/lib/constellation/graph";
-
-export interface SceneDef {
- route: string;
- label: string;
- /** Build the graph from typed SSR seed data. */
- build: (seed: SceneSeed) => ConstellationGraph;
-}
-
-/** Everything a scene may need — all optional, scenes pick what they use. */
-export interface SceneSeed {
- stats?: import("@/lib/types").DashboardStats;
- channels?: import("@/lib/types").DashboardChannel[];
- cultures?: import("@/lib/types").ChannelCultureRow[];
- guildLabel?: string;
-}
-
-export const SCENES: SceneDef[] = [
- {
- route: "/dashboard/",
- label: "Deck",
- build: (s) => (s.stats ? statsToGraph(s.stats) : { nodes: [], edges: [] }),
- },
- {
- route: "/channels/",
- label: "Channels",
- build: (s) => {
- if (s.cultures && s.cultures.length > 0)
- return culturesToGraph(s.cultures);
- if (s.channels) return channelsToGraph(s.channels);
- return { nodes: [], edges: [] };
- },
- },
-];
-
-export function resolveScene(pathname: string): SceneDef | undefined {
- return SCENES.find((sc) => sc.route === pathname);
-}
-
-export function buildDefaultGraph(
- scene: SceneDef,
- seed: SceneSeed,
-): ConstellationGraph {
- try {
- return scene.build(seed);
- } catch {
- return { nodes: [], edges: [] };
- }
-}
diff --git a/services/frontend/src/components/shell/scene-a11y-mirror.tsx b/services/frontend/src/components/shell/scene-a11y-mirror.tsx
deleted file mode 100644
index 05391e7e..00000000
--- a/services/frontend/src/components/shell/scene-a11y-mirror.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-"use client";
-
-/**
- * SceneA11yMirror — screen-reader/keyboard mirror of the live constellation.
- * The canvas itself is decorative (aria-hidden); this list exposes every
- * node as a real link/button so keyboard and AT users get the same graph.
- */
-import { usePathname } from "next/navigation";
-import { useSceneGraph } from "@/components/shell/scene-graph-context";
-
-export function SceneA11yMirror() {
- const pathname = usePathname() ?? "/";
- const { state, setFocus } = useSceneGraph();
- const nodes = state?.graph.nodes ?? [];
-
- return (
-
-
- constellation — {nodes.length} node
-
-
- {nodes.map((n) => (
-
- {n.href ? (
-
- {n.label} ({n.kind})
-
- ) : (
-
- setFocus((prev) => (prev === n.id ? null : n.id))
- }
- className="w-full rounded-lg px-2 py-1 text-left font-mono text-sm text-[var(--color-ink)] outline-none hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
- >
- {n.label} ({n.kind})
-
- )}
-
- ))}
- {nodes.length === 0 ? (
-
- tidak ada node aktif
-
- ) : null}
-
-
- );
-}
diff --git a/services/frontend/src/components/shell/scene-graph-context.tsx b/services/frontend/src/components/shell/scene-graph-context.tsx
deleted file mode 100644
index b5bd3a57..00000000
--- a/services/frontend/src/components/shell/scene-graph-context.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-"use client";
-
-import {
- createContext,
- type ReactNode,
- useCallback,
- useContext,
- useMemo,
- useRef,
- useState,
-} from "react";
-/**
- * SceneGraph bridge — views publish their live graph to the stage.
- * The frame provides the setter; the stage consumes the graph.
- * Keeps SSR seed pattern intact: page.tsx seeds view.tsx, view publishes.
- */
-import type { ConstellationGraph } from "@/lib/constellation/graph";
-
-export interface SceneGraphState {
- graph: ConstellationGraph;
- /** Node id currently focused (drives fly-to / highlight). */
- focus: string | null;
-}
-
-type FocusUpdate = string | null | ((prev: string | null) => string | null);
-
-interface SceneGraphContextValue {
- state: SceneGraphState | null;
- publish: (state: SceneGraphState) => void;
- setFocus: (update: FocusUpdate) => void;
-}
-
-const SceneGraphContext = createContext({
- state: null,
- publish: () => {},
- setFocus: () => {},
-});
-
-export function SceneGraphProvider({ children }: { children: ReactNode }) {
- const [state, setState] = useState(null);
- const stateRef = useRef(null);
-
- const publish = useCallback((next: SceneGraphState) => {
- const prev = stateRef.current;
- const sameShape =
- prev &&
- prev.graph.nodes.length === next.graph.nodes.length &&
- prev.graph.edges.length === next.graph.edges.length &&
- prev.focus === next.focus;
- if (!sameShape) {
- stateRef.current = next;
- setState(next);
- return;
- }
- // Same shape — still update node values/labels in place.
- let changed = false;
- if (prev) {
- for (let i = 0; i < next.graph.nodes.length; i++) {
- const a = prev.graph.nodes[i];
- const b = next.graph.nodes[i];
- if (a?.id !== b?.id || a?.label !== b?.label || a?.value !== b?.value) {
- changed = true;
- break;
- }
- }
- }
- if (changed || !prev) {
- stateRef.current = next;
- setState(next);
- }
- }, []);
-
- const setFocus = useCallback((update: FocusUpdate) => {
- setState((prev) => {
- if (!prev) return null;
- const next = typeof update === "function" ? update(prev.focus) : update;
- return next === prev.focus ? prev : { ...prev, focus: next };
- });
- }, []);
-
- const value = useMemo(
- () => ({ state, publish, setFocus }),
- [state, publish, setFocus],
- );
- return (
-
- {children}
-
- );
-}
-
-export function useScenePublish(): (state: SceneGraphState) => void {
- return useContext(SceneGraphContext).publish;
-}
-
-export function useSceneFocusSetter(): (update: FocusUpdate) => void {
- return useContext(SceneGraphContext).setFocus;
-}
-
-export type { FocusUpdate };
-
-export function useSceneGraph(): SceneGraphContextValue {
- return useContext(SceneGraphContext);
-}
diff --git a/services/frontend/src/components/shell/topbar.tsx b/services/frontend/src/components/shell/topbar.tsx
new file mode 100644
index 00000000..f2688cc5
--- /dev/null
+++ b/services/frontend/src/components/shell/topbar.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { Moon, Sun } from "lucide-react";
+import { usePathname } from "next/navigation";
+import { useTheme } from "next-themes";
+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();
+ const item = [...navItems]
+ .sort((a, b) => b.matchPrefix.length - a.matchPrefix.length)
+ .find((i) => pathname.startsWith(i.matchPrefix));
+ return item?.label ?? "Console";
+}
+
+export function TopBar() {
+ const label = useActiveLabel();
+ const { state } = useAmbient();
+ const { theme, setTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+
+ const signalTone =
+ state.tone === "vermilion"
+ ? "text-vermilion"
+ : state.tone === "amber"
+ ? "text-amber"
+ : "text-signal";
+
+ return (
+
+
+ GMW
+
+ {label}
+
+
+
+
+
+
+ {state.label ?? "nominal"}
+
+
+
+ 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"
+ >
+ ⌘K
+
+ setTheme(theme === "light" ? "dark" : "light")}
+ className="flex size-9 items-center justify-center rounded-[11px] border border-hairline bg-white/5 text-ink-soft transition-colors hover:text-ink hover:border-signal/40"
+ >
+ {mounted && theme === "light" ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/services/frontend/src/lib/constellation/camera.test.ts b/services/frontend/src/lib/constellation/camera.test.ts
deleted file mode 100644
index bf8317d4..00000000
--- a/services/frontend/src/lib/constellation/camera.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { fitCamera, flyTo } from "./camera";
-
-describe("flyTo", () => {
- const from = { x: 0, y: 0, z: 1 };
- const to = { x: 100, y: -50, z: 2 };
-
- test("t=0 returns origin", () => {
- expect(flyTo(from, to, 0)).toEqual({ x: 0, y: 0, z: 1 });
- });
-
- test("t=1 arrives exactly", () => {
- expect(flyTo(from, to, 1)).toEqual(to);
- });
-
- test("monotonic progress", () => {
- let prev = -Infinity;
- for (let i = 0; i <= 20; i++) {
- const s = flyTo(from, to, i / 20);
- expect(s.x).toBeGreaterThan(prev);
- prev = s.x;
- }
- });
-
- test("clamps out-of-range t", () => {
- expect(flyTo(from, to, -3)).toEqual(from);
- expect(flyTo(from, to, 7)).toEqual(to);
- });
-});
-
-describe("fitCamera", () => {
- test("empty scene centers", () => {
- const c = fitCamera([], 800, 600);
- expect(c.x).toBe(400);
- expect(c.y).toBe(300);
- });
-
- test("zooms to fit nodes with padding", () => {
- const nodes = [
- { x: 100, y: 100, r: 10 },
- { x: 500, y: 400, r: 10 },
- ];
- const c = fitCamera(nodes, 800, 600);
- expect(c.x).toBe(300);
- expect(c.y).toBe(250);
- expect(c.z).toBeGreaterThan(0);
- expect(c.z).toBeLessThanOrEqual(3);
- });
-});
diff --git a/services/frontend/src/lib/constellation/camera.ts b/services/frontend/src/lib/constellation/camera.ts
deleted file mode 100644
index cb02b36b..00000000
--- a/services/frontend/src/lib/constellation/camera.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * Camera math for constellation fly-to — pure, easing-based interpolation.
- */
-export interface CameraState {
- /** Camera center in canvas space. */
- x: number;
- y: number;
- /** Zoom factor (1 = fit). */
- z: number;
-}
-
-export type Easing = (t: number) => number;
-
-/** Smooth ease-in-out cubic. Monotonic on [0,1]. */
-export const easeInOutCubic: Easing = (t) =>
- t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
-
-export function flyTo(
- from: CameraState,
- to: CameraState,
- t: number,
- ease: Easing = easeInOutCubic,
-): CameraState {
- const clamped = Math.max(0, Math.min(1, t));
- const k = ease(clamped);
- return {
- x: from.x + (to.x - from.x) * k,
- y: from.y + (to.y - from.y) * k,
- z: from.z + (to.z - from.z) * k,
- };
-}
-
-/** Fit-to-view camera so a scene always starts framed. */
-export function fitCamera(
- nodes: { x: number; y: number; r?: number }[],
- width: number,
- height: number,
- padding = 80,
-): CameraState {
- if (nodes.length === 0 || width <= 0 || height <= 0) {
- return { x: width / 2, y: height / 2, z: 1 };
- }
- let minX = Infinity;
- let minY = Infinity;
- let maxX = -Infinity;
- let maxY = -Infinity;
- for (const n of nodes) {
- const r = n.r ?? 8;
- minX = Math.min(minX, n.x - r);
- maxX = Math.max(maxX, n.x + r);
- minY = Math.min(minY, n.y - r);
- maxY = Math.max(maxY, n.y + r);
- }
- const w = Math.max(1, maxX - minX);
- const h = Math.max(1, maxY - minY);
- const z = Math.min(
- 3,
- Math.max(
- 0.35,
- Math.min((width - padding * 2) / w, (height - padding * 2) / h),
- ),
- );
- return {
- x: (minX + maxX) / 2,
- y: (minY + maxY) / 2,
- z,
- };
-}
diff --git a/services/frontend/src/lib/constellation/graph.ts b/services/frontend/src/lib/constellation/graph.ts
deleted file mode 100644
index 17517bf1..00000000
--- a/services/frontend/src/lib/constellation/graph.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * Constellation graph model — pure data, no React/DOM.
- * Builders convert existing API payloads into star-graph structures.
- */
-import type {
- ChannelCultureRow,
- DashboardChannel,
- DashboardStats,
-} from "@/lib/types";
-
-export type NodeKind =
- | "guild"
- | "channel"
- | "message"
- | "flagged"
- | "speaker"
- | "media"
- | "term"
- | "metric";
-
-export interface GraphNode {
- id: string;
- label: string;
- kind: NodeKind;
- /** Optional route this node navigates to (plain , trailingSlash). */
- href?: string;
- /** Relative magnitude used for radius/glow (0..1 normalized by caller). */
- value?: number;
- /** Extra scene-specific payload (e.g. flagged count, culture summary). */
- meta?: Record;
-}
-
-export interface GraphEdge {
- source: string;
- target: string;
- /** Edge thickness/pulse driver. Defaults to 0.5. */
- weight?: number;
-}
-
-export interface ConstellationGraph {
- nodes: GraphNode[];
- edges: GraphEdge[];
-}
-
-const clamp01 = (n: number, max: number): number =>
- max <= 0 ? 0.25 : Math.min(1, Math.max(0.05, n / max));
-
-/** Dashboard scene: guild star at center, top channels orbiting it. */
-export function statsToGraph(stats: DashboardStats): ConstellationGraph {
- const top = stats.top_channels ?? [];
- const maxCount = top.reduce((m, c) => Math.max(m, c.message_count), 0);
- const nodes: GraphNode[] = [
- {
- id: "guild",
- label: "GMW",
- kind: "guild",
- value: 1,
- meta: {
- total_messages: stats.total_messages,
- total_flagged: stats.total_flagged,
- active_users_24h: stats.active_users_24h,
- },
- },
- ];
- const edges: GraphEdge[] = [];
- for (const ch of top) {
- nodes.push({
- id: `channel:${ch.channel_id}`,
- label: ch.channel_name || ch.channel_id,
- kind: "channel",
- href: "/channels/",
- value: clamp01(ch.message_count, maxCount),
- meta: { message_count: ch.message_count },
- });
- edges.push({
- source: "guild",
- target: `channel:${ch.channel_id}`,
- weight: clamp01(ch.message_count, maxCount),
- });
- }
- return { nodes, edges };
-}
-
-/** Channels scene: every channel is a star; size = traffic, color = flags. */
-export function channelsToGraph(
- channels: DashboardChannel[],
-): ConstellationGraph {
- const maxMsg = channels.reduce((m, c) => Math.max(m, c.total_messages), 0);
- const nodes: GraphNode[] = channels.map((c) => ({
- id: `channel:${c.channel_id}`,
- label: c.channel_name || c.channel_id,
- kind: "channel",
- value: clamp01(c.total_messages, maxMsg),
- meta: {
- flagged_count: c.flagged_count,
- culture_summary: c.culture_summary,
- },
- }));
- return { nodes, edges: [] };
-}
-
-/** Channels scene variant fed by culture-knowledge rows. */
-export function culturesToGraph(rows: ChannelCultureRow[]): ConstellationGraph {
- const nodes: GraphNode[] = rows.map((r, i) => ({
- id: `channel:${r.channel_id}`,
- label: r.channel_name || r.channel_id,
- kind: "channel",
- value: r.culture_summary ? 0.4 + (i % 5) * 0.12 : 0.2,
- meta: {
- culture_summary: r.culture_summary,
- last_analyzed_at: r.last_analyzed_at,
- },
- }));
- return { nodes, edges: [] };
-}
diff --git a/services/frontend/src/lib/constellation/layout-graph.test.ts b/services/frontend/src/lib/constellation/layout-graph.test.ts
deleted file mode 100644
index 615c2cd0..00000000
--- a/services/frontend/src/lib/constellation/layout-graph.test.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { channelsToGraph, statsToGraph } from "./graph";
-import { computeLayout, radiusFor } from "./layout";
-
-const statsFixture = {
- total_messages: 1200,
- total_users: 40,
- total_flagged: 30,
- total_clean: 1100,
- total_warned: 12,
- total_error: 2,
- total_voice_recordings: 8,
- total_profiles: 40,
- today_messages: 55,
- today_flagged: 3,
- active_users_24h: 18,
- top_channels: [
- { channel_id: "c1", channel_name: "general", message_count: 400 },
- { channel_id: "c2", channel_name: "random", message_count: 200 },
- ],
- moderation_overview: { pending: 1, processing: 0, error: 0 },
-};
-
-describe("statsToGraph", () => {
- test("guild center + channel nodes + edges", () => {
- const g = statsToGraph(statsFixture);
- expect(g.nodes.length).toBe(3);
- expect(g.nodes[0]?.id).toBe("guild");
- expect(g.edges.length).toBe(2);
- expect(g.edges[0]?.source).toBe("guild");
- });
-
- test("bigger channel gets bigger value", () => {
- const g = statsToGraph(statsFixture);
- const c1 = g.nodes.find((n) => n.label === "general");
- const c2 = g.nodes.find((n) => n.label === "random");
- expect((c1?.value ?? 0) > (c2?.value ?? 0)).toBe(true);
- });
-
- test("empty top_channels still yields guild node", () => {
- const g = statsToGraph({ ...statsFixture, top_channels: [] });
- expect(g.nodes.length).toBe(1);
- expect(g.edges.length).toBe(0);
- });
-});
-
-describe("channelsToGraph", () => {
- test("maps every channel", () => {
- const chans = [
- {
- channel_id: "a",
- channel_name: "alpha",
- total_messages: 10,
- flagged_count: 1,
- },
- { channel_id: "b", total_messages: 5, flagged_count: 0 },
- ];
- const g = channelsToGraph(chans);
- expect(g.nodes.length).toBe(2);
- expect(g.nodes[1]?.label).toBe("b"); // falls back to id when name null
- });
-});
-
-describe("computeLayout", () => {
- const nodes = [
- { id: "guild", kind: "guild", value: 1 },
- { id: "channel:c1", kind: "channel", value: 0.8 },
- { id: "channel:c2", kind: "channel", value: 0.4 },
- ];
- const edges = [
- { source: "guild", target: "channel:c1" },
- { source: "guild", target: "channel:c2" },
- ];
-
- test("deterministic for same seed", () => {
- const a = computeLayout(nodes, edges, {
- width: 900,
- height: 700,
- seed: 42,
- });
- const b = computeLayout(nodes, edges, {
- width: 900,
- height: 700,
- seed: 42,
- });
- expect(a.map((n) => [n.id, Math.round(n.x), Math.round(n.y)])).toEqual(
- b.map((n) => [n.id, Math.round(n.x), Math.round(n.y)]),
- );
- });
-
- test("all finite positions within canvas bounds", () => {
- const out = computeLayout(nodes, edges, {
- width: 900,
- height: 700,
- seed: 7,
- });
- for (const n of out) {
- expect(Number.isFinite(n.x)).toBe(true);
- expect(Number.isFinite(n.y)).toBe(true);
- expect(n.x).toBeGreaterThanOrEqual(-50);
- expect(n.x).toBeLessThanOrEqual(950);
- expect(n.y).toBeGreaterThanOrEqual(-50);
- expect(n.y).toBeLessThanOrEqual(750);
- }
- });
-
- test("reduced motion returns static ring", () => {
- const out = computeLayout(nodes, edges, {
- width: 900,
- height: 700,
- reduced: true,
- });
- expect(out.length).toBe(3);
- expect(Number.isFinite(out[0]?.y ?? NaN)).toBe(true);
- });
-});
-
-describe("radiusFor", () => {
- test("guild larger than channel larger than message", () => {
- expect(radiusFor("guild", 1)).toBeGreaterThan(radiusFor("channel", 1));
- expect(radiusFor("channel", 1)).toBeGreaterThan(radiusFor("message", 1));
- });
-});
diff --git a/services/frontend/src/lib/constellation/layout.ts b/services/frontend/src/lib/constellation/layout.ts
deleted file mode 100644
index 382853de..00000000
--- a/services/frontend/src/lib/constellation/layout.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * Deterministic constellation layout — pure functions over graph data.
- * d3-force with a seeded PRNG so every render produces the same sky.
- * `prefers-reduced-motion` callers use the static radial fallback.
- */
-import {
- forceCenter,
- forceCollide,
- forceLink,
- forceManyBody,
- forceSimulation,
- type SimulationNodeDatum,
-} from "d3-force";
-
-export interface LayoutNode extends SimulationNodeDatum {
- id: string;
- x: number;
- y: number;
- /** Screen-space radius in px (already scaled by value + kind). */
- r: number;
-}
-
-export interface LayoutOptions {
- /** Canvas width in CSS px. */
- width: number;
- height: number;
- seed?: number;
- /** Static radial ring layout (no simulation). */
- reduced?: boolean;
-}
-
-/** Mulberry32 — small, fast, deterministic. */
-function mulberry32(seed: number): () => number {
- let a = seed >>> 0;
- return () => {
- a |= 0;
- a = (a + 0x6d2b79f5) | 0;
- let t = Math.imul(a ^ (a >>> 15), 1 | a);
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) | 0;
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
- };
-}
-
-export function radiusFor(kind: string, value = 0.5): number {
- const base =
- kind === "guild"
- ? 34
- : kind === "channel"
- ? 16
- : kind === "flagged"
- ? 11
- : 9;
- return base * (0.55 + 0.9 * Math.max(0, Math.min(1, value)));
-}
-
-/**
- * Compute final positions. Same inputs (+seed) ⇒ identical output positions.
- */
-export function computeLayout(
- nodesIn: { id: string; kind: string; value?: number }[],
- edges: { source: string; target: string }[],
- opts: LayoutOptions,
-): LayoutNode[] {
- const rand = mulberry32(opts.seed ?? 42);
- const cx = opts.width / 2;
- const cy = opts.height / 2;
-
- if (opts.reduced || nodesIn.length === 0) {
- return nodesIn.map((n, i) => ({
- ...n,
- x: cx + radiusFor(n.kind, n.value),
- y:
- cy +
- Math.sin((i / Math.max(1, nodesIn.length)) * Math.PI * 2) *
- Math.min(cx, cy) *
- 0.62,
- r: radiusFor(n.kind, n.value),
- }));
- }
-
- const simNodes = nodesIn.map((n) => ({
- id: n.id,
- kind: n.kind,
- value: n.value ?? 0.5,
- r: radiusFor(n.kind, n.value),
- x: cx + (rand() - 0.5) * opts.width * 0.8,
- y: cy + (rand() - 0.5) * opts.height * 0.8,
- }));
-
- const sim = forceSimulation(simNodes)
- .force("charge", forceManyBody().strength(-420))
- .force(
- "link",
- forceLink(edges.map((e) => ({ ...e })))
- .id((d) => (d as { id: string }).id)
- .distance(120)
- .strength(0.6),
- )
- .force("center", forceCenter(cx, cy))
- .force(
- "collide",
- forceCollide((d) => (d as { r: number }).r + 14),
- )
- .stop();
-
- // Fixed tick budget keeps the result deterministic across machines.
- for (let i = 0; i < 240; i++) sim.tick();
- sim.on("tick", null);
-
- return simNodes.map((n) => ({
- id: n.id,
- x: Number.isFinite(n.x) ? n.x : cx,
- y: Number.isFinite(n.y) ? n.y : cy,
- r: n.r,
- }));
-}
diff --git a/services/frontend/src/lib/constellation/palette.test.ts b/services/frontend/src/lib/constellation/palette.test.ts
deleted file mode 100644
index 42c43cff..00000000
--- a/services/frontend/src/lib/constellation/palette.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { cssColorToHexInt, oklchToHexInt } from "./palette";
-
-describe("oklchToHexInt", () => {
- test("white", () => {
- expect(oklchToHexInt(1, 0, 0)).toBe(0xffffff);
- });
- test("black", () => {
- expect(oklchToHexInt(0, 0, 0)).toBe(0x000000);
- });
- test("green-ish signal stays recognizable", () => {
- const hex = oklchToHexInt(0.86, 0.19, 128);
- expect(hex).not.toBeNull();
- if (hex === null) throw new Error("hex is null");
- const r = (hex >> 16) & 0xff;
- const g = (hex >> 8) & 0xff;
- expect(g).toBeGreaterThan(r);
- });
- test("invalid input returns null", () => {
- expect(oklchToHexInt(Number.NaN, 0, 0)).toBeNull();
- });
-});
-
-describe("cssColorToHexInt", () => {
- test("parses oklch()", () => {
- expect(cssColorToHexInt("oklch(1 0 0)")).toBe(0xffffff);
- });
- test("parses rgb()", () => {
- expect(cssColorToHexInt("rgb(255, 0, 0)")).toBe(0xff0000);
- });
- test("parses hex", () => {
- expect(cssColorToHexInt("#ff8000")).toBe(0xff8000);
- });
- test("garbage returns null", () => {
- expect(cssColorToHexInt("nonsense")).toBeNull();
- });
-});
diff --git a/services/frontend/src/lib/constellation/palette.ts b/services/frontend/src/lib/constellation/palette.ts
deleted file mode 100644
index 8d0faea0..00000000
--- a/services/frontend/src/lib/constellation/palette.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * Color helpers for the constellation stage.
- * Reads CSS custom properties (oklch strings) and converts them to
- * THREE-friendly hex integers. Pure math + DOM reader separated.
- */
-
-/** oklch(L C H) → sRGB hex integer (#rrggbb). Returns null on invalid input. */
-export function oklchToHexInt(l: number, c: number, h: number): number | null {
- if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) {
- return null;
- }
- // oklch → oklab
- const hr = (h * Math.PI) / 180;
- const a = c * Math.cos(hr);
- const b = c * Math.sin(hr);
-
- const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
- const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
- const s_ = l - 0.0894841775 * a - 1.291485548 * b;
-
- const L = l_ * l_ * l_;
- const M = m_ * m_ * m_;
- const S = s_ * s_ * s_;
-
- let r = 4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S;
- let g = -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S;
- let bb = -0.0041960863 * L - 0.7034186147 * M + 1.707614701 * S;
-
- r = gamma(r);
- g = gamma(g);
- bb = gamma(bb);
-
- if ([r, g, bb].some((v) => !Number.isFinite(v))) return null;
-
- const to255 = (v: number) => Math.max(0, Math.min(255, Math.round(v * 255)));
- return (to255(r) << 16) | (to255(g) << 8) | to255(bb);
-}
-
-function gamma(v: number): number {
- const abs = Math.abs(v);
- if (abs <= 0.0031308) return 12.92 * v;
- return (Math.sign(v) || 1) * (1.055 * abs ** (1 / 2.4) - 0.055);
-}
-
-/** Parse "oklch(0.86 0.19 128)" (or legacy "rgb(...)") into hex int. */
-export function cssColorToHexInt(color: string): number | null {
- const oklch = color.match(/oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.-]+)/i);
- if (oklch) {
- const lStr = oklch[1] ?? "";
- const l = lStr.endsWith("%")
- ? (Number.parseFloat(lStr) || 0) / 100
- : Number.parseFloat(lStr);
- return oklchToHexInt(
- l,
- Number.parseFloat(oklch[2] ?? "0"),
- Number.parseFloat(oklch[3] ?? "0"),
- );
- }
- const rgb = color.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
- if (rgb) {
- return (
- (Math.round(Number(rgb[1])) << 16) |
- (Math.round(Number(rgb[2])) << 8) |
- Math.round(Number(rgb[3]))
- );
- }
- const hex = color.match(/^#([0-9a-f]{6})$/i);
- if (hex?.[1]) return Number.parseInt(hex[1], 16);
- return null;
-}
-
-export interface StagePalette {
- signal: number;
- vermilion: number;
- amber: number;
- ink: number;
- inkSoft: number;
- inkFaint: number;
-}
-
-const FALLBACK_DARK: StagePalette = {
- signal: 0x7dd87a,
- vermilion: 0xe05642,
- amber: 0xd9a441,
- ink: 0xf2ede2,
- inkSoft: 0xa89f90,
- inkFaint: 0x807767,
-};
-
-/** Read theme tokens off :root computed style; fall back to dark set. */
-export function readPalette(): StagePalette {
- if (typeof window === "undefined") return FALLBACK_DARK;
- const cs = getComputedStyle(document.documentElement);
- const pick = (name: string, fb: number): number =>
- cssColorToHexInt(cs.getPropertyValue(name).trim()) ?? fb;
- return {
- signal: pick("--color-signal", FALLBACK_DARK.signal),
- vermilion: pick("--color-vermilion", FALLBACK_DARK.vermilion),
- amber: pick("--color-amber", FALLBACK_DARK.amber),
- ink: pick("--color-ink", FALLBACK_DARK.ink),
- inkSoft: pick("--color-ink-soft", FALLBACK_DARK.inkSoft),
- inkFaint: pick("--color-ink-faint", FALLBACK_DARK.inkFaint),
- };
-}