@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
JavaScript
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