diff --git a/src/app/theme/mappers/theme-css-vars.mapper.ts b/src/app/theme/mappers/theme-css-vars.mapper.ts index 43a9e10..ba75c2d 100644 --- a/src/app/theme/mappers/theme-css-vars.mapper.ts +++ b/src/app/theme/mappers/theme-css-vars.mapper.ts @@ -2,7 +2,21 @@ import { ThemeConfig } from '../../shared/models/config'; import { ThemeCssVariables } from '../tokens/theme-css-variable.model'; 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 ? theme.spacing.scale : [0.25, 0.5, 1, 1.5, 2]; @@ -49,5 +63,5 @@ export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariab vars[`--shadow-${key}`] = value; } - return vars; + return effectiveMode === 'dark' ? { ...vars, ...DARK_MODE_OVERRIDES } : vars; } diff --git a/src/app/theme/runtime/theme-engine.service.ts b/src/app/theme/runtime/theme-engine.service.ts index fbd11bf..d430b40 100644 --- a/src/app/theme/runtime/theme-engine.service.ts +++ b/src/app/theme/runtime/theme-engine.service.ts @@ -10,6 +10,9 @@ export class ThemeEngineService { private readonly document = inject(DOCUMENT); private readonly configService = inject(ConfigService); + private systemDarkQuery?: MediaQueryList; + private systemDarkListener?: (event: MediaQueryListEvent) => void; + initialize(): void { this.configService.loadBootstrap().pipe(take(1)).subscribe({ next: (bootstrap) => this.applyTheme(bootstrap.theme), @@ -20,7 +23,20 @@ export class ThemeEngineService { } 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; 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-mode', theme.mode); + root.setAttribute('data-theme-mode', effectiveMode); 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; + } }