diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx
index 5e9d6d1..6da2684 100644
--- a/dashboard/src/App.jsx
+++ b/dashboard/src/App.jsx
@@ -1,4 +1,5 @@
import { useState } from 'react'
+import { ThemeProvider } from './contexts/ThemeContext.jsx'
import TopBar from './components/layout/TopBar.jsx'
import TabNav from './components/layout/TabNav.jsx'
import Overview from './pages/Overview.jsx'
@@ -18,13 +19,15 @@ export default function App() {
const Page = PAGES[tab] || Overview
return (
-
+
+
+
)
}
diff --git a/dashboard/src/components/audio/ZoneCard.jsx b/dashboard/src/components/audio/ZoneCard.jsx
index 901b68e..b05e9e6 100644
--- a/dashboard/src/components/audio/ZoneCard.jsx
+++ b/dashboard/src/components/audio/ZoneCard.jsx
@@ -81,49 +81,57 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
const styles = {
card: {
padding: 14,
- background: 'var(--surface)',
+ background: 'var(--glass-bg)',
+ backdropFilter: 'blur(var(--glass-blur))',
+ WebkitBackdropFilter: 'blur(var(--glass-blur))',
borderRadius: 'var(--radius)',
- border: '1px solid var(--border)',
+ border: '1px solid rgba(255, 255, 255, 0.1)',
display: 'flex', flexDirection: 'column', gap: 10,
- transition: 'border-color 0.2s, opacity 0.2s',
+ transition: 'all 0.3s cubic-bezier(0.23, 1, 0.320, 1)',
+ cursor: 'pointer',
},
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 },
titleArea: { display: 'flex', flexDirection: 'column', gap: 6, flex: 1 },
- name: { fontWeight: 600, fontSize: 14 },
+ name: { fontWeight: 600, fontSize: 15, letterSpacing: '-0.01em' },
sourceTag: {
- fontSize: 11, padding: '3px 8px', borderRadius: 4, fontWeight: 600,
+ fontSize: 11, padding: '4px 8px', borderRadius: 6, fontWeight: 600,
display: 'inline-block', width: 'fit-content',
+ transition: 'all 0.2s',
},
badges: { display: 'flex', gap: 4 },
- badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' },
+ badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s' },
groupInfo: {
fontSize: 11, color: 'var(--muted)',
- background: '#38bdf811', padding: 8, borderRadius: 4,
+ background: 'rgba(14, 165, 233, 0.08)', padding: 8, borderRadius: 6,
borderLeft: '2px solid var(--accent)',
+ animation: 'slideInUp 0.3s ease-out',
},
- groupLabel: { fontWeight: 600, marginBottom: 4 },
+ groupLabel: { fontWeight: 600, marginBottom: 4, display: 'block' },
groupZones: { display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 },
groupZone: {
- fontSize: 10, padding: '2px 6px', background: 'var(--accent)',
- color: 'var(--bg)', borderRadius: 3, fontWeight: 600,
+ fontSize: 10, padding: '3px 8px', background: 'var(--accent)',
+ color: 'white', borderRadius: 4, fontWeight: 600,
+ animation: 'slideInUp 0.2s ease-out',
},
sourceControl: { display: 'flex', gap: 8, alignItems: 'center' },
sourceSelect: {
- flex: 1, padding: '8px 10px', background: 'var(--bg)', color: 'var(--text)',
- border: '1px solid var(--border)', borderRadius: 'var(--radius)',
+ flex: 1, padding: '8px 10px', background: 'rgba(255, 255, 255, 0.05)',
+ color: 'var(--text)',
+ border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
+ transition: 'all 0.2s',
},
groupBtn: {
- minWidth: 44, minHeight: 44, padding: 0,
- background: 'var(--border)', color: 'var(--text)',
- border: 'none', borderRadius: 'var(--radius)', cursor: 'pointer',
- fontSize: 18, transition: 'background 0.2s',
- '&:hover': { background: 'var(--accent)' },
+ minWidth: 40, minHeight: 40, padding: 0,
+ background: 'rgba(255, 255, 255, 0.08)', color: 'var(--text)',
+ border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)',
+ cursor: 'pointer',
+ fontSize: 16, transition: 'all 0.2s',
},
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
- muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44, background: 'none', border: 'none', cursor: 'pointer' },
- volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' },
+ muteBtn: { fontSize: 18, minWidth: 40, minHeight: 40, background: 'none', border: 'none', cursor: 'pointer', transition: 'transform 0.2s' },
+ volVal: { fontFamily: 'var(--font-mono)', fontSize: 12, minWidth: 28, textAlign: 'right', color: 'var(--muted)' },
}
diff --git a/dashboard/src/components/audio/ZoneGrid.jsx b/dashboard/src/components/audio/ZoneGrid.jsx
index d5a0334..57f48cb 100644
--- a/dashboard/src/components/audio/ZoneGrid.jsx
+++ b/dashboard/src/components/audio/ZoneGrid.jsx
@@ -100,25 +100,29 @@ const styles = {
gridTemplateColumns: '240px 1fr',
gap: 16,
height: '100%',
+ padding: 16,
},
groupingPanel: {
- background: 'var(--surface)',
- borderRadius: 'var(--radius)',
- border: '1px solid var(--border)',
- padding: 12,
+ background: 'var(--glass-bg)',
+ backdropFilter: 'blur(var(--glass-blur))',
+ WebkitBackdropFilter: 'blur(var(--glass-blur))',
+ borderRadius: 'var(--radius-lg)',
+ border: '1px solid rgba(255, 255, 255, 0.1)',
+ padding: 14,
display: 'flex',
flexDirection: 'column',
gap: 10,
- maxHeight: '100vh',
+ maxHeight: 'calc(100vh - 200px)',
overflowY: 'auto',
+ animation: 'slideInDown 0.3s ease-out',
},
panelTitle: {
- fontSize: 12,
+ fontSize: 11,
fontWeight: 700,
color: 'var(--muted)',
margin: '0 0 8px 0',
textTransform: 'uppercase',
- letterSpacing: 0.5,
+ letterSpacing: 0.6,
},
emptyText: {
fontSize: 11,
@@ -132,13 +136,14 @@ const styles = {
gap: 6,
},
groupListItem: {
- background: '#38bdf811',
- border: '1px solid var(--accent)',
- borderRadius: 4,
+ background: 'rgba(14, 165, 233, 0.08)',
+ border: '1px solid rgba(14, 165, 233, 0.2)',
+ borderRadius: 6,
padding: '8px 10px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
+ transition: 'all 0.2s',
},
groupName: {
fontSize: 11,
@@ -155,11 +160,13 @@ const styles = {
padding: '0 4px',
minWidth: 24,
minHeight: 24,
+ transition: 'transform 0.2s',
},
grid: {
display: 'grid',
- gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
+ gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: 12,
overflowY: 'auto',
+ animation: 'slideInUp 0.3s ease-out',
}
}
diff --git a/dashboard/src/components/layout/TabNav.jsx b/dashboard/src/components/layout/TabNav.jsx
index f4e6afd..e826159 100644
--- a/dashboard/src/components/layout/TabNav.jsx
+++ b/dashboard/src/components/layout/TabNav.jsx
@@ -16,6 +16,7 @@ export default function TabNav({ activeTab, onTabChange }) {
...(activeTab === tab.id ? styles.active : {}),
}}
onClick={() => onTabChange(tab.id)}
+ className={activeTab === tab.id ? 'slide-in-down' : ''}
>
{tab.icon}
{tab.label}
@@ -31,6 +32,7 @@ const styles = {
background: 'var(--surface)',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
+ backdropFilter: 'blur(8px)',
},
tab: {
flex: 1,
@@ -43,14 +45,14 @@ const styles = {
fontSize: 11,
color: 'var(--muted)',
borderRadius: 0,
- borderBottom: '2px solid transparent',
- transition: 'color 0.15s, border-color 0.15s',
+ borderBottom: '3px solid transparent',
+ transition: 'color 0.2s cubic-bezier(0.23, 1, 0.320, 1), border-color 0.2s cubic-bezier(0.23, 1, 0.320, 1)',
minHeight: 48,
},
active: {
color: 'var(--accent)',
- borderBottom: '2px solid var(--accent)',
+ borderBottom: '3px solid var(--accent)',
},
- icon: { fontSize: 16 },
+ icon: { fontSize: 18, transition: 'transform 0.2s' },
label: { fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase' },
}
diff --git a/dashboard/src/components/layout/TopBar.jsx b/dashboard/src/components/layout/TopBar.jsx
index c5a0cc8..aa2cbf7 100644
--- a/dashboard/src/components/layout/TopBar.jsx
+++ b/dashboard/src/components/layout/TopBar.jsx
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useNMEA } from '../../hooks/useNMEA.js'
+import { useTheme } from '../../contexts/ThemeContext.jsx'
const isMock = import.meta.env.VITE_USE_MOCK === 'true'
const isDev = import.meta.env.DEV
@@ -10,6 +11,7 @@ function formatTime() {
export default function TopBar() {
const { sog, heading, connected } = useNMEA()
+ const { isDark, toggleTheme } = useTheme()
const [time, setTime] = useState(formatTime())
// Clock tick
@@ -23,7 +25,7 @@ export default function TopBar() {
⚓ Bordanlage
{isMock && DEV · MOCK DATA}
- {isDev && !isMock && DEV · LIVE}
+ {isDev && !isMock && DEV · LIVE}
@@ -45,6 +47,14 @@ export default function TopBar() {
+
{time}
diff --git a/dashboard/src/contexts/ThemeContext.jsx b/dashboard/src/contexts/ThemeContext.jsx
new file mode 100644
index 0000000..b290af5
--- /dev/null
+++ b/dashboard/src/contexts/ThemeContext.jsx
@@ -0,0 +1,35 @@
+import { createContext, useContext, useEffect, useState } from 'react'
+
+const ThemeContext = createContext()
+
+export function ThemeProvider({ children }) {
+ const [isDark, setIsDark] = useState(() => {
+ const saved = localStorage.getItem('theme')
+ if (saved) return saved === 'dark'
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
+ })
+
+ useEffect(() => {
+ const root = document.documentElement
+ if (isDark) {
+ root.classList.remove('light-mode')
+ } else {
+ root.classList.add('light-mode')
+ }
+ localStorage.setItem('theme', isDark ? 'dark' : 'light')
+ }, [isDark])
+
+ const toggleTheme = () => setIsDark(prev => !prev)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useTheme() {
+ const context = useContext(ThemeContext)
+ if (!context) throw new Error('useTheme must be used within ThemeProvider')
+ return context
+}
diff --git a/dashboard/src/index.css b/dashboard/src/index.css
index 700c6a2..e2b2c99 100644
--- a/dashboard/src/index.css
+++ b/dashboard/src/index.css
@@ -1,13 +1,16 @@
/* ─── CSS Custom Properties ───────────────────────────────────────────────── */
:root {
+ /* Dark Mode Colors */
--bg: #07111f;
--surface: #0a1928;
--surface2: #0d2035;
--border: #1e2a3a;
--text: #e2eaf2;
--muted: #4a6080;
- --accent: #38bdf8;
- --success: #34d399;
+
+ /* Brand Colors */
+ --accent: #0ea5e9;
+ --success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--spotify: #1DB954;
@@ -18,6 +21,34 @@
--radius: 8px;
--radius-lg: 16px;
+
+ /* Glassmorphism */
+ --glass-bg: rgba(10, 25, 40, 0.7);
+ --glass-blur: 12px;
+}
+
+/* Light Mode */
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg: #f8fafc;
+ --surface: #f1f5f9;
+ --surface2: #e2e8f0;
+ --border: #cbd5e1;
+ --text: #0f172a;
+ --muted: #64748b;
+ --glass-bg: rgba(241, 245, 249, 0.8);
+ }
+}
+
+/* Custom Light Mode Toggle */
+html.light-mode {
+ --bg: #f8fafc;
+ --surface: #f1f5f9;
+ --surface2: #e2e8f0;
+ --border: #cbd5e1;
+ --text: #0f172a;
+ --muted: #64748b;
+ --glass-bg: rgba(241, 245, 249, 0.8);
}
/* ─── Reset ───────────────────────────────────────────────────────────────── */
@@ -39,11 +70,76 @@ html, body, #root {
::-webkit-scrollbar-track { background: var(--surface); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
+/* ─── Animations ───────────────────────────────────────────────────────────── */
+@keyframes slideInUp {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes slideInDown {
+ from { opacity: 0; transform: translateY(-10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+@keyframes shimmer {
+ 0% { background-position: -1000px 0; }
+ 100% { background-position: 1000px 0; }
+}
+
+/* ─── Glassmorphic Components ───────────────────────────────────────────────── */
+.glass-panel {
+ background: var(--glass-bg);
+ backdrop-filter: blur(var(--glass-blur));
+ -webkit-backdrop-filter: blur(var(--glass-blur));
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: var(--radius-lg);
+}
+
+.glass-card {
+ background: var(--glass-bg);
+ backdrop-filter: blur(var(--glass-blur));
+ -webkit-backdrop-filter: blur(var(--glass-blur));
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: var(--radius);
+ transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
+}
+
+.glass-card:hover {
+ background: rgba(10, 25, 40, 0.85);
+ border-color: rgba(255, 255, 255, 0.2);
+ transform: translateY(-2px);
+ box-shadow: 0 8px 32px rgba(14, 165, 233, 0.15);
+}
+
+/* Light mode card hover */
+html.light-mode .glass-card:hover {
+ background: rgba(241, 245, 249, 0.95);
+ box-shadow: 0 8px 32px rgba(14, 165, 233, 0.1);
+}
+
/* ─── Utilities ───────────────────────────────────────────────────────────── */
.mono { font-family: var(--font-mono); }
.muted { color: var(--muted); }
.accent { color: var(--accent); }
+.fade-in { animation: fadeIn 0.3s ease-out; }
+.slide-in-up { animation: slideInUp 0.3s cubic-bezier(0.23, 1, 0.320, 1); }
+.slide-in-down { animation: slideInDown 0.3s cubic-bezier(0.23, 1, 0.320, 1); }
+.pulse { animation: pulse 2s ease-in-out infinite; }
+
+/* ─── Button Variants ───────────────────────────────────────────────────────── */
+
+/* ─── Button Variants ───────────────────────────────────────────────────────── */
button {
cursor: pointer;
background: none;
@@ -56,9 +152,52 @@ button {
align-items: center;
justify-content: center;
border-radius: var(--radius);
- transition: background 0.15s, opacity 0.15s;
+ transition: all 0.2s cubic-bezier(0.23, 1, 0.320, 1);
+ font-weight: 500;
+}
+
+button:hover {
+ transform: scale(1.05);
+}
+
+button:active {
+ opacity: 0.8;
+ transform: scale(0.98);
+}
+
+/* Primary Button */
+button.primary {
+ background: var(--accent);
+ color: white;
+ box-shadow: 0 4px 15px rgba(14, 165, 233, 0.3);
+}
+
+button.primary:hover {
+ background: #06b6d4;
+ box-shadow: 0 6px 20px rgba(14, 165, 233, 0.4);
+}
+
+/* Ghost Button */
+button.ghost {
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+}
+
+button.ghost:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.25);
+}
+
+/* Icon Button */
+button.icon {
+ border-radius: 50%;
+ width: 40px;
+ height: 40px;
+}
+
+button.icon:hover {
+ background: rgba(255, 255, 255, 0.1);
}
-button:active { opacity: 0.7; }
input[type=range] {
-webkit-appearance: none;