feat: wire up dark mode selector with a real dark palette
theme.mode wrote data-theme-mode to the DOM but no CSS ever reacted to it, and mapThemeConfigToCssVariables() never looked at mode at all - selecting Dark had zero visible effect. Added dark-mode neutral overrides (background/text/border axis only - brand colors stay as configured) using the palette provided by the user (colorhunt.co/palette/091413285a48408a71b0e4cc): --bg-primary: #091413 --bg-secondary: #285a48 --text-primary: #b0e4cc --text-secondary: #408a71 --border-color: #285a48 ThemeEngineService now resolves 'system' mode via prefers-color-scheme and re-renders live on OS theme changes, and sets data-theme-mode to the *effective* resolved mode instead of the raw setting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,21 @@ import { ThemeConfig } from '../../shared/models/config';
|
|||||||
import { ThemeCssVariables } from '../tokens/theme-css-variable.model';
|
import { ThemeCssVariables } from '../tokens/theme-css-variable.model';
|
||||||
import { THEME_VARIABLE_MAP } from '../tokens/theme-variable-map';
|
import { THEME_VARIABLE_MAP } from '../tokens/theme-variable-map';
|
||||||
|
|
||||||
export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariables {
|
/**
|
||||||
|
* Dark-mode neutral/surface overrides. Brand colors (primary/secondary/
|
||||||
|
* accent/success/warning/danger/info) are intentionally left as configured -
|
||||||
|
* only the background/text/border axis flips for dark mode, same as most
|
||||||
|
* dark-theme implementations.
|
||||||
|
*/
|
||||||
|
const DARK_MODE_OVERRIDES: ThemeCssVariables = {
|
||||||
|
[THEME_VARIABLE_MAP.backgroundPrimary]: '#091413',
|
||||||
|
[THEME_VARIABLE_MAP.backgroundSecondary]: '#285a48',
|
||||||
|
[THEME_VARIABLE_MAP.textPrimary]: '#b0e4cc',
|
||||||
|
[THEME_VARIABLE_MAP.textSecondary]: '#408a71',
|
||||||
|
[THEME_VARIABLE_MAP.border]: '#285a48',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapThemeConfigToCssVariables(theme: ThemeConfig, effectiveMode: 'light' | 'dark' = 'light'): ThemeCssVariables {
|
||||||
const spacingScale = Array.isArray(theme.spacing.scale) && theme.spacing.scale.length > 0
|
const spacingScale = Array.isArray(theme.spacing.scale) && theme.spacing.scale.length > 0
|
||||||
? theme.spacing.scale
|
? theme.spacing.scale
|
||||||
: [0.25, 0.5, 1, 1.5, 2];
|
: [0.25, 0.5, 1, 1.5, 2];
|
||||||
@@ -49,5 +63,5 @@ export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariab
|
|||||||
vars[`--shadow-${key}`] = value;
|
vars[`--shadow-${key}`] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return vars;
|
return effectiveMode === 'dark' ? { ...vars, ...DARK_MODE_OVERRIDES } : vars;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export class ThemeEngineService {
|
|||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly configService = inject(ConfigService);
|
private readonly configService = inject(ConfigService);
|
||||||
|
|
||||||
|
private systemDarkQuery?: MediaQueryList;
|
||||||
|
private systemDarkListener?: (event: MediaQueryListEvent) => void;
|
||||||
|
|
||||||
initialize(): void {
|
initialize(): void {
|
||||||
this.configService.loadBootstrap().pipe(take(1)).subscribe({
|
this.configService.loadBootstrap().pipe(take(1)).subscribe({
|
||||||
next: (bootstrap) => this.applyTheme(bootstrap.theme),
|
next: (bootstrap) => this.applyTheme(bootstrap.theme),
|
||||||
@@ -20,7 +23,20 @@ export class ThemeEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyTheme(theme: ThemeConfig): void {
|
applyTheme(theme: ThemeConfig): void {
|
||||||
const variables = mapThemeConfigToCssVariables(theme);
|
this.teardownSystemModeListener();
|
||||||
|
|
||||||
|
const effectiveMode = this.resolveEffectiveMode(theme.mode);
|
||||||
|
this.render(theme, effectiveMode);
|
||||||
|
|
||||||
|
if (theme.mode === 'system' && typeof window !== 'undefined' && window.matchMedia) {
|
||||||
|
this.systemDarkQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
this.systemDarkListener = () => this.render(theme, this.resolveEffectiveMode('system'));
|
||||||
|
this.systemDarkQuery.addEventListener('change', this.systemDarkListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(theme: ThemeConfig, effectiveMode: 'light' | 'dark'): void {
|
||||||
|
const variables = mapThemeConfigToCssVariables(theme, effectiveMode);
|
||||||
const root = this.document.documentElement;
|
const root = this.document.documentElement;
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(variables)) {
|
for (const [name, value] of Object.entries(variables)) {
|
||||||
@@ -28,7 +44,27 @@ export class ThemeEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
root.setAttribute('data-theme-id', theme.themeId);
|
root.setAttribute('data-theme-id', theme.themeId);
|
||||||
root.setAttribute('data-theme-mode', theme.mode);
|
root.setAttribute('data-theme-mode', effectiveMode);
|
||||||
root.setAttribute('data-icon-set', theme.iconSet);
|
root.setAttribute('data-icon-set', theme.iconSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveEffectiveMode(mode: ThemeConfig['mode']): 'light' | 'dark' {
|
||||||
|
if (mode === 'dark') {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
if (mode === 'light') {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardownSystemModeListener(): void {
|
||||||
|
if (this.systemDarkQuery && this.systemDarkListener) {
|
||||||
|
this.systemDarkQuery.removeEventListener('change', this.systemDarkListener);
|
||||||
|
}
|
||||||
|
this.systemDarkQuery = undefined;
|
||||||
|
this.systemDarkListener = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user