From 1e550319aa96253bb860122b194b96d553c6e5e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Mon, 17 Aug 2026 14:27:07 +0200 Subject: [PATCH] feat: visual identity, theme toggle, custom CSS, loading states (M4) Grounds the visual design in the product's own subject matter (vital- signs monitoring) rather than generic dark-mode defaults: a signature "pulse" green tied to healthy status (the accent color and the "up" status color are the same hue - a steady green pulse reads as healthy, same convention as a cardiac monitor), a small ECG-trace brand mark, and a restrained pulsing-glow animation on healthy status dots (prefers-reduced-motion respected) as the one deliberate motion touch. Typography moves off the bare system-ui stack: IBM Plex Sans for UI text, IBM Plex Mono with tabular numerals for metric values (CPU/mem/uptime/latency), Space Grotesk used once for the wordmark - chosen partly for IBM Plex's own systems-monitoring heritage. Functional additions: - Light/dark toggle, independent of config.yml's theme.mode, persisted to localStorage with a blocking inline script to avoid a flash of the wrong theme on load; auto mode now genuinely follows prefers-color-scheme instead of hardcoding dark. - theme.customCssPath support via a new route handler that serves a user-mounted CSS file at runtime (can't be a build-time import, hence the targeted no-css-tags lint suppression), with a path- traversal guard since it's still reading from disk on every request. - Loading-skeleton state for widgets awaiting their first WebSocket result, distinct from both the error state and genuine no-data. Verified visually in Chrome: theme toggle switches instantly in both directions, a live docker widget's status dot and monospace metrics render correctly in both palettes, and config hot-reload still adds a new widget without a page refresh. --- app/api/theme/custom-css/route.ts | 34 ++++++++++++ app/globals.css | 74 +++++++++++++++++++------- app/layout.tsx | 44 +++++++++++++-- components/layout/Dashboard.tsx | 12 ++++- components/layout/PulseMark.tsx | 21 ++++++++ components/layout/ThemeToggle.tsx | 50 +++++++++++++++++ components/widgets/Skeleton.tsx | 13 +++++ components/widgets/StatusDot.tsx | 7 ++- components/widgets/database/Widget.tsx | 4 +- components/widgets/docker/Widget.tsx | 29 +++++++--- components/widgets/http/Widget.tsx | 13 +++-- components/widgets/system/Widget.tsx | 11 ++-- components/widgets/traefik/Widget.tsx | 8 ++- 13 files changed, 276 insertions(+), 44 deletions(-) create mode 100644 app/api/theme/custom-css/route.ts create mode 100644 components/layout/PulseMark.tsx create mode 100644 components/layout/ThemeToggle.tsx create mode 100644 components/widgets/Skeleton.tsx diff --git a/app/api/theme/custom-css/route.ts b/app/api/theme/custom-css/route.ts new file mode 100644 index 0000000..0768eee --- /dev/null +++ b/app/api/theme/custom-css/route.ts @@ -0,0 +1,34 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { configStore } from "@/lib/config/loader"; + +export const dynamic = "force-dynamic"; + +const CONFIG_DIR = path.join(process.cwd(), "config"); + +export async function GET(): Promise { + let customCssPath: string | undefined; + try { + customCssPath = configStore.get().theme.customCssPath; + } catch { + return new Response("", { status: 204 }); + } + + if (!customCssPath) { + return new Response("", { status: 204 }); + } + + const resolved = path.normalize(path.join(CONFIG_DIR, customCssPath)); + if (!resolved.startsWith(CONFIG_DIR + path.sep)) { + return new Response("", { status: 204 }); + } + + try { + const css = await readFile(resolved, "utf-8"); + return new Response(css, { + headers: { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-cache" }, + }); + } catch { + return new Response("", { status: 204 }); + } +} diff --git a/app/globals.css b/app/globals.css index 1d9e961..2ac014f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,32 +1,47 @@ @import "tailwindcss"; :root { - --pn-bg: #0b0d12; - --pn-surface: #12151c; - --pn-surface-raised: #171b24; - --pn-border: #242938; - --pn-fg: #e6e8ee; - --pn-fg-muted: #8a90a2; - --pn-accent: #38bdf8; + --pn-bg: #0a0e13; + --pn-surface: #10151c; + --pn-surface-raised: #161d27; + --pn-border: #232b38; + --pn-fg: #e8ecf1; + --pn-fg-muted: #7c8798; + --pn-accent: #3ee8a8; --pn-radius: 0.75rem; - --pn-status-up: #34d399; - --pn-status-down: #f87171; - --pn-status-degraded: #fbbf24; + --pn-status-up: #3ee8a8; + --pn-status-down: #ff5f6d; + --pn-status-degraded: #ffb84c; } :root[data-theme="light"] { - --pn-bg: #f5f6f8; + --pn-bg: #f6f7f5; --pn-surface: #ffffff; - --pn-surface-raised: #f0f1f4; - --pn-border: #e2e4ea; - --pn-fg: #14161c; - --pn-fg-muted: #666b7a; - --pn-accent: #0284c7; - --pn-status-up: #059669; + --pn-surface-raised: #eef1ef; + --pn-border: #dde1de; + --pn-fg: #12181a; + --pn-fg-muted: #5c6b66; + --pn-accent: #0e9f6e; + --pn-status-up: #0e9f6e; --pn-status-down: #dc2626; --pn-status-degraded: #d97706; } +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + --pn-bg: #f6f7f5; + --pn-surface: #ffffff; + --pn-surface-raised: #eef1ef; + --pn-border: #dde1de; + --pn-fg: #12181a; + --pn-fg-muted: #5c6b66; + --pn-accent: #0e9f6e; + --pn-status-up: #0e9f6e; + --pn-status-down: #dc2626; + --pn-status-degraded: #d97706; + } +} + @theme inline { --color-bg: var(--pn-bg); --color-surface: var(--pn-surface); @@ -39,11 +54,32 @@ --color-status-down: var(--pn-status-down); --color-status-degraded: var(--pn-status-degraded); --radius-widget: var(--pn-radius); + --font-sans: var(--font-plex-sans); + --font-mono: var(--font-plex-mono); + --font-display: var(--font-space-grotesk); } body { background: var(--pn-bg); color: var(--pn-fg); - font-family: - ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +@keyframes pulse-glow { + 0%, + 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--pn-status-up) 55%, transparent); + } + 50% { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pn-status-up) 0%, transparent); + } +} + +.status-pulse { + animation: pulse-glow 2s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .status-pulse { + animation: none; + } } diff --git a/app/layout.tsx b/app/layout.tsx index 8480889..80fafc8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,31 +1,67 @@ import type { Metadata } from "next"; +import { IBM_Plex_Sans, IBM_Plex_Mono, Space_Grotesk } from "next/font/google"; import "./globals.css"; import { configStore } from "@/lib/config/loader"; import { ThemeStyleInjector } from "@/components/layout/ThemeStyleInjector"; +const plexSans = IBM_Plex_Sans({ + variable: "--font-plex-sans", + subsets: ["latin"], + weight: ["400", "500", "600"], +}); + +const plexMono = IBM_Plex_Mono({ + variable: "--font-plex-mono", + subsets: ["latin"], + weight: ["400", "500"], +}); + +const spaceGrotesk = Space_Grotesk({ + variable: "--font-space-grotesk", + subsets: ["latin"], + weight: ["600"], +}); + export const metadata: Metadata = { title: "PulseNode", description: "Self-hosted infrastructure dashboard", }; +const THEME_STORAGE_KEY = "pulsenode-theme"; + +const themeInitScript = `try{var t=localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});if(t==="dark"||t==="light"){document.documentElement.dataset.theme=t;}}catch(e){}`; + export default function RootLayout({ children }: LayoutProps<"/">) { - let mode: "dark" | "light" = "dark"; + let mode: "dark" | "light" | "auto" = "dark"; let variables: Record = {}; + let customCssPath: string | undefined; try { const config = configStore.get(); - mode = config.theme.mode === "light" ? "light" : "dark"; + mode = config.theme.mode; variables = config.theme.variables; + customCssPath = config.theme.customCssPath; } catch { // page.tsx surfaces the actual config error; layout falls back to defaults } return ( - + +