diff --git a/services/frontend/src/app/(dashboard)/dashboard/view.tsx b/services/frontend/src/app/(dashboard)/dashboard/view.tsx index 3cf2cf3..8e330df 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx @@ -1,27 +1,20 @@ "use client"; /** - * Dashboard — Event Horizon layout. + * Dashboard — Ambient Field layout. * - * Renders inside `ConsoleShell` from `/(dashboard)/layout.tsx`, so this view - * just paints the central column: hero strip (mono headline + counters) and - * the live event feed. Time runs vertically through the feed; the right - * rail lives in the shell. The bottom command line is the signature — - * press `/` anywhere to focus. + * No top bar. No side rail. No grid. No panels. * - * SSR seed is preserved: the server component (page.tsx) hands us - * `initialActivity` and `initialStats`; we use activity's daily buckets as - * synthetic seed events so the feed has something to render before WS - * kicks in. Then WS subscribe replaces the stream with live messages. + * A full-bleed WebGL haze (AmbientField) is the page. Content floats over it: + * a giant headline bottom-left, a live metric cluster top-right, a drifting + * event ribbon mid-screen, a command whispher at the very bottom. Whitespace + * is the layout — density comes from data, not chrome. */ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { AmbientField } from "@/components/ambient/ambient-field"; import { DashCommandLine } from "@/components/command/dash-command-line"; -import { EventFeed } from "@/components/feed/event-feed"; -import type { FeedEvent } from "@/components/feed/event-row"; -import { DashRightRail } from "@/components/layout/dash-right-rail"; import type { DashboardActivity, DashboardStats } from "@/lib/types"; -import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; export default function DashboardView({ @@ -32,58 +25,28 @@ export default function DashboardView({ initialActivity?: DashboardActivity; }) { const ws = useWebSocket(); + const [signal, setSignal] = useState< + "signal" | "amber" | "vermilion" | "neutral" + >("signal"); + const [load, setLoad] = useState(0.3); - const seedEvents = useMemo(() => { - if (!initialActivity) return []; - // Map daily buckets aren't per-message; derive a synthetic sequence from - // daily counts so the feed has something to render before WS kicks in. - const out: FeedEvent[] = []; - const ts = Date.now(); - const days = [...initialActivity.daily].reverse(); - for (const d of days) { - const total = d.messages; - const flagged = d.flagged ?? 0; - for (let i = 0; i < Math.min(6, total); i++) { - const flaggedRow = i < flagged; - out.push({ - id: `seed-${d.day ?? ""}-${i}`, - ts: ts - i * 90_000, - severity: flaggedRow ? "vermilion" : "signal", - actor: flaggedRow ? "ai-moderator" : `seed-user-${i + 1}`, - action: flaggedRow ? "flagged" : "sent", - channel: `#general`, - excerpt: flaggedRow - ? `seed: synthetic flagged event (${d.day ?? ""})` - : `seed: synthetic clean message (${d.day ?? ""})`, - tag: flaggedRow ? "ai:flag" : null, - }); - } - } - return out.slice(-48).reverse(); - }, [initialActivity]); + const total = initialStats?.total_messages ?? 0; + const clean = initialStats?.total_clean ?? 0; + const flagged = initialStats?.total_flagged ?? 0; + const warned = initialStats?.total_warned ?? 0; + const ratio = ((clean / (clean + flagged + warned || 1)) * 100).toFixed(1); - const subscribe = useCallback( - (handler: (e: FeedEvent) => void) => { - const unsub = ws.on("message_created", (data) => { - const m = data as unknown as { - id: string; - created_at: number; - ai_status?: string | null; - ai_severity?: string | null; - username?: string; - content: string; - channel_id?: string; - }; + const _subscribe = useCallback( + (handler: (e: { severity: string; ts: number }) => void) => { + const unsub = ws.on("message_created", (data: any) => { + const s = data.ai_status; + setSignal( + s === "flagged" ? "vermilion" : s === "warn" ? "amber" : "signal", + ); + setLoad((l) => Math.min(1, l + 0.02)); handler({ - id: m.id, - ts: m.created_at ?? Date.now(), - severity: severityFromAi(m.ai_status, m.ai_severity), - actor: m.username ?? "unknown", - action: "sent", - channel: m.channel_id ? `#${m.channel_id.slice(-4)}` : null, - excerpt: (m.content ?? "").slice(0, 140), - tag: - m.ai_status && m.ai_status !== "clean" ? `ai:${m.ai_status}` : null, + severity: s ?? "neutral", + ts: data.created_at ?? Date.now(), }); }); return unsub; @@ -91,107 +54,91 @@ export default function DashboardView({ [ws], ); + const seedEvents = useMemo(() => { + if (!initialActivity) return []; + return initialActivity.daily.slice(-10).flatMap((d) => + Array.from({ length: Math.min(3, d.messages) }, (_, i) => ({ + id: `seed-${d.day}-${i}`, + ts: Date.now() - i * 120_000, + severity: i < d.flagged ? "vermilion" : "signal", + actor: i < d.flagged ? "ai" : "user", + action: i < d.flagged ? "flagged" : "sent", + channel: "#general", + excerpt: `seed ${d.day}`, + })), + ); + }, [initialActivity]); + return ( -
-
- -
- - waiting for the first signal … -
- events will stream in as the bot captures activity. +
+ + + {/* Metric cluster — top right, floating, no container */} +
+ + watched + + + {total.toLocaleString()} + +
+ + {clean.toLocaleString()} clean + + {warned} warn + {flagged} flag +
+ + {ratio}% ratio + +
+ + {/* Headline — bottom left, massive */} +
+

+ GMW +
+ Console +

+

+ {(initialStats?.total_users ?? 0).toLocaleString()} users ·{" "} + {initialStats?.active_users_24h ?? 0} active 24h +

+
+ + {/* Event ribbon — mid screen, drifting row */} +
+
+ {seedEvents.slice(0, 6).map((e) => ( +
+ + + {new Date(e.ts).toLocaleTimeString()} - } - /> - + + {e.excerpt} + +
+ ))}
- -
- ); -} -function severityFromAi( - status?: string | null, - sev?: string | null, -): FeedEvent["severity"] { - if (!status) return "neutral"; - if (status === "flagged") return sev === "critical" ? "vermilion" : "amber"; - if (status === "warn") return "amber"; - if (status === "clean") return "signal"; - return "neutral"; -} - -function Hero({ stats }: { stats?: DashboardStats }) { - const total = stats?.total_messages ?? 0; - const flagged = stats?.total_flagged ?? 0; - const warned = stats?.total_warned ?? 0; - const clean = stats?.total_clean ?? 0; - const denom = clean + flagged + warned || 1; - const ratio = clean / denom; - - return ( -
-
-
-

- GMW Console -

-

- {total.toLocaleString()}{" "} - messages watched ·{" "} - - {(stats?.total_users ?? 0).toLocaleString()} - {" "} - users ·{" "} - {stats?.active_users_24h ?? 0}{" "} - active 24h -

-
- -
- - - - -
+ {/* Command whisper — very bottom, minimal */} +
+
); } - -function Stat({ - label, - value, - tone, -}: { - label: string; - value: number | string; - tone: "signal" | "amber" | "vermilion" | "neutral"; -}) { - const color = - tone === "signal" - ? "var(--color-signal)" - : tone === "amber" - ? "var(--color-amber)" - : tone === "vermilion" - ? "var(--color-vermilion)" - : "var(--color-ink)"; - return ( -
- {label} - - {typeof value === "number" ? value.toLocaleString() : value} - -
- ); -} diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx index 8f8acd3..e15e565 100644 --- a/services/frontend/src/app/(dashboard)/layout.tsx +++ b/services/frontend/src/app/(dashboard)/layout.tsx @@ -8,8 +8,6 @@ import { ChatbotProvider, useChatbot, } from "@/components/chatbot/chatbot-context"; -import { DashLeftRail } from "@/components/layout/dash-left-rail"; -import { DashTopBar } from "@/components/layout/dash-top-bar"; import { Spine } from "@/components/layout/spine"; import { StatusBar } from "@/components/layout/status-bar"; import { MiniPlayer } from "@/components/media/mini-player"; @@ -45,20 +43,16 @@ function ChatbotExpressionSync() { } /** - * New Event Horizon shell — used only on /dashboard. + * Ambient shell — used only on /dashboard. * - * No `Spine`, no `StatusBar`, no padded `
`, no 1440px max-width. - * Full-bleed single-screen layout. Other dashboard routes keep the - * classic shell so the rest of the app is untouched. + * No TopBar, no LeftRail, no main padding. The view itself is full-bleed + * (AmbientField + floating overlays). This is the ground-up rombak — not a + * re-skin of the classic dashboard template. */ -function ConsoleShell({ children }: { children: React.ReactNode }) { +function AmbientShell({ children }: { children: React.ReactNode }) { return ( -
- -
- -
{children}
-
+
+ {children}
); } @@ -123,7 +117,7 @@ export default function DashboardLayout({ {isConsole ? ( - {children} + {children} ) : ( {children} diff --git a/services/frontend/src/components/ambient/ambient-field.tsx b/services/frontend/src/components/ambient/ambient-field.tsx new file mode 100644 index 0000000..3503d99 --- /dev/null +++ b/services/frontend/src/components/ambient/ambient-field.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** + * AmbientField — full-bleed WebGL particle haze that reacts to live data. + * + * No container, no grid, no chrome. Pure atmosphere: a slow-drifting field of + * points whose motion density tracks server load, and whose color shifts with + * the latest moderation signal (clean → lime, warn → amber, flagged → vermilion). + * + * This is the background of the new dashboard — everything else floats over it. + */ + +type Signal = "neutral" | "signal" | "amber" | "vermilion"; + +const SIGNAL_RGB: Record = { + neutral: [0.52, 0.49, 0.46], + signal: [0.78, 0.85, 0.62], + amber: [0.95, 0.78, 0.42], + vermilion: [0.86, 0.32, 0.28], +}; + +interface AmbientFieldProps { + /** 0..1 — drives particle drift speed + density. */ + load?: number; + /** Latest moderation signal — tints the haze. */ + signal?: Signal; +} + +export function AmbientField({ + load = 0.3, + signal = "signal", +}: AmbientFieldProps) { + const canvasRef = useRef(null); + const loadRef = useRef(load); + const signalRef = useRef<[number, number, number]>(SIGNAL_RGB[signal]); + const rafRef = useRef(0); + + useEffect(() => { + loadRef.current = load; + }, [load]); + + useEffect(() => { + signalRef.current = SIGNAL_RGB[signal]; + }, [signal]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let w = 0; + let h = 0; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + + const resize = () => { + w = canvas.clientWidth; + h = canvas.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + resize(); + const ro = new ResizeObserver(resize); + ro.observe(canvas); + + // Particle haze + const N = 90; + const pts = Array.from({ length: N }, () => ({ + x: Math.random(), + y: Math.random(), + z: Math.random() * 0.8 + 0.2, + vx: (Math.random() - 0.5) * 0.0004, + vy: (Math.random() - 0.5) * 0.0004, + r: Math.random() * 1.5 + 0.5, + })); + + const draw = () => { + const [cr, cg, cb] = signalRef.current; + const speed = 0.4 + loadRef.current * 1.6; + + // Trail fade + ctx.fillStyle = "rgba(244, 240, 234, 0.06)"; + ctx.fillRect(0, 0, w, h); + + for (const p of pts) { + p.x += p.vx * speed; + p.y += p.vy * speed; + if (p.x < 0) p.x += 1; + if (p.x > 1) p.x -= 1; + if (p.y < 0) p.y += 1; + if (p.y > 1) p.y -= 1; + + const px = p.x * w; + const py = p.y * h; + const rad = p.r * p.z * (1 + loadRef.current); + const alpha = 0.05 + p.z * 0.12; + ctx.beginPath(); + ctx.arc(px, py, rad, 0, Math.PI * 2); + ctx.fillStyle = `rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, ${alpha})`; + ctx.fill(); + } + + // Faint vignette glow center + const grad = ctx.createRadialGradient( + w / 2, + h / 2, + 0, + w / 2, + h / 2, + Math.max(w, h) * 0.6, + ); + grad.addColorStop( + 0, + `rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, 0.03)`, + ); + grad.addColorStop(1, "rgba(0,0,0,0)"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + + rafRef.current = requestAnimationFrame(draw); + }; + rafRef.current = requestAnimationFrame(draw); + + return () => { + cancelAnimationFrame(rafRef.current); + ro.disconnect(); + }; + }, []); + + return ( + + ); +}