As a web app user, I want to choose from multiple available themes — sometimes light, other times dark.
Develop a TypeScript-based API that provides helper functions or classes for building a theme picker in web applications. The API should be framework‑agnostic, while optionally offering convenient integration points for Angular applications.
colors.css, with an optional dark‑mode variant like colors-dark.css.Using the requirements as prompt, I asked M365 Copilot to generate sourcecode, and the following is the result.
I’ll create a comprehensive TypeScript theme management system that meets all your requirements. Let me start by reading the relevant skills to ensure I build this properly.Now I’ll create a robust theme management system with the following architecture:
Core Components:
theme-manager.ts - Main theme manager class (framework-agnostic)config-loader.ts - Runtime-adjustable configuration loaderstorage.ts - LocalStorage adapter with fallbacktypes.ts - Complete TypeScript type definitionsindex.ts - Main export fileAngular Integration:
angular/theme.service.ts - Angular service with RxJS supportangular/theme.module.ts - Angular module with APP_INITIALIZERangular/theme-picker.component.ts - Example UI component✅ Functional Requirements:
isDark property)stylesheetUrl + colorStylesheetUrl)switchTheme())✅ Technical Requirements:
README.md - Complete API referenceIMPLEMENTATION_GUIDE.md - Step-by-step integration guideexamples/ - Working examples for Angular & vanilla JStests/ - Unit test examplesStart with the README for a quick overview, then dive into the IMPLEMENTATION_GUIDE for detailed setup instructions!
A flexible, framework-agnostic TypeScript theme management solution with first-class Angular support. Provides flicker-free theme loading, cross-tab synchronization, and runtime-adjustable configurations.
✅ Multiple Themes - Support for 3+ themes (requirement met)
✅ Light & Dark Modes - Built-in support for both modes
✅ Zero Flicker - Anti-FOUC technique for instant theme application
✅ Cross-Tab Sync - Theme changes propagate across all open tabs
✅ Persistent Preferences - User selections saved in localStorage
✅ PWA & Offline Ready - Works in Progressive Web Apps and offline
✅ Runtime Adjustable - Config can be changed after deployment
✅ CDN Support - Host themes locally or on CDN
✅ Minimal API - Simple, easy-to-use interface
✅ Framework Agnostic - Core works anywhere, Angular integration included
✅ TypeScript First - Full type safety throughout
npm install theme-manager-system
# or
yarn add theme-manager-system
import { ThemeManager } from 'theme-manager-system';
const manager = new ThemeManager({
themes: [
{
id: 'light',
name: 'Light Theme',
stylesheetUrl: '/themes/light.css',
colorStylesheetUrl: '/themes/colors.css',
isDark: false
},
{
id: 'dark',
name: 'Dark Theme',
stylesheetUrl: '/themes/dark.css',
colorStylesheetUrl: '/themes/colors-dark.css',
isDark: true
},
{
id: 'high-contrast',
name: 'High Contrast',
stylesheetUrl: '/themes/high-contrast.css',
isDark: false
}
],
defaultThemeId: 'light'
});
// IMPORTANT: Initialize before rendering content
await manager.initialize();
// Switch themes
await manager.switchTheme('dark');
app.module.ts:
import { ThemeModule } from 'theme-manager-system';
@NgModule({
imports: [
ThemeModule.forRoot({
config: '/assets/theme-config.json' // or static config object
})
]
})
export class AppModule { }
component.ts:
import { Component } from '@angular/core';
import { ThemeService } from 'theme-manager-system';
@Component({
selector: 'app-root',
template: `
<app-theme-picker></app-theme-picker>
<div>Current theme: </div>
`
})
export class AppComponent {
constructor(public themeService: ThemeService) {}
async switchToDark() {
await this.themeService.switchTheme('dark');
}
}
const config: ThemeConfig = {
themes: [
{
id: 'light',
name: 'Light Theme',
stylesheetUrl: '/themes/light.css',
colorStylesheetUrl: '/themes/colors.css', // Optional
isDark: false,
metadata: { /* custom data */ } // Optional
},
// ... more themes (minimum 3 total)
],
defaultThemeId: 'light',
storageKey: 'my-app-theme', // Optional, default: 'app-theme-preference'
preventFlicker: true, // Optional, default: true
dataAttribute: 'data-theme' // Optional, default: 'data-theme'
};
theme-config.json:
{
"themes": [
{
"id": "light-default",
"name": "Light Default",
"stylesheetUrl": "/themes/light.css",
"colorStylesheetUrl": "/themes/colors.css",
"isDark": false
},
{
"id": "dark-default",
"name": "Dark Default",
"stylesheetUrl": "/themes/dark.css",
"colorStylesheetUrl": "/themes/colors-dark.css",
"isDark": true
},
{
"id": "blue-ocean",
"name": "Blue Ocean",
"stylesheetUrl": "https://cdn.example.com/themes/blue.css",
"isDark": false
}
],
"defaultThemeId": "light-default",
"storageKey": "my-app-theme"
}
Load it:
import { ThemeConfigLoader, ThemeManager } from 'theme-manager-system';
const config = await ThemeConfigLoader.loadFromUrl('/assets/theme-config.json');
const manager = new ThemeManager(config);
await manager.initialize();
To prevent the flash of unstyled content (FOUC), add this inline script to your HTML <head>:
<head>
<script>
(function() {
const STORAGE_KEY = 'app-theme-preference';
const DEFAULT_THEME = 'light';
const savedThemeId = localStorage.getItem(STORAGE_KEY) || DEFAULT_THEME;
document.documentElement.setAttribute('data-theme', savedThemeId);
// Inject theme stylesheet synchronously
const themeMap = {
'light': '/themes/light.css',
'dark': '/themes/dark.css'
};
const href = themeMap[savedThemeId] || themeMap[DEFAULT_THEME];
document.write('<link rel="stylesheet" href="' + href + '">');
})();
</script>
</head>
initialize(): Promise<void> - Initialize the manager (call before rendering)switchTheme(themeId: string, options?: ThemeSwitchOptions): Promise<void> - Switch to a different themegetCurrentTheme(): Theme | null - Get the currently active themegetAvailableThemes(): Theme[] - Get all available themesgetThemeById(themeId: string): Theme | undefined - Get a specific themeonThemeChange(callback: ThemeChangeCallback): () => void - Subscribe to theme changes (returns unsubscribe function)updateConfig(newConfig: Partial<ThemeConfig>): void - Update configuration at runtimepreloadTheme(themeId: string): Promise<void> - Preload a theme for faster switchingtheme$: Observable<Theme | null> - Observable of the current themecurrentTheme: Theme | null - Current theme (synchronous)availableThemes: Theme[] - All available themesswitchTheme(themeId: string): Promise<void> - Switch themestoggleDarkMode(): Promise<void> - Toggle between light and darkpreloadTheme(themeId: string): Promise<void> - Preload a themeupdateConfig(newConfig: Partial<ThemeConfig>): void - Update configuration// Admin can update themes after deployment
manager.updateConfig({
themes: [
// ... new theme list
],
defaultThemeId: 'new-default'
});
// Preload alternate themes for instant switching
const themes = manager.getAvailableThemes();
await Promise.all(
themes.slice(0, 3).map(t => manager.preloadTheme(t.id))
);
The system automatically synchronizes theme changes across browser tabs using localStorage events. No additional code needed.
// Listen for service worker updates
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
const current = manager.getCurrentTheme();
if (current) {
manager.switchTheme(current.id, { forceReload: true });
}
});
}
| Requirement | Status | Implementation |
|---|---|---|
| Light & dark theme support | ✅ | isDark property on themes |
| 3+ themes | ✅ | Config validation enforces minimum 3 themes |
| Prebuilt themes + custom colors | ✅ | stylesheetUrl + optional colorStylesheetUrl |
| Dynamic theme switching | ✅ | switchTheme() method |
| Cross-tab persistence | ✅ | localStorage + storage events |
| Reusable across apps | ✅ | Framework-agnostic core |
| Minimal API | ✅ | Simple, focused interface |
| UI-neutral | ✅ | No UI components in core (examples provided) |
| No flicker | ✅ | Anti-FOUC inline script + preloading |
| SPA & PWA support | ✅ | Works in all environments |
| Offline functionality | ✅ | LocalStorage fallback |
| Post-deployment adjustable | ✅ | External JSON config + updateConfig() |
| CDN support | ✅ | Themes can be hosted anywhere |
| No reload when same theme | ✅ | Checks current theme before switching |
| Separated core from UI | ✅ | ThemeManager (core) separate from picker components |
| Config loaded before render | ✅ | initialize() + APP_INITIALIZER in Angular |
MIT
Contributions welcome! Please open an issue or PR.
Complete guide for implementing the Theme Management System in your application.
npm install theme-manager-system
import { ThemeManager } from 'theme-manager-system';
const manager = new ThemeManager({
themes: [
{ id: 'light', name: 'Light', stylesheetUrl: '/themes/light.css', isDark: false },
{ id: 'dark', name: 'Dark', stylesheetUrl: '/themes/dark.css', isDark: true },
{ id: 'auto', name: 'Auto', stylesheetUrl: '/themes/auto.css', isDark: false }
],
defaultThemeId: 'light'
});
await manager.initialize();
npm install theme-manager-system
src/assets/theme-config.json:
{
"themes": [
{
"id": "light-default",
"name": "Light",
"stylesheetUrl": "/themes/light.css",
"colorStylesheetUrl": "/themes/colors.css",
"isDark": false
},
{
"id": "dark-default",
"name": "Dark",
"stylesheetUrl": "/themes/dark.css",
"colorStylesheetUrl": "/themes/colors-dark.css",
"isDark": true
},
{
"id": "high-contrast",
"name": "High Contrast",
"stylesheetUrl": "/themes/high-contrast.css",
"isDark": false
}
],
"defaultThemeId": "light-default",
"storageKey": "my-app-theme"
}
app.module.ts:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ThemeModule } from 'theme-manager-system';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
ThemeModule.forRoot({
config: '/assets/theme-config.json'
})
],
bootstrap: [AppComponent]
})
export class AppModule { }
app.component.ts:
import { Component, OnInit } from '@angular/core';
import { ThemeService } from 'theme-manager-system';
import { Observable } from 'rxjs';
import { Theme } from 'theme-manager-system';
@Component({
selector: 'app-root',
template: `
<header>
<h1>My App</h1>
<div class="theme-controls">
<select (change)="onThemeChange($event)">
<option *ngFor="let theme of themes"
[value]="theme.id"
[selected]="theme.id === (currentTheme$ | async)?.id">
</option>
</select>
<button (click)="toggleDarkMode()">
</button>
</div>
</header>
`
})
export class AppComponent implements OnInit {
currentTheme$!: Observable<Theme | null>;
themes: Theme[] = [];
constructor(private themeService: ThemeService) {}
ngOnInit() {
this.currentTheme$ = this.themeService.theme$;
this.themes = this.themeService.availableThemes;
}
async onThemeChange(event: Event) {
const select = event.target as HTMLSelectElement;
await this.themeService.switchTheme(select.value);
}
async toggleDarkMode() {
await this.themeService.toggleDarkMode();
}
}
main.ts:
import { ThemeManager, ThemeConfigLoader } from 'theme-manager-system';
class App {
private themeManager!: ThemeManager;
async init() {
// Load configuration
const config = await ThemeConfigLoader.loadFromUrl('/assets/theme-config.json');
// Create manager
this.themeManager = new ThemeManager(config);
// Initialize (MUST be called before rendering)
await this.themeManager.initialize();
// Setup UI
this.setupThemePicker();
this.preloadAlternateThemes();
}
private setupThemePicker() {
const select = document.getElementById('theme-select') as HTMLSelectElement;
const toggleBtn = document.getElementById('theme-toggle') as HTMLButtonElement;
// Populate select
this.themeManager.getAvailableThemes().forEach(theme => {
const option = document.createElement('option');
option.value = theme.id;
option.textContent = theme.name;
select.appendChild(option);
});
// Set current theme
select.value = this.themeManager.getCurrentTheme()?.id || '';
// Handle changes
select.addEventListener('change', async (e) => {
const target = e.target as HTMLSelectElement;
await this.themeManager.switchTheme(target.value);
});
toggleBtn.addEventListener('click', async () => {
const current = this.themeManager.getCurrentTheme();
if (!current) return;
const themes = this.themeManager.getAvailableThemes();
const opposite = themes.find(t => t.isDark !== current.isDark);
if (opposite) {
await this.themeManager.switchTheme(opposite.id);
}
});
// Listen for theme changes
this.themeManager.onThemeChange((event) => {
select.value = event.currentTheme.id;
this.updateToggleButton(event.currentTheme);
});
}
private updateToggleButton(theme: Theme) {
const btn = document.getElementById('theme-toggle') as HTMLButtonElement;
btn.textContent = theme.isDark ? '☀️ Light Mode' : '🌙 Dark Mode';
}
private async preloadAlternateThemes() {
const currentId = this.themeManager.getCurrentTheme()?.id;
const themes = this.themeManager.getAvailableThemes();
const preloadPromises = themes
.filter(t => t.id !== currentId)
.slice(0, 2)
.map(t => this.themeManager.preloadTheme(t.id));
await Promise.all(preloadPromises).catch(console.warn);
}
}
// Initialize app
const app = new App();
app.init().catch(console.error);
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My App</title>
<!-- Anti-flicker script -->
<script>
(function() {
const savedTheme = localStorage.getItem('my-app-theme') || 'light-default';
document.documentElement.setAttribute('data-theme', savedTheme);
const themeUrls = {
'light-default': '/themes/light.css',
'dark-default': '/themes/dark.css',
'high-contrast': '/themes/high-contrast.css'
};
const url = themeUrls[savedTheme] || themeUrls['light-default'];
document.write('<link rel="stylesheet" href="' + url + '">');
})();
</script>
</head>
<body>
<header>
<h1>My App</h1>
<div>
<select id="theme-select"></select>
<button id="theme-toggle">Toggle Dark Mode</button>
</div>
</header>
<main>
<!-- Your content -->
</main>
<script type="module" src="/main.js"></script>
</body>
</html>
service-worker.js:
// Cache theme files for offline use
const CACHE_NAME = 'theme-cache-v1';
const THEME_URLS = [
'/themes/light.css',
'/themes/dark.css',
'/themes/high-contrast.css',
'/themes/colors.css',
'/themes/colors-dark.css',
'/assets/theme-config.json'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(THEME_URLS);
})
);
});
self.addEventListener('fetch', (event) => {
// Cache-first strategy for theme files
if (THEME_URLS.some(url => event.request.url.includes(url))) {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
}
});
import { ThemeManager } from 'theme-manager-system';
// Register service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js');
// Handle service worker updates
navigator.serviceWorker.addEventListener('controllerchange', () => {
// Reload current theme after SW update
const currentTheme = themeManager.getCurrentTheme();
if (currentTheme) {
themeManager.switchTheme(currentTheme.id, { forceReload: true });
}
});
}
Benefits:
theme-config.json:
{
"themes": [
{
"id": "light",
"name": "Light Theme",
"stylesheetUrl": "/themes/light.css",
"colorStylesheetUrl": "/themes/colors.css",
"isDark": false,
"metadata": {
"description": "Clean light theme",
"author": "Design Team"
}
}
],
"defaultThemeId": "light",
"storageKey": "app-theme",
"preventFlicker": true
}
const config: ThemeConfig = {
themes: [/* ... */],
defaultThemeId: 'light',
storageKey: 'app-theme'
};
const manager = new ThemeManager(config);
Place in <head> before any other stylesheets:
<script>
(function() {
'use strict';
const STORAGE_KEY = 'my-app-theme';
const DEFAULT_THEME = 'light';
// Get saved preference
let themeId = DEFAULT_THEME;
try {
themeId = localStorage.getItem(STORAGE_KEY) || DEFAULT_THEME;
} catch (e) {
// localStorage unavailable
}
// Set data attribute
document.documentElement.setAttribute('data-theme', themeId);
// Theme URL mapping
const themes = {
'light': { css: '/themes/light.css', colors: '/themes/colors.css' },
'dark': { css: '/themes/dark.css', colors: '/themes/colors-dark.css' },
'high-contrast': { css: '/themes/high-contrast.css' }
};
const theme = themes[themeId] || themes[DEFAULT_THEME];
// Inject stylesheets synchronously
document.write('<link rel="stylesheet" href="' + theme.css + '">');
if (theme.colors) {
document.write('<link rel="stylesheet" href="' + theme.colors + '">');
}
})();
</script>
my-theme.css:
:root[data-theme="my-custom-theme"] {
--bg-primary: #custom-color;
--text-primary: #another-color;
/* ... more variables */
}
.my-component {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
function generateTheme(baseColor: string): Theme {
return {
id: `custom-${Date.now()}`,
name: 'Custom Theme',
stylesheetUrl: `/api/themes/generate?color=${baseColor}`,
isDark: false
};
}
import { ThemeManager } from 'theme-manager-system';
describe('Theme Switching', () => {
let manager: ThemeManager;
beforeEach(async () => {
manager = new ThemeManager(testConfig);
await manager.initialize();
});
test('switches theme correctly', async () => {
await manager.switchTheme('dark');
expect(manager.getCurrentTheme()?.id).toBe('dark');
});
});
test('theme persists across page reload', async () => {
await manager.switchTheme('dark');
// Simulate page reload
const newManager = new ThemeManager(config);
await newManager.initialize();
expect(newManager.getCurrentTheme()?.id).toBe('dark');
});
<head>index.ts
/**
* Theme Management System
* A flexible, framework-agnostic theme management solution for TypeScript applications
*/
// Core exports
export { ThemeManager } from './theme-manager';
export { ThemeConfigLoader } from './config-loader';
export { LocalStorageAdapter } from './storage';
// Type exports
export type {
Theme,
ThemeConfig,
ThemeChangeEvent,
ThemeChangeCallback,
ThemeStorage,
ThemeSwitchOptions
} from './types';
// Angular exports (optional - only import if using Angular)
export { ThemeService, themeInitializerFactory } from './angular/theme.service';
export { ThemeModule } from './angular/theme.module';
export { ThemePickerComponent } from './angular/theme-picker.component';
export type { ThemeModuleConfig } from './angular/theme.module';
themeManager.ts
import {
Theme,
ThemeConfig,
ThemeChangeEvent,
ThemeChangeCallback,
ThemeStorage,
ThemeSwitchOptions
} from './types';
import { LocalStorageAdapter } from './storage';
/**
* Core theme manager - framework agnostic
* Handles theme loading, switching, and persistence
*/
export class ThemeManager {
private config: ThemeConfig;
private currentTheme: Theme | null = null;
private storage: ThemeStorage;
private listeners: Set<ThemeChangeCallback> = new Set();
private loadedStylesheets: Map<string, HTMLLinkElement> = new Map();
private initialized: boolean = false;
private readonly DEFAULT_STORAGE_KEY = 'app-theme-preference';
private readonly DEFAULT_DATA_ATTRIBUTE = 'data-theme';
constructor(config: ThemeConfig, storage?: ThemeStorage) {
this.validateConfig(config);
this.config = config;
this.storage = storage ?? new LocalStorageAdapter();
}
/**
* Initialize the theme manager
* MUST be called before any components render
*/
async initialize(): Promise<void> {
if (this.initialized) {
console.warn('ThemeManager already initialized');
return;
}
// Determine which theme to load
const savedThemeId = this.getSavedThemeId();
const themeToLoad = this.getThemeById(savedThemeId) ?? this.getDefaultTheme();
// Apply theme synchronously to prevent flicker
await this.applyTheme(themeToLoad, { skipPersistence: !!savedThemeId });
// Listen for cross-tab theme changes
this.setupCrossTabSync();
this.initialized = true;
}
/**
* Switch to a different theme
*/
async switchTheme(themeId: string, options?: ThemeSwitchOptions): Promise<void> {
const theme = this.getThemeById(themeId);
if (!theme) {
throw new Error(`Theme not found: ${themeId}`);
}
// Skip if already active (unless forced)
if (!options?.forceReload && this.currentTheme?.id === themeId) {
return;
}
await this.applyTheme(theme, options);
}
/**
* Get the currently active theme
*/
getCurrentTheme(): Theme | null {
return this.currentTheme;
}
/**
* Get all available themes
*/
getAvailableThemes(): Theme[] {
return [...this.config.themes];
}
/**
* Get a theme by ID
*/
getThemeById(themeId: string | null): Theme | undefined {
if (!themeId) return undefined;
return this.config.themes.find(t => t.id === themeId);
}
/**
* Subscribe to theme change events
*/
onThemeChange(callback: ThemeChangeCallback): () => void {
this.listeners.add(callback);
// Return unsubscribe function
return () => this.listeners.delete(callback);
}
/**
* Update configuration (supports post-deployment changes)
*/
updateConfig(newConfig: Partial<ThemeConfig>): void {
if (newConfig.themes) {
this.validateThemes(newConfig.themes);
this.config.themes = newConfig.themes;
}
if (newConfig.defaultThemeId) {
this.config.defaultThemeId = newConfig.defaultThemeId;
}
if (newConfig.storageKey !== undefined) {
this.config.storageKey = newConfig.storageKey;
}
if (newConfig.dataAttribute !== undefined) {
this.config.dataAttribute = newConfig.dataAttribute;
}
}
/**
* Preload a theme's stylesheets without activating
*/
async preloadTheme(themeId: string): Promise<void> {
const theme = this.getThemeById(themeId);
if (!theme) {
throw new Error(`Theme not found: ${themeId}`);
}
await Promise.all([
this.loadStylesheet(theme.stylesheetUrl, false),
theme.colorStylesheetUrl ? this.loadStylesheet(theme.colorStylesheetUrl, false) : Promise.resolve()
]);
}
// Private methods
private validateConfig(config: ThemeConfig): void {
if (!config.themes || config.themes.length < 3) {
throw new Error('At least 3 themes must be provided');
}
this.validateThemes(config.themes);
if (!config.defaultThemeId) {
throw new Error('defaultThemeId is required');
}
const defaultTheme = config.themes.find(t => t.id === config.defaultThemeId);
if (!defaultTheme) {
throw new Error(`Default theme '${config.defaultThemeId}' not found in themes list`);
}
// Ensure at least one light and one dark theme
const hasLight = config.themes.some(t => !t.isDark);
const hasDark = config.themes.some(t => t.isDark);
if (!hasLight || !hasDark) {
throw new Error('Configuration must include at least one light and one dark theme');
}
}
private validateThemes(themes: Theme[]): void {
const ids = new Set<string>();
for (const theme of themes) {
if (!theme.id || !theme.name || !theme.stylesheetUrl) {
throw new Error('Each theme must have id, name, and stylesheetUrl');
}
if (ids.has(theme.id)) {
throw new Error(`Duplicate theme ID: ${theme.id}`);
}
ids.add(theme.id);
}
}
private async applyTheme(theme: Theme, options?: ThemeSwitchOptions): Promise<void> {
const previousTheme = this.currentTheme;
// Load new theme stylesheets
await Promise.all([
this.loadStylesheet(theme.stylesheetUrl, true),
theme.colorStylesheetUrl ? this.loadStylesheet(theme.colorStylesheetUrl, true) : Promise.resolve()
]);
// Remove old theme stylesheets
if (previousTheme) {
this.unloadStylesheet(previousTheme.stylesheetUrl);
if (previousTheme.colorStylesheetUrl) {
this.unloadStylesheet(previousTheme.colorStylesheetUrl);
}
}
// Update current theme
this.currentTheme = theme;
// Set data attribute on document element
const attrName = this.config.dataAttribute ?? this.DEFAULT_DATA_ATTRIBUTE;
document.documentElement.setAttribute(attrName, theme.id);
// Set dark mode indicator
document.documentElement.setAttribute('data-theme-mode', theme.isDark ? 'dark' : 'light');
// Persist to storage
if (!options?.skipPersistence) {
const storageKey = this.config.storageKey ?? this.DEFAULT_STORAGE_KEY;
this.storage.set(storageKey, theme.id);
}
// Notify listeners
this.notifyListeners({
previousTheme,
currentTheme: theme,
timestamp: Date.now()
});
}
private loadStylesheet(url: string, activate: boolean): Promise<void> {
return new Promise((resolve, reject) => {
// Check if already loaded
if (this.loadedStylesheets.has(url)) {
const link = this.loadedStylesheets.get(url)!;
if (activate && link.disabled) {
link.disabled = false;
}
resolve();
return;
}
// Create new link element
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.disabled = !activate;
link.onload = () => {
this.loadedStylesheets.set(url, link);
resolve();
};
link.onerror = () => {
reject(new Error(`Failed to load stylesheet: ${url}`));
};
// Insert at the beginning of head for proper cascade
const head = document.head;
if (head.firstChild) {
head.insertBefore(link, head.firstChild);
} else {
head.appendChild(link);
}
});
}
private unloadStylesheet(url: string): void {
const link = this.loadedStylesheets.get(url);
if (link) {
link.disabled = true;
// Don't remove from DOM - keep for fast reactivation
}
}
private getSavedThemeId(): string | null {
const storageKey = this.config.storageKey ?? this.DEFAULT_STORAGE_KEY;
return this.storage.get(storageKey);
}
private getDefaultTheme(): Theme {
const theme = this.getThemeById(this.config.defaultThemeId);
if (!theme) {
throw new Error(`Default theme '${this.config.defaultThemeId}' not found`);
}
return theme;
}
private notifyListeners(event: ThemeChangeEvent): void {
this.listeners.forEach(callback => {
try {
callback(event);
} catch (err) {
console.error('Error in theme change listener:', err);
}
});
}
private setupCrossTabSync(): void {
if (typeof window === 'undefined') return;
window.addEventListener('storage', (event) => {
const storageKey = this.config.storageKey ?? this.DEFAULT_STORAGE_KEY;
if (event.key === storageKey && event.newValue) {
const themeId = event.newValue;
if (this.currentTheme?.id !== themeId) {
this.switchTheme(themeId, { skipPersistence: true }).catch(err => {
console.error('Failed to sync theme from another tab:', err);
});
}
}
});
}
}
configLoader.ts
import { ThemeConfig } from './types';
/**
* Configuration loader for theme settings
* Supports loading from JSON files for post-deployment adjustments
*/
export class ThemeConfigLoader {
/**
* Load configuration from a JSON file
* Useful for admin-adjustable configurations
*/
static async loadFromUrl(url: string): Promise<ThemeConfig> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load config from ${url}: ${response.statusText}`);
}
const config = await response.json();
return this.normalizeConfig(config);
} catch (error) {
console.error('Error loading theme configuration:', error);
throw error;
}
}
/**
* Load configuration from inline script tag
* Useful for server-side rendered configurations
*/
static loadFromScriptTag(scriptId: string): ThemeConfig {
const scriptElement = document.getElementById(scriptId);
if (!scriptElement) {
throw new Error(`Script tag with id '${scriptId}' not found`);
}
try {
const config = JSON.parse(scriptElement.textContent || '{}');
return this.normalizeConfig(config);
} catch (error) {
console.error('Error parsing theme configuration from script tag:', error);
throw error;
}
}
/**
* Create a static configuration
* Useful for simple setups without external config files
*/
static createStatic(config: ThemeConfig): ThemeConfig {
return this.normalizeConfig(config);
}
/**
* Merge configuration with defaults and normalize URLs
*/
private static normalizeConfig(config: any): ThemeConfig {
if (!config.themes || !Array.isArray(config.themes)) {
throw new Error('Invalid configuration: themes array is required');
}
// Normalize theme URLs (support relative paths)
const normalizedThemes = config.themes.map((theme: any) => ({
...theme,
stylesheetUrl: this.normalizeUrl(theme.stylesheetUrl),
colorStylesheetUrl: theme.colorStylesheetUrl
? this.normalizeUrl(theme.colorStylesheetUrl)
: undefined
}));
return {
themes: normalizedThemes,
defaultThemeId: config.defaultThemeId,
storageKey: config.storageKey,
preventFlicker: config.preventFlicker ?? true,
dataAttribute: config.dataAttribute
};
}
/**
* Normalize URL to absolute path or full URL
*/
private static normalizeUrl(url: string): string {
// Already absolute URL
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) {
return url;
}
// Absolute path
if (url.startsWith('/')) {
return url;
}
// Relative path - resolve against base
const base = document.baseURI || window.location.href;
try {
return new URL(url, base).href;
} catch {
// Fallback to simple concatenation
return url;
}
}
/**
* Validate configuration before use
*/
static validate(config: ThemeConfig): boolean {
try {
if (!config.themes || config.themes.length < 3) {
console.error('Configuration must have at least 3 themes');
return false;
}
if (!config.defaultThemeId) {
console.error('Configuration must specify defaultThemeId');
return false;
}
const defaultExists = config.themes.some(t => t.id === config.defaultThemeId);
if (!defaultExists) {
console.error(`Default theme '${config.defaultThemeId}' not found in themes`);
return false;
}
const hasLight = config.themes.some(t => !t.isDark);
const hasDark = config.themes.some(t => t.isDark);
if (!hasLight || !hasDark) {
console.error('Configuration must include at least one light and one dark theme');
return false;
}
return true;
} catch (error) {
console.error('Configuration validation error:', error);
return false;
}
}
}
theme.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { ThemeManager } from './theme-manager';
import { Theme, ThemeConfig, ThemeSwitchOptions } from './types';
import { ThemeConfigLoader } from './config-loader';
/**
* Angular service for theme management
* Provides reactive theme state and Angular-friendly API
*/
@Injectable({
providedIn: 'root'
})
export class ThemeService implements OnDestroy {
private manager: ThemeManager | null = null;
private currentTheme$ = new BehaviorSubject<Theme | null>(null);
private unsubscribe: (() => void) | null = null;
/**
* Observable of the current theme
*/
get theme$(): Observable<Theme | null> {
return this.currentTheme$.asObservable();
}
/**
* Get the current theme synchronously
*/
get currentTheme(): Theme | null {
return this.manager?.getCurrentTheme() ?? null;
}
/**
* Get all available themes
*/
get availableThemes(): Theme[] {
return this.manager?.getAvailableThemes() ?? [];
}
/**
* Initialize the theme service with configuration
* MUST be called in APP_INITIALIZER before app renders
*/
async initialize(config: ThemeConfig): Promise<void> {
if (this.manager) {
console.warn('ThemeService already initialized');
return;
}
this.manager = new ThemeManager(config);
// Subscribe to theme changes
this.unsubscribe = this.manager.onThemeChange(event => {
this.currentTheme$.next(event.currentTheme);
});
// Initialize the manager
await this.manager.initialize();
// Set initial value
this.currentTheme$.next(this.manager.getCurrentTheme());
}
/**
* Initialize from a configuration file URL
*/
async initializeFromUrl(configUrl: string): Promise<void> {
const config = await ThemeConfigLoader.loadFromUrl(configUrl);
await this.initialize(config);
}
/**
* Switch to a different theme
*/
async switchTheme(themeId: string, options?: ThemeSwitchOptions): Promise<void> {
if (!this.manager) {
throw new Error('ThemeService not initialized');
}
await this.manager.switchTheme(themeId, options);
}
/**
* Toggle between light and dark theme
*/
async toggleDarkMode(): Promise<void> {
if (!this.manager) {
throw new Error('ThemeService not initialized');
}
const current = this.manager.getCurrentTheme();
if (!current) return;
const themes = this.manager.getAvailableThemes();
// Find a theme with opposite mode
const targetTheme = themes.find(t => t.isDark !== current.isDark);
if (targetTheme) {
await this.switchTheme(targetTheme.id);
}
}
/**
* Preload a theme for faster switching
*/
async preloadTheme(themeId: string): Promise<void> {
if (!this.manager) {
throw new Error('ThemeService not initialized');
}
await this.manager.preloadTheme(themeId);
}
/**
* Update configuration (supports admin changes)
*/
updateConfig(newConfig: Partial<ThemeConfig>): void {
if (!this.manager) {
throw new Error('ThemeService not initialized');
}
this.manager.updateConfig(newConfig);
}
ngOnDestroy(): void {
if (this.unsubscribe) {
this.unsubscribe();
}
this.currentTheme$.complete();
}
}
/**
* Factory function for APP_INITIALIZER
* Ensures theme is loaded before app renders
*/
export function themeInitializerFactory(
themeService: ThemeService,
configUrlOrConfig: string | ThemeConfig
): () => Promise<void> {
return async () => {
if (typeof configUrlOrConfig === 'string') {
await themeService.initializeFromUrl(configUrlOrConfig);
} else {
await themeService.initialize(configUrlOrConfig);
}
};
}
theme.module.ts
import { NgModule, APP_INITIALIZER, ModuleWithProviders } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ThemeService, themeInitializerFactory } from './theme.service';
import { ThemeConfig } from '../types';
/**
* Configuration for ThemeModule
*/
export interface ThemeModuleConfig {
/** URL to load configuration from, or static config object */
config: string | ThemeConfig;
}
/**
* Angular module for theme management
* Import this in your AppModule with forRoot()
*/
@NgModule({
imports: [CommonModule]
})
export class ThemeModule {
/**
* Configure the theme module with initialization
* Use this in your AppModule imports
*
* Example:
* ```typescript
* ThemeModule.forRoot({
* config: '/assets/theme-config.json'
* })
* ```
*/
static forRoot(config: ThemeModuleConfig): ModuleWithProviders<ThemeModule> {
return {
ngModule: ThemeModule,
providers: [
ThemeService,
{
provide: APP_INITIALIZER,
useFactory: themeInitializerFactory,
deps: [ThemeService],
multi: true,
useValue: () => themeInitializerFactory(
// These will be injected by Angular
null as any,
config.config
)
}
]
};
}
}
themeConfig.json
{
"themes": [
{
"id": "light-default",
"name": "Light Default",
"stylesheetUrl": "/themes/light.css",
"colorStylesheetUrl": "/themes/colors.css",
"isDark": false,
"metadata": {
"description": "Clean light theme with blue accents"
}
},
{
"id": "dark-default",
"name": "Dark Default",
"stylesheetUrl": "/themes/dark.css",
"colorStylesheetUrl": "/themes/colors-dark.css",
"isDark": true,
"metadata": {
"description": "Elegant dark theme with reduced eye strain"
}
},
{
"id": "high-contrast",
"name": "High Contrast",
"stylesheetUrl": "/themes/high-contrast.css",
"isDark": false,
"metadata": {
"description": "Maximum contrast for accessibility"
}
},
{
"id": "blue-ocean",
"name": "Blue Ocean",
"stylesheetUrl": "https://cdn.example.com/themes/blue-ocean.css",
"colorStylesheetUrl": "/themes/colors.css",
"isDark": false,
"metadata": {
"description": "Calming blue theme",
"cdnHosted": true
}
},
{
"id": "midnight",
"name": "Midnight",
"stylesheetUrl": "https://cdn.example.com/themes/midnight.css",
"colorStylesheetUrl": "/themes/colors-dark.css",
"isDark": true,
"metadata": {
"description": "Deep blue dark theme",
"cdnHosted": true
}
}
],
"defaultThemeId": "light-default",
"storageKey": "my-app-theme",
"preventFlicker": true,
"dataAttribute": "data-theme"
}
vanilla usage.ts
/**
* Vanilla JavaScript/TypeScript Usage Example
* Shows how to use the theme system without Angular
*/
import { ThemeManager, ThemeConfigLoader } from './theme-system';
// Example 1: Initialize with static configuration
async function initializeWithStaticConfig() {
const manager = new ThemeManager({
themes: [
{
id: 'light',
name: 'Light Theme',
stylesheetUrl: '/themes/light.css',
colorStylesheetUrl: '/themes/colors.css',
isDark: false
},
{
id: 'dark',
name: 'Dark Theme',
stylesheetUrl: '/themes/dark.css',
colorStylesheetUrl: '/themes/colors-dark.css',
isDark: true
},
{
id: 'sepia',
name: 'Sepia Theme',
stylesheetUrl: '/themes/sepia.css',
isDark: false
}
],
defaultThemeId: 'light',
storageKey: 'my-app-theme'
});
// IMPORTANT: Initialize before rendering any content
await manager.initialize();
return manager;
}
// Example 2: Initialize with external config file
async function initializeWithConfigFile() {
const config = await ThemeConfigLoader.loadFromUrl('/assets/theme-config.json');
const manager = new ThemeManager(config);
await manager.initialize();
return manager;
}
// Example 3: Build a simple theme picker UI
function createThemePicker(manager: ThemeManager) {
const container = document.createElement('div');
container.className = 'theme-picker';
// Create select dropdown
const select = document.createElement('select');
select.id = 'theme-selector';
const themes = manager.getAvailableThemes();
themes.forEach(theme => {
const option = document.createElement('option');
option.value = theme.id;
option.textContent = theme.name;
if (manager.getCurrentTheme()?.id === theme.id) {
option.selected = true;
}
select.appendChild(option);
});
// Handle theme changes
select.addEventListener('change', async (e) => {
const target = e.target as HTMLSelectElement;
await manager.switchTheme(target.value);
});
// Create dark mode toggle button
const toggleBtn = document.createElement('button');
toggleBtn.className = 'dark-mode-toggle';
const updateToggleButton = () => {
const current = manager.getCurrentTheme();
toggleBtn.textContent = current?.isDark ? '☀️ Light Mode' : '🌙 Dark Mode';
};
updateToggleButton();
toggleBtn.addEventListener('click', async () => {
const current = manager.getCurrentTheme();
if (!current) return;
const themes = manager.getAvailableThemes();
const oppositeMode = themes.find(t => t.isDark !== current.isDark);
if (oppositeMode) {
await manager.switchTheme(oppositeMode.id);
}
});
// Listen for theme changes to update UI
manager.onThemeChange((event) => {
select.value = event.currentTheme.id;
updateToggleButton();
console.log('Theme changed:', event);
});
container.appendChild(select);
container.appendChild(toggleBtn);
return container;
}
// Example 4: Complete initialization with UI
async function initializeApp() {
try {
// Load configuration
const config = await ThemeConfigLoader.loadFromUrl('/assets/theme-config.json');
// Validate before use
if (!ThemeConfigLoader.validate(config)) {
throw new Error('Invalid theme configuration');
}
// Create manager
const manager = new ThemeManager(config);
// Initialize (must happen before render)
await manager.initialize();
// Preload alternate themes for smooth switching
const themes = manager.getAvailableThemes();
const currentId = manager.getCurrentTheme()?.id;
const preloadPromises = themes
.filter(t => t.id !== currentId)
.slice(0, 2) // Preload first 2 alternate themes
.map(t => manager.preloadTheme(t.id));
// Preload in background (don't await)
Promise.all(preloadPromises).catch(err => {
console.warn('Failed to preload some themes:', err);
});
// Create and mount theme picker
const picker = createThemePicker(manager);
document.getElementById('theme-picker-container')?.appendChild(picker);
// Log successful initialization
console.log('Theme system initialized successfully');
console.log('Current theme:', manager.getCurrentTheme());
return manager;
} catch (error) {
console.error('Failed to initialize theme system:', error);
throw error;
}
}
// Example 5: Usage in PWA with service worker
async function initializeForPWA() {
const manager = await initializeWithConfigFile();
// Listen for service worker updates
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
// Reload theme after service worker update
const currentTheme = manager.getCurrentTheme();
if (currentTheme) {
manager.switchTheme(currentTheme.id, { forceReload: true });
}
});
}
return manager;
}
// Export for use
export {
initializeWithStaticConfig,
initializeWithConfigFile,
createThemePicker,
initializeApp,
initializeForPWA
};
// Auto-initialize if this is the main script
if (typeof window !== 'undefined') {
// Wait for DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Theme System Example - Anti-Flicker Demo</title>
<!--
ANTI-FLICKER TECHNIQUE:
This inline script loads and applies the theme BEFORE the page renders,
preventing the flash of unstyled content (FOUC).
-->
<script>
(function() {
// Load saved theme preference
const STORAGE_KEY = 'app-theme-preference';
const DEFAULT_THEME = 'light';
let savedThemeId;
try {
savedThemeId = localStorage.getItem(STORAGE_KEY);
} catch (e) {
// localStorage unavailable
}
const themeId = savedThemeId || DEFAULT_THEME;
// Set data attribute immediately
document.documentElement.setAttribute('data-theme', themeId);
// Inline critical theme configuration
const themeMap = {
'light': {
stylesheet: '/themes/light.css',
colors: '/themes/colors.css',
isDark: false
},
'dark': {
stylesheet: '/themes/dark.css',
colors: '/themes/colors-dark.css',
isDark: true
},
'high-contrast': {
stylesheet: '/themes/high-contrast.css',
isDark: false
}
};
const theme = themeMap[themeId] || themeMap[DEFAULT_THEME];
// Set theme mode
document.documentElement.setAttribute('data-theme-mode', theme.isDark ? 'dark' : 'light');
// Inject stylesheets synchronously
function injectStylesheet(href) {
document.write('<link rel="stylesheet" href="' + href + '">');
}
injectStylesheet(theme.stylesheet);
if (theme.colors) {
injectStylesheet(theme.colors);
}
})();
</script>
<!-- Base styles that work with all themes -->
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
padding: 2rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #000000);
transition: background-color 0.3s ease, color 0.3s ease;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.theme-picker {
display: flex;
gap: 1rem;
align-items: center;
padding: 1rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
margin-bottom: 2rem;
}
select, button {
padding: 0.75rem 1rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
background: var(--input-bg, #fff);
color: var(--input-text, #000);
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
button:hover {
background: var(--input-hover-bg, #f0f0f0);
}
.card {
background: var(--card-bg, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 2rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px var(--shadow-color, rgba(0, 0, 0, 0.1));
}
h1, h2 {
margin-bottom: 1rem;
color: var(--heading-color, #333);
}
.info {
padding: 1rem;
background: var(--info-bg, #e3f2fd);
border-left: 4px solid var(--info-border, #2196f3);
border-radius: 4px;
margin: 1rem 0;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Theme System Demo</h1>
<p>Demonstrating flicker-free theme loading and switching</p>
</header>
<div id="theme-picker-container" class="theme-picker">
<!-- Theme picker will be inserted here by JavaScript -->
</div>
<main>
<div class="card">
<h2>Anti-Flicker Technique</h2>
<p>
This page loads the theme <strong>before</strong> rendering any content,
preventing the flash of unstyled content (FOUC). The inline script in the
<code><head></code> section:
</p>
<ul>
<li>Reads the saved theme preference from localStorage</li>
<li>Sets data attributes on the document element</li>
<li>Injects theme stylesheets synchronously</li>
<li>All before the page renders</li>
</ul>
</div>
<div class="card">
<h2>Features Demonstrated</h2>
<ul>
<li>✅ Multiple theme support (3+ themes)</li>
<li>✅ Light and dark mode support</li>
<li>✅ No visual flicker on load or theme switch</li>
<li>✅ Cross-tab synchronization</li>
<li>✅ Persistent theme preference</li>
<li>✅ PWA and offline support</li>
</ul>
</div>
<div class="info">
<strong>Try it:</strong> Switch themes using the picker above, then open this page
in a new tab. Your theme preference will be preserved. Try switching themes in one
tab and watch it update in the other!
</div>
</main>
</div>
<!-- Main application script -->
<script type="module">
import { initializeApp } from './examples/vanilla-usage.js';
// Initialize the full theme system
// The anti-flicker script above already applied the theme,
// this just sets up the interactive features
initializeApp().catch(err => {
console.error('Failed to initialize theme system:', err);
});
</script>
</body>
</html>
I have crafted one from scratch based on the same functional requirements and technical requirements, conforming to my design principles for UI, UX and Developer Experience:
Comparing with my hand-crafted one and what generated by Copilot, as a dedicated AI code generator, Claude.ai is much more diligent in crafting codes with more complex implementation, while the API design is as complex as those generated by Copilot.