Bläddra i källkod

* 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
maziggy 4 månader sedan
förälder
incheckning
bca1a80172

+ 17 - 0
CHANGELOG.md

@@ -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
README.md

@@ -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 - 0
backend/app/schemas/settings.py

@@ -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
backend/tests/integration/test_settings_api.py

@@ -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 - 0
frontend/src/api/client.ts

@@ -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
frontend/src/components/AMSHistoryModal.tsx

@@ -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(() => {

+ 3 - 3
frontend/src/components/AddExternalLinkModal.tsx

@@ -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
frontend/src/components/Card.tsx

@@ -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
frontend/src/components/Layout.tsx

@@ -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>
           )}

+ 0 - 52
frontend/src/components/ThemeContext.tsx

@@ -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;
-}

+ 149 - 21
frontend/src/contexts/ThemeContext.tsx

@@ -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') {
+
+    // 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.remove('dark');
+      root.classList.add(`style-${lightStyle}`);
+      root.classList.add(`bg-${lightBackground}`);
+      root.classList.add(`accent-${lightAccent}`);
     }
-    localStorage.setItem('theme', theme);
-  }, [theme]);
 
-  const toggleTheme = () => {
-    setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
+    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
frontend/src/index.css

@@ -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
frontend/src/pages/ExternalLinkPage.tsx

@@ -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
frontend/src/pages/PrintersPage.tsx

@@ -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
frontend/src/pages/SettingsPage.tsx

@@ -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>

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/index-BmBv9fEm.css


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/index-BuWV4aNb.css


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/index-VFINsn3X.js


+ 2 - 2
static/index.html

@@ -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>

Vissa filer visades inte eftersom för många filer har ändrats