/** * @file SplashScreen.tsx * @description Branding splash shown once per browser session on app load. A * dark-tech "constellation" overlay built around the node-graph brand mark: * a time-aware greeting, a bold (localized) tagline, and two subtexts reveal * in a staggered cascade. The tagline and the subtext pair are picked at * random (per mount) from localized pools in `splash.json`, so the copy is * fresh each session. The overlay holds for ~2.5s, then fades out and * unmounts. Clicking anywhere skips it; honors * `prefers-reduced-motion`. CSS-only animations (no extra deps). * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## Public surface * - `SplashScreen` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **SplashScreen** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; const SESSION_KEY = "splash-shown-v1"; const HOLD_MS = 2500; // visible dwell after the entrance settles const EXIT_MS = 600; // fade-out duration /** Map the local hour to a greeting bucket. */ function greetingKey(hour: number): "morning" | "afternoon" | "evening" | "night" { if (hour >= 5 && hour < 12) return "morning"; if (hour >= 12 && hour < 17) return "afternoon"; if (hour >= 17 && hour < 22) return "evening"; return "night"; } export function SplashScreen() { const { t } = useTranslation("splash"); // Show at most once per tab session. Read synchronously so we never flash an // empty overlay on a repeat mount (StrictMode double-invoke, refresh, etc.). const [mounted, setMounted] = useState(() => { try { return !sessionStorage.getItem(SESSION_KEY); } catch { return true; } }); const [exiting, setExiting] = useState(false); const exitTimer = useRef(null); const doneTimer = useRef(null); // Pick the tagline + subtext pair ONCE per mount from the localized pools. // Falls back to the singular keys if a locale ships no array. Must run as an // unconditional hook (before the early return below). const [copy] = useState(() => { const pick = (arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]!; const taglines = t("taglines", { returnObjects: true }) as unknown as string[]; const subs = t("subs", { returnObjects: true }) as unknown as string[][]; const tagline = Array.isArray(taglines) && taglines.length > 0 ? pick(taglines) : t("tagline"); const pair = Array.isArray(subs) && subs.length > 0 ? pick(subs) : [t("sub1"), t("sub2")]; return { tagline, sub1: pair?.[0] ?? t("sub1"), sub2: pair?.[1] ?? t("sub2"), }; }); const beginExit = () => { if (exiting) return; setExiting(true); doneTimer.current = window.setTimeout(() => setMounted(false), EXIT_MS); }; useEffect(() => { if (!mounted) return; try { sessionStorage.setItem(SESSION_KEY, "1"); } catch { /* sessionStorage may be unavailable (private mode) - show anyway */ } exitTimer.current = window.setTimeout(beginExit, HOLD_MS); return () => { if (exitTimer.current) window.clearTimeout(exitTimer.current); if (doneTimer.current) window.clearTimeout(doneTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [mounted]); if (!mounted) return null; const hour = new Date().getHours(); const greeting = t(`greeting.${greetingKey(hour)}`); return (
{/* Atmosphere: layered radial glows + drifting constellation + grain */}