-
+
+
Sign in with the same Google account you use in the extension.
-
+
- {clientId ? (
- <>
-
-
- >
- ) : (
-
- NEXT_PUBLIC_GOOGLE_CLIENT_ID is not configured.
-
- )}
-
+
+ {clientId ? (
+ <>
+
+
+ >
+ ) : (
+
+ NEXT_PUBLIC_GOOGLE_CLIENT_ID is not configured.
+
+ )}
+
+
);
}
diff --git a/dashboard/app/providers.tsx b/dashboard/app/providers.tsx
index 9d965e3..b2893db 100644
--- a/dashboard/app/providers.tsx
+++ b/dashboard/app/providers.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ToastProvider, useToast } from "@/components/toast";
+import { SmoothScroll } from "@/components/smooth-scroll";
function QueryProviderInner({ children }: { children: React.ReactNode }) {
const toast = useToast();
@@ -33,8 +34,10 @@ function QueryProviderInner({ children }: { children: React.ReactNode }) {
export function Providers({ children }: { children: React.ReactNode }) {
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
diff --git a/dashboard/components/glitch-canvas.tsx b/dashboard/components/glitch-canvas.tsx
new file mode 100644
index 0000000..70a3f1f
--- /dev/null
+++ b/dashboard/components/glitch-canvas.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useRef } from "react";
+import { Canvas, useFrame } from "@react-three/fiber";
+
+const vert = /* glsl */ `
+ varying vec2 vUv;
+ void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
+`;
+
+const frag = /* glsl */ `
+ uniform float uTime;
+ uniform float uWave;
+ varying vec2 vUv;
+
+ vec3 sampleBg(vec2 uv) {
+ vec3 bg = vec3(0.004, 0.004, 0.005);
+ float d1 = length(uv - vec2(0.08, 0.88));
+ float d2 = length(uv - vec2(0.92, 0.62));
+ float d3 = length(uv - vec2(0.50, 0.02));
+ vec3 c = bg
+ + mix(vec3(0.12), vec3(0.0, 0.898, 0.690), 0.28) * smoothstep(0.55, 0.0, d1) * 0.07
+ + mix(vec3(0.10), vec3(0.486, 0.227, 1.0), 0.24) * smoothstep(0.50, 0.0, d2) * 0.06
+ + mix(vec3(0.08), vec3(1.0, 0.0, 0.439), 0.20) * smoothstep(0.45, 0.0, d3) * 0.04;
+ float v = length((uv - 0.5) * 2.0);
+ return c * (1.0 - smoothstep(0.25, 1.0, v) * 0.78);
+ }
+
+ void main() {
+ float w1 = sin(vUv.y * 9.0 + uTime * 1.6) * 0.022;
+ float w2 = sin(vUv.y * 5.5 - uTime * 1.1) * 0.014;
+ float w3 = sin(vUv.x * 7.0 + uTime * 2.0) * 0.011;
+ vec2 wd = vec2(w1 + w2, w3);
+ float d = uWave * 0.042;
+ vec3 r = sampleBg(vUv + wd * d);
+ vec3 g = sampleBg(vUv);
+ vec3 b = sampleBg(vUv - wd * d);
+ gl_FragColor = vec4(r.r, g.g, b.b, 1.0);
+ }
+`;
+
+// Module-level: lives outside React, freely mutable by useFrame each tick.
+// The ESLint react-hooks/immutability rule only guards hook-returned values.
+const bgUniforms = { uTime: { value: 0 }, uWave: { value: 0 } };
+
+function Background({ intensityRef }: { intensityRef: React.MutableRefObject
}) {
+ const timeRef = useRef(0);
+ const waveRef = useRef(0);
+
+ useFrame((_, delta) => {
+ const t = intensityRef.current;
+ waveRef.current += (t - waveRef.current) * (t > waveRef.current ? 0.07 : 0.045);
+ timeRef.current += delta;
+ bgUniforms.uTime.value = timeRef.current;
+ bgUniforms.uWave.value = waveRef.current;
+ });
+
+ return (
+
+
+
+
+ );
+}
+
+type Props = { intensityRef: React.MutableRefObject };
+
+export default function GlitchCanvas({ intensityRef }: Props) {
+ return (
+
+ );
+}
diff --git a/dashboard/components/page-transition.tsx b/dashboard/components/page-transition.tsx
new file mode 100644
index 0000000..a2cf53a
--- /dev/null
+++ b/dashboard/components/page-transition.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
+import { usePathname } from "next/navigation";
+import { useState } from "react";
+
+const NAV_ORDER: Record = {
+ "/dashboard": 0,
+ "/dashboard/reports": 1,
+ "/dashboard/music": 2,
+};
+
+export function PageTransition({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const reduced = useReducedMotion();
+
+ // getDerivedStateFromProps pattern: call setState during render (not in an effect).
+ // React re-runs the render synchronously with updated state, so the entering
+ // motion.div always mounts with the correct direction in its `initial` prop.
+ const [nav, setNav] = useState({ prevPath: pathname, dir: 0 });
+
+ if (nav.prevPath !== pathname) {
+ const prev = NAV_ORDER[nav.prevPath] ?? 0;
+ const curr = NAV_ORDER[pathname] ?? 0;
+ setNav({ prevPath: pathname, dir: curr >= prev ? 1 : -1 });
+ }
+
+ const dir = nav.dir;
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/dashboard/components/parallax-bg.tsx b/dashboard/components/parallax-bg.tsx
new file mode 100644
index 0000000..930acb7
--- /dev/null
+++ b/dashboard/components/parallax-bg.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { useRef } from "react";
+import { useMotionValueEvent, useReducedMotion, useScroll, useVelocity } from "framer-motion";
+import { TimerField } from "./timer-field";
+
+const GlitchCanvas = dynamic(() => import("./glitch-canvas"), { ssr: false });
+
+const VELOCITY_THRESHOLD = 80;
+
+export function ParallaxBg() {
+ const { scrollY } = useScroll();
+ const velocity = useVelocity(scrollY);
+ const reduced = useReducedMotion();
+ const intensityRef = useRef(0);
+
+ useMotionValueEvent(velocity, "change", (v) => {
+ if (reduced) return;
+ const speed = Math.abs(v);
+ intensityRef.current = speed > VELOCITY_THRESHOLD
+ ? Math.min((speed - VELOCITY_THRESHOLD) / 520, 1)
+ : 0;
+ });
+
+ if (reduced) return null;
+
+ return (
+ <>
+ {/* Three.js: dark base + prism wave on scroll */}
+
+
+
+
+ {/* 2D canvas: timer field — green idle, red + downward on scroll */}
+
+ >
+ );
+}
diff --git a/dashboard/components/smooth-scroll.tsx b/dashboard/components/smooth-scroll.tsx
new file mode 100644
index 0000000..e31a492
--- /dev/null
+++ b/dashboard/components/smooth-scroll.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { ReactLenis } from "lenis/react";
+import type { LenisOptions } from "lenis";
+
+const OPTIONS: LenisOptions = {
+ duration: 1.4, // inertia length in seconds
+ easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // expo ease-out
+ smoothWheel: true,
+};
+
+export function SmoothScroll({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/dashboard/components/timer-field.tsx b/dashboard/components/timer-field.tsx
new file mode 100644
index 0000000..dffefa4
--- /dev/null
+++ b/dashboard/components/timer-field.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useMotionValueEvent, useReducedMotion, useScroll, useVelocity } from "framer-motion";
+
+// ─── Config ───────────────────────────────────────────────────────────────────
+
+const COUNT = 85;
+const BASE_FONT = 11; // px — sizes range 0.6× – 1.5×
+const SCROLL_THRESHOLD = 80; // px/s before effects kick in
+const REVERSE_SPEED = 45; // ms subtracted per real-ms when scrolling fast
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function fmt(ms: number): string {
+ const total = Math.max(0, Math.floor(Math.abs(ms)));
+ const h = Math.floor(total / 3_600_000);
+ const m = Math.floor((total % 3_600_000) / 60_000);
+ const s = Math.floor((total % 60_000) / 1_000);
+ const cs = Math.floor((total % 1_000) / 10);
+ return h > 0
+ ? `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
+ : `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(cs).padStart(2, "0")}`;
+}
+
+interface T {
+ x: number; // px
+ y: number; // px (drifts with scroll)
+ size: number; // font scale 0.6–1.5
+ ms: number; // current timer value in ms
+ driftX: number; // gentle horizontal drift
+ driftY: number; // gentle vertical drift (natural)
+}
+
+// ─── Component ────────────────────────────────────────────────────────────────
+
+export function TimerField() {
+ const canvasRef = useRef(null);
+ const timersRef = useRef([]);
+ const velRef = useRef(0);
+ const rafRef = useRef(0);
+ const reduced = useReducedMotion();
+
+ const { scrollY } = useScroll();
+ const velocity = useVelocity(scrollY);
+
+ useMotionValueEvent(velocity, "change", (v) => { velRef.current = v; });
+
+ // Init timers after canvas mounts so we have window dimensions
+ useEffect(() => {
+ const W = window.innerWidth;
+ const H = window.innerHeight;
+ timersRef.current = Array.from({ length: COUNT }, () => ({
+ x: Math.random() * W,
+ y: Math.random() * H,
+ size: 0.6 + Math.random() * 0.9,
+ ms: Math.random() * 5_400_000, // 0–90 min spread
+ driftX: (Math.random() - 0.5) * 0.05,
+ driftY: (Math.random() - 0.5) * 0.06,
+ }));
+ }, []);
+
+ useEffect(() => {
+ if (reduced) return;
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d")!;
+ let last = performance.now();
+
+ const c = canvas; // stable reference for closures
+
+ function resize() {
+ c.width = window.innerWidth;
+ c.height = window.innerHeight;
+ }
+ resize();
+ window.addEventListener("resize", resize);
+
+ function draw(now: number) {
+ const dt = Math.min(now - last, 50);
+ last = now;
+
+ const speed = Math.abs(velRef.current);
+ const intensity = Math.max(0, Math.min((speed - SCROLL_THRESHOLD) / 520, 1));
+ const scrolling = speed > SCROLL_THRESHOLD;
+
+ ctx.clearRect(0, 0, c.width, c.height);
+
+ for (const t of timersRef.current) {
+ // ── Time value ──────────────────────────────────────────────────────
+ if (scrolling) {
+ t.ms -= dt * (1 + intensity * REVERSE_SPEED); // reverse + accelerate
+ if (t.ms < 0) t.ms = 5_400_000;
+ } else {
+ t.ms += dt; // normal forward tick
+ if (t.ms > 5_400_000) t.ms = 0;
+ }
+
+ // ── Position ────────────────────────────────────────────────────────
+ t.x += t.driftX;
+ // natural drift + downward parallax proportional to depth (size)
+ t.y += t.driftY + intensity * t.size * 2.2;
+
+ // wrap
+ if (t.x < -80) t.x = c.width + 60;
+ if (t.x > c.width + 80) t.x = -60;
+ if (t.y < -20) t.y = c.height + 10;
+ if (t.y > c.height + 20) t.y = -10;
+
+ // ── Style ───────────────────────────────────────────────────────────
+ const fontSize = Math.round(BASE_FONT * t.size);
+
+ if (scrolling) {
+ // Aggressive red — brighter/stronger with intensity
+ const g = Math.round((1 - intensity) * 40);
+ const a = 0.45 + intensity * 0.50;
+ ctx.fillStyle = `rgba(255, ${g}, 30, ${a})`;
+ ctx.shadowColor = `rgba(255, 0, 70, ${intensity * 0.9})`;
+ ctx.shadowBlur = 4 + intensity * 12;
+ } else {
+ // Default green — dim, subtle
+ const a = 0.15 + t.size * 0.10;
+ ctx.fillStyle = `rgba(0, 229, 176, ${a})`;
+ ctx.shadowColor = "rgba(0, 229, 176, 0.25)";
+ ctx.shadowBlur = 2;
+ }
+
+ ctx.font = `${fontSize}px "Courier New", monospace`;
+ ctx.fillText(fmt(t.ms), t.x, t.y);
+ }
+
+ ctx.shadowBlur = 0;
+ rafRef.current = requestAnimationFrame(draw);
+ }
+
+ rafRef.current = requestAnimationFrame(draw);
+ return () => {
+ cancelAnimationFrame(rafRef.current);
+ window.removeEventListener("resize", resize);
+ };
+ }, [reduced]);
+
+ if (reduced) return null;
+
+ return (
+
+ );
+}
diff --git a/dashboard/components/toast.tsx b/dashboard/components/toast.tsx
index 44dcf6b..3874b3d 100644
--- a/dashboard/components/toast.tsx
+++ b/dashboard/components/toast.tsx
@@ -7,6 +7,7 @@ import {
useRef,
useState,
} from "react";
+import { AnimatePresence, motion } from "framer-motion";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -80,36 +81,40 @@ function ToastStack({
toasts: ToastItem[];
onDismiss: (id: number) => void;
}) {
- if (toasts.length === 0) return null;
-
return (
- {toasts.map((t) => (
-
-
{t.message}
-
-
- ))}
+
{t.message}
+
+
+ ))}
+
);
}
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index 7907e85..99b417a 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -10,12 +10,15 @@
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
+ "@react-three/fiber": "^9.6.1",
"@tanstack/react-query": "^5.100.10",
+ "@types/three": "^0.184.1",
"chart.js": "^4.5.1",
"d3": "^7.9.0",
- "framer-motion": "^12.38.0",
+ "framer-motion": "^12.40.0",
"google-auth-library": "^10.6.2",
"jose": "^6.2.3",
+ "lenis": "^1.3.23",
"next": "16.2.6",
"pg": "^8.20.0",
"prisma": "^7.8.0",
@@ -24,6 +27,7 @@
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
+ "three": "^0.184.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -245,6 +249,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -293,6 +306,12 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "license": "Apache-2.0"
+ },
"node_modules/@electric-sql/pglite": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
@@ -1769,6 +1788,54 @@
}
}
},
+ "node_modules/@react-three/fiber": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
+ "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.17.8",
+ "@types/webxr": "*",
+ "base64-js": "^1.5.1",
+ "buffer": "^6.0.3",
+ "its-fine": "^2.0.0",
+ "react-use-measure": "^2.1.7",
+ "scheduler": "^0.27.0",
+ "suspend-react": "^0.1.3",
+ "use-sync-external-store": "^1.4.0",
+ "zustand": "^5.0.3"
+ },
+ "peerDependencies": {
+ "expo": ">=43.0",
+ "expo-asset": ">=8.4",
+ "expo-file-system": ">=11.0",
+ "expo-gl": ">=11.0",
+ "react": ">=19 <19.3",
+ "react-dom": ">=19 <19.3",
+ "react-native": ">=0.78",
+ "three": ">=0.156"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ },
+ "expo-asset": {
+ "optional": true
+ },
+ "expo-file-system": {
+ "optional": true
+ },
+ "expo-gl": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2095,6 +2162,12 @@
"react": "^18 || ^19"
}
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "license": "MIT"
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2575,6 +2648,15 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/react-reconciler": {
+ "version": "0.28.9",
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
+ "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -2596,12 +2678,38 @@
"@types/node": "*"
}
},
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.184.1",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
+ "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
@@ -3703,6 +3811,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -5628,6 +5760,12 @@
"node": "^12.20 || >= 14.13"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -5769,13 +5907,13 @@
}
},
"node_modules/framer-motion": {
- "version": "12.38.0",
- "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
- "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
+ "version": "12.40.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
+ "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
"license": "MIT",
"dependencies": {
- "motion-dom": "^12.38.0",
- "motion-utils": "^12.36.0",
+ "motion-dom": "^12.40.0",
+ "motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
@@ -6325,6 +6463,26 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6924,6 +7082,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/its-fine": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
+ "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react-reconciler": "^0.28.9"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0"
+ }
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -7108,6 +7278,37 @@
"node": ">=0.10"
}
},
+ "node_modules/lenis": {
+ "version": "1.3.23",
+ "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
+ "integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==",
+ "license": "MIT",
+ "workspaces": [
+ "packages/*",
+ "playground",
+ "playground/*"
+ ],
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/darkroomengineering"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": ">=3.0.0",
+ "react": ">=17.0.0",
+ "vue": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -7805,6 +8006,12 @@
"node": ">= 8"
}
},
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "license": "MIT"
+ },
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -8433,18 +8640,18 @@
}
},
"node_modules/motion-dom": {
- "version": "12.38.0",
- "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
- "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
+ "version": "12.40.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
+ "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
"license": "MIT",
"dependencies": {
- "motion-utils": "^12.36.0"
+ "motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
- "version": "12.36.0",
- "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
- "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
+ "version": "12.39.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
+ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/ms": {
@@ -9612,6 +9819,21 @@
"react": ">=18"
}
},
+ "node_modules/react-use-measure": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
+ "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.13",
+ "react-dom": ">=16.13"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -10540,6 +10762,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/suspend-react": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
+ "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=17.0"
+ }
+ },
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -10561,6 +10792,12 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/three": {
+ "version": "0.184.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
+ "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -11067,6 +11304,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/valibot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
@@ -11310,6 +11556,35 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
+ "node_modules/zustand": {
+ "version": "5.0.14",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
+ "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/dashboard/package.json b/dashboard/package.json
index d9e9b64..1558371 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -11,12 +11,15 @@
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
+ "@react-three/fiber": "^9.6.1",
"@tanstack/react-query": "^5.100.10",
+ "@types/three": "^0.184.1",
"chart.js": "^4.5.1",
"d3": "^7.9.0",
- "framer-motion": "^12.38.0",
+ "framer-motion": "^12.40.0",
"google-auth-library": "^10.6.2",
"jose": "^6.2.3",
+ "lenis": "^1.3.23",
"next": "16.2.6",
"pg": "^8.20.0",
"prisma": "^7.8.0",
@@ -25,6 +28,7 @@
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
+ "three": "^0.184.0",
"zod": "^4.4.3"
},
"devDependencies": {
diff --git a/plans/week4/framer-motion-animations.md b/plans/week4/framer-motion-animations.md
new file mode 100644
index 0000000..609b6a8
--- /dev/null
+++ b/plans/week4/framer-motion-animations.md
@@ -0,0 +1,204 @@
+# AC 4 — Framer Motion Animations
+
+## Scope
+
+Add Framer Motion animations across the WorkTrace dashboard.
+Five animation groups, ordered by visual impact.
+
+---
+
+## 0. Installation
+
+```bash
+cd dashboard && npm install framer-motion
+```
+
+---
+
+## 1. Scroll-Reactive Background (Layout Layer)
+
+**File:** `dashboard/app/dashboard/layout.tsx`
+
+A fixed canvas sitting behind all dashboard content. Three gradient orbs at different z-depths move at different parallax speeds as the user scrolls. The orbs never move enough to be distracting — max 60–80px travel — but give the page a sense of depth.
+
+**Implementation:**
+
+Create `dashboard/components/parallax-bg.tsx` — a `"use client"` component:
+
+- `useScroll({ container: ref })` tracks scroll offset of the main scrollable div in the layout.
+- Three `motion.div` orbs with `useTransform(scrollY, [0, 1000], [0, -N])` where N differs per orb (60 / 40 / 25 px), creating depth.
+- Orbs: blurred radial gradients (cyan `#00ffff22`, purple `#8b5cf622`, pink `#ec489922`) — same palette as the app.
+- `will-change: transform` + `pointer-events-none` — zero layout impact.
+
+Layout passes the scroll container ref into the component via context or direct prop.
+
+**Result:** Subtle 3D depth on every dashboard page, scroll down/up triggers smooth orb drift.
+
+---
+
+## 2. Page Transitions (Between FEED / REPORTS / MUSIC)
+
+**File:** `dashboard/app/dashboard/layout.tsx` wrapping `{children}`
+
+Navigation order: FEED (0) → REPORTS (1) → MUSIC (2).
+
+**Implementation:**
+
+- Wrap `{children}` in ``.
+- A `` client component wraps each page — it reads the current pathname to determine direction (left/right).
+- Forward navigation (FEED→REPORTS, REPORTS→MUSIC): page slides in from right (`x: 40`), current page exits to left (`x: -40`).
+- Backward navigation (MUSIC→REPORTS, REPORTS→FEED): reverse direction.
+- Both combined with `opacity: 0 → 1` and a quick scale `0.97 → 1`.
+- Duration: `0.3s ease-out` — fast enough not to feel slow, slow enough to register.
+
+```
+variants = {
+ enter(dir): { x: dir > 0 ? 40 : -40, opacity: 0, scale: 0.97 }
+ center: { x: 0, opacity: 1, scale: 1 }
+ exit(dir): { x: dir > 0 ? -40 : 40, opacity: 0, scale: 0.97 }
+}
+```
+
+**Create:** `dashboard/components/page-transition.tsx`
+
+---
+
+## 3. AI Report Loading Indicator
+
+**File:** `dashboard/app/dashboard/reports/report-form.tsx` → `LoadingPanel`
+
+Current: a single `animate-pulse` cyan bar + static text.
+
+Replace with a **three-layer animation**:
+
+**Layer A — Scanning bar:** A `motion.div` with a bright cyan gradient sweeps left→right infinitely (`x: "-100%" → "100%"`, `repeat: Infinity`, `duration: 1.4s`, `ease: "linear"`). Container is `overflow-hidden` with a subtle cyan track.
+
+**Layer B — Pulsing orbs:** Three small dots (`●●●`) each animate `opacity` and `y` with staggered delays (`0 / 0.15 / 0.3s`) — a breathing wave effect.
+
+**Layer C — Typewriter text:** "ASKING THE MODEL" text with each character using `staggerChildren` on mount. Characters animate `opacity: 0 → 1` and `y: 4 → 0`.
+
+Total visual: strong, clearly communicates "AI is working", fits the terminal/hacker aesthetic.
+
+---
+
+## 4. Modal Animations (ApiKeyModal)
+
+**File:** `dashboard/app/dashboard/reports/api-key-modal.tsx`
+
+Current: instant appear/disappear via `createPortal`.
+
+**Implementation:**
+
+- Wrap the portal root in `` (conditional render → animate out before unmount).
+- **Backdrop:** `motion.div` fades `opacity: 0 → 0.7`, `duration: 0.2s`.
+- **Modal panel:** `motion.div` with spring physics:
+ ```
+ initial: { opacity: 0, scale: 0.92, y: 16 }
+ animate: { opacity: 1, scale: 1, y: 0 } — spring(stiffness:400, damping:28)
+ exit: { opacity: 0, scale: 0.95, y: 8 } — duration: 0.15s
+ ```
+- Enter feels snappy (spring), exit is quick fade-shrink.
+- Applies to all future modals (the pattern becomes reusable).
+
+---
+
+## 5. Event Feed Cards (Staggered Entrance)
+
+**Files:**
+- `dashboard/app/dashboard/event-feed.tsx` — list container
+- `dashboard/app/dashboard/event-card.tsx` — individual card
+
+**Implementation:**
+
+Container (`motion.ul` / `motion.div`) uses `variants` with `staggerChildren: 0.05s`.
+
+Each card on mount:
+```
+initial: { opacity: 0, y: 20 }
+animate: { opacity: 1, y: 0 } — duration: 0.3s, ease: [0.25, 0.1, 0.25, 1]
+```
+
+- Only the first "page" of cards staggers (use `custom` prop to cap stagger at index 10 — beyond that, instant appear to avoid long delays on deep scroll).
+- Hover: `whileHover={{ scale: 1.015, transition: { duration: 0.15 } }}` — subtle lift.
+- The existing `border-purple` hover CSS stays; scale adds depth.
+
+---
+
+## 6. Additional Targeted Animations
+
+| Location | Animation |
+|---|---|
+| `toast.tsx` | `AnimatePresence` + slide-in from right (`x: 120 → 0`), fade-out on dismiss |
+| `mobile-nav.tsx` | Replace CSS `max-h` trick with `motion.div` height `0 → auto` via `layout` prop, spring easing |
+| `header-nav.tsx` | Shared `layoutId="nav-indicator"` pill that slides between active links |
+| `top-sessions.tsx` | Progress bars animate width `0 → actual%` on mount via `motion.div` |
+| `music-client.tsx` | Summary stat numbers count up from 0 using custom `useCountUp` + `useInView` |
+| `charts.tsx` | Fade-in container when data replaces skeleton (`AnimatePresence` swap) |
+| `login/page.tsx` | Logo entrance: `scale: 0.8, opacity: 0 → 1`, staggered with button |
+
+---
+
+## File Inventory
+
+### New files
+```
+dashboard/components/parallax-bg.tsx — scroll-reactive background orbs
+dashboard/components/page-transition.tsx — directional page slide wrapper
+```
+
+### Modified files
+```
+dashboard/app/dashboard/layout.tsx — integrate ParallaxBg + PageTransition + AnimatePresence
+dashboard/app/dashboard/event-feed.tsx — stagger container
+dashboard/app/dashboard/event-card.tsx — card entrance + whileHover
+dashboard/app/dashboard/reports/report-form.tsx — LoadingPanel overhaul
+dashboard/app/dashboard/reports/api-key-modal.tsx — spring modal + AnimatePresence
+dashboard/app/dashboard/header-nav.tsx — layoutId nav indicator
+dashboard/app/dashboard/mobile-nav.tsx — motion height animation
+dashboard/app/dashboard/top-sessions.tsx — progress bar animate
+dashboard/app/dashboard/music/music-client.tsx — count-up stats
+dashboard/components/toast.tsx — slide-in/out with AnimatePresence
+dashboard/app/login/page.tsx — entrance stagger
+```
+
+---
+
+## Technical Notes
+
+- **SSR safety:** All Framer Motion components that use `useScroll`/`useMotionValue` must be `"use client"`. Already the case for every file in scope.
+- **`AnimatePresence mode="wait"`** on page transitions prevents overlap flash.
+- **`layout` prop** on progress bars / nav indicator uses Framer's FLIP algorithm — no manual width math.
+- **Reduced-motion:** Wrap all animation variants behind `useReducedMotion()` fallback — respect OS accessibility setting.
+- **Bundle size:** `framer-motion` is ~45 kB gzipped. Acceptable for a dashboard app. Use tree-shaking (named imports only).
+
+---
+
+## Commit Plan
+
+| # | Commit message | Що входить |
+|---|---|---|
+| 1 | `docs: add framer-motion animations plan` | цей файл `/plans/week4/framer-motion-animations.md` |
+| 2 | `chore: install framer-motion` | `npm install framer-motion`, оновлений `package.json` / `package-lock.json` |
+| 3 | `feat(anim): parallax background orbs on scroll` | `parallax-bg.tsx` + інтеграція в `dashboard/layout.tsx` |
+| 4 | `feat(anim): directional page transitions` | `page-transition.tsx` + `AnimatePresence` у `dashboard/layout.tsx` |
+| 5 | `feat(anim): AI report loading panel` | `reports/report-form.tsx` — `LoadingPanel` overhaul |
+| 6 | `feat(anim): modal spring entrance + AnimatePresence` | `reports/api-key-modal.tsx` |
+| 7 | `feat(anim): event feed stagger + card hover` | `event-feed.tsx`, `event-card.tsx` |
+| 8 | `feat(anim): nav indicator, mobile-nav, progress bars` | `header-nav.tsx`, `mobile-nav.tsx`, `top-sessions.tsx` |
+| 9 | `feat(anim): toasts slide, music count-up, login entrance` | `toast.tsx`, `music-client.tsx`, `login/page.tsx` |
+| 10 | *(резервний)* `fix(anim): post-review fixes` | Залишається порожнім — для виправлень після твого огляду |
+
+---
+
+## Acceptance Criteria (AC 4)
+
+- [ ] Framer Motion installed, no TS errors, existing tests green.
+- [ ] Background orbs visible on all three dashboard pages; move on scroll.
+- [ ] Page transition fires on FEED ↔ REPORTS ↔ MUSIC nav clicks (directional).
+- [ ] AI report loading panel shows scanning bar + dot wave + typewriter text.
+- [ ] ApiKeyModal scales in with spring; backdrop fades; both animate out on close.
+- [ ] Event feed cards stagger in on first load; hover lifts cards.
+- [ ] Toast slides in from right; slides out on dismiss.
+- [ ] Nav active indicator slides between links (no jump).
+- [ ] Top sessions progress bars animate from 0 on mount.
+- [ ] `prefers-reduced-motion` respected — animations disabled when OS requests it.