From 7dca7fee7dd0ebf6e7b092250eb72946ecd5bca2 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 3 Jul 2026 01:33:52 +0400 Subject: [PATCH] phase-5: add runtime theme and branding engine services --- .../theme/mappers/theme-css-vars.mapper.ts | 33 +++++++++++++++++ .../theme/runtime/branding-engine.service.ts | 36 +++++++++++++++++++ src/app/theme/runtime/theme-engine.service.ts | 34 ++++++++++++++++++ .../theme/tokens/theme-css-variable.model.ts | 3 ++ src/app/theme/tokens/theme-variable-map.ts | 14 ++++++++ 5 files changed, 120 insertions(+) create mode 100644 src/app/theme/mappers/theme-css-vars.mapper.ts create mode 100644 src/app/theme/runtime/branding-engine.service.ts create mode 100644 src/app/theme/runtime/theme-engine.service.ts create mode 100644 src/app/theme/tokens/theme-css-variable.model.ts create mode 100644 src/app/theme/tokens/theme-variable-map.ts diff --git a/src/app/theme/mappers/theme-css-vars.mapper.ts b/src/app/theme/mappers/theme-css-vars.mapper.ts new file mode 100644 index 0000000..99daa2b --- /dev/null +++ b/src/app/theme/mappers/theme-css-vars.mapper.ts @@ -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; +} diff --git a/src/app/theme/runtime/branding-engine.service.ts b/src/app/theme/runtime/branding-engine.service.ts new file mode 100644 index 0000000..c22ffe5 --- /dev/null +++ b/src/app/theme/runtime/branding-engine.service.ts @@ -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); + } + } +} diff --git a/src/app/theme/runtime/theme-engine.service.ts b/src/app/theme/runtime/theme-engine.service.ts new file mode 100644 index 0000000..fbd11bf --- /dev/null +++ b/src/app/theme/runtime/theme-engine.service.ts @@ -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); + } +} diff --git a/src/app/theme/tokens/theme-css-variable.model.ts b/src/app/theme/tokens/theme-css-variable.model.ts new file mode 100644 index 0000000..230877f --- /dev/null +++ b/src/app/theme/tokens/theme-css-variable.model.ts @@ -0,0 +1,3 @@ +export interface ThemeCssVariables { + [cssVariable: string]: string; +} diff --git a/src/app/theme/tokens/theme-variable-map.ts b/src/app/theme/tokens/theme-variable-map.ts new file mode 100644 index 0000000..96a9dae --- /dev/null +++ b/src/app/theme/tokens/theme-variable-map.ts @@ -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;