diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a01c3ae5..bc4b4233b 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index a169acd6d..d66ecb126 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ -**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 --- diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index a1e167162..295ab61e9 100644 --- a/backend/app/schemas/settings.py +++ b/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 diff --git a/backend/tests/integration/test_settings_api.py b/backend/tests/integration/test_settings_api.py index 51a692dd7..5e308ebff 100644 --- a/backend/tests/integration/test_settings_api.py +++ b/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/") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index eb03271ac..8bcdc3484 100644 --- a/frontend/src/api/client.ts +++ b/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; diff --git a/frontend/src/components/AMSHistoryModal.tsx b/frontend/src/components/AMSHistoryModal.tsx index 576def789..6fabab41a 100644 --- a/frontend/src/components/AMSHistoryModal.tsx +++ b/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('24h'); const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode); - const isDark = theme === 'dark'; + const isDark = themeMode === 'dark'; // Close on Escape key useEffect(() => { diff --git a/frontend/src/components/AddExternalLinkModal.tsx b/frontend/src/components/AddExternalLinkModal.tsx index 086508497..fa9417db1 100644 --- a/frontend/src/components/AddExternalLinkModal.tsx +++ b/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(null); @@ -166,7 +166,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
{useCustomIcon && customIconPreview ? ( - + ) : ( )} @@ -233,7 +233,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp /> {useCustomIcon && customIconPreview ? (
- Custom icon + Custom icon Bambuddy @@ -326,7 +326,7 @@ export function Layout() { {/* Logo */}
Bambuddy @@ -379,7 +379,7 @@ export function Layout() { ) : ( LinkIcon && @@ -500,11 +500,11 @@ export function Layout() {
{/* Bottom row: version */} @@ -577,11 +577,11 @@ export function Layout() {
)} diff --git a/frontend/src/components/ThemeContext.tsx b/frontend/src/components/ThemeContext.tsx deleted file mode 100644 index a635552e8..000000000 --- a/frontend/src/components/ThemeContext.tsx +++ /dev/null @@ -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(undefined); - -export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setThemeState] = useState(() => { - 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 ( - - {children} - - ); -} - -export function useTheme() { - const context = useContext(ThemeContext); - if (!context) { - throw new Error('useTheme must be used within a ThemeProvider'); - } - return context; -} diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx index a635552e8..a87236961 100644 --- a/frontend/src/contexts/ThemeContext.tsx +++ b/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(undefined); export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setThemeState] = useState(() => { - const stored = localStorage.getItem('theme') as Theme | null; - if (stored) return stored; - // Default to dark theme - return 'dark'; + // Mode + const [mode, setModeState] = useState(() => { + 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(() => { + return (localStorage.getItem('dark-style') as ThemeStyle) || 'classic'; + }); + const [darkBackground, setDarkBackgroundState] = useState(() => { + return (localStorage.getItem('dark-background') as DarkBackground) || 'neutral'; + }); + const [darkAccent, setDarkAccentState] = useState(() => { + return (localStorage.getItem('dark-accent') as ThemeAccent) || 'green'; + }); + + // Light mode settings + const [lightStyle, setLightStyleState] = useState(() => { + return (localStorage.getItem('light-style') as ThemeStyle) || 'classic'; + }); + const [lightBackground, setLightBackgroundState] = useState(() => { + return (localStorage.getItem('light-background') as LightBackground) || 'neutral'; + }); + const [lightAccent, setLightAccentState] = useState(() => { + 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 ( - + {children} ); @@ -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 }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 32a23ad67..f5847012d 100644 --- a/frontend/src/index.css +++ b/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); +} diff --git a/frontend/src/pages/ExternalLinkPage.tsx b/frontend/src/pages/ExternalLinkPage.tsx index 12c837b85..b67f7ac1f 100644 --- a/frontend/src/pages/ExternalLinkPage.tsx +++ b/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() {