* Add customizable theme system with style, background, and accent options

Implement comprehensive theme customization with independent settings for
  dark and light modes:

  - Style layer: Classic (clean shadows), Glow (accent-colored glow effects),
    Vibrant (dramatic deep shadows)
  - Background layer: Neutral, Warm, Cool (light mode); plus OLED, Slate,
    Forest (dark mode only)
  - Accent colors: Green, Teal, Blue, Orange, Purple, Red

  All combinations work independently (e.g., Glow + Forest + Teal). Settings
  sync across devices via database and show toast confirmations on change.

  Backend:
  - Add 6 new settings fields (dark_style, dark_background, dark_accent,
    light_style, light_background, light_accent)
  - Add integration test for theme settings API

  Frontend:
  - Refactor index.css with 3-layer CSS variable system
  - Update ThemeContext for dual-mode theme management
  - Add Appearance section to Settings page with 6 dropdowns
  - Update components for new ThemeContext API
This commit is contained in:
maziggy
2025-12-31 15:44:52 +01:00
parent 297c3212fa
commit bca1a80172
19 changed files with 715 additions and 286 deletions
+17
View File
@@ -2,6 +2,23 @@
All notable changes to Bambuddy will be documented in this file.
## [0.1.6b3] - 2025-12-31
### Added
- **Customizable Theme System** - Comprehensive theme customization with independent settings for dark and light modes:
- **Style**: Classic (clean shadows), Glow (accent-colored glow effects), Vibrant (dramatic deep shadows)
- **Background**: Neutral, Warm, Cool (light mode) + OLED, Slate, Forest (dark mode only)
- **Accent Colors**: Green, Teal, Blue, Orange, Purple, Red
- All combinations work together (e.g., Glow style + Forest background + Teal accent)
- Settings sync across devices via database
- Live preview in Settings → Appearance
### Fixed
- **Printer hour counter** - Fixed bug in printer's hour counter display
### Changed
- **Sidebar power switch** - Added confirmation modal to sidebar's quick power switch
## [0.1.6b2] - 2025-12-29
### Added
+1 -1
View File
@@ -109,7 +109,7 @@
</tr>
</table>
**Plus:** Dark/light theme • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
**Plus:** Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
---
+18
View File
@@ -56,6 +56,18 @@ class AppSettings(BaseModel):
virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
virtual_printer_mode: str = Field(default="immediate", description="Archive mode: 'immediate' or 'queue'")
# Dark mode theme settings
dark_style: str = Field(default="classic", description="Dark mode style: classic, glow, vibrant")
dark_background: str = Field(
default="neutral", description="Dark mode background: neutral, warm, cool, oled, slate, forest"
)
dark_accent: str = Field(default="green", description="Dark mode accent: green, teal, blue, orange, purple, red")
# Light mode theme settings
light_style: str = Field(default="classic", description="Light mode style: classic, glow, vibrant")
light_background: str = Field(default="neutral", description="Light mode background: neutral, warm, cool")
light_accent: str = Field(default="green", description="Light mode accent: green, teal, blue, orange, purple, red")
class AppSettingsUpdate(BaseModel):
"""Schema for updating settings (all fields optional)."""
@@ -84,3 +96,9 @@ class AppSettingsUpdate(BaseModel):
virtual_printer_enabled: bool | None = None
virtual_printer_access_code: str | None = None
virtual_printer_mode: str | None = None
dark_style: str | None = None
dark_background: str | None = None
dark_accent: str | None = None
light_style: str | None = None
light_background: str | None = None
light_accent: str | None = None
+36 -35
View File
@@ -53,10 +53,7 @@ class TestSettingsAPI:
# Update to opposite value
new_value = not original
response = await async_client.put(
"/api/v1/settings/",
json={"auto_archive": new_value}
)
response = await async_client.put("/api/v1/settings/", json={"auto_archive": new_value})
assert response.status_code == 200
assert response.json()["auto_archive"] == new_value
@@ -65,10 +62,7 @@ class TestSettingsAPI:
@pytest.mark.integration
async def test_update_currency(self, async_client: AsyncClient):
"""Verify currency can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"currency": "EUR"}
)
response = await async_client.put("/api/v1/settings/", json={"currency": "EUR"})
assert response.status_code == 200
assert response.json()["currency"] == "EUR"
@@ -77,10 +71,7 @@ class TestSettingsAPI:
@pytest.mark.integration
async def test_update_date_format(self, async_client: AsyncClient):
"""Verify date format can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"date_format": "eu"}
)
response = await async_client.put("/api/v1/settings/", json={"date_format": "eu"})
assert response.status_code == 200
assert response.json()["date_format"] == "eu"
@@ -89,10 +80,7 @@ class TestSettingsAPI:
@pytest.mark.integration
async def test_update_time_format(self, async_client: AsyncClient):
"""Verify time format can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"time_format": "24h"}
)
response = await async_client.put("/api/v1/settings/", json={"time_format": "24h"})
assert response.status_code == 200
assert response.json()["time_format"] == "24h"
@@ -101,10 +89,7 @@ class TestSettingsAPI:
@pytest.mark.integration
async def test_update_filament_cost(self, async_client: AsyncClient):
"""Verify default filament cost can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"default_filament_cost": 30.0}
)
response = await async_client.put("/api/v1/settings/", json={"default_filament_cost": 30.0})
assert response.status_code == 200
assert response.json()["default_filament_cost"] == 30.0
@@ -113,10 +98,7 @@ class TestSettingsAPI:
@pytest.mark.integration
async def test_update_energy_cost(self, async_client: AsyncClient):
"""Verify energy cost can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"energy_cost_per_kwh": 0.20}
)
response = await async_client.put("/api/v1/settings/", json={"energy_cost_per_kwh": 0.20})
assert response.status_code == 200
assert response.json()["energy_cost_per_kwh"] == 0.20
@@ -132,7 +114,7 @@ class TestSettingsAPI:
"date_format": "iso",
"time_format": "12h",
"save_thumbnails": False,
}
},
)
assert response.status_code == 200
@@ -152,7 +134,7 @@ class TestSettingsAPI:
"spoolman_enabled": True,
"spoolman_url": "http://localhost:7912",
"spoolman_sync_mode": "manual",
}
},
)
assert response.status_code == 200
@@ -172,7 +154,7 @@ class TestSettingsAPI:
"ams_humidity_fair": 55,
"ams_temp_good": 25.0,
"ams_temp_fair": 32.0,
}
},
)
assert response.status_code == 200
@@ -186,10 +168,7 @@ class TestSettingsAPI:
@pytest.mark.integration
async def test_update_notification_language(self, async_client: AsyncClient):
"""Verify notification language can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"notification_language": "de"}
)
response = await async_client.put("/api/v1/settings/", json={"notification_language": "de"})
assert response.status_code == 200
assert response.json()["notification_language"] == "de"
@@ -198,15 +177,37 @@ class TestSettingsAPI:
# Settings persistence tests
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_theme_settings(self, async_client: AsyncClient):
"""Verify theme settings can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={
"dark_style": "glow",
"dark_background": "forest",
"dark_accent": "teal",
"light_style": "vibrant",
"light_background": "warm",
"light_accent": "blue",
},
)
assert response.status_code == 200
result = response.json()
assert result["dark_style"] == "glow"
assert result["dark_background"] == "forest"
assert result["dark_accent"] == "teal"
assert result["light_style"] == "vibrant"
assert result["light_background"] == "warm"
assert result["light_accent"] == "blue"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_settings_persist_after_update(self, async_client: AsyncClient):
"""CRITICAL: Verify settings changes persist across requests."""
# Update settings
await async_client.put(
"/api/v1/settings/",
json={"currency": "JPY", "check_updates": False}
)
await async_client.put("/api/v1/settings/", json={"currency": "JPY", "check_updates": False})
# Verify persistence in new request
response = await async_client.get("/api/v1/settings/")
+8
View File
@@ -544,6 +544,14 @@ export interface AppSettings {
default_printer_id: number | null;
// Telemetry
telemetry_enabled: boolean;
// Dark mode theme settings
dark_style: 'classic' | 'glow' | 'vibrant';
dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
dark_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
// Light mode theme settings
light_style: 'classic' | 'glow' | 'vibrant';
light_background: 'neutral' | 'warm' | 'cool';
light_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
}
export type AppSettingsUpdate = Partial<AppSettings>;
+2 -2
View File
@@ -52,10 +52,10 @@ export function AMSHistoryModal({
thresholds,
}: AMSHistoryModalProps) {
const { t } = useTranslation();
const { theme } = useTheme();
const { mode: themeMode } = useTheme();
const [timeRange, setTimeRange] = useState<TimeRange>('24h');
const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);
const isDark = theme === 'dark';
const isDark = themeMode === 'dark';
// Close on Escape key
useEffect(() => {
@@ -14,7 +14,7 @@ interface AddExternalLinkModalProps {
export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
const queryClient = useQueryClient();
const { theme } = useTheme();
const { mode } = useTheme();
const isEditing = !!link;
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -166,7 +166,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
{useCustomIcon && customIconPreview ? (
<img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
<img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
) : (
<PresetIcon className="w-5 h-5" />
)}
@@ -233,7 +233,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
/>
{useCustomIcon && customIconPreview ? (
<div className="flex items-center gap-2">
<img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
<img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
<button
type="button"
onClick={handleRemoveCustomIcon}
+1 -1
View File
@@ -10,7 +10,7 @@ interface CardProps {
export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
return (
<div
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary ${className}`}
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${className}`}
onClick={onClick}
onContextMenu={onContextMenu}
>
+10 -10
View File
@@ -64,7 +64,7 @@ export function setDefaultView(path: string) {
export function Layout() {
const navigate = useNavigate();
const location = useLocation();
const { theme, toggleTheme } = useTheme();
const { mode, toggleMode } = useTheme();
const { t } = useTranslation();
const isMobile = useIsMobile();
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
@@ -300,7 +300,7 @@ export function Layout() {
<Menu className="w-6 h-6 text-white" />
</button>
<img
src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
src={mode === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
alt="Bambuddy"
className="h-8 ml-3"
/>
@@ -326,7 +326,7 @@ export function Layout() {
{/* Logo */}
<div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isMobile || sidebarExpanded ? 'p-4' : 'p-2'}`}>
<img
src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
src={mode === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
alt="Bambuddy"
className={isMobile || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
/>
@@ -379,7 +379,7 @@ export function Layout() {
<img
src={`/api/v1/external-links/${link.id}/icon`}
alt=""
className={`w-5 h-5 flex-shrink-0 ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
className={`w-5 h-5 flex-shrink-0 ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
@@ -500,11 +500,11 @@ export function Layout() {
<Keyboard className="w-5 h-5" />
</button>
<button
onClick={toggleTheme}
onClick={toggleMode}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
>
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
</div>
{/* Bottom row: version */}
@@ -577,11 +577,11 @@ export function Layout() {
<Keyboard className="w-5 h-5" />
</button>
<button
onClick={toggleTheme}
onClick={toggleMode}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
>
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
</div>
)}
-52
View File
@@ -1,52 +0,0 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) return stored;
// Default to dark theme
return 'dark';
});
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
+152 -24
View File
@@ -1,43 +1,171 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { api } from '../api/client';
type Theme = 'light' | 'dark';
type ThemeMode = 'light' | 'dark';
type ThemeStyle = 'classic' | 'glow' | 'vibrant';
type DarkBackground = 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
type LightBackground = 'neutral' | 'warm' | 'cool';
type ThemeAccent = 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
mode: ThemeMode;
// Dark mode settings
darkStyle: ThemeStyle;
darkBackground: DarkBackground;
darkAccent: ThemeAccent;
// Light mode settings
lightStyle: ThemeStyle;
lightBackground: LightBackground;
lightAccent: ThemeAccent;
// Actions
toggleMode: () => void;
setMode: (mode: ThemeMode) => void;
setDarkStyle: (style: ThemeStyle) => void;
setDarkBackground: (background: DarkBackground) => void;
setDarkAccent: (accent: ThemeAccent) => void;
setLightStyle: (style: ThemeStyle) => void;
setLightBackground: (background: LightBackground) => void;
setLightAccent: (accent: ThemeAccent) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) return stored;
// Default to dark theme
return 'dark';
// Mode
const [mode, setModeState] = useState<ThemeMode>(() => {
const stored = localStorage.getItem('theme-mode') as ThemeMode | null;
const legacy = localStorage.getItem('theme') as ThemeMode | null;
return stored || legacy || 'dark';
});
// Dark mode settings
const [darkStyle, setDarkStyleState] = useState<ThemeStyle>(() => {
return (localStorage.getItem('dark-style') as ThemeStyle) || 'classic';
});
const [darkBackground, setDarkBackgroundState] = useState<DarkBackground>(() => {
return (localStorage.getItem('dark-background') as DarkBackground) || 'neutral';
});
const [darkAccent, setDarkAccentState] = useState<ThemeAccent>(() => {
return (localStorage.getItem('dark-accent') as ThemeAccent) || 'green';
});
// Light mode settings
const [lightStyle, setLightStyleState] = useState<ThemeStyle>(() => {
return (localStorage.getItem('light-style') as ThemeStyle) || 'classic';
});
const [lightBackground, setLightBackgroundState] = useState<LightBackground>(() => {
return (localStorage.getItem('light-background') as LightBackground) || 'neutral';
});
const [lightAccent, setLightAccentState] = useState<ThemeAccent>(() => {
return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
});
// Sync from API on mount
useEffect(() => {
api.getSettings().then((settings) => {
// Dark settings
if (settings.dark_style) {
setDarkStyleState(settings.dark_style as ThemeStyle);
localStorage.setItem('dark-style', settings.dark_style);
}
if (settings.dark_background) {
setDarkBackgroundState(settings.dark_background as DarkBackground);
localStorage.setItem('dark-background', settings.dark_background);
}
if (settings.dark_accent) {
setDarkAccentState(settings.dark_accent as ThemeAccent);
localStorage.setItem('dark-accent', settings.dark_accent);
}
// Light settings
if (settings.light_style) {
setLightStyleState(settings.light_style as ThemeStyle);
localStorage.setItem('light-style', settings.light_style);
}
if (settings.light_background) {
setLightBackgroundState(settings.light_background as LightBackground);
localStorage.setItem('light-background', settings.light_background);
}
if (settings.light_accent) {
setLightAccentState(settings.light_accent as ThemeAccent);
localStorage.setItem('light-accent', settings.light_accent);
}
}).catch(() => {});
}, []);
// Apply theme classes based on current mode
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
// Remove all theme classes
root.classList.remove(
'dark',
'style-classic', 'style-glow', 'style-vibrant',
'bg-neutral', 'bg-warm', 'bg-cool', 'bg-oled', 'bg-slate', 'bg-forest',
'accent-green', 'accent-teal', 'accent-blue', 'accent-orange', 'accent-purple', 'accent-red'
);
// Apply based on current mode
if (mode === 'dark') {
root.classList.add('dark');
root.classList.add(`style-${darkStyle}`);
root.classList.add(`bg-${darkBackground}`);
root.classList.add(`accent-${darkAccent}`);
} else {
root.classList.add(`style-${lightStyle}`);
root.classList.add(`bg-${lightBackground}`);
root.classList.add(`accent-${lightAccent}`);
}
localStorage.setItem('theme-mode', mode);
localStorage.removeItem('theme');
}, [mode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]);
const toggleMode = () => setModeState(prev => prev === 'dark' ? 'light' : 'dark');
const setMode = (m: ThemeMode) => setModeState(m);
// Dark setters
const setDarkStyle = (v: ThemeStyle) => {
setDarkStyleState(v);
localStorage.setItem('dark-style', v);
api.updateSettings({ dark_style: v }).catch(() => {});
};
const setDarkBackground = (v: DarkBackground) => {
setDarkBackgroundState(v);
localStorage.setItem('dark-background', v);
api.updateSettings({ dark_background: v }).catch(() => {});
};
const setDarkAccent = (v: ThemeAccent) => {
setDarkAccentState(v);
localStorage.setItem('dark-accent', v);
api.updateSettings({ dark_accent: v }).catch(() => {});
};
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
// Light setters
const setLightStyle = (v: ThemeStyle) => {
setLightStyleState(v);
localStorage.setItem('light-style', v);
api.updateSettings({ light_style: v }).catch(() => {});
};
const setLightBackground = (v: LightBackground) => {
setLightBackgroundState(v);
localStorage.setItem('light-background', v);
api.updateSettings({ light_background: v }).catch(() => {});
};
const setLightAccent = (v: ThemeAccent) => {
setLightAccentState(v);
localStorage.setItem('light-accent', v);
api.updateSettings({ light_accent: v }).catch(() => {});
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
<ThemeContext.Provider value={{
mode,
darkStyle, darkBackground, darkAccent,
lightStyle, lightBackground, lightAccent,
toggleMode, setMode,
setDarkStyle, setDarkBackground, setDarkAccent,
setLightStyle, setLightBackground, setLightAccent,
}}>
{children}
</ThemeContext.Provider>
);
@@ -45,8 +173,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
}
export type { ThemeMode, ThemeStyle, DarkBackground, LightBackground, ThemeAccent };
+190 -6
View File
@@ -5,10 +5,10 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Bambu Lab brand colors - always the same */
--color-bambu-green: #00ae42;
--color-bambu-green-light: #00c64d;
--color-bambu-green-dark: #009438;
/* Accent colors - use CSS variables for theming */
--color-bambu-green: var(--accent);
--color-bambu-green-light: var(--accent-light);
--color-bambu-green-dark: var(--accent-dark);
/* Theme-aware colors via CSS variables */
--color-bambu-dark: var(--bg-primary);
@@ -19,8 +19,17 @@
--color-bambu-gray-dark: var(--text-tertiary);
}
/* Light mode (default) */
/* ============================================
BASE DEFAULTS
============================================ */
:root {
/* Default accent color (green) */
--accent: #00ae42;
--accent-light: #00c64d;
--accent-dark: #009438;
/* Default light mode background (neutral) */
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--bg-tertiary: #e5e5e5;
@@ -30,6 +39,10 @@
--text-tertiary: #808080;
--border-color: #d4d4d4;
/* Default style (classic) */
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--glow-color: transparent;
font-family: 'Inter', system-ui, sans-serif;
line-height: 1.5;
font-weight: 400;
@@ -39,7 +52,7 @@
-moz-osx-font-smoothing: grayscale;
}
/* Dark mode */
/* Dark mode base */
.dark {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
@@ -49,6 +62,172 @@
--text-muted: #808080;
--text-tertiary: #4a4a4a;
--border-color: #3d3d3d;
--card-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
/* ============================================
LAYER 1: BACKGROUND PALETTES
============================================ */
/* Light mode backgrounds */
.bg-neutral {
/* Default - already set in :root */
}
.bg-warm {
--bg-primary: #faf8f5;
--bg-secondary: #fffefa;
--bg-tertiary: #e8e4dd;
--text-primary: #2d2a26;
--text-secondary: #5c5750;
--text-muted: #7a756c;
--text-tertiary: #9a9590;
--border-color: #d8d4cc;
}
.bg-cool {
--bg-primary: #f0f4f8;
--bg-secondary: #ffffff;
--bg-tertiary: #dce4ec;
--text-primary: #1a2530;
--text-secondary: #4a5568;
--text-muted: #6b7a8a;
--text-tertiary: #8a9aaa;
--border-color: #c8d4e0;
}
/* Dark mode backgrounds */
.dark.bg-neutral {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #3d3d3d;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--text-muted: #808080;
--text-tertiary: #4a4a4a;
--border-color: #3d3d3d;
}
.dark.bg-warm {
--bg-primary: #1c1a18;
--bg-secondary: #2e2a26;
--bg-tertiary: #3e3a36;
--text-primary: #f5f0ea;
--text-secondary: #b0a898;
--text-muted: #8a8278;
--text-tertiary: #5a5248;
--border-color: #3e3a36;
}
.dark.bg-cool {
--bg-primary: #181c20;
--bg-secondary: #262c32;
--bg-tertiary: #363e46;
--text-primary: #f0f4f8;
--text-secondary: #98a8b8;
--text-muted: #788898;
--text-tertiary: #4a5a6a;
--border-color: #363e46;
}
.dark.bg-oled {
--bg-primary: #000000;
--bg-secondary: #141414;
--bg-tertiary: #1f1f1f;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--text-muted: #707070;
--text-tertiary: #404040;
--border-color: #2a2a2a;
}
.dark.bg-slate {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-tertiary: #475569;
--border-color: #334155;
}
.dark.bg-forest {
--bg-primary: #121a16;
--bg-secondary: #1c2a22;
--bg-tertiary: #2a3d30;
--text-primary: #e8f5ec;
--text-secondary: #8aa894;
--text-muted: #6a8874;
--text-tertiary: #4a6854;
--border-color: #2a3d30;
}
/* ============================================
LAYER 2: STYLE EFFECTS
============================================ */
/* Classic - default, clean minimal shadows */
.style-classic {
/* Uses default shadows from :root and .dark */
}
/* Glow - accent-colored glow effects on cards */
.style-glow {
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 25px color-mix(in srgb, var(--accent) 12%, transparent);
}
.dark.style-glow {
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 40px color-mix(in srgb, var(--accent) 15%, transparent);
}
/* Vibrant - dramatic deep shadows, more contrast */
.style-vibrant {
--card-shadow: 0 8px 30px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark.style-vibrant {
--card-shadow: 0 10px 40px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* ============================================
LAYER 3: ACCENT COLORS
============================================ */
.accent-green {
--accent: #00ae42;
--accent-light: #00c64d;
--accent-dark: #009438;
}
.accent-teal {
--accent: #14b8a6;
--accent-light: #2dd4bf;
--accent-dark: #0d9488;
}
.accent-blue {
--accent: #3b82f6;
--accent-light: #60a5fa;
--accent-dark: #2563eb;
}
.accent-orange {
--accent: #f97316;
--accent-light: #fb923c;
--accent-dark: #ea580c;
}
.accent-purple {
--accent: #8b5cf6;
--accent-light: #a78bfa;
--accent-dark: #7c3aed;
}
.accent-red {
--accent: #ef4444;
--accent-light: #f87171;
--accent-dark: #dc2626;
}
body {
@@ -181,3 +360,8 @@ body {
.animate-slide-in-left {
animation: slide-in-left 0.3s ease-out;
}
/* Card shadows - uses theme-specific shadow */
.card-shadow {
box-shadow: var(--card-shadow);
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { useTheme } from '../contexts/ThemeContext';
export function ExternalLinkPage() {
const { id } = useParams<{ id: string }>();
const { theme } = useTheme();
const { mode } = useTheme();
const { data: link, isLoading, error } = useQuery({
queryKey: ['external-link', id],
@@ -35,7 +35,7 @@ export function ExternalLinkPage() {
<iframe
src={link.url}
className="h-full w-full border-0"
style={{ colorScheme: theme }}
style={{ colorScheme: mode }}
title={link.name}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
/>
+3 -3
View File
@@ -43,9 +43,9 @@ import { AMSHistoryModal } from '../components/AMSHistoryModal';
// Nozzle side indicators (Bambu Lab style - square badge with L/R)
function NozzleBadge({ side }: { side: 'L' | 'R' }) {
const { theme } = useTheme();
// Light theme: #e7f5e9 (light green), Dark theme: #1a4d2e (dark green)
const bgColor = theme === 'dark' ? '#1a4d2e' : '#e7f5e9';
const { mode } = useTheme();
// Light mode: #e7f5e9 (light green), Dark mode: #1a4d2e (dark green)
const bgColor = mode === 'dark' ? '#1a4d2e' : '#e7f5e9';
return (
<span
className="inline-flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded"
+128 -3
View File
@@ -21,12 +21,21 @@ import { virtualPrinterApi } from '../api/client';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
import { availableLanguages } from '../i18n';
import { useToast } from '../contexts/ToastContext';
import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Palette } from 'lucide-react';
export function SettingsPage() {
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const { showToast, showPersistentToast, dismissToast } = useToast();
const {
mode,
darkStyle, darkBackground, darkAccent,
lightStyle, lightBackground, lightAccent,
setDarkStyle, setDarkBackground, setDarkAccent,
setLightStyle, setLightBackground, setLightAccent,
} = useTheme();
const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
const [showPlugModal, setShowPlugModal] = useState(false);
const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
@@ -586,6 +595,121 @@ export function SettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Palette className="w-5 h-5" />
Appearance
</h2>
</CardHeader>
<CardContent className="space-y-6">
{/* Dark Mode Settings */}
<div className={`space-y-3 p-4 rounded-lg border ${mode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
<h3 className="text-sm font-medium text-white flex items-center gap-2">
Dark Mode
{mode === 'dark' && <span className="text-xs text-bambu-green">(active)</span>}
</h3>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-bambu-gray mb-1">Background</label>
<select
value={darkBackground}
onChange={(e) => { setDarkBackground(e.target.value as DarkBackground); showToast('Settings saved', 'success'); }}
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="neutral">Neutral</option>
<option value="warm">Warm</option>
<option value="cool">Cool</option>
<option value="oled">OLED Black</option>
<option value="slate">Slate Blue</option>
<option value="forest">Forest Green</option>
</select>
</div>
<div>
<label className="block text-xs text-bambu-gray mb-1">Accent</label>
<select
value={darkAccent}
onChange={(e) => { setDarkAccent(e.target.value as ThemeAccent); showToast('Settings saved', 'success'); }}
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="green">Green</option>
<option value="teal">Teal</option>
<option value="blue">Blue</option>
<option value="orange">Orange</option>
<option value="purple">Purple</option>
<option value="red">Red</option>
</select>
</div>
<div>
<label className="block text-xs text-bambu-gray mb-1">Style</label>
<select
value={darkStyle}
onChange={(e) => { setDarkStyle(e.target.value as ThemeStyle); showToast('Settings saved', 'success'); }}
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="classic">Classic</option>
<option value="glow">Glow</option>
<option value="vibrant">Vibrant</option>
</select>
</div>
</div>
</div>
{/* Light Mode Settings */}
<div className={`space-y-3 p-4 rounded-lg border ${mode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
<h3 className="text-sm font-medium text-white flex items-center gap-2">
Light Mode
{mode === 'light' && <span className="text-xs text-bambu-green">(active)</span>}
</h3>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-bambu-gray mb-1">Background</label>
<select
value={lightBackground}
onChange={(e) => { setLightBackground(e.target.value as LightBackground); showToast('Settings saved', 'success'); }}
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="neutral">Neutral</option>
<option value="warm">Warm</option>
<option value="cool">Cool</option>
</select>
</div>
<div>
<label className="block text-xs text-bambu-gray mb-1">Accent</label>
<select
value={lightAccent}
onChange={(e) => { setLightAccent(e.target.value as ThemeAccent); showToast('Settings saved', 'success'); }}
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="green">Green</option>
<option value="teal">Teal</option>
<option value="blue">Blue</option>
<option value="orange">Orange</option>
<option value="purple">Purple</option>
<option value="red">Red</option>
</select>
</div>
<div>
<label className="block text-xs text-bambu-gray mb-1">Style</label>
<select
value={lightStyle}
onChange={(e) => { setLightStyle(e.target.value as ThemeStyle); showToast('Settings saved', 'success'); }}
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="classic">Classic</option>
<option value="glow">Glow</option>
<option value="vibrant">Vibrant</option>
</select>
</div>
</div>
</div>
<p className="text-xs text-bambu-gray">
Toggle between dark and light mode using the sun/moon icon in the sidebar.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">Archive Settings</h2>
@@ -658,6 +782,10 @@ export function SettingsPage() {
</CardContent>
</Card>
</div>
{/* Second Column - Cost, AMS & Spoolman */}
<div className="space-y-6 flex-1 lg:max-w-md">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
@@ -730,10 +858,7 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</div>
{/* Second Column - AMS & Spoolman */}
<div className="space-y-6 flex-1 lg:max-w-md">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CtglbIix.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BuWV4aNb.css">
<script type="module" crossorigin src="/assets/index-VFINsn3X.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BmBv9fEm.css">
</head>
<body>
<div id="root"></div>