phase-5: add runtime theme and branding engine services
This commit is contained in:
33
src/app/theme/mappers/theme-css-vars.mapper.ts
Normal file
33
src/app/theme/mappers/theme-css-vars.mapper.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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 {
|
||||
const vars: ThemeCssVariables = {
|
||||
[THEME_VARIABLE_MAP.primary]: theme.palette.primary,
|
||||
[THEME_VARIABLE_MAP.secondary]: theme.palette.secondary,
|
||||
[THEME_VARIABLE_MAP.accent]: theme.palette.accent,
|
||||
[THEME_VARIABLE_MAP.success]: theme.palette.success,
|
||||
[THEME_VARIABLE_MAP.warning]: theme.palette.warning,
|
||||
[THEME_VARIABLE_MAP.danger]: theme.palette.danger,
|
||||
[THEME_VARIABLE_MAP.info]: theme.palette.info,
|
||||
[THEME_VARIABLE_MAP.textPrimary]: theme.palette.textPrimary,
|
||||
[THEME_VARIABLE_MAP.textSecondary]: theme.palette.textSecondary,
|
||||
[THEME_VARIABLE_MAP.backgroundPrimary]: theme.palette.backgroundPrimary,
|
||||
[THEME_VARIABLE_MAP.backgroundSecondary]: theme.palette.backgroundSecondary,
|
||||
[THEME_VARIABLE_MAP.border]: theme.palette.border,
|
||||
'--app-font-family': theme.typography.primaryFontFamily,
|
||||
'--app-font-size': `${theme.typography.baseFontSize}px`,
|
||||
'--app-spacing-unit': `${theme.spacing.unit}px`
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(theme.borderRadiusScale)) {
|
||||
vars[`--radius-${key}`] = value;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(theme.shadows)) {
|
||||
vars[`--shadow-${key}`] = value;
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
36
src/app/theme/runtime/branding-engine.service.ts
Normal file
36
src/app/theme/runtime/branding-engine.service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
import { BrandingConfig } from '../../shared/models/config';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BrandingEngineService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly configService = inject(ConfigService);
|
||||
|
||||
initialize(): void {
|
||||
this.configService.loadBootstrap().pipe(take(1)).subscribe({
|
||||
next: (bootstrap) => this.applyBranding(bootstrap.branding),
|
||||
error: () => {
|
||||
// Branding bootstrapping should not break app startup.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyBranding(branding: BrandingConfig): void {
|
||||
const root = this.document.documentElement;
|
||||
root.style.setProperty('--brand-logo-url', `url('${branding.logoUrl}')`);
|
||||
root.style.setProperty('--brand-logo-compact-url', `url('${branding.logoCompactUrl ?? branding.logoUrl}')`);
|
||||
|
||||
const existing = this.document.querySelector("link[rel='icon']") as HTMLLinkElement | null;
|
||||
if (existing) {
|
||||
existing.href = branding.faviconUrl;
|
||||
} else {
|
||||
const link = this.document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.href = branding.faviconUrl;
|
||||
this.document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/app/theme/runtime/theme-engine.service.ts
Normal file
34
src/app/theme/runtime/theme-engine.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
import { ThemeConfig } from '../../shared/models/config';
|
||||
import { mapThemeConfigToCssVariables } from '../mappers/theme-css-vars.mapper';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeEngineService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly configService = inject(ConfigService);
|
||||
|
||||
initialize(): void {
|
||||
this.configService.loadBootstrap().pipe(take(1)).subscribe({
|
||||
next: (bootstrap) => this.applyTheme(bootstrap.theme),
|
||||
error: () => {
|
||||
// Theme bootstrapping should not break app startup.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyTheme(theme: ThemeConfig): void {
|
||||
const variables = mapThemeConfigToCssVariables(theme);
|
||||
const root = this.document.documentElement;
|
||||
|
||||
for (const [name, value] of Object.entries(variables)) {
|
||||
root.style.setProperty(name, value);
|
||||
}
|
||||
|
||||
root.setAttribute('data-theme-id', theme.themeId);
|
||||
root.setAttribute('data-theme-mode', theme.mode);
|
||||
root.setAttribute('data-icon-set', theme.iconSet);
|
||||
}
|
||||
}
|
||||
3
src/app/theme/tokens/theme-css-variable.model.ts
Normal file
3
src/app/theme/tokens/theme-css-variable.model.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface ThemeCssVariables {
|
||||
[cssVariable: string]: string;
|
||||
}
|
||||
14
src/app/theme/tokens/theme-variable-map.ts
Normal file
14
src/app/theme/tokens/theme-variable-map.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const THEME_VARIABLE_MAP = {
|
||||
primary: '--primary-color',
|
||||
secondary: '--secondary-color',
|
||||
accent: '--accent-color',
|
||||
success: '--success-color',
|
||||
warning: '--warning-color',
|
||||
danger: '--error-color',
|
||||
info: '--info-color',
|
||||
textPrimary: '--text-primary',
|
||||
textSecondary: '--text-secondary',
|
||||
backgroundPrimary: '--bg-primary',
|
||||
backgroundSecondary: '--bg-secondary',
|
||||
border: '--border-color'
|
||||
} as const;
|
||||
Reference in New Issue
Block a user