diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index a406585..771777b 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/sora": "^5.3.0", + "@gsap/react": "^2.1.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", @@ -27,6 +28,8 @@ "idb": "^8.0.4", "lenis": "^1.3.27", "lucide-react": "^1.61.0", + "motion": "^14.6.0", + "ogl": "^1.0.11", "react": "^19.3.1", "react-dom": "^19.3.1", "react-router-dom": "^7.18.4", @@ -576,6 +579,16 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@gsap/react": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@gsap/react/-/react-2.1.2.tgz", + "integrity": "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==", + "license": "SEE LICENSE AT https://gsap.com/standard-license", + "peerDependencies": { + "gsap": "^3.12.5", + "react": ">=17" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3862,6 +3875,28 @@ "node": ">=4" } }, + "node_modules/motion": { + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-14.6.0.tgz", + "integrity": "sha512-qqaZbyubgk7x3zxjPgP1l3Br/KQXol/IIV1uqb358SoN4ApcqNuV7+wyDPg/XLErmuyQL5lL8JVI/fOmDsmBKQ==", + "license": "MIT", + "dependencies": { + "framer-motion": "14.6.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/motion-dom": { "version": "14.6.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.6.0.tgz", @@ -3910,6 +3945,12 @@ "node": ">=12.20.0" } }, + "node_modules/ogl": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ogl/-/ogl-1.0.11.tgz", + "integrity": "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==", + "license": "Unlicense" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 53566e7..36a0060 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -16,6 +16,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/sora": "^5.3.0", + "@gsap/react": "^2.1.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", @@ -33,6 +34,8 @@ "idb": "^8.0.4", "lenis": "^1.3.27", "lucide-react": "^1.61.0", + "motion": "^14.6.0", + "ogl": "^1.0.11", "react": "^19.3.1", "react-dom": "^19.3.1", "react-router-dom": "^7.18.4", diff --git a/dashboard/src/animations/motion.ts b/dashboard/src/animations/motion.ts new file mode 100644 index 0000000..58c84b2 --- /dev/null +++ b/dashboard/src/animations/motion.ts @@ -0,0 +1,23 @@ +import type { Transition, Variants } from "framer-motion"; + +export const motionTiming = { + card: 0.38, + chart: 0.76, + page: 0.32, + stagger: 0.055, +} as const; + +export const motionEase = { + interface: [0.23, 1, 0.32, 1] as const, + exit: [0.4, 0, 1, 1] as const, +}; + +export const pageVariants: Variants = { + initial: { opacity: 0, y: 9, filter: "blur(2px)" }, + enter: { opacity: 1, y: 0, filter: "blur(0px)" }, +}; + +export const pageTransition: Transition = { + duration: motionTiming.page, + ease: motionEase.interface, +}; diff --git a/dashboard/src/animations/theme-transition.ts b/dashboard/src/animations/theme-transition.ts new file mode 100644 index 0000000..93b27bf --- /dev/null +++ b/dashboard/src/animations/theme-transition.ts @@ -0,0 +1,62 @@ +import { flushSync } from "react-dom"; + +export function transitionTheme( + origin: HTMLElement | null, + next: "light" | "dark", + apply: () => void, +) { + const root = document.documentElement; + const reduced = + window.matchMedia("(prefers-reduced-motion: reduce)").matches || + root.dataset.motion === "reduced" || + root.dataset.performance === "on"; + const start = () => { + root.dataset.theme = next; + root.style.colorScheme = next; + apply(); + }; + + if (reduced || typeof document.startViewTransition !== "function") { + flushSync(start); + return; + } + + const rect = origin?.getBoundingClientRect(); + const x = rect ? rect.left + rect.width / 2 : innerWidth / 2; + const y = rect ? rect.top + rect.height / 2 : innerHeight / 2; + const radius = Math.hypot( + Math.max(x, innerWidth - x), + Math.max(y, innerHeight - y), + ); + root.dataset.themeTransition = "active"; + let transition: ViewTransition; + try { + transition = document.startViewTransition(() => flushSync(start)); + } catch { + delete root.dataset.themeTransition; + flushSync(start); + return; + } + transition.finished + .finally(() => { + delete root.dataset.themeTransition; + }) + .catch(() => undefined); + transition.ready + .then(() => { + root.animate( + { + clipPath: [ + `circle(0 at ${x}px ${y}px)`, + `circle(${radius}px at ${x}px ${y}px)`, + ], + }, + { + duration: 520, + easing: "cubic-bezier(0.23, 1, 0.32, 1)", + pseudoElement: "::view-transition-new(root)", + }, + ); + }) + .catch(() => undefined); +} diff --git a/dashboard/src/components/AppShell.tsx b/dashboard/src/components/AppShell.tsx index 069ec7a..e947fab 100644 --- a/dashboard/src/components/AppShell.tsx +++ b/dashboard/src/components/AppShell.tsx @@ -12,6 +12,7 @@ import { Grid2X2, Layers3, Menu, + Puzzle, Search, Settings, Sparkles, @@ -22,9 +23,12 @@ import { import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; +import { pageTransition, pageVariants } from "../animations/motion"; import { useDashboardStore } from "../state/useDashboardStore"; import { BranchedMenu } from "./BranchedMenu"; import { Dock } from "./Dock"; +import { BellToggle } from "./react-bits/controls"; +import { AnimatedThemeToggler } from "./react-bits/utilities"; import { Button } from "./ui"; const navItems = [ @@ -37,6 +41,7 @@ const navItems = [ { label: "AI Software", to: "/software", icon: TerminalSquare }, { label: "Tasks", to: "/tasks", icon: Activity }, { label: "Monitoring", to: "/monitoring", icon: Gauge }, + { label: "Component Lab", to: "/component-lab", icon: Puzzle }, { label: "Activity", to: "/activity", icon: Bell }, ]; const routeNames: Record = { @@ -51,6 +56,7 @@ const routeNames: Record = { "/monitoring": "Monitoring", "/activity": "Activity", "/settings": "Settings", + "/component-lab": "Component Lab", }; const branchedItems = [ { label: "Overview", value: "/", icon: }, @@ -66,6 +72,11 @@ const branchedItems = [ { value: "/nodes", label: "Nodes", icon: }, { value: "/monitoring", label: "Monitoring", icon: }, { value: "/activity", label: "Activity", icon: }, + { + value: "/component-lab", + label: "Component Lab", + icon: , + }, ], }, { @@ -113,6 +124,7 @@ export function AppShell() { const collapsed = useDashboardStore((s) => s.sidebarCollapsed); const toggleSidebar = useDashboardStore((s) => s.toggleSidebar); const theme = useDashboardStore((s) => s.theme); + const setTheme = useDashboardStore((s) => s.setTheme); const demoMotion = useDashboardStore((s) => s.demoMotion); const compactDashboard = useDashboardStore((s) => s.compactDashboard); const performanceMode = useDashboardStore((s) => s.performanceMode); @@ -123,12 +135,28 @@ export function AppShell() { (s) => s.clearPersistenceError, ); const prefersReducedMotion = useReducedMotion(); + const [systemTheme, setSystemTheme] = useState<"light" | "dark">("light"); const [searchOpen, setSearchOpen] = useState(false); + const [notificationsEnabled, setNotificationsEnabled] = useState(true); const [query, setQuery] = useState(""); const searchInput = useRef(null); + useEffect(() => { + const readPreference = () => + setNotificationsEnabled( + localStorage.getItem("opencluster.notifications") !== "false", + ); + readPreference(); + window.addEventListener("opencluster:notifications-change", readPreference); + return () => + window.removeEventListener( + "opencluster:notifications-change", + readPreference, + ); + }, []); useEffect(() => { const media = window.matchMedia("(prefers-color-scheme: light)"); const updateTheme = () => { + setSystemTheme(media.matches ? "light" : "dark"); document.documentElement.dataset.theme = theme === "system" ? (media.matches ? "light" : "dark") : theme; }; @@ -292,6 +320,12 @@ export function AppShell() { {routeNames[location.pathname] ?? "Dashboard"}
+ + { + localStorage.setItem( + "opencluster.notifications", + String(enabled), + ); + setNotificationsEnabled(enabled); + window.dispatchEvent( + new Event("opencluster:notifications-change"), + ); + }} + label="Activity notifications" + className="top-notification-toggle" + />
OC @@ -338,11 +387,12 @@ export function AppShell() { className="page-transition" initial={ demoMotion && !performanceMode && !prefersReducedMotion - ? { opacity: 0, y: 8 } + ? "initial" : false } - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.24, ease: "easeOut" }} + animate="enter" + variants={pageVariants} + transition={pageTransition} > diff --git a/dashboard/src/components/Charts.tsx b/dashboard/src/components/Charts.tsx index b90f809..a230ad5 100644 --- a/dashboard/src/components/Charts.tsx +++ b/dashboard/src/components/Charts.tsx @@ -1,3 +1,5 @@ +import { useReducedMotion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; import { Area, AreaChart, @@ -7,6 +9,7 @@ import { XAxis, YAxis, } from "recharts"; +import { motionTiming } from "../animations/motion"; import type { TelemetryPoint } from "../data/models"; import { useLocalCollection } from "../data/useDashboardData"; @@ -21,6 +24,7 @@ export function ResourceChart({ range?: "15m" | "1h" | "24h" | "7d"; compareMemory?: boolean; }) { + const prefersReducedMotion = useReducedMotion(); const { data: telemetry = [] } = useLocalCollection("telemetry"); const series = @@ -63,6 +67,38 @@ export function ResourceChart({ ? `${index * 5} min` : point.time, })); + + const animationKey = `${metric}:${range}:${compareMemory ? "compare" : "single"}`; + const [completedKey, setCompletedKey] = useState(null); + const chartSnapshot = useRef<{ + key: string; + data: typeof data; + } | null>(null); + if ( + chartSnapshot.current?.key !== animationKey || + !data.length || + prefersReducedMotion || + completedKey === animationKey + ) { + chartSnapshot.current = { key: animationKey, data }; + } + const animateChart = !prefersReducedMotion && completedKey !== animationKey; + const chartData = animateChart ? (chartSnapshot.current?.data ?? data) : data; + + useEffect(() => { + if (!data.length) return undefined; + if (prefersReducedMotion) { + setCompletedKey(animationKey); + return undefined; + } + if (completedKey === animationKey) return undefined; + + const timeout = window.setTimeout(() => { + setCompletedKey(animationKey); + }, motionTiming.chart * 1000); + return () => window.clearTimeout(timeout); + }, [animationKey, completedKey, data.length, prefersReducedMotion]); + if (!data.length) return (
@@ -73,7 +109,8 @@ export function ResourceChart({
@@ -127,7 +164,9 @@ export function ResourceChart({ stroke={s.color} strokeWidth={2} fill={`url(#fill-${s.key})`} - isAnimationActive={false} + isAnimationActive={animateChart} + animationDuration={motionTiming.chart * 1000} + animationEasing="ease-out" /> ))} diff --git a/dashboard/src/components/react-bits/controls/BellButton.tsx b/dashboard/src/components/react-bits/controls/BellButton.tsx new file mode 100644 index 0000000..2fa022f --- /dev/null +++ b/dashboard/src/components/react-bits/controls/BellButton.tsx @@ -0,0 +1,45 @@ +import { Bell, BellOff } from "lucide-react"; +import { useState } from "react"; + +export interface BellButtonProps { + subscribed?: boolean; + defaultSubscribed?: boolean; + onSubscribedChange?: (subscribed: boolean) => void; + count?: number; + label?: string; + subscribedLabel?: string; + className?: string; +} +/** Local notification preference control; it does not request browser permissions. */ +export function BellButton({ + subscribed: controlled, + defaultSubscribed = false, + onSubscribedChange, + count = 0, + label = "Notify me", + subscribedLabel = "Notifications on", + className = "", +}: BellButtonProps) { + const [internal, setInternal] = useState(defaultSubscribed); + const active = controlled ?? internal; + return ( + + ); +} +export default BellButton; diff --git a/dashboard/src/components/react-bits/controls/BellToggle.tsx b/dashboard/src/components/react-bits/controls/BellToggle.tsx new file mode 100644 index 0000000..3611ca7 --- /dev/null +++ b/dashboard/src/components/react-bits/controls/BellToggle.tsx @@ -0,0 +1,39 @@ +import { Bell, BellOff } from "lucide-react"; +import { useState } from "react"; + +export interface BellToggleProps { + checked?: boolean; + defaultChecked?: boolean; + onCheckedChange?: (checked: boolean) => void; + label?: string; + className?: string; +} + +export function BellToggle({ + checked, + defaultChecked = false, + onCheckedChange, + label = "Notifications", + className = "", +}: BellToggleProps) { + const [internal, setInternal] = useState(defaultChecked); + const active = checked ?? internal; + return ( + + ); +} + +export default BellToggle; diff --git a/dashboard/src/components/react-bits/controls/LatticeLoader.tsx b/dashboard/src/components/react-bits/controls/LatticeLoader.tsx new file mode 100644 index 0000000..417ab7c --- /dev/null +++ b/dashboard/src/components/react-bits/controls/LatticeLoader.tsx @@ -0,0 +1,25 @@ +export interface LatticeLoaderProps { + label?: string; + size?: number; + className?: string; +} +export function LatticeLoader({ + label = "Loading", + size = 32, + className = "", +}: LatticeLoaderProps) { + return ( + + + + + + + ); +} +export default LatticeLoader; diff --git a/dashboard/src/components/react-bits/controls/ParticleButton.tsx b/dashboard/src/components/react-bits/controls/ParticleButton.tsx new file mode 100644 index 0000000..26d6fd1 --- /dev/null +++ b/dashboard/src/components/react-bits/controls/ParticleButton.tsx @@ -0,0 +1,109 @@ +import { AlertCircle, Check, LoaderCircle } from "lucide-react"; +import { + type ButtonHTMLAttributes, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; + +type Status = "idle" | "pending" | "success" | "error"; +export interface ParticleButtonProps + extends Omit< + ButtonHTMLAttributes, + "onClick" | "children" + > { + children: ReactNode; + onClick?: (event: React.MouseEvent) => unknown; + pendingLabel?: ReactNode; + successLabel?: ReactNode; + errorLabel?: ReactNode; + successDuration?: number; + errorDuration?: number; + onError?: (error: unknown) => void; +} +export function ParticleButton({ + children, + onClick, + pendingLabel = "Saving…", + successLabel = "Saved", + errorLabel = "Try again", + successDuration = 1600, + errorDuration = 3200, + onError, + className = "", + disabled, + ...props +}: ParticleButtonProps) { + const [status, setStatus] = useState("idle"); + const run = useRef(0); + const timer = useRef(undefined); + useEffect( + () => () => { + run.current++; + window.clearTimeout(timer.current); + }, + [], + ); + const settle = (next: "success" | "error") => { + setStatus(next); + timer.current = window.setTimeout( + () => setStatus("idle"), + next === "success" ? successDuration : errorDuration, + ); + }; + const handleClick: ButtonHTMLAttributes["onClick"] = ( + event, + ) => { + if (status === "pending") return; + const result = onClick?.(event as React.MouseEvent); + if (event.defaultPrevented) return; + if (result && typeof (result as PromiseLike).then === "function") { + const current = ++run.current; + setStatus("pending"); + Promise.resolve(result).then( + () => { + if (run.current === current) settle("success"); + }, + (error: unknown) => { + if (run.current === current) { + settle("error"); + onError?.(error); + } + }, + ); + } else settle("success"); + }; + const content = + status === "pending" ? ( + <> + + {pendingLabel} + + ) : status === "success" ? ( + <> + + {successLabel} + + ) : status === "error" ? ( + <> + + {errorLabel} + + ) : ( + children + ); + return ( + + ); +} +export default ParticleButton; diff --git a/dashboard/src/components/react-bits/controls/PromptBar.tsx b/dashboard/src/components/react-bits/controls/PromptBar.tsx new file mode 100644 index 0000000..480c7ee --- /dev/null +++ b/dashboard/src/components/react-bits/controls/PromptBar.tsx @@ -0,0 +1,68 @@ +import { ArrowUp, Paperclip } from "lucide-react"; +import { type FormEvent, type TextareaHTMLAttributes, useState } from "react"; + +export interface PromptBarProps + extends Omit, "onSubmit"> { + onSend: (prompt: string) => void | Promise; + placeholder?: string; + disabled?: boolean; + attachments?: boolean; +} + +/** A local prompt composer. It sends text to the caller and has no AI service. */ +export function PromptBar({ + onSend, + placeholder = "Ask about your cluster...", + disabled = false, + attachments = false, + className = "", + ...props +}: PromptBarProps) { + const [value, setValue] = useState(""); + const [sending, setSending] = useState(false); + const submit = async (event: FormEvent) => { + event.preventDefault(); + const prompt = value.trim(); + if (!prompt || disabled || sending) return; + setSending(true); + try { + await onSend(prompt); + setValue(""); + } finally { + setSending(false); + } + }; + return ( +
+ {attachments && ( + + )} +