mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
fix(ux): resolve light theme issues and enhance theme management
- Fixed light theme to correctly apply the blue topbar and ensure text colors are consistent with the selected theme. - Updated theme management to persist the selected theme and its corresponding palette across sessions. - Enhanced documentation to clarify theme color mappings and behavior. - Introduced built-in theme palettes for light and dark modes to streamline theme application. - Improved CSS variable handling for better theme integration in the UI. This update addresses user feedback regarding theme inconsistencies and improves overall theme functionality.
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
+5
-5
@@ -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 `<html>`, 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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 */ });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<link rel="stylesheet" href="/css/main.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/theme.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/ui-polish.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/branding.css?v=<%= typeof brandingRevision !== 'undefined' ? brandingRevision : cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/ux35-tokens.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/ux35.css?v=<%= cacheVersion %>">
|
||||
|
||||
Reference in New Issue
Block a user