diff --git a/services/frontend/src/app/(dashboard)/channels/view.tsx b/services/frontend/src/app/(dashboard)/channels/view.tsx
index 9e33728e..11f0370e 100644
--- a/services/frontend/src/app/(dashboard)/channels/view.tsx
+++ b/services/frontend/src/app/(dashboard)/channels/view.tsx
@@ -1,8 +1,18 @@
"use client";
-import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary";
+/**
+ * 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 { 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({
@@ -11,12 +21,92 @@ 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 (
-
- {cultures ? (
-
+
+ {/* Search whisper — top-left */}
+
+
+ {/* Culture dossier for the selected star */}
+ {selectedRow ? (
+
) : (
-
+
+ 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 5e870ee7..b9288964 100644
--- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx
+++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx
@@ -1,32 +1,18 @@
"use client";
-import {
- Activity,
- Flag,
- MessageSquare,
- Mic,
- Radio,
- ShieldAlert,
- Users,
-} from "lucide-react";
-import { useEffect } from "react";
+/**
+ * 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 { useAmbient } from "@/components/ambient/ambient-context";
import { AreaActivity, RadialGauge } from "@/components/charts";
-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 { 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 { formatNumber } from "@/lib/format";
import type { DashboardStats } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
@@ -50,11 +36,19 @@ export function DashboardView({
initialStats?: DashboardStats;
initialActivity?: Awaited
>["data"];
}) {
- const { data: stats, isLoading, error } = useStats(initialStats);
+ const { data: stats, 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);
@@ -66,303 +60,134 @@ export function DashboardView({
}, [stats, ambient]);
if (error && !stats) return ;
- if (!stats && isLoading)
- return (
-
- );
- if (!stats) return ;
+ if (!stats) return ;
const s = stats;
const total = s.total_flagged + s.total_clean || 1;
const cleanRatio = s.total_clean / total;
return (
-
- {/* 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}
-
-
-
-
-
-
+ {/* Metric whisper — top-left under brand */}
+
+ Operations · {deriveSignal(s).label}
+
+ }
- className="animate-stagger"
- style={staggerDelay(0)}
+ tone="ink"
/>
- 0 ? "vermilion" : "neutral"}
+ tone={s.total_flagged > 0 ? "vermilion" : "ink"}
hint={`${s.today_flagged} today`}
- className="animate-stagger"
- style={staggerDelay(1)}
/>
- }
- className="animate-stagger"
- style={staggerDelay(2)}
/>
- }
- className="animate-stagger"
- style={staggerDelay(3)}
+ tone="ink"
/>
-
+
- {/* Activity */}
-
-
- Activity & moderation
-
- }
- action={
-
-
- messages
-
-
- flagged
-
-
- }
- />
+ {/* Activity ribbon — bottom-left */}
+
+
+ 14-day signal
+
{activity ? (
) : (
)}
-
+
- {/* 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 &&
}
-
-
-
+ {/* 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}
+
+
+
);
}
-function Row({
- icon,
- label,
- value,
-}: {
- icon: React.ReactNode;
- label: string;
- value: string;
-}) {
- return (
-
- {icon}
- {label}
- {value}
-
- );
-}
-
-function Mini({
+function WhisperRow({
label,
value,
tone,
+ hint,
}: {
label: string;
- value: number;
- tone: "signal" | "amber" | "vermilion";
+ value: string;
+ tone: "ink" | "signal" | "vermilion";
+ hint?: string;
}) {
const color =
tone === "vermilion"
? "text-vermilion"
- : tone === "amber"
- ? "text-amber"
- : "text-signal";
+ : tone === "signal"
+ ? "text-signal"
+ : "text-ink";
return (
-
- );
-}
-
-function EmptyHint() {
- return (
-
- Awaiting data…
-
+
+
+ {label}
+
+ {value}
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
);
}
diff --git a/services/frontend/src/components/shell/constellation-frame.tsx b/services/frontend/src/components/shell/constellation-frame.tsx
index bc5f4205..28ef1e73 100644
--- a/services/frontend/src/components/shell/constellation-frame.tsx
+++ b/services/frontend/src/components/shell/constellation-frame.tsx
@@ -3,7 +3,9 @@
/**
* ConstellationFrame — replaces the classic AppFrame chrome.
* The stage canvas sits fixed behind everything; page content is an
- * overlay layer (absolute, no top bar / nav rail / scroll shell).
+ * 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";
@@ -11,39 +13,67 @@ 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 { resolveScene, type SceneSeed } from "@/components/shell/route-scenes";
+import {
+ buildDefaultGraph,
+ resolveScene,
+ type SceneSeed,
+} from "@/components/shell/route-scenes";
+import {
+ SceneGraphProvider,
+ useSceneGraph,
+} from "@/components/shell/scene-graph-context";
-export interface ConstellationFrameProps {
- children: ReactNode;
- /** Typed SSR seed consumed by the active scene's graph builder. */
- sceneSeed?: SceneSeed;
+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) {
- const pathname = usePathname() ?? "/";
- const scene = useMemo(() => resolveScene(pathname), [pathname]);
- const graph = useMemo(
- () => (scene ? scene.build(sceneSeed ?? {}) : { nodes: [], edges: [] }),
- [scene, sceneSeed],
- );
-
+}: ConstellationFrameProps_) {
return (
-
-
{
- if (id.startsWith("channel:")) window.location.assign("/channels/");
- }}
- />
-
-
- {/* Overlay content region — scenes place floating panels inside. */}
-
- {children}
-
-
+
+
+
+
+
+ {/* Overlay content region — scenes place floating panels inside. */}
+
+ {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
index 4d73e1eb..7b008207 100644
--- a/services/frontend/src/components/shell/constellation-stage.tsx
+++ b/services/frontend/src/components/shell/constellation-stage.tsx
@@ -17,6 +17,8 @@ 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;
}
@@ -26,6 +28,7 @@ const Z_MAX = 3;
export function ConstellationStage({
graph,
seed = 42,
+ selectedId = null,
onNodeClick,
}: ConstellationStageProps) {
const canvasRef = useRef(null);
@@ -34,7 +37,7 @@ export function ConstellationStage({
const layoutRef = useRef([]);
const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n])));
const [size, setSize] = useState({ w: 0, h: 0 });
- const [hovered, setHovered] = useState(null);
+ const hoveredRef = useRef(null);
const [reduced, setReduced] = useState(false);
nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n]));
@@ -222,7 +225,7 @@ export function ConstellationStage({
return;
}
const hit = pickNode(ev.clientX, ev.clientY);
- setHovered(hit ? hit.id : null);
+ hoveredRef.current = hit ? hit.id : null;
canvas.style.cursor = hit ? "pointer" : "grab";
};
const onPointerUp = (ev: PointerEvent) => {
@@ -318,7 +321,7 @@ export function ConstellationStage({
v.halo.material.opacity =
(v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse);
}
- const hov = v.id === hovered;
+ const hov = v.id === hoveredRef.current || v.id === selectedId;
v.core.scale.setScalar(hov ? 1.25 : 1);
i += 1;
}
@@ -334,7 +337,9 @@ export function ConstellationStage({
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 === hovered ? "var(--color-signal)" : "var(--color-ink-soft)";
+ id === hoveredRef.current || id === selectedId
+ ? "var(--color-signal)"
+ : "var(--color-ink-soft)";
}
}
};
@@ -354,7 +359,7 @@ export function ConstellationStage({
scene.clear();
renderer.dispose();
};
- }, [graph, size.w, size.h, reduced, hovered, onNodeClick]);
+ }, [graph, size.w, size.h, reduced, selectedId, onNodeClick]);
const emptyGraph = graph.nodes.length === 0;
const hint = useMemo(
diff --git a/services/frontend/src/components/shell/route-scenes.ts b/services/frontend/src/components/shell/route-scenes.ts
index 3b5066b5..d64bea3a 100644
--- a/services/frontend/src/components/shell/route-scenes.ts
+++ b/services/frontend/src/components/shell/route-scenes.ts
@@ -4,7 +4,11 @@
*/
import type { ConstellationGraph } from "@/lib/constellation/graph";
-import { channelsToGraph, statsToGraph } from "@/lib/constellation/graph";
+import {
+ channelsToGraph,
+ culturesToGraph,
+ statsToGraph,
+} from "@/lib/constellation/graph";
export interface SceneDef {
route: string;
@@ -17,6 +21,7 @@ export interface SceneDef {
export interface SceneSeed {
stats?: import("@/lib/types").DashboardStats;
channels?: import("@/lib/types").DashboardChannel[];
+ cultures?: import("@/lib/types").ChannelCultureRow[];
guildLabel?: string;
}
@@ -29,11 +34,26 @@ export const SCENES: SceneDef[] = [
{
route: "/channels/",
label: "Channels",
- build: (s) =>
- s.channels ? channelsToGraph(s.channels) : { nodes: [], edges: [] },
+ 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-graph-context.tsx b/services/frontend/src/components/shell/scene-graph-context.tsx
new file mode 100644
index 00000000..b5bd3a57
--- /dev/null
+++ b/services/frontend/src/components/shell/scene-graph-context.tsx
@@ -0,0 +1,104 @@
+"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/lib/constellation/graph.ts b/services/frontend/src/lib/constellation/graph.ts
index 394e3fff..17517bf1 100644
--- a/services/frontend/src/lib/constellation/graph.ts
+++ b/services/frontend/src/lib/constellation/graph.ts
@@ -2,7 +2,11 @@
* Constellation graph model — pure data, no React/DOM.
* Builders convert existing API payloads into star-graph structures.
*/
-import type { DashboardChannel, DashboardStats } from "@/lib/types";
+import type {
+ ChannelCultureRow,
+ DashboardChannel,
+ DashboardStats,
+} from "@/lib/types";
export type NodeKind =
| "guild"
@@ -94,3 +98,18 @@ export function channelsToGraph(
}));
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: [] };
+}