Imports the visual direction from a claude.ai/design session ("PulseNode
Dashboard.dc.html") and reconciles it with the app's existing choices
rather than a wholesale swap: kept the IBM Plex Sans/Mono + Space
Grotesk typography and the pulse-green accent concept from the earlier
pass, adopted the imported design's structural ideas - a real
tonal/elevation token system (derived divider/muted colors via
color-mix instead of hardcoded per-theme duplicates, actual box-shadow
elevation), a 12-column bento grid with per-widget-type column spans
instead of uniform equal-width cards, fading-edge section dividers,
and a shared WidgetCard/MetricBar/StatusTag vocabulary so every widget
type stops repeating its own card markup.
Icons come from @phosphor-icons/react's /ssr entrypoint (bundled at
build time) rather than the imported design's unpkg.com CDN script -
that script is fine for the standalone design-tool preview, but a
runtime third-party call would break the self-hosted-only principle
already established (fonts self-hosted via next/font, no external
requests). New app/icon.svg reuses the same nav badge mark as the
favicon, replacing the never-touched create-next-app default.
Two new pieces of real functionality prompted by the design's mockup
toast/live-indicator, not just decoration: a "live" WebSocket
connection-status indicator (lib/ws/client.ts now tracks and exposes
real connection state), and a toast that fires on actual config:update
and config:error events - the latter finally surfaces config validation
failures in the browser, previously visible only in server logs despite
being designed for exactly this back in M1.
54 lines
1.8 KiB
TypeScript
54 lines
1.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { MoonStars, Sun } from "@phosphor-icons/react/ssr";
|
|
|
|
const STORAGE_KEY = "pulsenode-theme";
|
|
|
|
export function ThemeToggle() {
|
|
const [theme, setTheme] = useState<"dark" | "light" | null>(null);
|
|
|
|
useEffect(() => {
|
|
// One-time read of browser-only state (DOM attribute set by the anti-FOUC
|
|
// inline script, or the OS color-scheme preference) to resolve the theme
|
|
// React couldn't know during SSR. Not a case of syncing to external state
|
|
// that changes over time, so a single setState here is the right call.
|
|
const attr = document.documentElement.dataset.theme;
|
|
if (attr === "light" || attr === "dark") {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setTheme(attr);
|
|
return;
|
|
}
|
|
const prefersLight = window.matchMedia("(prefers-color-scheme: light)").matches;
|
|
setTheme(prefersLight ? "light" : "dark");
|
|
}, []);
|
|
|
|
function toggle() {
|
|
const next = theme === "light" ? "dark" : "light";
|
|
setTheme(next);
|
|
document.documentElement.dataset.theme = next;
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, next);
|
|
} catch {
|
|
// private browsing or storage disabled; toggle still works for this session
|
|
}
|
|
}
|
|
|
|
if (theme === null) {
|
|
return <div className="h-9 w-9 rounded-md border border-border bg-surface" />;
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={toggle}
|
|
className="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-surface text-fg-muted transition-colors hover:text-fg"
|
|
aria-label="Toggle color theme"
|
|
>
|
|
<span key={theme} className="pn-spin-in flex">
|
|
{theme === "dark" ? <MoonStars size={17} /> : <Sun size={17} />}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|