diff --git a/CHANGELOG.md b/CHANGELOG.md index f8929826..1e039ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ### Added - **UX 3.5 default console shell:** native full-list sidebar + topbar (promoted from retired Beta 3.1), glass branding, Dark/Light/Custom themes in Settings → Branding, resizable sidebar, tablet/phone drawer. Login / RdClient / remote viewer unchanged. Docs: `docs/wiki/UX-3.5.md`. Ships via panel update. +### Fixed +- **UX 3.5 light theme only recolored the blue topbar:** `branding.css` kept emitting saved dark palette colors, and `ui-polish.css` was not loaded on the console layout — so body/sidebar text stayed light-on-dark. Light/Dark now resolve built-in palettes in `generateThemeCss()`, glass defaults follow the mode, and the topbar theme toggle persists the matching palette. + ### Changed - **Console chrome:** TeamViewer-style rail/flyout and Desktop Mode are no longer loaded as the management UI; UX 3.5 is the single console interface on `dev`. diff --git a/docs/wiki/UX-3.5.md b/docs/wiki/UX-3.5.md index 40208b6d..daced518 100644 --- a/docs/wiki/UX-3.5.md +++ b/docs/wiki/UX-3.5.md @@ -35,13 +35,13 @@ Branding `themeMode` (Settings → Branding → Colors): | Mode | Meaning | |------|---------| -| `dark` | Built-in dark palette | -| `light` | Built-in light palette | -| `custom` | Operator-edited colors (auto-selected when a color picker changes) | +| `dark` | Built-in dark palette (always used for CSS, even if DB still has old custom hex values) | +| `light` | Built-in light palette + blue topbar; body/sidebar text use dark ink on light surfaces | +| `custom` | Operator-edited colors from branding form | -CSS: `data-theme` / `data-theme-mode` on ``, colors and glass from `/css/branding.css` (`brandingService.generateThemeCss()`). +`/css/branding.css` is generated by `resolveThemeColors()` + `generateThemeCss()`. The topbar theme button persists `themeMode` **and** the matching built-in palette so a reload does not snap back to dark text on a “light” shell. -Glass/blur tokens (`--surface-glass-*`) map onto topbar, sidebar, and cards. +Glass: when `glassColor` is empty, light mode defaults to `#ffffff` and dark to `#161b22`. ## Breakpoints diff --git a/web-nodejs/public/css/ux35-tokens.css b/web-nodejs/public/css/ux35-tokens.css index 87dd7929..01a2313f 100644 --- a/web-nodejs/public/css/ux35-tokens.css +++ b/web-nodejs/public/css/ux35-tokens.css @@ -1,6 +1,7 @@ /** * BetterDesk Console — UX 3.5 Design Tokens * Spacing scale, chrome dimensions, glass surfaces for the default shell. + * Theme colors come from branding.css (resolveThemeColors); tokens here are fallbacks. */ :root { @@ -12,13 +13,11 @@ --ux35-space-5: 24px; --ux35-space-6: 32px; - /* Content padding — pages must use these, not ad-hoc values */ --ux35-content-padding: var(--ux35-space-4); --ux35-content-padding-lg: var(--ux35-space-5); --ux35-card-gap: var(--ux35-space-4); --ux35-section-gap: var(--ux35-space-5); - /* Chrome */ --ux35-topbar-height: 48px; --ux35-sidebar-width: 220px; --ux35-sidebar-width-min: 200px; @@ -28,7 +27,6 @@ --ux35-motion: 150ms; --ux35-motion-fast: 120ms; - /* Semantic aliases → existing branding / glass tokens */ --ux35-bg: var(--bg-primary); --ux35-topbar-bg: var(--surface-glass-bg-elevated, var(--bg-secondary)); --ux35-sidebar-bg: var(--surface-glass-bg-secondary, var(--bg-secondary)); @@ -40,26 +38,35 @@ --ux35-hover: var(--bg-hover); --ux35-active-bg: var(--accent-blue-muted); --ux35-primary: var(--accent-blue); - --ux35-topbar-fg: #fff; - --ux35-topbar-fg-muted: rgba(255, 255, 255, 0.75); + --ux35-topbar-fg: var(--text-primary); + --ux35-topbar-fg-muted: var(--text-secondary); --ux35-glass-blur: var(--surface-glass-blur, 16px); --ux35-glass-saturate: var(--surface-glass-saturate, 1.2); } +/* Light: ISP-style blue topbar + light shell; body/sidebar text from branding palette */ [data-theme="light"] { - --ux35-topbar-bg: var(--accent-blue, #2b579a); - --ux35-sidebar-bg: var(--surface-glass-bg-secondary, rgba(255, 255, 255, 0.82)); - --ux35-card-bg: var(--card-bg, rgba(255, 255, 255, 0.9)); + --ux35-topbar-bg: var(--accent-blue, #0969da); + --ux35-topbar-fg: #ffffff; + --ux35-topbar-fg-muted: rgba(255, 255, 255, 0.78); + --ux35-sidebar-bg: var(--surface-glass-bg-secondary, var(--bg-secondary, #ffffff)); + --ux35-card-bg: var(--card-bg, var(--bg-secondary, #ffffff)); --ux35-bg: var(--bg-primary, #f0f2f5); - --ux35-border: var(--border-primary, #dde1e6); - --ux35-border-light: var(--border-secondary, #eef0f3); + --ux35-border: var(--border-primary, #d0d7de); + --ux35-border-light: var(--border-secondary, #eaeef2); --ux35-text: var(--text-primary, #1f2328); --ux35-muted: var(--text-secondary, #656d76); - --ux35-hover: rgba(0, 0, 0, 0.04); - --ux35-active-bg: var(--accent-blue-muted, #e8eef7); + --ux35-hover: var(--bg-hover, rgba(0, 0, 0, 0.04)); + --ux35-active-bg: var(--accent-blue-muted, rgba(9, 105, 218, 0.1)); --ux35-primary: var(--accent-blue, #0969da); } +[data-theme="dark"] { + --ux35-topbar-bg: var(--surface-glass-bg-elevated, var(--bg-secondary, #161b22)); + --ux35-topbar-fg: var(--text-primary, #e6edf3); + --ux35-topbar-fg-muted: var(--text-secondary, #8b949e); +} + @media (prefers-reduced-motion: reduce) { :root { --ux35-motion: 0ms; diff --git a/web-nodejs/public/css/ux35.css b/web-nodejs/public/css/ux35.css index 9b24b3f6..69adc54b 100644 --- a/web-nodejs/public/css/ux35.css +++ b/web-nodejs/public/css/ux35.css @@ -6,8 +6,8 @@ /* ── App shell ── */ body.app-page.ux35-page { margin: 0; - background: var(--ux35-bg); - color: var(--ux35-text); + background: var(--ux35-bg, var(--bg-primary)); + color: var(--ux35-text, var(--text-primary)); font-family: var(--font-family); } @@ -17,6 +17,8 @@ body.app-page.ux35-page { height: 100vh; height: 100dvh; overflow: hidden; + background: var(--ux35-bg, var(--bg-primary)); + color: var(--ux35-text, var(--text-primary)); } .ux35-shell.embed-mode { @@ -66,7 +68,7 @@ body.app-page.ux35-page { border-radius: var(--ux35-radius-sm); cursor: pointer; } -.ux35-topbar-menu-btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } +.ux35-topbar-menu-btn:hover { background: rgba(255, 255, 255, 0.12); color: var(--ux35-topbar-fg); } .ux35-topbar-menu-btn .material-icons { font-size: 22px; } .ux35-topbar-breadcrumb { @@ -82,8 +84,8 @@ body.app-page.ux35-page { color: var(--ux35-topbar-fg-muted); text-decoration: none; } -.ux35-topbar-breadcrumb a:hover { color: #fff; } -.ux35-topbar-breadcrumb .current { color: #fff; font-weight: 600; } +.ux35-topbar-breadcrumb a:hover { color: var(--ux35-topbar-fg); } +.ux35-topbar-breadcrumb .current { color: var(--ux35-topbar-fg); font-weight: 600; } .ux35-topbar-breadcrumb .separator { opacity: 0.5; } .ux35-topbar-spacer { flex: 1; } @@ -106,9 +108,15 @@ body.app-page.ux35-page { position: relative; transition: background var(--ux35-motion-fast), color var(--ux35-motion-fast); } -.ux35-topbar-btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } +.ux35-topbar-btn:hover { background: rgba(127, 127, 127, 0.18); color: var(--ux35-topbar-fg); } .ux35-topbar-btn .material-icons { font-size: 20px; } +[data-theme="light"] .ux35-topbar-btn:hover, +[data-theme="light"] .ux35-topbar-menu-btn:hover { + background: rgba(255, 255, 255, 0.16); + color: #fff; +} + .ux35-topbar .notif-badge { position: absolute; top: 2px; @@ -320,7 +328,8 @@ body.app-page.ux35-page { display: flex; flex-direction: column; overflow: hidden; - background: var(--ux35-bg); + background: var(--ux35-bg, var(--bg-primary)); + color: var(--ux35-text, var(--text-primary)); } .ux35-content.main-content { @@ -440,8 +449,34 @@ body.embed-mode .ux35-content { padding: var(--ux35-content-padding); } } } -/* Hide legacy rail layout leftovers if any */ -.app-page.ux35-page .app-layout > .sidebar, -.app-page.ux35-page .main-wrapper > .navbar { - display: none; +/* Topbar dropdowns inherit shell text; force readable panels in both themes */ +.ux35-topbar .lang-dropdown, +.ux35-topbar .notif-dropdown { + background: var(--bg-elevated, var(--bg-secondary)); + color: var(--text-primary); + border: 1px solid var(--border-primary); +} +.ux35-topbar .lang-option { + color: var(--text-primary); +} +.ux35-topbar .lang-option:hover { + background: var(--bg-hover); +} + +/* Page body text follows theme tokens */ +.ux35-content, +.ux35-content h1, +.ux35-content h2, +.ux35-content h3, +.ux35-content p, +.ux35-content .page-title, +.ux35-content .dashboard-header, +.ux35-content .devices-title h1 { + color: var(--text-primary); +} +.ux35-content .text-muted, +.ux35-content .page-subtitle, +.ux35-content .dashboard-header p, +.ux35-sidebar-heading { + color: var(--text-secondary); } diff --git a/web-nodejs/public/js/settings.js b/web-nodejs/public/js/settings.js index 5b1b3e52..af349372 100644 --- a/web-nodejs/public/js/settings.js +++ b/web-nodejs/public/js/settings.js @@ -2169,12 +2169,16 @@ if (!radio.checked) return; if (radio.value === 'light') { applyBuiltInPalette({ - bgPrimary: '#ffffff', bgSecondary: '#f6f8fa', bgTertiary: '#eaeef2', bgElevated: '#ffffff', + bgPrimary: '#f0f2f5', bgSecondary: '#ffffff', bgTertiary: '#eaeef2', bgElevated: '#ffffff', textPrimary: '#1f2328', textSecondary: '#656d76', accentBlue: '#0969da', accentBlueHover: '#0550ae', accentGreen: '#1a7f37', accentRed: '#cf222e', accentYellow: '#9a6700', accentPurple: '#8250df', borderPrimary: '#d0d7de', borderSecondary: '#eaeef2' }); + const glassColor = document.getElementById('glass-color'); + const glassPicker = document.getElementById('glass-color-picker'); + if (glassColor) glassColor.value = '#ffffff'; + if (glassPicker) glassPicker.value = '#ffffff'; } else if (radio.value === 'dark') { applyBuiltInPalette({ bgPrimary: '#0d1117', bgSecondary: '#161b22', bgTertiary: '#21262d', bgElevated: '#30363d', @@ -2183,6 +2187,10 @@ accentGreen: '#2ea44f', accentRed: '#f85149', accentYellow: '#d29922', accentPurple: '#a371f7', borderPrimary: '#30363d', borderSecondary: '#21262d' }); + const glassColor = document.getElementById('glass-color'); + const glassPicker = document.getElementById('glass-color-picker'); + if (glassColor) glassColor.value = '#161b22'; + if (glassPicker) glassPicker.value = '#161b22'; } onBrandingFieldChange(); }); diff --git a/web-nodejs/public/js/ux35-shell.js b/web-nodejs/public/js/ux35-shell.js index a81fd677..ab05ae40 100644 --- a/web-nodejs/public/js/ux35-shell.js +++ b/web-nodejs/public/js/ux35-shell.js @@ -64,11 +64,75 @@ } } + var THEME_PALETTES = { + dark: { + bgPrimary: '#0d1117', bgSecondary: '#161b22', bgTertiary: '#21262d', bgElevated: '#30363d', + textPrimary: '#e6edf3', textSecondary: '#8b949e', + accentBlue: '#58a6ff', accentBlueHover: '#79c0ff', accentBlueMuted: '#58a6ff', + accentGreen: '#2ea44f', accentGreenHover: '#3fb950', accentGreenMuted: '#2ea44f', + accentRed: '#f85149', accentRedHover: '#ff6b6b', accentRedMuted: '#f85149', + accentYellow: '#d29922', accentYellowHover: '#e3b341', accentYellowMuted: '#d29922', + accentPurple: '#a371f7', accentPurpleHover: '#bc8cff', accentPurpleMuted: '#a371f7', + borderPrimary: '#30363d', borderSecondary: '#21262d' + }, + light: { + bgPrimary: '#f0f2f5', bgSecondary: '#ffffff', bgTertiary: '#eaeef2', bgElevated: '#ffffff', + textPrimary: '#1f2328', textSecondary: '#656d76', + accentBlue: '#0969da', accentBlueHover: '#0550ae', accentBlueMuted: '#0969da', + accentGreen: '#1a7f37', accentGreenHover: '#116329', accentGreenMuted: '#1a7f37', + accentRed: '#cf222e', accentRedHover: '#a40e26', accentRedMuted: '#cf222e', + accentYellow: '#9a6700', accentYellowHover: '#7d4e00', accentYellowMuted: '#9a6700', + accentPurple: '#8250df', accentPurpleHover: '#6639ba', accentPurpleMuted: '#8250df', + borderPrimary: '#d0d7de', borderSecondary: '#eaeef2' + } + }; + + function applyThemeLocally(next) { + document.documentElement.setAttribute('data-theme', next); + document.documentElement.setAttribute('data-theme-mode', next); + var palette = THEME_PALETTES[next] || THEME_PALETTES.dark; + var root = document.documentElement.style; + var map = { + bgPrimary: '--bg-primary', bgSecondary: '--bg-secondary', bgTertiary: '--bg-tertiary', + bgElevated: '--bg-elevated', textPrimary: '--text-primary', textSecondary: '--text-secondary', + accentBlue: '--accent-blue', accentBlueHover: '--accent-blue-hover', accentBlueMuted: '--accent-blue-muted', + accentGreen: '--accent-green', accentGreenHover: '--accent-green-hover', accentGreenMuted: '--accent-green-muted', + accentRed: '--accent-red', accentRedHover: '--accent-red-hover', accentRedMuted: '--accent-red-muted', + accentYellow: '--accent-yellow', accentYellowHover: '--accent-yellow-hover', accentYellowMuted: '--accent-yellow-muted', + accentPurple: '--accent-purple', accentPurpleHover: '--accent-purple-hover', accentPurpleMuted: '--accent-purple-muted', + borderPrimary: '--border-primary', borderSecondary: '--border-secondary' + }; + Object.keys(palette).forEach(function (key) { + if (map[key]) root.setProperty(map[key], palette[key]); + }); + // Soft glass for light/dark until branding.css reloads + if (next === 'light') { + root.setProperty('--surface-glass-bg-secondary', 'rgba(255,255,255,0.82)'); + root.setProperty('--surface-glass-bg-elevated', 'rgba(255,255,255,0.9)'); + root.setProperty('--surface-glass-border', 'rgba(208,215,222,0.9)'); + root.setProperty('--card-bg', 'rgba(255,255,255,0.92)'); + root.setProperty('--ux35-topbar-bg', palette.accentBlue); + root.setProperty('--ux35-topbar-fg', '#ffffff'); + root.setProperty('--ux35-topbar-fg-muted', 'rgba(255,255,255,0.78)'); + } else { + root.setProperty('--surface-glass-bg-secondary', 'rgba(22,27,34,0.55)'); + root.setProperty('--surface-glass-bg-elevated', 'rgba(48,54,61,0.7)'); + root.setProperty('--surface-glass-border', 'rgba(48,54,61,0.5)'); + root.setProperty('--card-bg', 'rgba(22,27,34,0.55)'); + root.removeProperty('--ux35-topbar-bg'); + root.removeProperty('--ux35-topbar-fg'); + root.removeProperty('--ux35-topbar-fg-muted'); + } + root.setProperty('--ux35-bg', palette.bgPrimary); + root.setProperty('--ux35-text', palette.textPrimary); + root.setProperty('--ux35-muted', palette.textSecondary); + root.setProperty('--ux35-primary', palette.accentBlue); + syncThemeIcon(); + } + /** - * Cycle visual preview: dark ↔ light. - * Server-side themeMode (Settings → Branding) is the source of truth; - * this only toggles local data-theme for immediate feedback when mode is dark/light. - * Custom mode opens Settings branding. + * Cycle visual theme: dark ↔ light. + * Persists themeMode + built-in palette so branding.css matches data-theme. */ function cycleThemePreview() { var mode = document.documentElement.getAttribute('data-theme-mode') || 'dark'; @@ -77,9 +141,8 @@ return; } var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'; - document.documentElement.setAttribute('data-theme', next); - syncThemeIcon(); - // Persist via branding API when operator has permission (best-effort) + applyThemeLocally(next); + if (window.BetterDesk && window.BetterDesk.csrfToken) { fetch('/api/settings/branding', { method: 'POST', @@ -88,16 +151,22 @@ 'X-CSRF-Token': window.BetterDesk.csrfToken }, credentials: 'same-origin', - body: JSON.stringify({ themeMode: next }) + body: JSON.stringify({ + themeMode: next, + colors: THEME_PALETTES[next], + glassColor: next === 'light' ? '#ffffff' : '#161b22' + }) }).then(function (res) { if (res.ok) { - document.documentElement.setAttribute('data-theme-mode', next); var link = document.querySelector('link[href*="branding.css"]'); if (link) { var url = new URL(link.href, window.location.origin); url.searchParams.set('v', String(Date.now())); link.href = url.pathname + url.search; } + if (window.BetterDesk.branding) { + window.BetterDesk.branding.themeMode = next; + } } }).catch(function () { /* ignore */ }); } diff --git a/web-nodejs/services/brandingService.js b/web-nodejs/services/brandingService.js index 85d46420..2c424918 100644 --- a/web-nodejs/services/brandingService.js +++ b/web-nodejs/services/brandingService.js @@ -409,6 +409,84 @@ const DEFAULT_BRANDING = { rdclientBgSize: 'cover' }; +/** Built-in palettes for themeMode light/dark (custom uses branding.colors). */ +const BUILTIN_THEME_PALETTES = { + dark: { + bgPrimary: '#0d1117', + bgSecondary: '#161b22', + bgTertiary: '#21262d', + bgElevated: '#30363d', + textPrimary: '#e6edf3', + textSecondary: '#8b949e', + accentBlue: '#58a6ff', + accentBlueHover: '#79c0ff', + accentBlueMuted: '#58a6ff', + accentGreen: '#2ea44f', + accentGreenHover: '#3fb950', + accentGreenMuted: '#2ea44f', + accentRed: '#f85149', + accentRedHover: '#ff6b6b', + accentRedMuted: '#f85149', + accentYellow: '#d29922', + accentYellowHover: '#e3b341', + accentYellowMuted: '#d29922', + accentPurple: '#a371f7', + accentPurpleHover: '#bc8cff', + accentPurpleMuted: '#a371f7', + borderPrimary: '#30363d', + borderSecondary: '#21262d' + }, + light: { + bgPrimary: '#f0f2f5', + bgSecondary: '#ffffff', + bgTertiary: '#eaeef2', + bgElevated: '#ffffff', + textPrimary: '#1f2328', + textSecondary: '#656d76', + accentBlue: '#0969da', + accentBlueHover: '#0550ae', + accentBlueMuted: '#0969da', + accentGreen: '#1a7f37', + accentGreenHover: '#116329', + accentGreenMuted: '#1a7f37', + accentRed: '#cf222e', + accentRedHover: '#a40e26', + accentRedMuted: '#cf222e', + accentYellow: '#9a6700', + accentYellowHover: '#7d4e00', + accentYellowMuted: '#9a6700', + accentPurple: '#8250df', + accentPurpleHover: '#6639ba', + accentPurpleMuted: '#8250df', + borderPrimary: '#d0d7de', + borderSecondary: '#eaeef2' + } +}; + +function normalizeThemeMode(mode) { + let m = String(mode || 'dark'); + if (m === 'auto') m = 'dark'; + if (!['dark', 'light', 'custom'].includes(m)) m = 'dark'; + return m; +} + +/** + * Resolve effective color map for CSS generation. + * light/dark ignore stale DB custom colors so themeMode always matches the UI. + */ +function resolveThemeColors(branding) { + const mode = normalizeThemeMode(branding && branding.themeMode); + if (mode === 'light' || mode === 'dark') { + return { ...BUILTIN_THEME_PALETTES[mode] }; + } + const stored = (branding && branding.colors) || {}; + const merged = { ...BUILTIN_THEME_PALETTES.dark }; + for (const [key, value] of Object.entries(stored)) { + if (value && String(value).trim()) merged[key] = String(value).trim(); + } + return merged; +} + // CSS variable name mapping const COLOR_TO_CSS_VAR = { bgPrimary: '--bg-primary', @@ -580,10 +658,7 @@ async function saveBranding(updates) { } else if (key === 'bgSize' || key === 'rdclientBgSize') { entries.push({ key, value: normalizeBackgroundSize(value) }); } else if (key === 'themeMode') { - let mode = String(value); - if (mode === 'auto') mode = 'dark'; - if (!['dark', 'light', 'custom'].includes(mode)) mode = 'dark'; - entries.push({ key, value: mode }); + entries.push({ key, value: normalizeThemeMode(value) }); } else if (key === 'customCss') { // Security: Neutralize CSS-based XSS / external resource loading. entries.push({ key, value: sanitizeCustomCss(value) }); @@ -735,17 +810,30 @@ function generateSemanticAliasCss(branding) { ' --color-text-muted: var(--text-secondary);', ' --color-border: var(--border-primary);', ' --focus-ring-color: var(--accent-blue-muted);', - ` color-scheme: ${(branding.themeMode || 'dark') === 'light' ? 'light' : 'dark'};`, + ` color-scheme: ${normalizeThemeMode(branding.themeMode) === 'light' ? 'light' : 'dark'};`, ' /* UX 3.5 chrome aliases (branding-aware) */', ' --ux35-bg: var(--bg-primary);', + ' --ux35-sidebar-bg: var(--surface-glass-bg-secondary, var(--bg-secondary));', ' --ux35-card-bg: var(--card-bg, var(--surface-glass-bg-secondary, var(--bg-secondary)));', ' --ux35-border: var(--surface-glass-border, var(--border-primary));', + ' --ux35-border-light: var(--border-secondary);', ' --ux35-text: var(--text-primary);', ' --ux35-muted: var(--text-secondary);', + ' --ux35-hover: var(--bg-hover);', ' --ux35-primary: var(--accent-blue);', ' --ux35-active-bg: var(--accent-blue-muted);', ' --ux35-glass-blur: var(--surface-glass-blur, 16px);' ]; + const mode = normalizeThemeMode(branding.themeMode); + if (mode === 'light') { + lines.push(' --ux35-topbar-bg: var(--accent-blue);'); + lines.push(' --ux35-topbar-fg: #ffffff;'); + lines.push(' --ux35-topbar-fg-muted: rgba(255, 255, 255, 0.78);'); + } else { + lines.push(' --ux35-topbar-bg: var(--surface-glass-bg-elevated, var(--bg-secondary));'); + lines.push(' --ux35-topbar-fg: var(--text-primary);'); + lines.push(' --ux35-topbar-fg-muted: var(--text-secondary);'); + } return `:root {\n${lines.join('\n')}\n}\n`; } @@ -755,14 +843,16 @@ function generateSemanticAliasCss(branding) { */ function generateThemeCss() { const branding = getBranding(); + const themeMode = normalizeThemeMode(branding.themeMode); + const colors = resolveThemeColors(branding); const overrides = []; - + for (const [key, cssVar] of Object.entries(COLOR_TO_CSS_VAR)) { - const value = branding.colors[key]; - if (value && value.trim()) { + const value = colors[key]; + if (value && String(value).trim()) { // For muted colors, auto-generate rgba if a hex color is provided - if (key.endsWith('Muted') && value.startsWith('#')) { - const hex = value.replace('#', ''); + if (key.endsWith('Muted') && String(value).startsWith('#')) { + const hex = String(value).replace('#', ''); const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); @@ -772,26 +862,29 @@ function generateThemeCss() { } } } - + let css = ''; - + // Font CSS (imports + heading/body font variables) const fontCss = fontService.generateFontCss(branding.fontHeading, branding.fontBody); if (fontCss) { css += fontCss + '\n'; } - - // Color overrides + + // Color overrides — always emit effective palette for the active themeMode if (overrides.length > 0) { css += `:root {\n${overrides.join('\n')}\n}\n`; } + // Keep data-theme attribute in sync for component selectors (ui-polish / theme.css) + css += `html { color-scheme: ${themeMode === 'light' ? 'light' : 'dark'}; }\n`; + // Semantic aliases consumed by newer UI and legacy components that use // --color-* names instead of the original BetterDesk token names. - css += generateSemanticAliasCss(branding); + css += generateSemanticAliasCss({ ...branding, themeMode, colors }); - // Glass surface tokens - css += generateGlassCss(branding); + // Glass surface tokens (light mode uses light glass base when unset) + css += generateGlassCss({ ...branding, themeMode, colors }); // Background wallpaper (console + login) and custom CSS css += generateBackgroundCss(branding); @@ -846,9 +939,14 @@ function generateGlassCss(branding) { const blur = clampNumber(branding.glassBlur, 0, 40) ?? 16; const opacity = (clampNumber(branding.glassOpacity, 0, 100) ?? 55) / 100; let color = (branding.glassColor || '').trim(); + const mode = normalizeThemeMode(branding.themeMode); if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) { - const fallback = (branding.colors && branding.colors.bgSecondary) || ''; - color = /^#[0-9a-fA-F]{6}$/.test(fallback) ? fallback : '#161b22'; + const fromColors = branding.colors && branding.colors.bgSecondary; + if (/^#[0-9a-fA-F]{6}$/.test(fromColors || '')) { + color = fromColors; + } else { + color = mode === 'light' ? '#ffffff' : '#161b22'; + } } const rgb = hexToRgb(color); if (!rgb) return ''; @@ -1188,6 +1286,9 @@ function invalidateCache() { module.exports = { DEFAULT_BRANDING, COLOR_TO_CSS_VAR, + BUILTIN_THEME_PALETTES, + normalizeThemeMode, + resolveThemeColors, loadBranding, getBranding, saveBranding, diff --git a/web-nodejs/tests/theme-palette.test.js b/web-nodejs/tests/theme-palette.test.js new file mode 100644 index 00000000..095b08be --- /dev/null +++ b/web-nodejs/tests/theme-palette.test.js @@ -0,0 +1,46 @@ +/** + * Theme palette resolution for UX 3.5 light/dark/custom + */ +const { + resolveThemeColors, + normalizeThemeMode, + BUILTIN_THEME_PALETTES +} = require('../services/brandingService'); + +describe('UX 3.5 theme palettes', () => { + test('normalizeThemeMode maps auto → dark and rejects junk', () => { + expect(normalizeThemeMode('auto')).toBe('dark'); + expect(normalizeThemeMode('light')).toBe('light'); + expect(normalizeThemeMode('custom')).toBe('custom'); + expect(normalizeThemeMode('nope')).toBe('dark'); + }); + + test('light mode ignores stale dark DB colors', () => { + const colors = resolveThemeColors({ + themeMode: 'light', + colors: { + bgPrimary: '#0d1117', + textPrimary: '#e6edf3' + } + }); + expect(colors.bgPrimary).toBe(BUILTIN_THEME_PALETTES.light.bgPrimary); + expect(colors.textPrimary).toBe(BUILTIN_THEME_PALETTES.light.textPrimary); + expect(colors.accentBlue).toBe('#0969da'); + }); + + test('dark mode uses built-in dark palette', () => { + const colors = resolveThemeColors({ themeMode: 'dark', colors: {} }); + expect(colors.bgPrimary).toBe('#0d1117'); + expect(colors.textPrimary).toBe('#e6edf3'); + }); + + test('custom mode keeps operator colors over dark defaults', () => { + const colors = resolveThemeColors({ + themeMode: 'custom', + colors: { bgPrimary: '#112233', textPrimary: '#abcdef' } + }); + expect(colors.bgPrimary).toBe('#112233'); + expect(colors.textPrimary).toBe('#abcdef'); + expect(colors.bgSecondary).toBe(BUILTIN_THEME_PALETTES.dark.bgSecondary); + }); +}); diff --git a/web-nodejs/views/layouts/main.ejs b/web-nodejs/views/layouts/main.ejs index 07fc1fbe..d773b573 100644 --- a/web-nodejs/views/layouts/main.ejs +++ b/web-nodejs/views/layouts/main.ejs @@ -11,6 +11,7 @@ +