@sixbell-telco/sdk
Version:
A collection of reusable components designed for use in Sixbell Telco Angular projects
1 lines • 122 kB
Source Map (JSON)
{"version":3,"file":"sixbell-telco-sdk-utils-theme.mjs","sources":["../../../projects/sdk/utils/theme/src/constants.ts","../../../projects/sdk/utils/theme/src/utils/utils.ts","../../../projects/sdk/utils/theme/src/theme-storage/theme-storage.service.ts","../../../projects/sdk/utils/theme/src/theme-assets/theme-assets.service.ts","../../../projects/sdk/utils/theme/src/theme-dom/theme-dom.service.ts","../../../projects/sdk/utils/theme/src/theme-scheme/theme-scheme.service.ts","../../../projects/sdk/utils/theme/src/theme.service.ts","../../../projects/sdk/utils/theme/src/providers/theme.provider.ts","../../../projects/sdk/utils/theme/sixbell-telco-sdk-utils-theme.ts"],"sourcesContent":["/**\n * Theme Constants and Injection Tokens\n * Centralized configuration values and DI tokens following Single Responsibility Principle\n */\n\nimport { RuntimeThemeConfig } from './models/theme';\n\n/**\n * Default base theme - always available unless explicitly excluded\n * This is the foundational theme that comes with the SDK\n */\nexport const DEFAULT_BASE_THEME = 'sixbell_telco' as const;\n\n/**\n * List of default themes available out of the box\n */\nexport const DEFAULT_THEMES = [DEFAULT_BASE_THEME] as const;\n\n/**\n * Type-safe default theme codes for IDE IntelliSense\n */\nexport const DEFAULT_THEME_CODES = [DEFAULT_BASE_THEME] as const;\n\n/**\n * Type representing valid default theme codes\n */\nexport type DefaultThemeCode = (typeof DEFAULT_THEME_CODES)[number];\n\n/**\n * LocalStorage key for persisting selected theme\n */\nexport const THEME_STORAGE_THEME_KEY = 'selectedTheme' as const;\n\n/**\n * LocalStorage key for persisting selected scheme\n */\nexport const THEME_STORAGE_SCHEME_KEY = 'selectedScheme' as const;\n\n/**\n * Namespace for storage keys to prevent collisions with other apps\n */\nexport const THEME_STORAGE_NAMESPACE = 'theme:' as const;\n\n/**\n * HTML attribute name for setting the active theme on document body\n */\nexport const THEME_ATTRIBUTE_NAME = 'data-theme' as const;\n\n/**\n * Separator used when combining theme name and scheme into a theme identifier\n * @example \"sixbell_telco__light\" (theme__scheme format)\n */\nexport const THEME_SCHEME_SEPARATOR = '__' as const;\n\n/**\n * Default theme scheme preference\n */\nexport const DEFAULT_SCHEME = 'system' as const;\n\n/**\n * Schema version for runtime theme configuration payloads.\n * Bump this when the JSON structure changes in a breaking way.\n */\nexport const THEME_SCHEMA_VERSION = '2' as const;\n\n/**\n * All valid scheme values\n */\nexport const VALID_SCHEMES = ['system', 'light', 'dark'] as const;\n\n/**\n * Minimal runtime configuration used as a resilient fallback when remote loading fails\n */\nexport const FALLBACK_RUNTIME_THEME_CONFIG: RuntimeThemeConfig = {\n\tmeta: {\n\t\tname: DEFAULT_BASE_THEME,\n\t\tupdatedAt: new Date().toISOString(),\n\t\thash: 'fallback-hash',\n\t\tschemaVersion: THEME_SCHEMA_VERSION,\n\t},\n\tfonts: [\n\t\t{\n\t\t\tfamily: 'Poppins',\n\t\t\tfaces: [\n\t\t\t\t{ weight: 400, style: 'normal', src: '/assets/fonts/Poppins-Regular.ttf' },\n\t\t\t\t{ weight: 500, style: 'normal', src: '/assets/fonts/Poppins-Medium.ttf' },\n\t\t\t\t{ weight: 600, style: 'normal', src: '/assets/fonts/Poppins-SemiBold.ttf' },\n\t\t\t\t{ weight: 700, style: 'normal', src: '/assets/fonts/Poppins-Bold.ttf' },\n\t\t\t\t{ weight: 800, style: 'normal', src: '/assets/fonts/Poppins-ExtraBold.ttf' },\n\t\t\t\t{ weight: 900, style: 'normal', src: '/assets/fonts/Poppins-Black.ttf' },\n\t\t\t],\n\t\t},\n\t],\n\tthemes: {\n\t\tlight: {\n\t\t\tvariables: {\n\t\t\t\tcolors: {\n\t\t\t\t\t'--color-base-100': 'oklch(96.65% 0.0045 258.32)',\n\t\t\t\t\t'--color-base-200': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-base-300': 'oklch(92.88% 0.0126 255.51)',\n\t\t\t\t\t'--color-base-content': 'oklch(13.49% 0.0024 286.07)',\n\t\t\t\t\t'--color-primary': 'oklch(65.64% 0.1155 219.3)',\n\t\t\t\t\t'--color-primary-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-secondary': 'oklch(86.57% 0.0064 255.48)',\n\t\t\t\t\t'--color-secondary-content': 'oklch(13.49% 0.0024 286.07)',\n\t\t\t\t\t'--color-accent': 'oklch(70.45% 0.1926 39.23)',\n\t\t\t\t\t'--color-accent-content': 'oklch(13.49% 0.0024 286.07)',\n\t\t\t\t\t'--color-neutral': 'oklch(92.08% 0.0063 255.48)',\n\t\t\t\t\t'--color-neutral-content': 'oklch(13.49% 0.0024 286.07)',\n\t\t\t\t\t'--color-info': 'oklch(55.86% 0.1907 256)',\n\t\t\t\t\t'--color-info-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-success': 'oklch(73.79% 0.2409 140.89)',\n\t\t\t\t\t'--color-success-content': 'oklch(36.22% 0.1147 140.02)',\n\t\t\t\t\t'--color-warning': 'oklch(85.44% 0.1839 108.37)',\n\t\t\t\t\t'--color-warning-content': 'oklch(46.41% 0.0998 109)',\n\t\t\t\t\t'--color-error': 'oklch(65.92% 0.2275 29.05)',\n\t\t\t\t\t'--color-error-content': 'oklch(25.91% 0.0904 29.25)',\n\t\t\t\t\t'--color-base-50': 'color-mix(in oklch, var(--color-base-200), #000 7%)',\n\t\t\t\t\t'--color-primary-light': 'color-mix(in oklch, var(--color-primary) 75%, #fff)',\n\t\t\t\t\t'--color-primary-gradient': 'color-mix(in oklch, var(--color-primary) 60%, #121212)',\n\t\t\t\t\t'--color-primary-gradient-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-secondary-gradient': 'color-mix(in oklch, var(--color-secondary) 60%, #121212)',\n\t\t\t\t\t'--color-secondary-gradient-content': 'oklch(13.49% 0.0024 286.07)',\n\t\t\t\t\t'--color-tertiary-gradient': 'color-mix(in oklch, var(--color-tertiary) 60%, #121212)',\n\t\t\t\t\t'--color-tertiary-gradient-content': 'oklch(13.49% 0.0024 286.07)',\n\t\t\t\t\t'--color-accent-gradient': 'color-mix(in oklch, var(--color-accent) 60%, #121212)',\n\t\t\t\t\t'--color-accent-gradient-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-tertiary': 'oklch(72.52% 0 0)',\n\t\t\t\t\t'--color-tertiary-content': 'oklch(13.49% 0.0024 286.07)',\n\t\t\t\t\t'--color-neutral-secondary': 'oklch(73.97% 0.0115 280.4)',\n\t\t\t\t\t'--color-neutral-secondary-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-neutral-tertiary': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-neutral-tertiary-content': 'oklch(73.97% 0.0115 280.4)',\n\t\t\t\t\t'--color-base-placeholder': 'oklch(51.6% 0.0073 255.51)',\n\t\t\t\t\t'--color-backdrop': 'oklch(0% 0 0/ 0.4)',\n\t\t\t\t\t'--color-shadow-primary': 'oklch(0.8975 0 0)',\n\t\t\t\t\t'--color-shadow-backdrop': 'oklch(0% 0 0/ 0.25)',\n\t\t\t\t\t'--color-metrics-increase': 'oklch(74.59% 0.1812 152.33)',\n\t\t\t\t\t'--color-metrics-decrease': 'oklch(63.07% 0.194 29.44)',\n\t\t\t\t\t'--color-metrics-neutral': 'oklch(62.99% 0.0152 202.37)',\n\t\t\t\t\t'--color-metrics-intensity-highest': 'oklch(65.64% 0.1155 219.3)',\n\t\t\t\t\t'--color-metrics-intensity-high': 'oklch(68.62% 0.0925 226.08)',\n\t\t\t\t\t'--color-metrics-intensity-medium': 'oklch(68.62% 0.0925 226.08)',\n\t\t\t\t\t'--color-metrics-intensity-low': 'oklch(76.18% 0.0656 213.66)',\n\t\t\t\t\t'--color-metrics-intensity-lowest': 'oklch(78% 0.0473 211.88)',\n\t\t\t\t\t'--color-scroll-thumb': 'oklch(75% 0 0)',\n\t\t\t\t},\n\t\t\t\tradius: {\n\t\t\t\t\t'--radius-selector': '0.5rem',\n\t\t\t\t\t'--radius-field': '0.25rem',\n\t\t\t\t\t'--radius-box': '0.5rem',\n\t\t\t\t},\n\t\t\t\tsizes: {\n\t\t\t\t\t'--size-selector': '0.25rem',\n\t\t\t\t\t'--size-field': '0.25rem',\n\t\t\t\t},\n\t\t\t\teffects: {\n\t\t\t\t\t'--border': '1px',\n\t\t\t\t\t'--depth': '0',\n\t\t\t\t\t'--noise': '0',\n\t\t\t\t},\n\t\t\t\tothers: {\n\t\t\t\t\t'--font-poppins': 'Poppins, sans-serif',\n\t\t\t\t\t'--font-heading': 'Poppins, sans-serif',\n\t\t\t\t\t'--font-body': 'Poppins, sans-serif',\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tdark: {\n\t\t\tvariables: {\n\t\t\t\tcolors: {\n\t\t\t\t\t'--color-base-100': 'oklch(18.22% 0 0)',\n\t\t\t\t\t'--color-base-200': 'oklch(23.5% 0 0)',\n\t\t\t\t\t'--color-base-300': 'oklch(28.5% 0 0)',\n\t\t\t\t\t'--color-base-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-primary': 'oklch(65.64% 0.1155 219.3)',\n\t\t\t\t\t'--color-primary-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-secondary': 'oklch(48.72% 0.0161 276.78)',\n\t\t\t\t\t'--color-secondary-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-accent': 'oklch(70.45% 0.1926 39.23)',\n\t\t\t\t\t'--color-accent-content': 'oklch(23.5% 0 0)',\n\t\t\t\t\t'--color-neutral': 'oklch(49.26% 0 0)',\n\t\t\t\t\t'--color-neutral-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-info': 'oklch(55.86% 0.1907 256)',\n\t\t\t\t\t'--color-info-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-success': 'oklch(73.79% 0.2409 140.89)',\n\t\t\t\t\t'--color-success-content': 'oklch(36.22% 0.1147 140.02)',\n\t\t\t\t\t'--color-warning': 'oklch(85.44% 0.1839 108.37)',\n\t\t\t\t\t'--color-warning-content': 'oklch(46.41% 0.0998 109)',\n\t\t\t\t\t'--color-error': 'oklch(65.92% 0.2275 29.05)',\n\t\t\t\t\t'--color-error-content': 'oklch(25.91% 0.0904 29.25)',\n\t\t\t\t\t'--color-base-50': 'color-mix(in oklch, var(--color-base-200), #000 7%)',\n\t\t\t\t\t'--color-primary-light': 'color-mix(in oklch, var(--color-primary) 75%, #000)',\n\t\t\t\t\t'--color-primary-gradient': 'color-mix(in oklch, var(--color-primary) 60%, #121212)',\n\t\t\t\t\t'--color-primary-gradient-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-secondary-gradient': 'color-mix(in oklch, var(--color-secondary) 60%, #121212)',\n\t\t\t\t\t'--color-secondary-gradient-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-tertiary-gradient': 'color-mix(in oklch, var(--color-tertiary) 60%, #121212)',\n\t\t\t\t\t'--color-tertiary-gradient-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-accent-gradient': 'color-mix(in oklch, var(--color-accent) 60%, #121212)',\n\t\t\t\t\t'--color-accent-gradient-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-tertiary': 'oklch(72.52% 0 0)',\n\t\t\t\t\t'--color-tertiary-content': 'oklch(13.49% 0.0024 286.07)',\n\t\t\t\t\t'--color-neutral-secondary': 'oklch(73.97% 0.0115 280.4)',\n\t\t\t\t\t'--color-neutral-secondary-content': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-neutral-tertiary': 'oklch(100% 0 0)',\n\t\t\t\t\t'--color-neutral-tertiary-content': 'oklch(73.97% 0.0115 280.4)',\n\t\t\t\t\t'--color-base-placeholder': 'oklch(51.6% 0.0073 255.51)',\n\t\t\t\t\t'--color-backdrop': 'oklch(0% 0 0/ 0.4)',\n\t\t\t\t\t'--color-shadow-primary': 'oklch(0 0 0)',\n\t\t\t\t\t'--color-shadow-backdrop': 'oklch(0% 0 0/ 0.25)',\n\t\t\t\t\t'--color-metrics-increase': 'oklch(74.59% 0.1812 152.33)',\n\t\t\t\t\t'--color-metrics-decrease': 'oklch(63.07% 0.194 29.44)',\n\t\t\t\t\t'--color-metrics-neutral': 'oklch(62.99% 0.0152 202.37)',\n\t\t\t\t\t'--color-metrics-intensity-highest': 'oklch(65.64% 0.1155 219.3)',\n\t\t\t\t\t'--color-metrics-intensity-high': 'oklch(68.62% 0.0925 226.08)',\n\t\t\t\t\t'--color-metrics-intensity-medium': 'oklch(68.62% 0.0925 226.08)',\n\t\t\t\t\t'--color-metrics-intensity-low': 'oklch(76.18% 0.0656 213.66)',\n\t\t\t\t\t'--color-metrics-intensity-lowest': 'oklch(78% 0.0473 211.88)',\n\t\t\t\t\t'--color-scroll-thumb': 'oklch(35% 0 0)',\n\t\t\t\t},\n\t\t\t\tradius: {\n\t\t\t\t\t'--radius-selector': '0.5rem',\n\t\t\t\t\t'--radius-field': '0.25rem',\n\t\t\t\t\t'--radius-box': '0.5rem',\n\t\t\t\t},\n\t\t\t\tsizes: {\n\t\t\t\t\t'--size-selector': '0.25rem',\n\t\t\t\t\t'--size-field': '0.25rem',\n\t\t\t\t},\n\t\t\t\teffects: {\n\t\t\t\t\t'--border': '1px',\n\t\t\t\t\t'--depth': '0',\n\t\t\t\t\t'--noise': '0',\n\t\t\t\t},\n\t\t\t\tothers: {\n\t\t\t\t\t'--font-poppins': 'Poppins, sans-serif',\n\t\t\t\t\t'--font-heading': 'Poppins, sans-serif',\n\t\t\t\t\t'--font-body': 'Poppins, sans-serif',\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n\tassets: [\n\t\t{\n\t\t\tname: 'logo',\n\t\t\tlight: '/assets/logos/sixbell-logo-light-mode.svg',\n\t\t\tdark: '/assets/logos/sixbell-logo-dark-mode.svg',\n\t\t},\n\t],\n};\n\n/**\n * CSS class name applied to document when dark theme is detected\n */\nexport const DARK_THEME_CLASS = 'dark' as const;\n\n/**\n * Media query for detecting system dark mode preference\n */\nexport const DARK_MODE_MEDIA_QUERY = '(prefers-color-scheme: dark)' as const;\n","/**\n * Theme Utility Functions\n * Pure, side-effect-free utilities for theme logic\n */\n\nimport { DEFAULT_BASE_THEME, VALID_SCHEMES } from '../constants';\nimport { ThemeStateConfig } from '../models/theme';\n\n/**\n * Validates that a given value is a valid theme scheme\n * @param value - The value to validate\n * @returns True if the value is a valid scheme ('system', 'light', or 'dark')\n */\nexport function isValidScheme(value: unknown): value is (typeof VALID_SCHEMES)[number] {\n\treturn typeof value === 'string' && (VALID_SCHEMES as readonly string[]).includes(value);\n}\n\n/**\n * Builds the final theme identifier by combining theme name and scheme\n * @param themeName - The name of the theme\n * @param scheme - The scheme variant (light or dark)\n * @returns Combined identifier (e.g., \"sixbell_telco__light\")\n */\nexport function buildThemeIdentifier(themeName: string, scheme: string): string {\n\treturn `${themeName}__${scheme}`;\n}\n\n/**\n * Extracts theme name and scheme from a combined identifier\n * @param identifier - The combined identifier (e.g., \"sixbell_telco__light\")\n * @returns Object with extracted themeName and scheme\n */\nexport function parseThemeIdentifier(identifier: string): { themeName: string; scheme: string } {\n\tconst [themeName, scheme] = identifier.split('__');\n\treturn { themeName, scheme };\n}\n\n/**\n * Determines the effective default theme from configuration\n * Ensures the default theme is in the available themes list\n * Falls back to first available theme if default is not available\n * @param defaultTheme - Configured default theme\n * @param availableThemes - List of available themes\n * @returns The effective default theme to use\n */\nexport function resolveDefaultTheme(defaultTheme: string | undefined, availableThemes: string[]): string {\n\t// Use provided default if valid\n\tif (defaultTheme && availableThemes.includes(defaultTheme)) {\n\t\treturn defaultTheme;\n\t}\n\n\t// Check if base theme is available\n\tif (availableThemes.includes(DEFAULT_BASE_THEME)) {\n\t\treturn DEFAULT_BASE_THEME;\n\t}\n\n\t// Fallback to first available\n\treturn availableThemes[0] || DEFAULT_BASE_THEME;\n}\n\n/**\n * Validates that a given theme exists in the available themes\n * @param theme - Theme name to validate\n * @param availableThemes - List of available themes\n * @returns True if theme is available\n */\nexport function isThemeAvailable(theme: string, availableThemes: string[]): boolean {\n\treturn availableThemes.includes(theme);\n}\n\n/**\n * Validates the configuration and logs warnings for invalid states\n * Returns true if configuration is valid for use\n * @param config - Configuration to validate\n * @param configName - Name of config for logging purposes\n * @returns True if configuration is valid\n */\nexport function validateThemeConfig(config: ThemeStateConfig, configName: string = 'ThemeConfig'): boolean {\n\tconst errors: string[] = [];\n\n\tif (!config.availableThemes || config.availableThemes.length === 0) {\n\t\terrors.push('availableThemes must be a non-empty array');\n\t}\n\n\tif (!config.defaultTheme) {\n\t\terrors.push('defaultTheme must be defined');\n\t}\n\n\tif (config.defaultTheme && !config.availableThemes.includes(config.defaultTheme)) {\n\t\terrors.push(`defaultTheme '${config.defaultTheme}' not in availableThemes`);\n\t}\n\n\tif (errors.length > 0) {\n\t\tconsole.error(`${configName} validation failed:`, errors.join(', '));\n\t\treturn false;\n\t}\n\n\treturn true;\n}\n\n/**\n * Formats a theme name for display to users\n * Converts snake_case to Title Case\n * @param theme - Theme name to format\n * @returns Formatted theme name\n */\nexport function formatThemeName(theme: string): string {\n\treturn theme.replace(/_/g, ' ').replace(/\\b\\w/g, (l) => l.toUpperCase());\n}\n\n/**\n * Formats a scheme name for display to users\n * Capitalizes first letter\n * @param scheme - Scheme name to format\n * @returns Formatted scheme name\n */\nexport function formatSchemeName(scheme: string): string {\n\treturn scheme.charAt(0).toUpperCase() + scheme.slice(1);\n}\n\n/**\n * Checks if the browser supports the matchMedia API\n * Safe to call in non-browser environments\n * @returns True if matchMedia is available\n */\nexport function supportsMediaQuery(): boolean {\n\treturn typeof window !== 'undefined' && typeof window.matchMedia === 'function';\n}\n\n/**\n * Checks if localStorage is available in the current environment\n * Safe to call in non-browser environments\n * @returns True if localStorage is accessible\n */\nexport function hasLocalStorage(): boolean {\n\ttry {\n\t\treturn typeof window !== 'undefined' && typeof localStorage !== 'undefined' && localStorage !== null;\n\t} catch {\n\t\treturn false;\n\t}\n}\n","/**\n * Theme Storage Service\n * Handles persistent theme preferences using localStorage\n */\n\nimport { Injectable, inject } from '@angular/core';\nimport { LoggerService } from '@sixbell-telco/sdk/utils/logger';\nimport { THEME_STORAGE_NAMESPACE, THEME_STORAGE_SCHEME_KEY, THEME_STORAGE_THEME_KEY } from '../constants';\nimport { ThemeScheme } from '../models/theme';\nimport { hasLocalStorage } from '../utils/utils';\n\n/**\n * Service for managing theme preferences in local storage\n * Provides safe access to localStorage with error handling\n * Works gracefully in non-browser environments (SSR)\n */\n@Injectable({ providedIn: 'root' })\nexport class ThemeStorageService {\n\tprivate readonly logger = inject(LoggerService);\n\t/**\n\t * Retrieves the saved theme preference from localStorage\n\t * Returns null if no saved preference or storage unavailable\n\t * @returns Saved theme name or null\n\t */\n\tgetSavedTheme(): string | null {\n\t\ttry {\n\t\t\tif (!hasLocalStorage()) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\tconst key = this.getStorageKey(THEME_STORAGE_THEME_KEY);\n\t\t\treturn localStorage.getItem(key);\n\t\t} catch (error) {\n\t\t\tthis.logger.error('Failed to retrieve saved theme', error as Error, {\n\t\t\t\tcomponent: 'ThemeStorageService',\n\t\t\t\taction: 'getSavedTheme',\n\t\t\t});\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t/**\n\t * Saves the current theme preference to localStorage\n\t * Fails silently if storage is unavailable\n\t * @param theme - Theme name to save\n\t */\n\tsaveTheme(theme: string): void {\n\t\ttry {\n\t\t\tif (!hasLocalStorage()) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst key = this.getStorageKey(THEME_STORAGE_THEME_KEY);\n\t\t\tlocalStorage.setItem(key, theme);\n\t\t} catch (error) {\n\t\t\tthis.logger.error('Failed to save theme', error as Error, {\n\t\t\t\tcomponent: 'ThemeStorageService',\n\t\t\t\taction: 'saveTheme',\n\t\t\t\ttheme,\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * Retrieves the saved scheme preference from localStorage\n\t * Returns null if no saved preference or storage unavailable\n\t * @returns Saved scheme ('system', 'light', 'dark') or null\n\t */\n\tgetSavedScheme(): ThemeScheme | null {\n\t\ttry {\n\t\t\tif (!hasLocalStorage()) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\tconst key = this.getStorageKey(THEME_STORAGE_SCHEME_KEY);\n\t\t\treturn localStorage.getItem(key) as ThemeScheme | null;\n\t\t} catch (error) {\n\t\t\tthis.logger.error('Failed to retrieve saved scheme', error as Error, {\n\t\t\t\tcomponent: 'ThemeStorageService',\n\t\t\t\taction: 'getSavedScheme',\n\t\t\t});\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t/**\n\t * Saves the current scheme preference to localStorage\n\t * Fails silently if storage is unavailable\n\t * @param scheme - Scheme to save\n\t */\n\tsaveScheme(scheme: ThemeScheme): void {\n\t\ttry {\n\t\t\tif (!hasLocalStorage()) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst key = this.getStorageKey(THEME_STORAGE_SCHEME_KEY);\n\t\t\tlocalStorage.setItem(key, scheme);\n\t\t} catch (error) {\n\t\t\tthis.logger.error('Failed to save scheme', error as Error, {\n\t\t\t\tcomponent: 'ThemeStorageService',\n\t\t\t\taction: 'saveScheme',\n\t\t\t\tscheme,\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * Clears all saved theme preferences from localStorage\n\t * Useful for resetting to defaults\n\t */\n\tclear(): void {\n\t\ttry {\n\t\t\tif (!hasLocalStorage()) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst themeKey = this.getStorageKey(THEME_STORAGE_THEME_KEY);\n\t\t\tconst schemeKey = this.getStorageKey(THEME_STORAGE_SCHEME_KEY);\n\n\t\t\tlocalStorage.removeItem(themeKey);\n\t\t\tlocalStorage.removeItem(schemeKey);\n\t\t} catch (error) {\n\t\t\tthis.logger.error('Failed to clear storage', error as Error, {\n\t\t\t\tcomponent: 'ThemeStorageService',\n\t\t\t\taction: 'clear',\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * Constructs a namespaced storage key to avoid collisions\n\t * @param key - The base storage key\n\t * @returns Namespaced key (e.g., \"theme:selectedTheme\")\n\t */\n\tprivate getStorageKey(key: string): string {\n\t\treturn `${THEME_STORAGE_NAMESPACE}${key}`;\n\t}\n}\n","/**\n * Theme Assets Service\n * Manages dynamic asset loading and retrieval based on selected theme and color scheme\n *\n * Responsibilities:\n * - Retrieve assets (images, logos, illustrations) for current theme\n * - Provide scheme-aware asset variants (light/dark)\n * - Cache asset references with config-based resilience strategy\n * - Validate asset availability and provide graceful fallback through memory layers\n *\n * Multi-Layer Caching Strategy (Like Theme Config Persistence):\n * - Layer 1: Current config assets (read from active RuntimeThemeConfig)\n * - Layer 2: Last working asset config (in-memory, persists across config updates)\n * - Falls back to last complete working config if current config breaks\n *\n * This matches the runtime-config and ThemeService pattern where:\n * - User preferences/working configs are stored in memory layers\n * - When new config fails, the last working config is used automatically\n * - Handles asset removal, name changes, path changes, config resets\n */\n\nimport { inject, Injectable, signal } from '@angular/core';\nimport { LoggerService } from '@sixbell-telco/sdk/utils/logger';\nimport { AssetDefinition, ResolvedThemeScheme, RuntimeThemeConfig, ThemeAsset, ThemeAssets } from '../models/theme';\nimport { CachedAsset } from './theme-assets.types';\n\n@Injectable({\n\tprovidedIn: 'root',\n})\nexport class ThemeAssetsService {\n\t/**\n\t * Signal holding current runtime configuration reference\n\t * Used to look up asset definitions for the active theme\n\t */\n\tprivate config = signal<RuntimeThemeConfig | null>(null);\n\tprivate themeConfigs = new Map<string, RuntimeThemeConfig>();\n\n\t/**\n\t * Layer 1: In-memory cache of resolved assets\n\t * Maps \"themeName:assetKey\" to CachedAsset for fast retrieval\n\t * Cleared when setConfig() is called to force re-resolution from new config\n\t */\n\tprivate assetCacheByTheme = new Map<string, Map<string, CachedAsset>>();\n\n\t/**\n\t * Layer 2: Last working config storage (memory-based)\n\t * Stores the most recent RuntimeThemeConfig that successfully resolved assets\n\t * Persists across config updates as a complete fallback mechanism\n\t * Similar to ThemeService's multi-layer config persistence\n\t *\n\t * When current config fails to provide an asset:\n\t * - Falls back to the last complete working config\n\t * - Handles asset removal, name changes, path changes\n\t * - Automatically restores working state on config resets\n\t * - No individual URL tracking - entire config stored once per successful load\n\t */\n\tprivate lastWorkingConfigLayer: RuntimeThemeConfig | null = null;\n\n\tprivate logger = inject(LoggerService);\n\n\tconstructor() {}\n\n\t/**\n\t * Initialize service with runtime configuration\n\t * Called by ThemeService after loading configuration\n\t *\n\t * If new config successfully resolves assets, it becomes the new fallback layer.\n\t * Otherwise, previous working config persists.\n\t *\n\t * Only updates Layer 2 if:\n\t * - Config has no PARTIAL/BROKEN assets (missing variants)\n\t * - Themes can be removed entirely (that's intentional)\n\t * - But existing assets must be COMPLETE (both light and dark)\n\t *\n\t * This ensures that accidental removals of variants trigger fallback\n\t * while intentional complete removals are allowed.\n\t *\n\t * @param config - Runtime configuration containing asset definitions\n\t */\n\tsetConfig(config: RuntimeThemeConfig): void {\n\t\tthis.config.set(config);\n\n\t\tconst hasPartialAssets = this.hasPartialAssets(config);\n\t\tconst hasValid = !hasPartialAssets;\n\n\t\tthis.logger.debug('setConfig called', {\n\t\t\tcomponent: 'ThemeAssetsService',\n\t\t\taction: 'setConfig',\n\t\t\thasPartialAssets,\n\t\t\thasValidAssets: hasValid,\n\t\t\tassets: config?.assets ? config.assets.map((asset) => asset.name) : [],\n\t\t\thasLastWorking: !!this.lastWorkingConfigLayer,\n\t\t});\n\n\t\t// Only store as last working config if it has NO partial/broken assets\n\t\tif (hasValid) {\n\t\t\tthis.logger.debug('Storing config as last working config', {\n\t\t\t\tcomponent: 'ThemeAssetsService',\n\t\t\t\taction: 'setConfig',\n\t\t\t});\n\t\t\tthis.lastWorkingConfigLayer = config;\n\t\t} else {\n\t\t\tthis.logger.debug('Config has partial/broken assets - keeping previous last working config', {\n\t\t\t\tcomponent: 'ThemeAssetsService',\n\t\t\t\taction: 'setConfig',\n\t\t\t});\n\t\t}\n\n\t\tthis.clearCache();\n\t}\n\n\tsetThemeConfig(themeName: string, config: RuntimeThemeConfig): void {\n\t\tthis.themeConfigs.set(themeName, config);\n\t\tthis.config.set(config);\n\t\tthis.lastWorkingConfigLayer = config;\n\t\tthis.clearCache(themeName);\n\t}\n\n\t/**\n\t * Internal: Check if config has any PARTIAL/BROKEN assets\n\t * Returns true if ANY asset is incomplete (missing light or dark variant)\n\t *\n\t * A partial asset is invalid:\n\t * - { name: \"logo\", light: \"...\" } ← missing dark\n\t * - { name: \"logo\", dark: \"...\" } ← missing light\n\t * - { name: \"logo\" } ← missing both\n\t *\n\t * Complete removals are OK:\n\t * - [] ← no assets at all (intentional)\n\t * - theme: {} ← theme removed (intentional)\n\t *\n\t * @private\n\t * @param config - Config to check\n\t * @returns True if config has partial/broken assets, false if all complete or intentionally removed\n\t */\n\tprivate hasPartialAssets(config: RuntimeThemeConfig | null): boolean {\n\t\tif (!config?.assets) return false;\n\n\t\treturn this.themeHasProblematicAssets(config.assets);\n\t}\n\n\t/**\n\t * Internal: Check if a theme asset list has ANY problematic assets\n\t * Problematic assets are:\n\t * - Partial assets: missing light or dark variant\n\t * - Empty assets: no variants at all\n\t *\n\t * NOT problematic:\n\t * - Completely empty list: [] (all assets intentionally removed - OK to accept)\n\t *\n\t * @private\n\t * @param obj - Theme's asset object to check\n\t * @returns True if object has problematic assets\n\t */\n\tprivate themeHasProblematicAssets(assets: ThemeAssets): boolean {\n\t\tif (!assets.length) {\n\t\t\treturn false;\n\t\t}\n\t\tfor (const asset of assets) {\n\t\t\tif (this.isPartialAsset(asset)) {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\t/**\n\t * Internal: Check if object is a PARTIAL asset (incomplete)\n\t * Partial means it LOOKS LIKE an asset (has some scheme keys) but is missing variants\n\t *\n\t * @private\n\t * @param obj - Object to check\n\t * @returns True if object is a partial/incomplete asset\n\t */\n\tprivate isPartialAsset(asset: AssetDefinition): boolean {\n\t\tconst hasLight = typeof asset.light === 'string';\n\t\tconst hasDark = typeof asset.dark === 'string';\n\t\tif (!hasLight && !hasDark) {\n\t\t\treturn true;\n\t\t}\n\t\treturn !hasLight || !hasDark;\n\t}\n\n\t/**\n\t * Get asset URL for the specified scheme\n\t * Retrieves a specific asset variant (light or dark) with config-based fallback\n\t *\n\t * Resolution Strategy (in order):\n\t * 1. Try to get the requested scheme variant from current config\n\t * 2. If not available, use last working config from memory layer\n\t * 3. If no last working config, return null\n\t *\n\t * This ensures:\n\t * - Graceful degradation when assets are removed from config\n\t * - Support for asset name changes, path changes, config resets\n\t * - Automatic restoration when config is fixed\n\t * - No automatic scheme switching (stays on requested scheme)\n\t * - Similar to ThemeService's config persistence pattern\n\t *\n\t * @param themeName - Name of the theme (e.g., 'sixbell_telco', 'wom')\n\t * @param assetKey - Asset identifier path (e.g., 'logo', 'banners.hero', 'illustrations.empty')\n\t * @param scheme - Color scheme ('light' or 'dark')\n\t * @returns Asset URL for the specified scheme, or from last working config, or null if not found\n\t *\n\t * @example\n\t * // Current config has dark - returns requested variant\n\t * getAssetUrl('wom', 'logo', 'dark')\n\t * // Returns: '/assets/logos/logo_wom_dark.svg'\n\t *\n\t * // Current config missing dark - returns from last working config\n\t * getAssetUrl('wom', 'logo', 'dark')\n\t * // Returns: '/assets/logos/logo_wom_dark.svg' (from last working config layer)\n\t *\n\t * // Never existed - no working config\n\t * getAssetUrl('wom', 'nonexistent', 'dark')\n\t * // Returns: null\n\t */\n\tgetAssetUrl(themeName: string, assetKey: string, scheme: ResolvedThemeScheme): string | null {\n\t\tconst cacheKey = `${themeName}:${assetKey}`;\n\t\tconst themeCache = this.assetCacheByTheme.get(themeName);\n\t\tconst cached = themeCache?.get(cacheKey);\n\t\tif (cached) {\n\t\t\treturn scheme === 'light' ? cached.light : cached.dark;\n\t\t}\n\t\t// Layer 1: Try to resolve from current config\n\t\tconst configToUse = this.themeConfigs.get(themeName) ?? this.config();\n\t\tconst url = this.resolveAssetUrl(themeName, assetKey, scheme, configToUse);\n\t\tif (url) {\n\t\t\tthis.logger.debug('Asset resolved from current config', {\n\t\t\t\tcomponent: 'ThemeAssetsService',\n\t\t\t\taction: 'getAssetUrl',\n\t\t\t\tassetKey,\n\t\t\t\tscheme,\n\t\t\t\turl,\n\t\t\t});\n\t\t\tthis.cacheAsset(themeName, assetKey, configToUse);\n\t\t\treturn url;\n\t\t}\n\n\t\t// Layer 2: Try to resolve from last working config\n\t\tif (this.lastWorkingConfigLayer) {\n\t\t\tconst lastWorkingUrl = this.resolveAssetUrl(themeName, assetKey, scheme, this.lastWorkingConfigLayer);\n\t\t\tif (lastWorkingUrl) {\n\t\t\t\tthis.logger.debug('Asset resolved from last working config (fallback)', {\n\t\t\t\t\tcomponent: 'ThemeAssetsService',\n\t\t\t\t\taction: 'getAssetUrl',\n\t\t\t\t\tfallback: true,\n\t\t\t\t\tassetKey,\n\t\t\t\t\tscheme,\n\t\t\t\t\turl: lastWorkingUrl,\n\t\t\t\t});\n\t\t\t\tthis.cacheAsset(themeName, assetKey, this.lastWorkingConfigLayer);\n\t\t\t\treturn lastWorkingUrl;\n\t\t\t}\n\t\t}\n\n\t\t// Layer 3: No URL available\n\t\tthis.logger.debug('Asset not found in any layer', {\n\t\t\tcomponent: 'ThemeAssetsService',\n\t\t\taction: 'getAssetUrl',\n\t\t\tassetKey,\n\t\t\tscheme,\n\t\t\thasLastWorking: !!this.lastWorkingConfigLayer,\n\t\t});\n\t\treturn null;\n\t}\n\n\t/**\n\t * Internal: Resolve asset URL from a specific config\n\t * Helper method to resolve URL from current or last working config\n\t *\n\t * Returns null if:\n\t * - Asset not found\n\t * - Asset is incomplete (missing either light or dark variant)\n\t * - Requested scheme variant not available\n\t *\n\t * @private\n\t * @param themeName - Theme name\n\t * @param assetKey - Asset key path\n\t * @param scheme - Scheme variant\n\t * @param configToUse - Config to resolve from (current or lastWorking)\n\t * @returns URL if found and complete, null otherwise\n\t */\n\tprivate resolveAssetUrl(themeName: string, assetKey: string, scheme: ResolvedThemeScheme, configToUse: RuntimeThemeConfig | null): string | null {\n\t\tif (!configToUse?.assets) return null;\n\t\tif (configToUse.meta?.name && configToUse.meta.name !== themeName) return null;\n\n\t\tconst asset = this.findAsset(configToUse.assets, assetKey);\n\t\tif (!asset || !this.isThemeAsset(asset)) return null;\n\t\tconst url = scheme === 'light' ? asset.light : asset.dark;\n\n\t\t// Return null if variant is missing (indicates incomplete asset)\n\t\treturn url || null;\n\t}\n\n\t/**\n\t * Get both light and dark variants of an asset\n\t * Returns an object with both scheme variants for easy access\n\t *\n\t * @param themeName - Name of the theme\n\t * @param assetKey - Asset identifier path\n\t * @returns Object with light and dark URLs, or null if asset not found\n\t *\n\t * @example\n\t * getAsset('wom', 'logo')\n\t * // Returns: { light: '/assets/logos/logo_wom_light.svg', dark: '/assets/logos/logo_wom_dark.svg' }\n\t */\n\tgetAsset(themeName: string, assetKey: string): ThemeAsset | null {\n\t\t// Try current config first\n\t\tconst configToUse = this.themeConfigs.get(themeName) ?? this.config();\n\t\tlet light = this.resolveAssetUrl(themeName, assetKey, 'light', configToUse);\n\t\tlet dark = this.resolveAssetUrl(themeName, assetKey, 'dark', configToUse);\n\n\t\t// If not available in current config, try last working config\n\t\tif ((!light || !dark) && this.lastWorkingConfigLayer) {\n\t\t\tif (!light) light = this.resolveAssetUrl(themeName, assetKey, 'light', this.lastWorkingConfigLayer);\n\t\t\tif (!dark) dark = this.resolveAssetUrl(themeName, assetKey, 'dark', this.lastWorkingConfigLayer);\n\t\t}\n\n\t\t// Return asset if at least one variant found\n\t\tif (light || dark) {\n\t\t\tthis.cacheAsset(themeName, assetKey, configToUse ?? this.lastWorkingConfigLayer);\n\t\t\treturn { light: light || undefined, dark: dark || undefined };\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t/**\n\t * Check if an asset exists in the specified theme\n\t * Useful for conditional rendering or fallback handling\n\t *\n\t * @param themeName - Name of the theme\n\t * @param assetKey - Asset identifier path\n\t * @returns True if the asset exists, false otherwise\n\t *\n\t * @example\n\t * hasAsset('wom', 'logo') // true\n\t * hasAsset('wom', 'nonexistent') // false\n\t */\n\thasAsset(themeName: string, assetKey: string): boolean {\n\t\t// Check if asset exists in current config\n\t\tconst configToUse = this.themeConfigs.get(themeName) ?? this.config();\n\t\tif (this.resolveAssetUrl(themeName, assetKey, 'light', configToUse) || this.resolveAssetUrl(themeName, assetKey, 'dark', configToUse)) {\n\t\t\treturn true;\n\t\t}\n\n\t\t// Check if asset exists in last working config\n\t\tif (this.lastWorkingConfigLayer) {\n\t\t\tif (\n\t\t\t\tthis.resolveAssetUrl(themeName, assetKey, 'light', this.lastWorkingConfigLayer) ||\n\t\t\t\tthis.resolveAssetUrl(themeName, assetKey, 'dark', this.lastWorkingConfigLayer)\n\t\t\t) {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\n\t\treturn false;\n\t}\n\n\t/**\n\t * Get all assets for a specific theme\n\t * Returns the complete asset set for further processing\n\t *\n\t * @param themeName - Name of the theme\n\t * @returns All assets for the theme, or null if theme has no assets\n\t */\n\tgetThemeAssets(themeName: string): ThemeAssets | null {\n\t\tconst cfg = this.themeConfigs.get(themeName) ?? this.config();\n\t\tif (!cfg?.assets) return null;\n\t\tif (cfg.meta?.name && cfg.meta.name !== themeName) return null;\n\t\treturn cfg.assets;\n\t}\n\n\t/**\n\t * Manually clear the asset cache\n\t * Typically called automatically by setConfig(), but can be used for:\n\t * - Testing and debugging\n\t * - Force cache refresh in special scenarios\n\t *\n\t * Note: The last-working-config layer is NOT cleared - only the config cache\n\t * This allows the fallback mechanism to persist across manual cache clears\n\t *\n\t * @example\n\t * // In tests\n\t * assetsService.clearCache();\n\t *\n\t * @example\n\t * // Force refresh (if config doesn't change but images do)\n\t * assetsService.clearCache();\n\t * assetsService.setConfig(currentConfig);\n\t */\n\tpublic clearCache(themeName?: string): void {\n\t\tif (themeName) {\n\t\t\tthis.assetCacheByTheme.delete(themeName);\n\t\t\treturn;\n\t\t}\n\t\tthis.assetCacheByTheme.clear();\n\t}\n\n\tpublic async prefetchAssets(themeName: string): Promise<void> {\n\t\tconst cfg = this.themeConfigs.get(themeName) ?? this.config();\n\t\tif (!cfg?.assets) {\n\t\t\treturn;\n\t\t}\n\t\tif (cfg.meta?.name && cfg.meta.name !== themeName) {\n\t\t\treturn;\n\t\t}\n\t\tconst urls = new Set<string>();\n\t\tthis.collectAssetUrls(cfg.assets, urls);\n\t\tawait Promise.allSettled(\n\t\t\t[...urls].map((url) =>\n\t\t\t\tfetch(url, {\n\t\t\t\t\tmethod: 'GET',\n\t\t\t\t\tcache: 'no-store',\n\t\t\t\t}).catch(() => undefined),\n\t\t\t),\n\t\t);\n\t}\n\n\t/**\n\t * Internal: Type guard to validate ThemeAsset structure\n\t * Requires BOTH light and dark variants to be present\n\t * Partial assets (missing either variant) are considered incomplete/broken\n\t *\n\t * Why both required:\n\t * - Assets are designed to work with both light and dark themes\n\t * - Missing variant indicates incomplete config (likely accidental removal)\n\t * - Triggers fallback to last complete working config\n\t * - Prevents partially broken configs from overwriting good state\n\t *\n\t * @private\n\t * @param obj - Object to validate\n\t * @returns True if object is a valid ThemeAsset (has BOTH light and dark)\n\t */\n\tprivate isThemeAsset(obj: unknown): obj is ThemeAsset {\n\t\tif (typeof obj !== 'object' || obj === null) return false;\n\n\t\tconst record = obj as Record<string, unknown>;\n\t\tconst hasLight = typeof record['light'] === 'string';\n\t\tconst hasDark = typeof record['dark'] === 'string';\n\n\t\t// Valid only if BOTH variants are present\n\t\treturn hasLight && hasDark;\n\t}\n\n\tprivate collectAssetUrls(assets: ThemeAssets, urls: Set<string>): void {\n\t\tfor (const asset of assets) {\n\t\t\tif (!this.isThemeAsset(asset)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (asset.light) urls.add(asset.light);\n\t\t\tif (asset.dark) urls.add(asset.dark);\n\t\t}\n\t}\n\n\tprivate findAsset(assets: ThemeAssets, assetKey: string): ThemeAsset | null {\n\t\treturn assets.find((asset) => asset.name === assetKey) ?? null;\n\t}\n\n\tprivate cacheAsset(themeName: string, assetKey: string, config: RuntimeThemeConfig | null): void {\n\t\tif (!config) {\n\t\t\treturn;\n\t\t}\n\t\tconst light = this.resolveAssetUrl(themeName, assetKey, 'light', config);\n\t\tconst dark = this.resolveAssetUrl(themeName, assetKey, 'dark', config);\n\t\tif (!light && !dark) {\n\t\t\treturn;\n\t\t}\n\t\tconst cacheKey = `${themeName}:${assetKey}`;\n\t\tif (!this.assetCacheByTheme.has(themeName)) {\n\t\t\tthis.assetCacheByTheme.set(themeName, new Map());\n\t\t}\n\t\tthis.assetCacheByTheme.get(themeName)?.set(cacheKey, {\n\t\t\tthemeName,\n\t\t\tassetKey,\n\t\t\tlight: light ?? null,\n\t\t\tdark: dark ?? null,\n\t\t});\n\t}\n}\n","/**\n * DOM and CSS Injection Service\n * Handles all DOM manipulation and CSS variable injection\n */\n\nimport { DOCUMENT } from '@angular/common';\nimport { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core';\nimport { THEME_ATTRIBUTE_NAME, THEME_SCHEME_SEPARATOR } from '../constants';\nimport { FontDefinition, FontFaceDefinition, ThemeVariablesByCategory, ThemeVariants } from '../models/theme';\n\n/**\n * Service responsible for all DOM manipulation and CSS injection\n * Decouples DOM operations from business logic\n */\n@Injectable({ providedIn: 'root' })\nexport class ThemeDomService {\n\tprivate readonly renderer: Renderer2;\n\n\tconstructor(\n\t\tprivate readonly rendererFactory: RendererFactory2,\n\t\t@Inject(DOCUMENT) private readonly document: Document,\n\t) {\n\t\tthis.renderer = this.rendererFactory.createRenderer(null, null);\n\t}\n\n\t/**\n\t * Injects font-face declarations into the document head\n\t * Creates a single style tag containing all font face definitions\n\t * @param fonts - Array of font definitions with faces\n\t */\n\tinjectFonts(fonts: FontDefinition[]): void {\n\t\tconst styleContent = this.buildFontFaceStyles(fonts);\n\n\t\tif (styleContent) {\n\t\t\tthis.appendStyleToHead(styleContent);\n\t\t}\n\t}\n\n\t/**\n\t * Injects global CSS variables as root-level custom properties\n\t * Sets variables directly on the document element\n\t * @param variables - Record of CSS variable names to values\n\t */\n\tinjectGlobalVariables(variables: Record<string, string>): void {\n\t\tconst root = this.document.documentElement || this.document.body;\n\n\t\tObject.entries(variables).forEach(([key, value]) => {\n\t\t\tthis.renderer.setStyle(root, key, value);\n\t\t});\n\t}\n\n\t/**\n\t * Injects theme-specific CSS variables as data-theme selectors\n\t * Creates styles for each theme variant (light/dark)\n\t * @param themes - Theme definitions with variants\n\t */\n\tinjectThemeStyles(themes: Record<string, ThemeVariants>): void {\n\t\tconst styleContent = this.buildThemeStyles(themes);\n\n\t\tif (styleContent) {\n\t\t\tthis.appendStyleToHead(styleContent);\n\t\t}\n\t}\n\n\t/**\n\t * Injects CSS variables for a single theme only\n\t * @param themeName - Theme key\n\t * @param variants - Theme variants to inject\n\t */\n\tinjectSingleThemeStyles(themeName: string, variants: ThemeVariants): void {\n\t\tconst styleBlocks = this.buildThemeVariantStyles(themeName, variants);\n\t\tconst styleContent = styleBlocks.filter((content) => content.length > 0).join('\\n\\n');\n\t\tif (styleContent) {\n\t\t\tthis.replaceThemeStyle(themeName, styleContent);\n\t\t}\n\t}\n\n\t/**\n\t * Sets the active theme on the document body\n\t * Uses data-theme attribute for CSS theme targeting\n\t * @param themeIdentifier - Theme identifier (e.g., \"sixbell_telco__light\")\n\t */\n\tsetActiveTheme(themeIdentifier: string): void {\n\t\tthis.renderer.setAttribute(this.document.body, THEME_ATTRIBUTE_NAME, themeIdentifier);\n\t}\n\n\t/**\n\t * Builds @font-face CSS rules from font definitions\n\t * Private helper to construct font-face declarations\n\t * @param fonts - Array of font definitions\n\t * @returns CSS content string with @font-face rules\n\t */\n\tprivate buildFontFaceStyles(fonts: FontDefinition[]): string {\n\t\treturn fonts\n\t\t\t.map((font) => this.buildFontFaceForFamily(font))\n\t\t\t.filter((content) => content.length > 0)\n\t\t\t.join('\\n');\n\t}\n\n\t/**\n\t * Builds @font-face rules for a single font family\n\t * @param font - Font definition with faces\n\t * @returns CSS content string with all faces for this family\n\t */\n\tprivate buildFontFaceForFamily(font: FontDefinition): string {\n\t\treturn font.faces.map((face) => this.buildFontFaceRule(font.family, face)).join('\\n');\n\t}\n\n\t/**\n\t * Builds a single @font-face rule\n\t * @param fontFamily - Font family name\n\t * @param face - Font face definition\n\t * @returns @font-face CSS rule\n\t */\n\tprivate buildFontFaceRule(fontFamily: string, face: FontFaceDefinition): string {\n\t\treturn `@font-face {\n\tfont-family: '${fontFamily}';\n\tfont-weight: ${face.weight};\n\tfont-style: ${face.style};\n\tsrc: url('${face.src}');\n}`;\n\t}\n\n\t/**\n\t * Builds theme style definitions as CSS custom properties\n\t * Creates selectors for each theme variant\n\t * @param themes - Theme definitions mapped by theme code\n\t * @returns CSS content string with theme definitions\n\t */\n\tprivate buildThemeStyles(themes: Record<string, ThemeVariants>): string {\n\t\tconst styleBlocks = Object.entries(themes)\n\t\t\t.flatMap(([themeName, variants]) => this.buildThemeVariantStyles(themeName, variants))\n\t\t\t.filter((content) => content.length > 0);\n\n\t\treturn styleBlocks.join('\\n\\n');\n\t}\n\n\t/**\n\t * Builds CSS rules for light and dark variants of a theme\n\t * @param themeName - Name of the theme\n\t * @param variants - Light and dark variants\n\t * @returns Array of CSS rules for each variant\n\t */\n\tprivate buildThemeVariantStyles(themeName: string, variants: ThemeVariants): string[] {\n\t\tconst styles: string[] = [];\n\n\t\tif (variants.light) {\n\t\t\tstyles.push(this.buildThemeVariantRule(themeName, 'light', variants.light.variables));\n\t\t}\n\n\t\tif (variants.dark) {\n\t\t\tstyles.push(this.buildThemeVariantRule(themeName, 'dark', variants.dark.variables));\n\t\t}\n\n\t\treturn styles;\n\t}\n\n\t/**\n\t * Builds a single theme variant CSS rule\n\t * @param themeName - Name of the theme\n\t * @param scheme - Scheme variant ('light' or 'dark')\n\t * @param variables - CSS variables for this variant\n\t * @returns CSS rule string\n\t */\n\tprivate buildThemeVariantRule(themeName: string, scheme: string, variables: ThemeVariablesByCategory): string {\n\t\tconst selector = `[${THEME_ATTRIBUTE_NAME}=\"${themeName}${THEME_SCHEME_SEPARATOR}${scheme}\"]`;\n\t\tconst flattened = this.flattenVariables(variables);\n\t\tconst declarations = Object.entries(flattened)\n\t\t\t.map(([key, value]) => `\\t${key}: ${value};`)\n\t\t\t.join('\\n');\n\n\t\treturn `${selector} {\n${declarations}\n}`;\n\t}\n\n\t/**\n\t * Appends a style tag to the document head\n\t * Helper to inject CSS into the document\n\t * @param content - CSS content to inject\n\t */\n\tprivate appendStyleToHead(content: string): void {\n\t\tconst style = this.renderer.createElement('style');\n\t\tstyle.textContent = content;\n\t\tthis.renderer.appendChild(this.document.head, style);\n\t}\n\n\tprivate flattenVariables(variables: ThemeVariablesByCategory): Record<string, string> {\n\t\treturn {\n\t\t\t...variables.colors,\n\t\t\t...variables.radius,\n\t\t\t...variables.sizes,\n\t\t\t...variables.effects,\n\t\t\t...variables.others,\n\t\t};\n\t}\n\n\tprivate replaceThemeStyle(themeName: string, content: string): void {\n\t\tconst existing = this.document.head.querySelector(`style[data-theme-style=\"${themeName}\"]`);\n\t\tif (existing) {\n\t\t\texisting.textContent = content;\n\t\t\treturn;\n\t\t}\n\t\tconst style = this.renderer.createElement('style');\n\t\tstyle.textContent = content;\n\t\tstyle.setAttribute('data-theme-style', themeName);\n\t\tthis.renderer.appendChild(this.document.head, style);\n\t}\n}\n","/**\n * Theme Scheme Resolver Service\n * Handles scheme detection and resolution (system -> light/dark)\n */\n\nimport { Injectable } from '@angular/core';\nimport { DARK_MODE_MEDIA_QUERY } from '../constants';\nimport { ResolvedThemeScheme, ThemeScheme } from '../models/theme';\nimport { supportsMediaQuery } from '../utils/utils';\n\n/**\n * Service for resolving and detecting color schemes\n * Handles system preference detection and scheme validation\n * Separates scheme logic from the main service\n */\n@Injectable({ providedIn: 'root' })\nexport class ThemeSchemeService {\n\t/**\n\t * Media query list for detecting dark mode preference\n\t * Cached to avoid repeated queries\n\t */\n\tprivate darkModeMediaQuery: MediaQueryList | null = null;\n\n\t/**\n\t * Resolves a theme scheme to its actual light/dark value\n\t * If scheme is 'system', detects the OS preference\n\t * If scheme is already 'light' or 'dark', returns it as-is\n\t * @param scheme - The scheme to resolve\n\t * @returns Resolved scheme ('light' or 'dark')\n\t */\n\tresolveScheme(scheme: ThemeScheme): ResolvedThemeScheme {\n\t\tif (scheme === 'system') {\n\t\t\treturn this.detectSystemScheme();\n\t\t}\n\n\t\treturn scheme as ResolvedThemeScheme;\n\t}\n\n\t/**\n\t * Detects the system's preferred color scheme\n\t * Uses CSS media query (prefers-color-scheme: dark)\n\t * Safely handles environments where matchMedia is not available\n\t * @returns 'dark' if system prefers dark, 'light' otherwise\n\t */\n\tdetectSystemScheme(): ResolvedThemeScheme {\n\t\tif (!supportsMediaQuery()) {\n\t\t\t// Fallback to light in non-browser environments\n\t\t\treturn 'light';\n\t\t}\n\n\t\t// Use cached media query list if available\n\t\tif (!this.darkModeMediaQuery) {\n\t\t\tthis.darkModeMediaQuery = this.createMediaQuery();\n\t\t}\n\n\t\treturn this.darkModeMediaQuery.matches ? 'dark' : 'light';\n\t}\n\n\t/**\n\t * Listens for changes in system color scheme preference\n\t * Allows components to react to OS theme changes\n\t * @param listener - Callback function when scheme changes\n\t * @returns Unsubscribe function to remove listener\n\t */\n\tlistenToSystemSchemeChanges(listener: (scheme: ResolvedThemeScheme) => void): () => void {\n\t\tif (!supportsMediaQuery()) {\n\t\t\t// Return no-op unsubscribe function if matchMedia not available\n\t\t\treturn () => {};\n\t\t}\n\n\t\tconst mediaQuery = this.createMediaQuery();\n\n\t\tconst handleChange = (e: MediaQueryListEvent) => {\n\t\t\tlistener(e.matches ? 'dark' : 'light');\n\t\t};\n\n\t\t// Use addEventListener for modern browsers\n\t\tif (mediaQuery.addEventListener) {\n\t\t\tmediaQuery.addEventListener('change', handleChange);\n\n\t\t\treturn () => mediaQuery.removeEventListener('change', handleChange);\n\t\t}\n\n\t\t// Fallback for older browsers using addListener (deprecated but supported)\n\t\tconst legacyMediaQuery = mediaQuery as MediaQueryList & {\n\t\t\taddListener?: (listener: (e: MediaQueryListEvent) => void) => void;\n\t\t\tremoveListener?: (listener: (e: MediaQueryListEvent) => void) => void;\n\t\t};\n\t\tlegacyMediaQuery.addListener?.(handleChange);\n\n\t\treturn () => legacyMediaQuery.removeListener?.(handleChange);\n\t}\n\n\t/**\n\t * Creates a media query for detecting dark mode\n\t * Extracted method to make testing easier\n\t * @returns MediaQueryList for dark mode preference\n\t */\n\tprivate createMediaQuery(): MediaQueryList {\n\t\treturn window.matchMedia(DARK_MODE_MEDIA_QUERY);\n\t}\n}\n","import { computed, inject, Injectable, signal } from '@angular/core';\nimport { LoggerService } from '@sixbell-telco/sdk/utils/logger';\nimport { RuntimeConfigLoader, RuntimeConfigLoadResult, RuntimeUpdateEvent } from '@sixbell-telco/sdk/utils/runtime-config';\nimport { TranslationService } from '@sixbell-telco/sdk/utils/translation';\nimport { DEFAULT_BASE_THEME, DEFAULT_SCHEME, VALID_SCHEMES } from './constants';\nimport {\n\tFontDefinition,\n\tResolvedThemeScheme,\n\tRuntimeThemeConfig,\n\tRuntimeThemeConfigFile,\n\tThemeScheme,\n\tThemeStateConfig,\n\tThemeStyleDefinition,\n\tThemeVariablesByCategory,\n} from './models/theme';\nimport { ThemeAssetsService } from './theme-assets/theme-assets.service';\nimport { ThemeDomService } from './theme-dom/theme-dom.