UNPKG

@sixbell-telco/sdk

Version:

A collection of reusable components designed for use in Sixbell Telco Angular projects

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