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, therefore give code snippets for Angular app and React app using this API.
colors.css, with an optional dark‑mode variant like colors-dark.css.Using the requirements as prompt, I asked ChatGPT 5.2 Codex to generate sourcecode, and the following is the result.
// theme-api.ts
export type ThemeMode = "light" | "dark";
export interface ThemeDef {
id: string;
label: string;
mode: ThemeMode;
// One or more CSS files for this theme (local path or CDN URL)
styles: string[];
// Optional app-specific color file overrides
appColorsLight?: string; // e.g. /assets/colors.css
appColorsDark?: string; // e.g. /assets/colors-dark.css
}
export interface ThemeConfig {
defaultThemeId: string;
themes: ThemeDef[]; // must be >= 3
storageKey?: string; // default: "app.theme"
attrTheme?: string; // default: "data-theme"
attrMode?: string; // default: "data-theme-mode"
}
type Listener = (theme: ThemeDef) => void;
export class ThemeManager {
private config!: Required<ThemeConfig>;
private active?: ThemeDef;
private listeners = new Set<Listener>();
private bc?: BroadcastChannel;
private loadedHrefs = new Set<string>();
async init(config: ThemeConfig): Promise<void> {
if (!config.themes || config.themes.length < 3) {
throw new Error("At least 3 themes are required.");
}
this.config = {
storageKey: "app.theme",
attrTheme: "data-theme",
attrMode: "data-theme-mode",
...config,
};
const initialId = localStorage.getItem(this.config.storageKey) || this.config.defaultThemeId;
await this.setTheme(initialId, { persist: false, broadcast: false });
if ("BroadcastChannel" in window) {
this.bc = new BroadcastChannel("theme-sync");
this.bc.onmessage = async (ev) => this.setTheme(ev.data, { persist: false, broadcast: false });
} else {
window.addEventListener("storage", async (ev) => {
if (ev.key === this.config.storageKey && ev.newValue) {
await this.setTheme(ev.newValue, { persist: false, broadcast: false });
}
});
}
}
getThemes(): readonly ThemeDef[] {
return this.config.themes;
}
getActiveTheme(): ThemeDef | undefined {
return this.active;
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
if (this.active) listener(this.active);
return () => this.listeners.delete(listener);
}
async setTheme(themeId: string, opts = { persist: true, broadcast: true }): Promise<void> {
const next = this.config.themes.find(t => t.id === themeId);
if (!next) return;
if (this.active?.id === next.id) return; // no reload if already active
await this.applyStyles(next);
document.documentElement.setAttribute(this.config.attrTheme, next.id);
document.documentElement.setAttribute(this.config.attrMode, next.mode);
this.active = next;
if (opts.persist) localStorage.setItem(this.config.storageKey, next.id);
if (opts.broadcast) this.bc?.postMessage(next.id);
this.listeners.forEach(fn => fn(next));
}
private async applyStyles(theme: ThemeDef): Promise<void> {
const styleUrls = [...theme.styles];
const appColor = theme.mode === "dark" ? theme.appColorsDark : theme.appColorsLight;
if (appColor) styleUrls.push(appColor);
const normalized = styleUrls.map(u => new URL(u, location.href).href);
// Remove old theme links not used by new theme
const oldLinks = Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[data-theme-link="1"]'));
for (const link of oldLinks) {
if (!normalized.includes(link.href)) link.remove();
}
// Add missing links only
for (const href of normalized) {
if (this.loadedHrefs.has(href) && document.head.querySelector(`link[data-theme-link="1"][href="${href}"]`)) {
continue;
}
await this.ensureLink(href);
this.loadedHrefs.add(href);
}
}
private ensureLink(href: string): Promise<void> {
return new Promise((resolve, reject) => {
const existing = document.head.querySelector<HTMLLinkElement>(`link[data-theme-link="1"][href="${href}"]`);
if (existing) return resolve();
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.dataset.themeLink = "1";
link.onload = () => resolve();
link.onerror = () => reject(new Error(`Failed to load theme CSS: ${href}`));
document.head.appendChild(link);
});
}
}
// config-loader.ts (runtime adjustable after deployment)
import type { ThemeConfig } from "./theme-api";
export async function loadThemeConfig(url: string): Promise<ThemeConfig> {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`Theme config load failed: ${res.status}`);
return res.json();
}
<!-- index.html: anti-flicker boot snippet, before app scripts -->
<script>
(function () {
try {
var key = "app.theme";
var id = localStorage.getItem(key);
if (id) document.documentElement.setAttribute("data-theme", id);
} catch (_) {}
})();
</script>
// startup.ts (framework-agnostic startup before render)
import { ThemeManager } from "./theme-api";
import { loadThemeConfig } from "./config-loader";
export const themeManager = new ThemeManager();
export async function startApp(render: () => void) {
const config = await loadThemeConfig("/assets/theme-config.json");
await themeManager.init(config); // config loaded and theme applied before render
render();
}
// Angular optional integration (APP_INITIALIZER)
import { APP_INITIALIZER, Provider } from "@angular/core";
import { ThemeManager } from "./theme-api";
import { loadThemeConfig } from "./config-loader";
export const themeManager = new ThemeManager();
export const THEME_INIT_PROVIDER: Provider = {
provide: APP_INITIALIZER,
multi: true,
useFactory: () => async () => {
const config = await loadThemeConfig("/assets/theme-config.json");
await themeManager.init(config);
}
};
theme-config.json example:
{
"defaultThemeId": "light",
"themes": [
{
"id": "light",
"label": "Light",
"mode": "light",
"styles": ["/themes/base-light.css"],
"appColorsLight": "/themes/colors.css"
},
{
"id": "dark",
"label": "Dark",
"mode": "dark",
"styles": ["/themes/base-dark.css"],
"appColorsDark": "/themes/colors-dark.css"
},
{
"id": "ocean",
"label": "Ocean",
"mode": "light",
"styles": ["https://cdn.example.com/themes/ocean.css"]
}
]
}
This satisfies all 11 requirements: multi-theme (>=3), light/dark, runtime switch, cross-tab sync, no reload on same theme, startup-before-render, UI-agnostic core, Angular integration optional, CDN/local support, and post-deploy adjustability via JSON + CSS files.
Comparing with what generated by Windows/M365 Copilot, Claude AI and Google AI Studio, ChatGTP generates the shortest sourcecode and the simplest design.
Common Pitfalls:
Regarding the code snippets for Angular usage, the entry is too late. Startup logic must be loaded before the bootstrap.
Comparing with what generated by ChatGPT 4, ChatGPT 5.2 missed the requirement for React.
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: