-
-
- {connected ? "Connected" : "Disconnected"}
-
- {connected && status?.activeChannelName && (
-
-
- {status.activeChannelName}
-
+
+
+
+
{
+ setGuildId(g);
+ setChannelId(c);
+ }}
+ />
+
+ {connected ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {listen.active && (
+
+
+
+
)}
+
- {!connected ? (
-
-
-
-
-
-
-
- ) : (
-
- )}
-
-
- {/* Stage hero */}
-
-
+
+
+ {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."}
/>
- }
- >
- 0 ? 0.6 : 0.2}
- className="absolute inset-0"
- />
-
-
-
-
+ ) : (
+ <>
+
+
+ {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 ?? "—"}
+
+
-
-
-
-
-
-
- listen.toggle(on)}
- volume={75}
- onVolume={handleListenVolume}
- />
-
-
-
- {/* Activity timeline */}
-
-
- Live session timeline
-
- ({
- id: s.userId,
- label: s.username,
- value: s.speaking ? 3 : 1,
- tone: s.speaking ? "signal" : "neutral",
- }))}
- />
-
-
-
);
}
diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css
index 6dca6d7..50bc960 100644
--- a/services/frontend/src/app/globals.css
+++ b/services/frontend/src/app/globals.css
@@ -3,69 +3,66 @@
@custom-variant dark (&:is(.dark *));
/*
- * GMW — new design system (visual overhaul).
+ * GMW — Ambient Ops Console design system.
*
- * Replaces the old teal-cyan + purple + glassmorphism language with a warm,
- * signal-driven ops-console aesthetic. Hierarchy comes from scale/weight and
- * tonal surface blocks, NOT from borders/shadows. Three semantic signals:
- * lime = OK / live (--signal)
- * amber = warn (--amber)
- * vermilion = flag/danger (--vermilion)
+ * A signal-driven, immersive ops aesthetic. Hierarchy comes from scale/weight
+ * and tonal surface blocks, not borders. The whole app sits behind a live
+ * WebGL haze (see components/ambient) tinted by a semantic signal:
+ * lime = OK / live
+ * amber = warn
+ * vermilion = flag / danger
*/
@theme {
- /* ── Surfaces ── */
- --color-canvas: oklch(0.96 0.012 80);
- --color-surface: oklch(0.92 0.014 80);
- --color-surface-2: oklch(0.88 0.016 80);
+ /* ── 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-2: oklch(0.26 0.024 70 / 0.45);
/* ── Ink ── */
- --color-ink: oklch(0.22 0.02 70);
- --color-ink-soft: oklch(0.46 0.02 70);
+ --color-ink: oklch(0.95 0.008 75);
+ --color-ink-soft: oklch(0.66 0.02 75);
+ --color-ink-faint: oklch(0.5 0.02 75);
/* ── Structural ── */
- --color-hairline: oklch(0.22 0.02 70 / 0.1);
+ --color-hairline: oklch(1 0 0 / 0.1);
--hairline-w: 1px;
/* ── Semantic signals ── */
- --color-signal: oklch(0.78 0.17 125);
- --color-signal-ink: oklch(0.20 0.03 70);
- --color-signal-glow: oklch(0.78 0.17 125 / 0.35);
- --color-amber: oklch(0.80 0.15 70);
- --color-vermilion: oklch(0.62 0.21 25);
- --color-vermilion-soft: oklch(0.62 0.21 25 / 0.15);
+ --color-signal: oklch(0.86 0.19 128);
+ --color-signal-ink: oklch(0.18 0.03 70);
+ --color-signal-glow: oklch(0.86 0.19 128 / 0.4);
+ --color-amber: oklch(0.85 0.15 72);
+ --color-vermilion: oklch(0.68 0.21 25);
+ --color-vermilion-glow: oklch(0.68 0.21 25 / 0.4);
--color-ring: var(--color-signal);
/* ── Fonts ── */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
- --font-mono: "JetBrains Mono", ui-monospace, monospace;
- --font-display: "Bricolage Grotesque", "Inter", sans-serif;
+ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
+ --font-display: "Bricolage Grotesque", "Inter", system-ui, sans-serif;
/* ── Radii ── */
- --radius-r: 14px;
+ --radius-r: 16px;
--radius-r-panel: 12px;
- --radius-r-control: 8px;
+ --radius-r-control: 9px;
--radius-r-pill: 9999px;
}
-/* ── Dark theme overrides ── */
-.dark {
- --color-canvas: oklch(0.13 0.015 70);
- --color-surface: oklch(0.18 0.02 70);
- --color-surface-2: oklch(0.23 0.022 70);
+/* ── Light theme overrides ── */
+.light {
+ --color-canvas: oklch(0.96 0.012 80);
+ --color-canvas-2: oklch(0.92 0.014 80);
+ --color-surface: oklch(1 0 0 / 0.7);
+ --color-surface-2: oklch(1 0 0 / 0.5);
- --color-ink: oklch(0.93 0.01 75);
- --color-ink-soft: oklch(0.62 0.02 75);
+ --color-ink: oklch(0.22 0.02 70);
+ --color-ink-soft: oklch(0.46 0.02 70);
+ --color-ink-faint: oklch(0.6 0.02 70);
- --color-hairline: oklch(1 0 0 / 0.09);
-
- --color-signal: oklch(0.88 0.18 125);
- --color-signal-ink: oklch(0.18 0.03 70);
- --color-signal-glow: oklch(0.88 0.18 125 / 0.4);
- --color-amber: oklch(0.85 0.15 70);
- --color-vermilion: oklch(0.68 0.21 25);
- --color-vermilion-soft: oklch(0.68 0.21 25 / 0.18);
+ --color-hairline: oklch(0.22 0.02 70 / 0.12);
}
@layer base {
@@ -79,41 +76,28 @@
body {
@apply bg-canvas text-ink font-sans antialiased;
- /* warm dot-grid texture + faint glow — replaces old bluish radial layers */
- background-image:
- radial-gradient(circle, oklch(0.45 0.03 70 / 0.05) 1px, transparent 1px),
- radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.78 0.17 125 / 0.06), transparent),
- radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.80 0.15 70 / 0.04), transparent);
- background-size: 26px 26px, 100% 100%, 100% 100%;
- }
-
- .dark body {
- background-image:
- radial-gradient(circle, oklch(1 0 0 / 0.022) 1px, transparent 1px),
- radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.88 0.18 125 / 0.05), transparent),
- radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.85 0.15 70 / 0.03), transparent);
- background-size: 26px 26px, 100% 100%, 100% 100%;
+ min-height: 100dvh;
}
::selection {
- background: oklch(0.78 0.17 125 / 0.35);
+ background: var(--color-signal-glow);
color: inherit;
}
- /* Scrollbar — warm */
+ /* Scrollbar */
::-webkit-scrollbar {
- width: 7px;
- height: 7px;
+ width: 8px;
+ height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
- background: oklch(0.4 0.02 70 / 0.22);
+ background: oklch(1 0 0 / 0.14);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
- background: oklch(0.4 0.02 70 / 0.38);
+ background: oklch(1 0 0 / 0.26);
}
:focus-visible {
@@ -122,59 +106,48 @@
}
}
-@layer utilities {
- /* Tonal block that replaces bordered cards */
- .surface {
+@layer components {
+ /* Glass panel — the workhorse container. Floating, blurred, faint glow. */
+ .glass {
background: var(--color-surface);
- border-radius: var(--radius-r);
border: var(--hairline-w) solid var(--color-hairline);
+ border-radius: var(--radius-r);
+ backdrop-filter: blur(18px) saturate(140%);
+ -webkit-backdrop-filter: blur(18px) saturate(140%);
+ box-shadow:
+ 0 1px 0 0 oklch(1 0 0 / 0.06) inset,
+ 0 18px 50px -28px oklch(0 0 0 / 0.8);
}
- .surface-2 {
+
+ .glass-2 {
background: var(--color-surface-2);
+ border: var(--hairline-w) solid var(--color-hairline);
border-radius: var(--radius-r-panel);
- border: var(--hairline-w) solid var(--color-hairline);
+ backdrop-filter: blur(14px) saturate(130%);
+ -webkit-backdrop-filter: blur(14px) saturate(130%);
}
- /* 1px animated pulse line marking a live/section header */
- .scan-tick {
- position: relative;
- overflow: hidden;
- }
- .scan-tick::after {
- content: "";
- position: absolute;
- inset-inline-start: 0;
- inset-block-start: 0;
- block-size: 1px;
- inline-size: 100%;
- background: linear-gradient(
- 90deg,
- transparent,
- var(--color-signal) 20%,
- var(--color-signal) 80%,
- transparent
- );
- background-size: 200% 100%;
- animation: scan 2.6s linear infinite;
- opacity: 0.7;
- }
-
- .ticker {
- background: var(--color-surface);
- border-radius: var(--radius-r);
- border: var(--hairline-w) solid var(--color-hairline);
- padding: clamp(1rem, 2vw, 1.5rem);
+ /* Section label — small uppercase mono eyebrow */
+ .eyebrow {
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--color-ink-faint);
}
.pill {
display: inline-flex;
align-items: center;
- gap: 0.35rem;
- padding: 0.2rem 0.7rem;
+ gap: 0.4rem;
+ padding: 0.22rem 0.7rem;
border-radius: var(--radius-r-pill);
font-size: 0.72rem;
font-weight: 600;
- letter-spacing: 0.02em;
+ letter-spacing: 0.01em;
+ background: oklch(1 0 0 / 0.06);
+ border: 1px solid var(--color-hairline);
+ color: var(--color-ink-soft);
}
.mono {
@@ -186,26 +159,58 @@
.display {
font-family: var(--font-display);
font-weight: 800;
- letter-spacing: -0.02em;
- line-height: 1.02;
+ letter-spacing: -0.03em;
+ line-height: 0.96;
}
+}
- /* text tone helpers */
+@layer utilities {
.text-signal { color: var(--color-signal); }
.text-amber { color: var(--color-amber); }
.text-vermilion { color: var(--color-vermilion); }
.text-ink-soft { color: var(--color-ink-soft); }
+ .text-ink-faint { color: var(--color-ink-faint); }
+
+ .glow-signal {
+ text-shadow: 0 0 22px var(--color-signal-glow);
+ }
+ .glow-vermilion {
+ text-shadow: 0 0 22px var(--color-vermilion-glow);
+ }
+
+ /* animated scan line for live headers */
+ .scan-line {
+ position: relative;
+ overflow: hidden;
+ }
+ .scan-line::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 1px;
+ width: 100%;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ var(--color-signal) 25%,
+ var(--color-signal) 75%,
+ transparent
+ );
+ background-size: 200% 100%;
+ animation: scan 2.8s linear infinite;
+ opacity: 0.7;
+ }
- /* focus ring helper for interactive blocks */
.ring-focus {
- transition: box-shadow 0.18s ease;
+ transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
.ring-focus:hover {
box-shadow: 0 0 0 1px var(--color-signal-glow);
}
}
-/* ── Keyframes ──────────────────────────── */
+/* ── Keyframes ── */
@keyframes scan {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
@@ -216,54 +221,52 @@
60% { transform: scaleY(0.5); }
}
@keyframes fade-up {
- from { opacity: 0; transform: translateY(8px); }
+ from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes spin-disc {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
-/* kept for live status dots */
@keyframes pulse-ring {
- 0% { transform: scale(0.8); opacity: 1; }
- 100% { transform: scale(2.5); opacity: 0; }
+ 0% { transform: scale(0.85); opacity: 1; }
+ 100% { transform: scale(2.6); opacity: 0; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
+@keyframes breathe {
+ 0%, 100% { opacity: 0.55; }
+ 50% { opacity: 1; }
+}
.animate-eq {
animation: eq 0.9s ease-in-out infinite;
transform-origin: bottom;
}
-.animate-spin-disc {
- animation: spin-disc 8s linear infinite;
-}
-.animate-spin-disc.paused {
- animation-play-state: paused;
-}
-.animate-pulse-ring {
- animation: pulse-ring 1.5s ease-out infinite;
-}
+.animate-spin-disc { animation: spin-disc 9s linear infinite; }
+.animate-spin-disc.paused { animation-play-state: paused; }
+.animate-pulse-ring { animation: pulse-ring 1.6s ease-out infinite; }
.animate-shimmer {
background: linear-gradient(
90deg,
transparent,
- oklch(0.78 0.17 125 / 0.08),
+ oklch(0.86 0.19 128 / 0.1),
transparent
);
background-size: 200% 100%;
- animation: shimmer 1.5s infinite;
+ animation: shimmer 1.6s infinite;
}
+.animate-breathe { animation: breathe 3.5s ease-in-out infinite; }
-/* ── Reduced motion: kill all decorative animation ── */
@media (prefers-reduced-motion: reduce) {
- .scan-tick::after,
+ .scan-line::after,
.animate-eq,
.animate-spin-disc,
.animate-pulse-ring,
- .animate-shimmer {
+ .animate-shimmer,
+ .animate-breathe {
animation: none !important;
}
html { scroll-behavior: auto; }
diff --git a/services/frontend/src/components/ambient/ambient-canvas.tsx b/services/frontend/src/components/ambient/ambient-canvas.tsx
new file mode 100644
index 0000000..36be34d
--- /dev/null
+++ b/services/frontend/src/components/ambient/ambient-canvas.tsx
@@ -0,0 +1,231 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import * as THREE from "three";
+import { SIGNAL_RGB, type SignalTone } from "./ambient-context";
+
+const VERT = /* glsl */ `
+ varying vec2 vUv;
+ void main(){
+ vUv = uv;
+ gl_Position = vec4(position.xy, 0.0, 1.0);
+ }
+`;
+
+const FRAG = /* glsl */ `
+ precision mediump float;
+ varying vec2 vUv;
+ uniform float uTime;
+ uniform vec3 uColor;
+ uniform float uIntensity;
+ uniform vec2 uRes;
+
+ float hash(vec2 p){ p=fract(p*vec2(123.34,456.21)); p+=dot(p,p+45.32); return fract(p.x*p.y); }
+ float noise(vec2 p){
+ vec2 i=floor(p); vec2 f=fract(p);
+ float a=hash(i), b=hash(i+vec2(1.,0.)), c=hash(i+vec2(0.,1.)), d=hash(i+vec2(1.,1.));
+ vec2 u=f*f*(3.-2.*f);
+ return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);
+ }
+ float fbm(vec2 p){
+ float v=0.0, a=0.5;
+ mat2 m=mat2(1.6,1.2,-1.2,1.6);
+ for(int i=0;i<5;i++){ v+=a*noise(p); p=m*p; a*=0.5; }
+ return v;
+ }
+
+ void main(){
+ vec2 uv=vUv;
+ vec2 p=uv-0.5;
+ p.x*=uRes.x/uRes.y;
+ float t=uTime*0.04*(0.6+uIntensity);
+ vec2 q=vec2(fbm(p*1.5+t), fbm(p*1.5-t+5.0));
+ float f=fbm(p*2.2 + q*1.8 + t*0.5);
+ vec2 c=vec2(sin(uTime*0.05)*0.25, cos(uTime*0.04)*0.18);
+ float d=length(p-c);
+ float glow=smoothstep(0.95,0.0,d)*0.5;
+ float haze=(f*0.7+glow)*uIntensity;
+ vec3 col=uColor*haze;
+ float g=hash(uv*uRes+uTime)*0.035;
+ col+=g;
+ float vig=smoothstep(1.25,0.15,length(p));
+ col*=0.35+0.65*vig;
+ gl_FragColor=vec4(col,1.0);
+ }
+`;
+
+const MOTE_COUNT = 140;
+
+export function AmbientCanvas({
+ targetRef,
+}: {
+ targetRef: React.MutableRefObject<{ tone: SignalTone; intensity: number }>;
+}) {
+ const mountRef = useRef
(null);
+
+ useEffect(() => {
+ const mount = mountRef.current;
+ if (!mount) return;
+
+ let renderer: THREE.WebGLRenderer;
+ try {
+ renderer = new THREE.WebGLRenderer({
+ antialias: false,
+ alpha: false,
+ powerPreference: "high-performance",
+ });
+ } catch {
+ return; // static CSS fallback remains
+ }
+
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
+ renderer.setPixelRatio(dpr);
+ renderer.setSize(mount.clientWidth, mount.clientHeight);
+ mount.appendChild(renderer.domElement);
+ renderer.domElement.style.width = "100%";
+ renderer.domElement.style.height = "100%";
+ renderer.domElement.style.display = "block";
+
+ const scene = new THREE.Scene();
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+
+ const uniforms = {
+ uTime: { value: 0 },
+ uColor: { value: new THREE.Color(...SIGNAL_RGB.signal) },
+ uIntensity: { value: 0.35 },
+ uRes: { value: new THREE.Vector2(1, 1) },
+ };
+
+ const quad = new THREE.Mesh(
+ new THREE.PlaneGeometry(2, 2),
+ new THREE.ShaderMaterial({
+ vertexShader: VERT,
+ fragmentShader: FRAG,
+ uniforms,
+ depthTest: false,
+ depthWrite: false,
+ }),
+ );
+ scene.add(quad);
+
+ // — Drifting motes —
+ const positions = new Float32Array(MOTE_COUNT * 3);
+ const speeds = new Float32Array(MOTE_COUNT);
+ for (let i = 0; i < MOTE_COUNT; i++) {
+ positions[i * 3] = (Math.random() - 0.5) * 2;
+ positions[i * 3 + 1] = (Math.random() - 0.5) * 2;
+ positions[i * 3 + 2] = 0;
+ speeds[i] = 0.01 + Math.random() * 0.03;
+ }
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
+ const moteMat = new THREE.PointsMaterial({
+ size: 0.012,
+ color: new THREE.Color(...SIGNAL_RGB.signal),
+ transparent: true,
+ opacity: 0.5,
+ blending: THREE.AdditiveBlending,
+ depthTest: false,
+ depthWrite: false,
+ });
+ const motes = new THREE.Points(geo, moteMat);
+ scene.add(motes);
+
+ const color = new THREE.Color();
+ const target = new THREE.Color();
+ let targetIntensity = 0.35;
+ let intensity = 0.35;
+ let raf = 0;
+ let last = performance.now();
+ let running = !reduce;
+
+ const resize = () => {
+ const w = mount.clientWidth || 1;
+ const h = mount.clientHeight || 1;
+ renderer.setSize(w, h);
+ uniforms.uRes.value.set(w * dpr, h * dpr);
+ };
+ const ro = new ResizeObserver(resize);
+ ro.observe(mount);
+ resize();
+
+ const onVisibility = () => {
+ running = !document.hidden && !reduce;
+ if (running) {
+ last = performance.now();
+ loop();
+ }
+ };
+ document.addEventListener("visibilitychange", onVisibility);
+
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
+
+ const frame = (now: number) => {
+ const dt = Math.min((now - last) / 1000, 0.05);
+ last = now;
+ uniforms.uTime.value += dt;
+
+ // Ease toward target signal/intensity each frame (no React re-render).
+ const tgt = targetRef.current;
+ target.set(...SIGNAL_RGB[tgt.tone]);
+ color.lerp(target, 0.04);
+ uniforms.uColor.value.copy(color);
+ moteMat.color.copy(color);
+ targetIntensity = 0.2 + tgt.intensity * 0.8;
+ intensity = lerp(intensity, targetIntensity, 0.04);
+ uniforms.uIntensity.value = intensity;
+
+ const pos = geo.attributes.position as THREE.BufferAttribute;
+ for (let i = 0; i < MOTE_COUNT; i++) {
+ let y = pos.getY(i) + speeds[i] * dt * (0.5 + tgt.intensity);
+ if (y > 1.1) y = -1.1;
+ pos.setY(i, y);
+ }
+ pos.needsUpdate = true;
+
+ renderer.render(scene, camera);
+ if (running) raf = requestAnimationFrame(frame);
+ };
+
+ const loop = () => {
+ if (raf) cancelAnimationFrame(raf);
+ last = performance.now();
+ raf = requestAnimationFrame(frame);
+ };
+
+ if (reduce) {
+ // single static frame
+ uniforms.uIntensity.value = 0.3;
+ renderer.render(scene, camera);
+ } else {
+ loop();
+ }
+
+ return () => {
+ cancelAnimationFrame(raf);
+ ro.disconnect();
+ document.removeEventListener("visibilitychange", onVisibility);
+ geo.dispose();
+ moteMat.dispose();
+ (quad.geometry as THREE.BufferGeometry).dispose();
+ (quad.material as THREE.Material).dispose();
+ renderer.dispose();
+ if (renderer.domElement.parentNode === mount) {
+ mount.removeChild(renderer.domElement);
+ }
+ };
+ }, [targetRef]);
+
+ return (
+
+ );
+}
diff --git a/services/frontend/src/components/ambient/ambient-context.tsx b/services/frontend/src/components/ambient/ambient-context.tsx
new file mode 100644
index 0000000..2533268
--- /dev/null
+++ b/services/frontend/src/components/ambient/ambient-context.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
+import { AmbientCanvas } from "./ambient-canvas";
+
+export type SignalTone = "signal" | "amber" | "vermilion";
+
+/** sRGB triplets for the three semantic signals (matches globals.css). */
+export const SIGNAL_RGB: Record = {
+ signal: [0.42, 1.0, 0.52],
+ amber: [1.0, 0.76, 0.28],
+ vermilion: [1.0, 0.34, 0.28],
+};
+
+export interface AmbientState {
+ tone: SignalTone;
+ /** 0..1 — drives haze density + drift speed (e.g. server load). */
+ intensity: number;
+ label?: string;
+}
+
+export interface AmbientControls {
+ set: (tone: SignalTone, intensity?: number, label?: string) => void;
+ reset: () => void;
+ state: AmbientState;
+}
+
+const DEFAULT: AmbientState = { tone: "signal", intensity: 0.35, label: "nominal" };
+
+const AmbientContext = createContext(null);
+
+/**
+ * Holds the live ambient signal. The canvas reads `targetRef` inside its
+ * render loop (no React re-render per frame); `state` is mirrored into React
+ * only so small UI bits (topbar) can reflect the current tone.
+ */
+export function AmbientProvider({ children }: { children: React.ReactNode }) {
+ const targetRef = useRef({ ...DEFAULT });
+ const [state, setState] = useState(DEFAULT);
+
+ const set = useCallback((tone: SignalTone, intensity?: number, label?: string) => {
+ targetRef.current = {
+ tone,
+ intensity: intensity ?? targetRef.current.intensity,
+ label: label ?? targetRef.current.label,
+ };
+ setState({ ...targetRef.current });
+ }, []);
+
+ const reset = useCallback(() => {
+ targetRef.current = { ...DEFAULT };
+ setState({ ...DEFAULT });
+ }, []);
+
+ const value = useMemo(
+ () => ({ set, reset, state }),
+ [set, reset, state],
+ );
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+export function useAmbient(): AmbientControls {
+ const ctx = useContext(AmbientContext);
+ if (!ctx) throw new Error("useAmbient must be used within ");
+ return ctx;
+}
diff --git a/services/frontend/src/components/ambient/ambient-field.tsx b/services/frontend/src/components/ambient/ambient-field.tsx
deleted file mode 100644
index 3503d99..0000000
--- a/services/frontend/src/components/ambient/ambient-field.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-"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 (
-
- );
-}
diff --git a/services/frontend/src/components/analysis/search-panel.tsx b/services/frontend/src/components/analysis/search-panel.tsx
deleted file mode 100644
index 7260af0..0000000
--- a/services/frontend/src/components/analysis/search-panel.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-"use client";
-
-import { Loader2, Search, Sparkles } from "lucide-react";
-import { useCallback, useState } from "react";
-import { Avatar } from "@/components/primitives/avatar";
-import { Badge } from "@/components/primitives/badge";
-import { Button } from "@/components/primitives/button";
-import { Input } from "@/components/primitives/input";
-import { Progress } from "@/components/primitives/progress";
-import { EmptyState, LoadingSkeleton } from "@/components/shared";
-import { useMessageSearch } from "@/hooks";
-import { renderMessageContent, safeParseJsonArray } from "@/lib/format";
-
-export function SearchPanel() {
- const [query, setQuery] = useState("");
- const [enabled, setEnabled] = useState(false);
-
- const { data: results, isValidating: isFetching } = useMessageSearch(
- query,
- enabled,
- );
-
- const handleSearch = useCallback(() => {
- if (!query.trim()) return;
- setEnabled(true);
- }, [query]);
-
- return (
-
-
-
-
- setQuery(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleSearch()}
- className="pl-9 h-9"
- />
-
-
-
-
- {isFetching ? (
-
- ) : results !== undefined ? (
- <>
-
- Found {results.length} result{results.length !== 1 ? "s" : ""}
-
- {results.length === 0 ? (
-
- ) : (
-
- {results.map((msg) => (
-
-
-
-
-
-
- {msg.username}
-
-
- {msg.created_at
- ? new Date(msg.created_at).toLocaleString()
- : ""}
-
- {msg.ai_status && (
-
- {msg.ai_status}
-
- )}
-
-
- {renderMessageContent(
- msg.edited_content ?? msg.content,
- msg.metadata,
- )}
-
- {msg.ai_moderation_flags &&
- msg.ai_moderation_flags !== "[]" && (
-
- {safeParseJsonArray(msg.ai_moderation_flags).map(
- (flag) => (
-
- {flag}
-
- ),
- )}
-
- )}
- {msg.ai_analysis && (
-
-
- {msg.ai_analysis}
-
- )}
- {msg.ai_confidence != null && (
-
-
-
- {(msg.ai_confidence * 100).toFixed(0)}%
-
-
- )}
-
-
-
- ))}
-
- )}
- >
- ) : (
-
-
-
- Enter a search query to find messages across all channels.
-
-
- Searches message content, AI flags, and analysis text.
-
-
- )}
-
- );
-}
diff --git a/services/frontend/src/components/charts/area-activity.tsx b/services/frontend/src/components/charts/area-activity.tsx
index 00f29ac..5d87306 100644
--- a/services/frontend/src/components/charts/area-activity.tsx
+++ b/services/frontend/src/components/charts/area-activity.tsx
@@ -1,83 +1,48 @@
-"use client";
-
-import { motion, useReducedMotion } from "motion/react";
-import { useId } from "react";
-
-export interface AreaPoint {
- label: string;
- value: number;
-}
-
-export interface AreaActivityProps {
- data: AreaPoint[];
- height?: number;
- stroke?: string;
- className?: string;
- label?: string;
-}
+import type { DailyActivityPoint } from "@/lib/types";
+/**
+ * Dual-area activity chart: total messages (signal) vs flagged (vermilion).
+ * Pure SVG, scales to container. Includes a 7-day trailing window hint.
+ */
export function AreaActivity({
- data,
- height = 160,
- stroke = "var(--color-signal)",
- className,
- label,
-}: AreaActivityProps) {
- const id = useId().replace(/:/g, "");
- const reduce = useReducedMotion();
- const width = 600;
- if (data.length === 0)
- return ;
+ daily,
+ height = 200,
+}: {
+ daily: DailyActivityPoint[];
+ height?: number;
+}) {
+ const w = 720;
+ const pad = 8;
+ const n = daily.length;
+ const max = Math.max(...daily.map((d) => d.messages), 1);
+ const x = (i: number) => pad + (i / Math.max(n - 1, 1)) * (w - pad * 2);
+ const y = (v: number) => height - pad - (v / max) * (height - pad * 2);
- const max = Math.max(...data.map((d) => d.value), 1);
- const stepX = width / Math.max(data.length - 1, 1);
- const pts = data.map((d, i) => {
- const x = i * stepX;
- const y = height - (d.value / max) * (height - 10) - 5;
- return [x, y] as const;
- });
- const line = pts
- .map(
- (p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
- )
- .join(" ");
- const area = `${line} L${width},${height} L0,${height} Z`;
- const pathLen = 1400;
+ const msgLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.messages).toFixed(1)}`).join(" ");
+ const flagLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.flagged).toFixed(1)}`).join(" ");
+ const msgArea = `${msgLine} L${x(n - 1).toFixed(1)},${height - pad} L${x(0).toFixed(1)},${height - pad} Z`;
return (
-