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.
Below is a framework‑agnostic TypeScript API (with an optional Angular PWA integration) that lets you configure, preload, and switch among multiple themes (≥3), with instant cross‑tab sync, no FOUC/flicker, offline/PWA support, and post‑deployment reconfigurability.
ThemeManager)<script> JSON// src/theme.types.ts
export type ThemeMode = 'light' | 'dark';
export interface ThemeEntry {
/** Unique theme identifier (e.g., 'light', 'dark', 'nord') */
id: string;
/** Human-readable name for pickers ("Light", "Dark", "Nord") */
label: string;
/** Light or dark; used for system color-scheme and optional color variants */
mode: ThemeMode;
/** URL for the base/prebuilt theme stylesheet (CDN or local) */
css: string;
/**
* App-specific color overrides for light mode (optional).
* e.g., '/assets/colors.css' or 'https://cdn/.../colors.css'
*/
colors?: string;
/**
* App-specific color overrides for dark mode (optional).
* If absent and mode === 'dark', `colors` is used (if present).
*/
colorsDark?: string;
/** Optional Subresource Integrity and CORS for CDN-hosted CSS */
integrity?: string;
crossorigin?: 'anonymous' | 'use-credentials';
/** Preload this theme’s CSS at startup (non-blocking) */
preload?: boolean;
}
export interface ThemeConfig {
/** Order matters; you can re-order after deployment */
themes: ThemeEntry[];
/**
* Default theme id to apply on first load if nothing is stored.
* If omitted, first theme in `themes` is used.
*/
defaultThemeId?: string;
}
export interface ThemeManagerOptions {
/** Key names for storage (allow multiple apps on same origin) */
storageKeyActiveId?: string; // default: 'theme.active'
storageKeyLinks?: string; // default: 'theme.links'
broadcastChannelName?: string; // default: 'theme-channel'
/** Optional element IDs if you want fixed link IDs */
coreLinkId?: string; // default: 'tm-core'
colorsLinkId?: string; // default: 'tm-colors'
}
// src/theme.manager.ts
import { ThemeConfig, ThemeEntry, ThemeManagerOptions } from './theme.types';
type Unsubscribe = () => void;
export class ThemeManager {
private config: ThemeConfig | null = null;
private activeThemeId: string | null = null;
private listeners = new Set<(id: string) => void>();
private readonly storageKeyActiveId: string;
private readonly storageKeyLinks: string;
private readonly broadcastChannelName: string;
private channel: BroadcastChannel | null = null;
private readonly coreLinkId: string;
private readonly colorsLinkId: string;
private initializing = false;
constructor(opts: ThemeManagerOptions = {}) {
this.storageKeyActiveId = opts.storageKeyActiveId ?? 'theme.active';
this.storageKeyLinks = opts.storageKeyLinks ?? 'theme.links';
this.broadcastChannelName = opts.broadcastChannelName ?? 'theme-channel';
this.coreLinkId = opts.coreLinkId ?? 'tm-core';
this.colorsLinkId = opts.colorsLinkId ?? 'tm-colors';
// Cross-tab sync via BroadcastChannel + storage events
try {
this.channel = new BroadcastChannel(this.broadcastChannelName);
this.channel.onmessage = (ev) => {
if (ev?.data?.type === 'theme-change' && ev?.data?.id) {
if (ev.data.id !== this.activeThemeId) {
// Don’t await to keep handler responsive
this.applyTheme(ev.data.id).catch(console.error);
}
}
};
} catch {
// BroadcastChannel may not exist — storage event fallback below
this.channel = null;
}
window.addEventListener('storage', (e) => {
if (e.key === this.storageKeyActiveId && typeof e.newValue === 'string') {
const id = e.newValue;
if (id && id !== this.activeThemeId) {
this.applyTheme(id).catch(console.error);
}
}
});
}
/** Initialize from one of: inline script JSON, direct config, or URL */
async init(options: {
embeddedScriptId?: string; // <script type="application/json" id="theme-config">…</script>
config?: ThemeConfig;
configUrl?: string; // '/theme.config.json'
} = {}): Promise<void> {
if (this.initializing) return;
this.initializing = true;
// Load configuration
if (options.config) {
this.setConfig(options.config);
} else if (options.embeddedScriptId) {
const el = document.getElementById(options.embeddedScriptId);
if (!el) throw new Error(`Theme config script#${options.embeddedScriptId} not found`);
const json = el.textContent?.trim() || '{}';
this.setConfig(JSON.parse(json));
} else if (options.configUrl) {
// Fetch at runtime → adjustable after deployment (works offline with SW)
const resp = await fetch(options.configUrl, { cache: 'no-cache' });
if (!resp.ok) throw new Error(`Failed to fetch theme config from ${options.configUrl}`);
this.setConfig(await resp.json());
} else {
throw new Error('ThemeManager.init requires one of: config, embeddedScriptId, or configUrl');
}
// Determine initial theme: stored → config.default → first theme
const storedId = localStorage.getItem(this.storageKeyActiveId);
const initialId =
(storedId && this.findTheme(storedId) ? storedId : null) ||
this.config!.defaultThemeId ||
this.config!.themes[0].id;
await this.applyTheme(initialId, { skipIfActive: true, fastPathIfCached: true });
// Optional: preload other themes marked with `preload: true`
this.preloadMarkedThemes().catch(console.error);
this.initializing = false;
}
/** In-memory config update (e.g., admin can swap order after deployment) */
setConfig(config: ThemeConfig) {
if (!config?.themes?.length) {
throw new Error('ThemeConfig must include at least one theme');
}
this.config = {
...config,
themes: config.themes.map(t => ({ ...t })),
};
}
getThemes(): ReadonlyArray<Pick<ThemeEntry, 'id' | 'label' | 'mode'>> {
if (!this.config) return [];
return this.config.themes.map(({ id, label, mode }) => ({ id, label, mode }));
}
getActiveThemeId(): string | null {
return this.activeThemeId;
}
/** Subscribe to theme changes */
onChange(cb: (id: string) => void): Unsubscribe {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
/** Apply a theme by id */
async applyTheme(id: string, opts: { skipIfActive?: boolean; forceReload?: boolean; fastPathIfCached?: boolean } = {}): Promise<void> {
if (!this.config) throw new Error('ThemeManager not initialized');
const theme = this.findTheme(id);
if (!theme) throw new Error(`Unknown theme id: ${id}`);
if (opts.skipIfActive && this.activeThemeId === id) return;
// If already active and not forced → no-op
if (!opts.forceReload && this.activeThemeId === id) return;
const { coreHref, colorsHref } = this.resolveHrefs(theme);
// If already loaded with same hrefs → no-op
const current = this.readCurrentLinkHrefs();
if (!opts.forceReload && current.coreHref === coreHref && current.colorsHref === colorsHref) {
if (this.activeThemeId !== id) {
this.activeThemeId = id;
this.afterApplied(theme);
}
return;
}
// Load core CSS first (swap seamlessly), then colors if provided
await this.swapStylesheet(this.coreLinkId, coreHref, theme.integrity, theme.crossorigin, { fastPathIfCached: opts.fastPathIfCached });
if (colorsHref) {
await this.swapStylesheet(this.colorsLinkId, colorsHref, theme.integrity, theme.crossorigin, { fastPathIfCached: opts.fastPathIfCached });
} else {
// Remove colors link if it exists
const colorsEl = document.getElementById(this.colorsLinkId);
if (colorsEl?.parentNode) colorsEl.parentNode.removeChild(colorsEl);
}
this.activeThemeId = id;
this.persist(id, coreHref, colorsHref);
this.afterApplied(theme);
this.broadcast(id);
}
/** Preload all themes flagged with `preload: true` */
async preloadMarkedThemes(): Promise<void> {
if (!this.config) return;
const tasks: Promise<unknown>[] = [];
for (const t of this.config.themes) {
if (t.preload) {
const { coreHref, colorsHref } = this.resolveHrefs(t);
tasks.push(this.preloadStylesheet(coreHref, t.integrity, t.crossorigin));
if (colorsHref) tasks.push(this.preloadStylesheet(colorsHref, t.integrity, t.crossorigin));
}
}
await Promise.allSettled(tasks);
}
destroy() {
this.channel?.close();
this.listeners.clear();
}
// ===== Internals =====
private findTheme(id: string): ThemeEntry | undefined {
return this.config!.themes.find(t => t.id === id);
}
private resolveHrefs(theme: ThemeEntry): { coreHref: string; colorsHref?: string } {
const coreHref = theme.css;
let colorsHref: string | undefined;
if (theme.mode === 'dark') {
colorsHref = theme.colorsDark || theme.colors; // Prefer dark override
} else {
colorsHref = theme.colors;
}
return { coreHref, colorsHref };
}
private afterApplied(theme: ThemeEntry) {
// Set DOM attributes for CSS hooks and accessibility
document.documentElement.setAttribute('data-theme', theme.id);
document.documentElement.setAttribute('data-theme-mode', theme.mode);
// Hint to UA for form controls, etc.
const colorScheme = theme.mode === 'dark' ? 'dark' : 'light';
let meta = document.querySelector('meta[name="color-scheme"]') as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement('meta');
meta.name = 'color-scheme';
document.head.appendChild(meta);
}
meta.content = colorScheme;
// Notify listeners
for (const cb of this.listeners) {
try { cb(theme.id); } catch (e) { console.error(e); }
}
}
private persist(id: string, coreHref: string, colorsHref?: string) {
localStorage.setItem(this.storageKeyActiveId, id);
localStorage.setItem(this.storageKeyLinks, JSON.stringify({ coreHref, colorsHref: colorsHref || '' }));
}
private broadcast(id: string) {
try { this.channel?.postMessage({ type: 'theme-change', id }); } catch {}
}
private readCurrentLinkHrefs(): { coreHref: string; colorsHref: string } {
const coreEl = document.getElementById(this.coreLinkId) as HTMLLinkElement | null;
const colorsEl = document.getElementById(this.colorsLinkId) as HTMLLinkElement | null;
return {
coreHref: coreEl?.href || '',
colorsHref: colorsEl?.href || '',
};
// Note: .href returns absolute URL; comparisons still work because we set via .href later.
}
private async swapStylesheet(
linkId: string,
href: string,
integrity?: string,
crossorigin?: 'anonymous' | 'use-credentials',
opts: { fastPathIfCached?: boolean } = {}
): Promise<void> {
const existing = document.getElementById(linkId) as HTMLLinkElement | null;
// If existing already points to same URL → no-op
if (existing && sameUrl(existing.href, href)) return;
// Create new link (preload then promote to stylesheet to avoid flash)
const preload = document.createElement('link');
preload.rel = 'preload';
preload.as = 'style';
preload.href = href;
preload.id = `${linkId}-preload`;
if (integrity) preload.integrity = integrity;
if (crossorigin) preload.crossOrigin = crossorigin;
const whenLoaded = new Promise<void>((resolve, reject) => {
preload.addEventListener('load', () => resolve());
preload.addEventListener('error', () => reject(new Error(`Failed to preload ${href}`)));
});
document.head.appendChild(preload);
// If CSS is cached, browsers often fulfill immediately (fast path).
if (opts.fastPathIfCached) {
// small microtask delay to allow load handler; then continue
await Promise.race([whenLoaded, microDelay(12)]);
} else {
await whenLoaded;
}
// Promote to real stylesheet
const next = document.createElement('link');
next.rel = 'stylesheet';
next.href = href;
next.id = linkId;
if (integrity) next.integrity = integrity;
if (crossorigin) next.crossOrigin = crossorigin;
// Ensure new stylesheet is ready before removing the old one
const onReady = new Promise<void>((resolve, reject) => {
next.addEventListener('load', () => resolve());
next.addEventListener('error', () => reject(new Error(`Failed to load ${href}`)));
});
document.head.appendChild(next);
await onReady;
// Remove preload and previous link
safeRemove(preload);
if (existing) safeRemove(existing);
}
private async preloadStylesheet(href: string, integrity?: string, crossorigin?: 'anonymous' | 'use-credentials') {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'style';
link.href = href;
if (integrity) link.integrity = integrity;
if (crossorigin) link.crossOrigin = crossorigin;
link.setAttribute('data-theme-preload', 'true');
const whenLoaded = new Promise<void>((resolve, reject) => {
link.addEventListener('load', () => resolve());
link.addEventListener('error', () => reject(new Error(`Failed to preload ${href}`)));
});
document.head.appendChild(link);
return whenLoaded.finally(() => safeRemove(link));
}
}
function sameUrl(a: string, b: string): boolean {
try {
return new URL(a, document.baseURI).href === new URL(b, document.baseURI).href;
} catch {
return a === b;
}
}
function safeRemove(node: Element | null) {
if (node && node.parentNode) node.parentNode.removeChild(node);
}
function microDelay(ms: number) {
return new Promise<void>(r => setTimeout(r, ms));
}
You can deliver the theme list in any of these ways (choose one):
/theme.config.json) — easy for admins to replace without rebuilding<script type="application/json" id="theme-config">/theme.config.json{
"defaultThemeId": "light",
"themes": [
{
"id": "light",
"label": "Light",
"mode": "light",
"css": "/themes/prebuilt/light.css",
"colors": "/themes/app/colors.css",
"preload": true
},
{
"id": "dark",
"label": "Dark",
"mode": "dark",
"css": "/themes/prebuilt/dark.css",
"colors": "/themes/app/colors.css",
"colorsDark": "/themes/app/colors-dark.css",
"preload": true
},
{
"id": "nord",
"label": "Nord",
"mode": "dark",
"css": "https://cdn.example.com/themes/nord.min.css",
"colors": "/themes/app/colors.css",
"colorsDark": "/themes/app/colors-dark.css",
"integrity": "sha384-...your-sri-hash...",
"crossorigin": "anonymous"
}
]
}
Local or CDN: URLs can be relative or absolute. SRI/CORS fields support CDN integrity checks.
<head>)This tiny, blocking snippet applies the last chosen theme before first paint, avoiding any “flash of unthemed content (FOUC)”.
It uses only what’s in localStorage (no network):
This ensures the exact same CSS URLs as last time are used immediately (usually from cache), eliminating flicker.
The fullThemeManagerwill load/validate config after your app boots and keep everything in sync.
// main.ts
import { ThemeManager } from './theme.manager';
const theme = new ThemeManager();
await theme.init({
// Pick one:
configUrl: '/theme.config.json',
// or embeddedScriptId: 'theme-config',
// or config: { ... }
});
// Wire up your UI picker (example)
const select = document.getElementById('theme-select') as HTMLSelectElement;
for (const t of theme.getThemes()) {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.label;
select.appendChild(opt);
}
select.value = theme.getActiveThemeId() || '';
select.addEventListener('change', () => theme.applyTheme(select.value));
// React to changes made in other tabs
theme.onChange((id) => {
select.value = id;
});
HTML example:
// theme.service.ts
import { Injectable, Inject } from '@angular/core';
import { ThemeManager } from './theme.manager';
import { ThemeConfig } from './theme.types';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private manager = new ThemeManager();
async initFromUrl(configUrl: string) {
await this.manager.init({ configUrl });
}
async initFromScript(scriptId: string) {
await this.manager.init({ embeddedScriptId: scriptId });
}
async initFromConfig(config: ThemeConfig) {
await this.manager.init({ config });
}
themes() { return this.manager.getThemes(); }
activeId() { return this.manager.getActiveThemeId(); }
onChange(cb: (id: string) => void) { return this.manager.onChange(cb); }
set(id: string) { return this.manager.applyTheme(id); }
}
// app.config.ts (Angular standalone) or app.module.ts providers
import { APP_INITIALIZER, Provider } from '@angular/core';
import { ThemeService } from './theme.service';
export function initTheme(theme: ThemeService) {
return () => theme.initFromUrl('/theme.config.json'); // or initFromScript('theme-config')
}
export const THEME_INIT_PROVIDER: Provider = {
provide: APP_INITIALIZER,
multi: true,
useFactory: initTheme,
deps: [ThemeService]
};
Add THEME_INIT_PROVIDER to your bootstrap providers.
// theme-picker.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-theme-picker',
template: `
<label>
Theme
<select [value]="activeId" (change)="onSelect($any($event.target).value)">
<option *ngFor="let t of themes" [value]="t.id"></option>
</select>
</label>
`
})
export class ThemePickerComponent implements OnInit, OnDestroy {
themes: { id: string; label: string; mode: 'light'|'dark' }[] = [];
activeId = '';
private unsub: () => void = () => {};
constructor(private theme: ThemeService) {}
ngOnInit() {
this.themes = this.theme.themes() as any;
this.activeId = this.theme.activeId() || '';
this.unsub = this.theme.onChange(id => this.activeId = id);
}
ngOnDestroy() { this.unsub(); }
onSelect(id: string) { this.theme.set(id); }
}
In ngsw-config.json, add your theme assets so they’re available offline:
{
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/index.html",
"/*.css",
"/*.js",
"/theme.config.json",
"/themes/**"
]
}
}
]
}
Rebuild with ng add @angular/pwa if you haven’t, then deploy.
Now PWAs work offline and theme CSS stays cached.
If you use Workbox:
// sw.js (simplified)
workbox.routing.registerRoute(
({url}) => url.pathname.startsWith('/themes/'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'theme-css',
plugins: [
new workbox.expiration.ExpirationPlugin({ maxEntries: 30, purgeOnQuotaError: true })
]
})
);
workbox.routing.registerRoute(
({url}) => url.pathname === '/theme.config.json',
new workbox.strategies.NetworkFirst({ cacheName: 'theme-config' })
);
ThemeEntry.mode ensures both are supported.css + optional colors/colorsDark.applyTheme(id) dynamically swaps stylesheets (preload → promote).localStorage + BroadcastChannel./theme.config.json or editable inline JSON script.integrity + crossorigin.APP_INITIALIZER, or call init() before bootstrapping; plus early‑boot snippet ensures visual consistency even before init completes.:root or [data-theme="id"] for per‑theme overrides./* Disable transitions during theme change (optional) */
html.theme-switching * { transition: none !important; }
And briefly add/remove that class around applyTheme if you like.
When hosting styles on a CDN, add Subresource Integrity (integrity) and, if needed, crossorigin: "anonymous" in the theme config. ThemeManager will place these attributes on the <link> elements.
<html data-theme> and meta color-schemeIf you’d like, tell me:
/theme.config.json file?I can tailor the code scaffolding to your stack and deliver a small sample repo layout.
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, this one generated is much more complex, while I haven’t tested if the sourcecode can be built successfully and executed properly.
And comparing with “Use Windows Copilot to Craft Web Theme Loader”, the requirements has one more technical requirement:
And the high level requirement “The implementation may provide friendly integrations for Angular or React” has been changed to “The implementation may provide friendly integrations for Angular”.
Both AI code generators have defined class ThemeManager to contain the core algorithm. Windows Copilot crafted 162 lines, and M365 Copilot crafted 327 lines with overall more complex design.
Despite the technical requirement “The config settings must be loaded during app startup before any component is rendered”, M365 Copilot still use JSON for config, obviously don’t understand that the implicit technical context: a JSON file is inherently loaded asynchronously, and only JSON object in JavaScript can be loaded synchronously so to become the startup settings of the Web app.
I realize that the prompt presented by the requirements does not explicitly state the need for the config to be loaded as JSON object in JavaScript referenced in index.html.
As an experienced developer, when writing codes, I have many implicit contexts in my mind, and have muscle memory of writing codes according to contents and contexts explicit and implicit, along with many background knowledge in mind or in subconscious.
Often only when writing codes, many more prompts could be popping up from my mind. Without writing codes, I could write very little prompts, mostly based on explicit knowledge and visual hints. If I have to write sufficient prompts for the AI code generator to write quality code, my time spent on writing the prompt may be longer than hand-crafting code.
Nevertheless, I will be looking forward to trying again with some dedicated AI code generators, like Claude.AI.