feat(dashboard): ground-up rombak jadi Ambient Field layout (bukan re-skin)

Hapus template dashboard lama (top bar + side rail + main + right panel +
bottom prompt). Ganti dengan layout yang benar-benar beda:

- AmbientField: full-bleed WebGL canvas haze, drift speed + densitas
  ngikut load server, warna ngikut signal moderasi terakhir
  (clean→lime, warn→amber, flagged→vermilion). Background tanpa container.
- View jadi full-bleed: headline raksasa bottom-left, metric cluster
  floating top-right (no box), event ribbon drift di tengah, command
  whisper di very bottom.
- AmbientShell di layout.tsx: gak ada TopBar/LeftRail untuk /dashboard
  exact. Route lain (messages/voice/media/dll) tetap ClassicShell.
- Tidak ada card, tidak ada grid, tidak ada panel, tidak ada tab.

Verified: tsc clean, next build 11/11 halaman, biome clean.
This commit is contained in:
asepharyana
2026-08-15 17:05:25 +07:00
parent 84757bdcf4
commit b98101c576
3 changed files with 254 additions and 173 deletions
@@ -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<Signal, [number, number, number]> = {
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<HTMLCanvasElement | null>(null);
const loadRef = useRef(load);
const signalRef = useRef<[number, number, number]>(SIGNAL_RGB[signal]);
const rafRef = useRef<number>(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 (
<canvas
ref={canvasRef}
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 h-full w-full"
/>
);
}