-
+
{data.label}
{data.ip && (
-
+
{data.ip}
)}
@@ -68,7 +82,12 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
title={data.status}
/>
-
+
)
diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx
index c2e9de0..3f5a8ea 100644
--- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx
+++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx
@@ -1,34 +1,46 @@
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { Layers } from 'lucide-react'
-import type { NodeData, NodeStatus } from '@/types'
+import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
+import { useThemeStore } from '@/stores/themeStore'
+import { THEMES } from '@/utils/themes'
import { BaseNode } from './BaseNode'
-const STATUS_COLORS: Record
= {
- online: '#39d353',
- offline: '#f85149',
- pending: '#e3b341',
- unknown: '#8b949e',
-}
-
export function ProxmoxGroupNode(props: NodeProps>) {
const { data, selected } = props
- const colors = resolveNodeColors(data)
+
+ const activeTheme = useThemeStore((s) => s.activeTheme)
+ const theme = THEMES[activeTheme]
+ const colors = resolveNodeColors(data, activeTheme)
// Render as a regular node when container mode is disabled
if (data.container_mode === false) {
+ const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
return (
<>
-
-
+
+
>
)
}
- const statusColor = STATUS_COLORS[data.status]
+ const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online'
const glow = colors.border
+ const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
return (
<>
@@ -37,7 +49,7 @@ export function ProxmoxGroupNode(props: NodeProps>) {
minHeight={160}
isVisible={selected}
lineStyle={{ borderColor: glow, opacity: 0.6 }}
- handleStyle={{ borderColor: glow, backgroundColor: '#21262d' }}
+ handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
/>
{/* Group border */}
@@ -56,38 +68,78 @@ export function ProxmoxGroupNode(props: NodeProps>) {
{/* Header bar */}
-
+
{data.label}
{data.ip && (
- {data.ip}
+
+ {data.ip}
+
)}
{/* Status dot */}
-
+
{/* Inner area — React Flow places children here */}
-
+
-
+
- {/* Cluster handles — left/right for same-cluster links */}
-
-
+ {/* Cluster handles */}
+
+
>
)
}
diff --git a/frontend/src/components/modals/ThemeModal.tsx b/frontend/src/components/modals/ThemeModal.tsx
new file mode 100644
index 0000000..23f1d6c
--- /dev/null
+++ b/frontend/src/components/modals/ThemeModal.tsx
@@ -0,0 +1,167 @@
+import { useState } from 'react'
+import { toast } from 'sonner'
+import { Check } from 'lucide-react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
+import { useThemeStore } from '@/stores/themeStore'
+import { useCanvasStore } from '@/stores/canvasStore'
+
+// Node-type accent colors to display as preview swatches
+const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
+
+interface ThemeCardProps {
+ themeId: ThemeId
+ selected: boolean
+ onClick: () => void
+}
+
+function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
+ const preset = THEMES[themeId]
+ const c = preset.colors
+
+ return (
+
+ )
+}
+
+interface ThemeModalProps {
+ open: boolean
+ onClose: () => void
+}
+
+export function ThemeModal({ open, onClose }: ThemeModalProps) {
+ const { activeTheme, setTheme } = useThemeStore()
+ const { markUnsaved } = useCanvasStore()
+
+ // Capture the theme that was active when the modal opened
+ const [originalTheme] = useState
(activeTheme)
+ const [selected, setSelected] = useState(activeTheme)
+
+ const handleSelect = (id: ThemeId) => {
+ setSelected(id)
+ // Live-preview the selected theme on the canvas
+ setTheme(id)
+ }
+
+ const handleApply = () => {
+ setTheme(selected)
+ markUnsaved()
+ onClose()
+ toast.info('Style applied — save your canvas to make it permanent', {
+ duration: 5000,
+ })
+ }
+
+ const handleCancel = () => {
+ // Revert to the original theme
+ setTheme(originalTheme)
+ onClose()
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx
index cc28d41..c2e0098 100644
--- a/frontend/src/components/panels/Sidebar.tsx
+++ b/frontend/src/components/panels/Sidebar.tsx
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react'
-import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square } from 'lucide-react'
+import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square } from 'lucide-react'
+import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
@@ -70,13 +71,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
{/* Logo */}
-
-
-
-
- {!collapsed && (
-
Homelable
- )}
+
+
{/* Views */}
diff --git a/frontend/src/components/panels/Toolbar.tsx b/frontend/src/components/panels/Toolbar.tsx
index eb65f59..ed00d7a 100644
--- a/frontend/src/components/panels/Toolbar.tsx
+++ b/frontend/src/components/panels/Toolbar.tsx
@@ -1,22 +1,28 @@
-import { Save, LayoutDashboard, Download } from 'lucide-react'
+import { Save, LayoutDashboard, Download, Palette } from 'lucide-react'
import { Button } from '@/components/ui/button'
+import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore'
interface ToolbarProps {
onSave: () => void
onAutoLayout: () => void
onExport: () => void
+ onChangeStyle: () => void
}
-export function Toolbar({ onSave, onAutoLayout, onExport }: ToolbarProps) {
+export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle }: ToolbarProps) {
const { hasUnsavedChanges } = useCanvasStore()
return (
+
+
diff --git a/frontend/src/components/ui/Logo.tsx b/frontend/src/components/ui/Logo.tsx
new file mode 100644
index 0000000..85a227b
--- /dev/null
+++ b/frontend/src/components/ui/Logo.tsx
@@ -0,0 +1,46 @@
+interface LogoProps {
+ size?: number;
+ showText?: boolean;
+ className?: string;
+}
+
+export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
+ return (
+
+
+ {showText && (
+
+ Home
+ lable
+
+ )}
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 0e83628..c41b4e1 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -90,7 +90,7 @@
/* React Flow overrides */
.react-flow__background {
- background-color: var(--surface-base) !important;
+ background-color: transparent;
}
.react-flow__minimap {
background-color: var(--surface-elevated) !important;
diff --git a/frontend/src/stores/__tests__/themeStore.test.ts b/frontend/src/stores/__tests__/themeStore.test.ts
new file mode 100644
index 0000000..a8639de
--- /dev/null
+++ b/frontend/src/stores/__tests__/themeStore.test.ts
@@ -0,0 +1,31 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { useThemeStore } from '@/stores/themeStore'
+
+describe('themeStore', () => {
+ beforeEach(() => {
+ useThemeStore.setState({ activeTheme: 'default' })
+ })
+
+ it('starts with default theme', () => {
+ expect(useThemeStore.getState().activeTheme).toBe('default')
+ })
+
+ it('setTheme updates activeTheme', () => {
+ useThemeStore.getState().setTheme('matrix')
+ expect(useThemeStore.getState().activeTheme).toBe('matrix')
+ })
+
+ it('setTheme can switch between all presets', () => {
+ const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const
+ for (const id of themes) {
+ useThemeStore.getState().setTheme(id)
+ expect(useThemeStore.getState().activeTheme).toBe(id)
+ }
+ })
+
+ it('setTheme back to default after neon', () => {
+ useThemeStore.getState().setTheme('neon')
+ useThemeStore.getState().setTheme('default')
+ expect(useThemeStore.getState().activeTheme).toBe('default')
+ })
+})
diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts
index f0ed5a6..313b4d1 100644
--- a/frontend/src/stores/canvasStore.ts
+++ b/frontend/src/stores/canvasStore.ts
@@ -32,6 +32,7 @@ interface CanvasState {
editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void
markSaved: () => void
+ markUnsaved: () => void
loadCanvas: (nodes: Node[], edges: Edge[]) => void
notifyScanDeviceFound: () => void
}
@@ -155,6 +156,8 @@ export const useCanvasStore = create((set) => ({
markSaved: () => set({ hasUnsavedChanges: false }),
+ markUnsaved: () => set({ hasUnsavedChanges: true }),
+
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
loadCanvas: (nodes, edges) => {
diff --git a/frontend/src/stores/themeStore.ts b/frontend/src/stores/themeStore.ts
new file mode 100644
index 0000000..edf4727
--- /dev/null
+++ b/frontend/src/stores/themeStore.ts
@@ -0,0 +1,12 @@
+import { create } from 'zustand'
+import type { ThemeId } from '@/utils/themes'
+
+interface ThemeState {
+ activeTheme: ThemeId
+ setTheme: (id: ThemeId) => void
+}
+
+export const useThemeStore = create((set) => ({
+ activeTheme: 'default',
+ setTheme: (id) => set({ activeTheme: id }),
+}))
diff --git a/frontend/src/utils/__tests__/nodeColors.test.ts b/frontend/src/utils/__tests__/nodeColors.test.ts
index 46ccc39..ef521c4 100644
--- a/frontend/src/utils/__tests__/nodeColors.test.ts
+++ b/frontend/src/utils/__tests__/nodeColors.test.ts
@@ -52,4 +52,25 @@ describe('resolveNodeColors', () => {
const result = resolveNodeColors({ type: 'unknown' as any, custom_colors: undefined })
expect(result).toEqual(NODE_DEFAULT_COLORS.generic)
})
+
+ it('uses theme colors when themeId is provided', () => {
+ const result = resolveNodeColors(makeData({ type: 'server' }), 'matrix')
+ expect(result.border).not.toBe(NODE_DEFAULT_COLORS.server.border)
+ expect(result.border).toBe('#008822') // matrix server accent
+ })
+
+ it('custom_colors override theme colors', () => {
+ const custom = { border: '#ff0000' }
+ const result = resolveNodeColors(makeData({ type: 'server', custom_colors: custom }), 'neon')
+ expect(result.border).toBe('#ff0000')
+ // background should come from neon theme, not default
+ expect(result.background).not.toBe(NODE_DEFAULT_COLORS.server.background)
+ })
+
+ it('groupRect always has transparent background regardless of theme', () => {
+ for (const themeId of ['default', 'light', 'matrix', 'neon', 'dark'] as const) {
+ const result = resolveNodeColors(makeData({ type: 'groupRect' }), themeId)
+ expect(result.background).toBe('transparent')
+ }
+ })
})
diff --git a/frontend/src/utils/__tests__/themes.test.ts b/frontend/src/utils/__tests__/themes.test.ts
new file mode 100644
index 0000000..e59fcfa
--- /dev/null
+++ b/frontend/src/utils/__tests__/themes.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect } from 'vitest'
+import { THEMES, THEME_ORDER, type ThemeId } from '../themes'
+import type { NodeType, EdgeType, NodeStatus } from '@/types'
+
+const NODE_TYPES: NodeType[] = [
+ 'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
+ 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'generic', 'groupRect',
+]
+const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
+const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
+
+describe('THEME_ORDER', () => {
+ it('contains all theme IDs', () => {
+ const keys = Object.keys(THEMES) as ThemeId[]
+ expect(THEME_ORDER).toHaveLength(keys.length)
+ expect(new Set(THEME_ORDER)).toEqual(new Set(keys))
+ })
+})
+
+describe('THEMES', () => {
+ for (const themeId of Object.keys(THEMES) as ThemeId[]) {
+ describe(`theme: ${themeId}`, () => {
+ const preset = THEMES[themeId]
+
+ it('has id, label, description', () => {
+ expect(preset.id).toBe(themeId)
+ expect(typeof preset.label).toBe('string')
+ expect(preset.label.length).toBeGreaterThan(0)
+ expect(typeof preset.description).toBe('string')
+ })
+
+ it('has nodeAccents for all node types', () => {
+ for (const type of NODE_TYPES) {
+ expect(preset.colors.nodeAccents[type]).toBeDefined()
+ expect(typeof preset.colors.nodeAccents[type].border).toBe('string')
+ expect(typeof preset.colors.nodeAccents[type].icon).toBe('string')
+ }
+ })
+
+ it('has surface colors', () => {
+ expect(typeof preset.colors.nodeCardBackground).toBe('string')
+ expect(typeof preset.colors.nodeIconBackground).toBe('string')
+ expect(typeof preset.colors.nodeLabelColor).toBe('string')
+ expect(typeof preset.colors.nodeSubtextColor).toBe('string')
+ })
+
+ it('has statusColors for all statuses', () => {
+ for (const status of STATUS_TYPES) {
+ expect(typeof preset.colors.statusColors[status]).toBe('string')
+ }
+ })
+
+ it('has edgeColors for all edge types', () => {
+ for (const type of EDGE_TYPES) {
+ expect(typeof preset.colors.edgeColors[type]).toBe('string')
+ }
+ })
+
+ it('has edge label colors', () => {
+ expect(typeof preset.colors.edgeSelectedColor).toBe('string')
+ expect(typeof preset.colors.edgeLabelBackground).toBe('string')
+ expect(typeof preset.colors.edgeLabelColor).toBe('string')
+ expect(typeof preset.colors.edgeLabelBorder).toBe('string')
+ })
+
+ it('has canvas colors', () => {
+ expect(typeof preset.colors.canvasBackground).toBe('string')
+ expect(typeof preset.colors.canvasDotColor).toBe('string')
+ })
+
+ it('has handle colors', () => {
+ expect(typeof preset.colors.handleBackground).toBe('string')
+ expect(typeof preset.colors.handleBorder).toBe('string')
+ })
+
+ it('has valid reactFlowColorMode', () => {
+ expect(['dark', 'light']).toContain(preset.colors.reactFlowColorMode)
+ })
+ })
+ }
+
+ it('default theme matches original hardcoded colors', () => {
+ const d = THEMES.default.colors
+ expect(d.nodeAccents.server.border).toBe('#a855f7')
+ expect(d.nodeAccents.isp.border).toBe('#00d4ff')
+ expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
+ expect(d.nodeCardBackground).toBe('#21262d')
+ expect(d.nodeIconBackground).toBe('#161b22')
+ expect(d.canvasBackground).toBe('#0d1117')
+ expect(d.canvasDotColor).toBe('#30363d')
+ expect(d.edgeColors.ethernet).toBe('#30363d')
+ expect(d.edgeColors.wifi).toBe('#00d4ff')
+ expect(d.statusColors.online).toBe('#39d353')
+ expect(d.statusColors.offline).toBe('#f85149')
+ })
+
+ it('light theme has reactFlowColorMode light', () => {
+ expect(THEMES.light.colors.reactFlowColorMode).toBe('light')
+ })
+
+ it('light theme has light canvas background', () => {
+ expect(THEMES.light.colors.canvasBackground).toBe('#f6f8fa')
+ })
+
+ it('matrix theme uses green accents', () => {
+ const m = THEMES.matrix.colors
+ expect(m.nodeAccents.isp.border).toMatch(/^#0/)
+ expect(m.nodeLabelColor).toBe('#00ff41')
+ expect(m.canvasBackground).toBe('#000000')
+ })
+})
diff --git a/frontend/src/utils/nodeColors.ts b/frontend/src/utils/nodeColors.ts
index 1ae58ec..19e558f 100644
--- a/frontend/src/utils/nodeColors.ts
+++ b/frontend/src/utils/nodeColors.ts
@@ -1,4 +1,5 @@
import type { NodeData, NodeType } from '@/types'
+import { THEMES, type ThemeId } from './themes'
export interface NodeColors {
border: string
@@ -6,27 +7,31 @@ export interface NodeColors {
icon: string
}
-export const NODE_DEFAULT_COLORS: Record = {
- isp: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
- router: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
- switch: { border: '#39d353', background: '#21262d', icon: '#39d353' },
- server: { border: '#a855f7', background: '#21262d', icon: '#a855f7' },
- proxmox: { border: '#ff6e00', background: '#21262d', icon: '#ff6e00' },
- vm: { border: '#a855f7', background: '#21262d', icon: '#a855f7' },
- lxc: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
- nas: { border: '#39d353', background: '#21262d', icon: '#39d353' },
- iot: { border: '#e3b341', background: '#21262d', icon: '#e3b341' },
- ap: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
- camera: { border: '#8b949e', background: '#21262d', icon: '#8b949e' },
- printer: { border: '#8b949e', background: '#21262d', icon: '#8b949e' },
- computer: { border: '#a855f7', background: '#21262d', icon: '#a855f7' },
- cpl: { border: '#e3b341', background: '#21262d', icon: '#e3b341' },
- generic: { border: '#8b949e', background: '#21262d', icon: '#8b949e' },
- groupRect: { border: '#00d4ff', background: 'transparent', icon: '#00d4ff' },
-}
+// Derived from the default theme — kept for backward compatibility and tests
+export const NODE_DEFAULT_COLORS: Record = Object.fromEntries(
+ (Object.entries(THEMES.default.colors.nodeAccents) as [NodeType, { border: string; icon: string }][]).map(
+ ([type, accent]) => [
+ type,
+ {
+ border: accent.border,
+ background: type === 'groupRect' ? 'transparent' : THEMES.default.colors.nodeCardBackground,
+ icon: accent.icon,
+ },
+ ]
+ )
+) as Record
-export function resolveNodeColors(data: Pick): NodeColors {
- const defaults = NODE_DEFAULT_COLORS[data.type] ?? NODE_DEFAULT_COLORS.generic
+export function resolveNodeColors(
+ data: Pick,
+ themeId: ThemeId = 'default',
+): NodeColors {
+ const theme = THEMES[themeId] ?? THEMES.default
+ const accent = theme.colors.nodeAccents[data.type] ?? theme.colors.nodeAccents.generic
+ const defaults: NodeColors = {
+ border: accent.border,
+ background: data.type === 'groupRect' ? 'transparent' : theme.colors.nodeCardBackground,
+ icon: accent.icon,
+ }
const custom = data.custom_colors
return {
border: custom?.border ?? defaults.border,
diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts
new file mode 100644
index 0000000..6989957
--- /dev/null
+++ b/frontend/src/utils/themes.ts
@@ -0,0 +1,306 @@
+import type { NodeType, EdgeType, NodeStatus } from '@/types'
+
+export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix'
+
+export interface ThemeColors {
+ // Per node-type accent (border + icon)
+ nodeAccents: Record
+ // Node surfaces
+ nodeCardBackground: string
+ nodeIconBackground: string
+ nodeLabelColor: string
+ nodeSubtextColor: string
+ // Status indicator dots
+ statusColors: Record
+ // Edges
+ edgeColors: Record
+ edgeSelectedColor: string
+ edgeLabelBackground: string
+ edgeLabelColor: string
+ edgeLabelBorder: string
+ // Canvas
+ canvasBackground: string
+ canvasDotColor: string
+ // React Flow handles
+ handleBackground: string
+ handleBorder: string
+ // React Flow colorMode for built-in controls
+ reactFlowColorMode: 'dark' | 'light'
+}
+
+export interface ThemePreset {
+ id: ThemeId
+ label: string
+ description: string
+ colors: ThemeColors
+}
+
+export const THEMES: Record = {
+ default: {
+ id: 'default',
+ label: 'Default',
+ description: 'Dark futuristic — the original Homelable look',
+ colors: {
+ nodeAccents: {
+ isp: { border: '#00d4ff', icon: '#00d4ff' },
+ router: { border: '#00d4ff', icon: '#00d4ff' },
+ switch: { border: '#39d353', icon: '#39d353' },
+ server: { border: '#a855f7', icon: '#a855f7' },
+ proxmox: { border: '#ff6e00', icon: '#ff6e00' },
+ vm: { border: '#a855f7', icon: '#a855f7' },
+ lxc: { border: '#00d4ff', icon: '#00d4ff' },
+ nas: { border: '#39d353', icon: '#39d353' },
+ iot: { border: '#e3b341', icon: '#e3b341' },
+ ap: { border: '#00d4ff', icon: '#00d4ff' },
+ camera: { border: '#8b949e', icon: '#8b949e' },
+ printer: { border: '#8b949e', icon: '#8b949e' },
+ computer: { border: '#a855f7', icon: '#a855f7' },
+ cpl: { border: '#e3b341', icon: '#e3b341' },
+ generic: { border: '#8b949e', icon: '#8b949e' },
+ groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
+ },
+ nodeCardBackground: '#21262d',
+ nodeIconBackground: '#161b22',
+ nodeLabelColor: '#e6edf3',
+ nodeSubtextColor: '#8b949e',
+ statusColors: {
+ online: '#39d353',
+ offline: '#f85149',
+ pending: '#e3b341',
+ unknown: '#8b949e',
+ },
+ edgeColors: {
+ ethernet: '#30363d',
+ wifi: '#00d4ff',
+ iot: '#e3b341',
+ vlan: '#00d4ff',
+ virtual: '#8b949e',
+ cluster: '#ff6e00',
+ },
+ edgeSelectedColor: '#00d4ff',
+ edgeLabelBackground:'#161b22',
+ edgeLabelColor: '#8b949e',
+ edgeLabelBorder: '#30363d',
+ canvasBackground: '#0d1117',
+ canvasDotColor: '#30363d',
+ handleBackground: '#30363d',
+ handleBorder: '#8b949e',
+ reactFlowColorMode: 'dark',
+ },
+ },
+
+ dark: {
+ id: 'dark',
+ label: 'Dark',
+ description: 'Pure black with maximum contrast',
+ colors: {
+ nodeAccents: {
+ isp: { border: '#22d3ee', icon: '#22d3ee' },
+ router: { border: '#22d3ee', icon: '#22d3ee' },
+ switch: { border: '#4ade80', icon: '#4ade80' },
+ server: { border: '#c084fc', icon: '#c084fc' },
+ proxmox: { border: '#fb923c', icon: '#fb923c' },
+ vm: { border: '#c084fc', icon: '#c084fc' },
+ lxc: { border: '#22d3ee', icon: '#22d3ee' },
+ nas: { border: '#4ade80', icon: '#4ade80' },
+ iot: { border: '#fbbf24', icon: '#fbbf24' },
+ ap: { border: '#22d3ee', icon: '#22d3ee' },
+ camera: { border: '#94a3b8', icon: '#94a3b8' },
+ printer: { border: '#94a3b8', icon: '#94a3b8' },
+ computer: { border: '#c084fc', icon: '#c084fc' },
+ cpl: { border: '#fbbf24', icon: '#fbbf24' },
+ generic: { border: '#94a3b8', icon: '#94a3b8' },
+ groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
+ },
+ nodeCardBackground: '#0a0a0a',
+ nodeIconBackground: '#111111',
+ nodeLabelColor: '#ffffff',
+ nodeSubtextColor: '#666666',
+ statusColors: {
+ online: '#4ade80',
+ offline: '#ef4444',
+ pending: '#fbbf24',
+ unknown: '#6b7280',
+ },
+ edgeColors: {
+ ethernet: '#1c1c1e',
+ wifi: '#22d3ee',
+ iot: '#fbbf24',
+ vlan: '#22d3ee',
+ virtual: '#6b7280',
+ cluster: '#fb923c',
+ },
+ edgeSelectedColor: '#22d3ee',
+ edgeLabelBackground:'#111111',
+ edgeLabelColor: '#666666',
+ edgeLabelBorder: '#1c1c1e',
+ canvasBackground: '#000000',
+ canvasDotColor: '#1a1a1a',
+ handleBackground: '#1c1c1e',
+ handleBorder: '#444444',
+ reactFlowColorMode: 'dark',
+ },
+ },
+
+ light: {
+ id: 'light',
+ label: 'Light',
+ description: 'Clean light theme with dark text',
+ colors: {
+ nodeAccents: {
+ isp: { border: '#0284c7', icon: '#0284c7' },
+ router: { border: '#0284c7', icon: '#0284c7' },
+ switch: { border: '#16a34a', icon: '#16a34a' },
+ server: { border: '#7c3aed', icon: '#7c3aed' },
+ proxmox: { border: '#ea580c', icon: '#ea580c' },
+ vm: { border: '#7c3aed', icon: '#7c3aed' },
+ lxc: { border: '#0284c7', icon: '#0284c7' },
+ nas: { border: '#16a34a', icon: '#16a34a' },
+ iot: { border: '#b45309', icon: '#b45309' },
+ ap: { border: '#0284c7', icon: '#0284c7' },
+ camera: { border: '#6b7280', icon: '#6b7280' },
+ printer: { border: '#6b7280', icon: '#6b7280' },
+ computer: { border: '#7c3aed', icon: '#7c3aed' },
+ cpl: { border: '#b45309', icon: '#b45309' },
+ generic: { border: '#6b7280', icon: '#6b7280' },
+ groupRect:{ border: '#0284c7', icon: '#0284c7' },
+ },
+ nodeCardBackground: '#ffffff',
+ nodeIconBackground: '#f0f6ff',
+ nodeLabelColor: '#1f2328',
+ nodeSubtextColor: '#57606a',
+ statusColors: {
+ online: '#16a34a',
+ offline: '#dc2626',
+ pending: '#d97706',
+ unknown: '#6b7280',
+ },
+ edgeColors: {
+ ethernet: '#d0d7de',
+ wifi: '#0284c7',
+ iot: '#d97706',
+ vlan: '#0284c7',
+ virtual: '#9ca3af',
+ cluster: '#ea580c',
+ },
+ edgeSelectedColor: '#0284c7',
+ edgeLabelBackground:'#ffffff',
+ edgeLabelColor: '#57606a',
+ edgeLabelBorder: '#d0d7de',
+ canvasBackground: '#f6f8fa',
+ canvasDotColor: '#d0d7de',
+ handleBackground: '#d0d7de',
+ handleBorder: '#9ca3af',
+ reactFlowColorMode: 'light',
+ },
+ },
+
+ neon: {
+ id: 'neon',
+ label: 'Neon',
+ description: 'Cyberpunk vibes with vivid glowing accents',
+ colors: {
+ nodeAccents: {
+ isp: { border: '#00ffff', icon: '#00ffff' },
+ router: { border: '#00ffff', icon: '#00ffff' },
+ switch: { border: '#00ff80', icon: '#00ff80' },
+ server: { border: '#ff00ff', icon: '#ff00ff' },
+ proxmox: { border: '#ff8800', icon: '#ff8800' },
+ vm: { border: '#ff00ff', icon: '#ff00ff' },
+ lxc: { border: '#00ffff', icon: '#00ffff' },
+ nas: { border: '#00ff80', icon: '#00ff80' },
+ iot: { border: '#ffff00', icon: '#ffff00' },
+ ap: { border: '#00ffff', icon: '#00ffff' },
+ camera: { border: '#8888ff', icon: '#8888ff' },
+ printer: { border: '#8888ff', icon: '#8888ff' },
+ computer: { border: '#ff00ff', icon: '#ff00ff' },
+ cpl: { border: '#ffff00', icon: '#ffff00' },
+ generic: { border: '#8888ff', icon: '#8888ff' },
+ groupRect:{ border: '#00ffff', icon: '#00ffff' },
+ },
+ nodeCardBackground: '#0f0f2a',
+ nodeIconBackground: '#0a0a1a',
+ nodeLabelColor: '#ffffff',
+ nodeSubtextColor: '#8888cc',
+ statusColors: {
+ online: '#00ff80',
+ offline: '#ff0040',
+ pending: '#ffff00',
+ unknown: '#8888cc',
+ },
+ edgeColors: {
+ ethernet: '#1a1a3a',
+ wifi: '#00ffff',
+ iot: '#ffff00',
+ vlan: '#00ffff',
+ virtual: '#8888cc',
+ cluster: '#ff8800',
+ },
+ edgeSelectedColor: '#00ffff',
+ edgeLabelBackground:'#0a0a1a',
+ edgeLabelColor: '#8888cc',
+ edgeLabelBorder: '#1a1a3a',
+ canvasBackground: '#05050f',
+ canvasDotColor: '#1a1a3a',
+ handleBackground: '#1a1a3a',
+ handleBorder: '#8888cc',
+ reactFlowColorMode: 'dark',
+ },
+ },
+
+ matrix: {
+ id: 'matrix',
+ label: 'Matrix',
+ description: 'Everything in terminal green',
+ colors: {
+ nodeAccents: {
+ isp: { border: '#00ff41', icon: '#00ff41' },
+ router: { border: '#00ff41', icon: '#00ff41' },
+ switch: { border: '#00cc33', icon: '#00cc33' },
+ server: { border: '#008822', icon: '#008822' },
+ proxmox: { border: '#33ff66', icon: '#33ff66' },
+ vm: { border: '#008822', icon: '#008822' },
+ lxc: { border: '#00ff41', icon: '#00ff41' },
+ nas: { border: '#00cc33', icon: '#00cc33' },
+ iot: { border: '#66ff33', icon: '#66ff33' },
+ ap: { border: '#00ff41', icon: '#00ff41' },
+ camera: { border: '#005500', icon: '#005500' },
+ printer: { border: '#005500', icon: '#005500' },
+ computer: { border: '#008822', icon: '#008822' },
+ cpl: { border: '#66ff33', icon: '#66ff33' },
+ generic: { border: '#006600', icon: '#006600' },
+ groupRect:{ border: '#00ff41', icon: '#00ff41' },
+ },
+ nodeCardBackground: '#001100',
+ nodeIconBackground: '#002200',
+ nodeLabelColor: '#00ff41',
+ nodeSubtextColor: '#006600',
+ statusColors: {
+ online: '#00ff41',
+ offline: '#ff0000',
+ pending: '#88ff00',
+ unknown: '#004400',
+ },
+ edgeColors: {
+ ethernet: '#003300',
+ wifi: '#00ff41',
+ iot: '#66ff33',
+ vlan: '#00cc33',
+ virtual: '#004400',
+ cluster: '#33ff66',
+ },
+ edgeSelectedColor: '#00ff41',
+ edgeLabelBackground:'#001100',
+ edgeLabelColor: '#006600',
+ edgeLabelBorder: '#003300',
+ canvasBackground: '#000000',
+ canvasDotColor: '#002200',
+ handleBackground: '#003300',
+ handleBorder: '#006600',
+ reactFlowColorMode: 'dark',
+ },
+ },
+}
+
+// Ordered list for display in the modal
+export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix']