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