I have crafted one from scratch based on specific functional requirements and technical requirements, conforming to my design principles for UI, UX and Developer Experience.
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 and React applications.
colors.css, with an optional dark‑mode variant like colors-dark.css.Using the requirements as prompt, I asked Windows Copilot to generate sourcecode, and the following is the result.
Designing TS API
I need to design a minimal but complete TypeScript API with helpers. It should include core components like ThemeManager, storage, config, CSS loading, and eventing. I’ll also add optional Angular/React hooks. I must ensure no flicker, using an inline bootstrap script example. Admins should be able to adjust config after deployment via JSON or data-attributes. Multi-tab sync via storage events is necessary, and I’ll avoid reloads if already active. I’ll structure it with an overview, core types, ThemeRegistry, persistence, runtime config, and integration snippets. Keeping it concise but detailed.
You’re basically asking for a small, framework‑agnostic theming kernel that can be dropped into any app, with optional Angular/React sugar. Let’s design it as:
ThemeManager) that:
// theme-types.ts
export type ThemeId = string;
export interface ThemeCssFile {
/** Absolute or relative URL; can be CDN or local */
href: string;
/** Optional media attribute, e.g. "(prefers-color-scheme: dark)" */
media?: string;
/** Optional integrity / crossorigin if you want CSP/CDN hardening */
integrity?: string;
crossOrigin?: '' | 'anonymous' | 'use-credentials';
}
export interface ThemeDefinition {
id: ThemeId;
/** Human-friendly label for pickers */
label: string;
/** Optional semantic tag: 'light' | 'dark' | 'high-contrast' | etc. */
kind?: 'light' | 'dark' | 'other';
/** One or more CSS files that together define this theme */
cssFiles: ThemeCssFile[];
}
export interface ThemeManagerOptions {
/** Storage key for persistence */
storageKey?: string;
/** Optional default theme id if nothing is stored */
defaultThemeId?: ThemeId;
/** Optional callback when theme changes */
onThemeChange?: (theme: ThemeDefinition) => void;
/** Optional attribute on <html> or <body> for styling hooks */
htmlAttributeName?: string; // e.g. "data-theme"
/** Where to attach <link> tags; defaults to document.head */
linkParent?: HTMLElement;
}
Key goals:
storage event.// theme-manager.ts
import {
ThemeDefinition,
ThemeId,
ThemeManagerOptions,
ThemeCssFile,
} from './theme-types';
export class ThemeManager {
private themes: Map<ThemeId, ThemeDefinition> = new Map();
private options: Required<ThemeManagerOptions>;
private currentThemeId: ThemeId | null = null;
private linkElements: Map<string, HTMLLinkElement> = new Map(); // key: href
constructor(
initialThemes: ThemeDefinition[] = [],
options: ThemeManagerOptions = {}
) {
this.options = {
storageKey: options.storageKey ?? 'app.theme',
defaultThemeId: options.defaultThemeId ?? (initialThemes[0]?.id ?? ''),
onThemeChange: options.onThemeChange ?? (() => {}),
htmlAttributeName: options.htmlAttributeName ?? 'data-theme',
linkParent: options.linkParent ?? document.head,
};
initialThemes.forEach(t => this.themes.set(t.id, t));
this.handleStorageEvent = this.handleStorageEvent.bind(this);
window.addEventListener('storage', this.handleStorageEvent);
const stored = this.getStoredThemeId();
const initialId = stored || this.options.defaultThemeId;
if (initialId && this.themes.has(initialId)) {
void this.applyTheme(initialId, { persist: false });
}
}
/** Clean up listeners if needed */
dispose(): void {
window.removeEventListener('storage', this.handleStorageEvent);
}
/** Replace all themes at runtime (e.g. admin updates config JSON) */
setThemes(themes: ThemeDefinition[]): void {
this.themes.clear();
themes.forEach(t => this.themes.set(t.id, t));
// Keep current theme if still present; otherwise fall back
if (!this.currentThemeId || !this.themes.has(this.currentThemeId)) {
const fallback = this.options.defaultThemeId || themes[0]?.id;
if (fallback) {
void this.applyTheme(fallback);
}
}
}
/** Add or update a single theme at runtime */
upsertTheme(theme: ThemeDefinition): void {
this.themes.set(theme.id, theme);
}
/** Remove a theme; if it's active, fallback to default */
removeTheme(id: ThemeId): void {
this.themes.delete(id);
if (this.currentThemeId === id) {
const fallback =
this.options.defaultThemeId || Array.from(this.themes.keys())[0];
if (fallback) {
void this.applyTheme(fallback);
}
}
}
getAvailableThemes(): ThemeDefinition[] {
return Array.from(this.themes.values());
}
getCurrentTheme(): ThemeDefinition | null {
return this.currentThemeId ? this.themes.get(this.currentThemeId) ?? null : null;
}
async applyTheme(id: ThemeId, opts?: { persist?: boolean }): Promise<void> {
if (id === this.currentThemeId) {
// Requirement 9: selecting already loaded theme does nothing
return;
}
const theme = this.themes.get(id);
if (!theme) {
throw new Error(`Unknown theme id: ${id}`);
}
// Load all CSS files for the new theme first
await this.ensureCssFilesLoaded(theme.cssFiles);
// Now "activate" the theme: update attribute, currentThemeId, storage, callback
this.currentThemeId = id;
this.setHtmlAttribute(theme);
if (opts?.persist !== false) {
this.storeThemeId(id);
}
this.options.onThemeChange(theme);
}
/** For SSR or early bootstrapping: just set attribute without loading CSS */
setHtmlAttribute(theme: ThemeDefinition): void {
const attr = this.options.htmlAttributeName;
document.documentElement.setAttribute(attr, theme.id);
if (theme.kind) {
document.documentElement.setAttribute(`${attr}-kind`, theme.kind);
}
}
private async ensureCssFilesLoaded(files: ThemeCssFile[]): Promise<void> {
const parent = this.options.linkParent;
const promises = files.map(file => {
const existing = this.linkElements.get(file.href);
if (existing) {
// Already loaded; no need to reload
return Promise.resolve();
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = file.href;
if (file.media) link.media = file.media;
if (file.integrity) link.integrity = file.integrity;
if (file.crossOrigin !== undefined) link.crossOrigin = file.crossOrigin;
const p = new Promise<void>((resolve, reject) => {
link.onload = () => resolve();
link.onerror = () => reject(new Error(`Failed to load CSS: ${file.href}`));
});
parent.appendChild(link);
this.linkElements.set(file.href, link);
return p;
});
await Promise.all(promises);
}
private getStoredThemeId(): ThemeId | null {
try {
return localStorage.getItem(this.options.storageKey) as ThemeId | null;
} catch {
return null;
}
}
private storeThemeId(id: ThemeId): void {
try {
localStorage.setItem(this.options.storageKey, id);
} catch {
// ignore (offline/intranet with restricted storage)
}
}
private handleStorageEvent(ev: StorageEvent): void {
if (ev.key !== this.options.storageKey) return;
const newId = ev.newValue as ThemeId | null;
if (!newId || newId === this.currentThemeId) return;
// Sync across tabs
void this.applyTheme(newId, { persist: false });
}
}
You can host CSS locally or on a CDN. You can also split prebuilt theme vs app‑specific colors.
// theme-config.ts
import { ThemeDefinition } from './theme-types';
export const THEMES: ThemeDefinition[] = [
{
id: 'light-default',
label: 'Light',
kind: 'light',
cssFiles: [
// Prebuilt UI library theme (e.g. Angular Material, MUI, etc.)
{ href: '/assets/themes/material-light.css' },
// App-specific colors
{ href: '/assets/colors.css' },
],
},
{
id: 'dark-default',
label: 'Dark',
kind: 'dark',
cssFiles: [
{ href: '/assets/themes/material-dark.css' },
{ href: '/assets/colors-dark.css' },
],
},
{
id: 'solarized',
label: 'Solarized',
kind: 'other',
cssFiles: [
{ href: 'https://cdn.example.com/themes/solarized.css' },
{ href: '/assets/colors-solarized.css' },
],
},
];
Admins can change THEMES after deployment by:
/theme-config.json) and fetching it at runtime.<script type="application/json" id="theme-config"> tag that can be edited without rebuilding.// main.ts
import { ThemeManager } from './theme-manager';
import { THEMES } from './theme-config';
export const themeManager = new ThemeManager(THEMES, {
storageKey: 'myapp.theme',
defaultThemeId: 'light-default',
htmlAttributeName: 'data-theme',
onThemeChange: theme => {
console.log('Theme changed to', theme.id);
},
});
// Example: wire to a simple select element
const select = document.getElementById('theme-select') as HTMLSelectElement | null;
if (select) {
// Populate options
themeManager.getAvailableThemes().forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.label;
select.appendChild(opt);
});
const current = themeManager.getCurrentTheme();
if (current) {
select.value = current.id;
}
select.addEventListener('change', () => {
void themeManager.applyTheme(select.value);
});
}
This works in:
themeManager once.localStorage works offline; no external dependencies required.To avoid a flash of wrong theme before JS runs, you can:
localStorage (already done by ThemeManager).<head> that:
data-theme (and optionally injects a <link> for the base theme) before the page renders.<!doctype html>
<html lang="en" data-theme="light-default">
<head>
<script>
(function () {
try {
var key = 'myapp.theme';
var id = localStorage.getItem(key);
if (id) {
document.documentElement.setAttribute('data-theme', id);
}
} catch (e) {
// ignore
}
})();
</script>
<!-- Optionally preload the most common theme CSS -->
<link rel="stylesheet" href="/assets/themes/material-light.css" />
<link rel="stylesheet" href="/assets/colors.css" />
</head>
<body>
<!-- app root -->
</body>
</html>
The ThemeManager will later load any missing CSS files for the selected theme, but the attribute is already correct, so your CSS can avoid flicker (e.g. by scoping theme rules under [data-theme="dark-default"] etc.).
A minimal Angular service that wraps the core:
// theme.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { ThemeManager } from './theme-manager';
import { THEMES } from './theme-config';
import { ThemeDefinition } from './theme-types';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private manager = new ThemeManager(THEMES, {
storageKey: 'myapp.theme',
defaultThemeId: 'light-default',
htmlAttributeName: 'data-theme',
onThemeChange: theme => this._currentTheme.next(theme),
});
private _currentTheme = new BehaviorSubject<ThemeDefinition | null>(
this.manager.getCurrentTheme()
);
currentTheme$ = this._currentTheme.asObservable();
getAvailableThemes(): ThemeDefinition[] {
return this.manager.getAvailableThemes();
}
setTheme(id: string): Promise<void> {
return this.manager.applyTheme(id);
}
}
Simple Angular component:
// theme-picker.component.ts
import { Component } from '@angular/core';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-theme-picker',
template: `
<label>
Theme:
<select [ngModel]="currentId" (ngModelChange)="onChange($event)">
<option *ngFor="let t of themes" [value]="t.id">
</option>
</select>
</label>
`,
})
export class ThemePickerComponent {
themes = this.themeService.getAvailableThemes();
currentId = this.themeService.getAvailableThemes()[0]?.id;
constructor(private themeService: ThemeService) {
this.themeService.currentTheme$.subscribe(t => {
if (t) this.currentId = t.id;
});
}
onChange(id: string): void {
void this.themeService.setTheme(id);
}
}
A small hook that talks to the same core:
// theme-context.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';
import { ThemeManager } from './theme-manager';
import { THEMES } from './theme-config';
import { ThemeDefinition } from './theme-types';
const manager = new ThemeManager(THEMES, {
storageKey: 'myapp.theme',
defaultThemeId: 'light-default',
htmlAttributeName: 'data-theme',
});
interface ThemeContextValue {
themes: ThemeDefinition[];
currentTheme: ThemeDefinition | null;
setTheme: (id: string) => Promise<void>;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [currentTheme, setCurrentTheme] = useState<ThemeDefinition | null>(
manager.getCurrentTheme()
);
useEffect(() => {
const unsubscribe = (theme: ThemeDefinition) => setCurrentTheme(theme);
// Patch onThemeChange dynamically
(manager as any).options.onThemeChange = unsubscribe;
return () => {
(manager as any).options.onThemeChange = () => {};
};
}, []);
const value: ThemeContextValue = {
themes: manager.getAvailableThemes(),
currentTheme,
setTheme: (id: string) => manager.applyTheme(id),
};
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
Example React picker:
// ThemePicker.tsx
import React from 'react';
import { useTheme } from './theme-context';
export const ThemePicker: React.FC = () => {
const { themes, currentTheme, setTheme } = useTheme();
return (
<label>
Theme:
<select
value={currentTheme?.id ?? ''}
onChange={e => void setTheme(e.target.value)}
>
{themes.map(t => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</label>
);
};
If you want, we can next:
/theme-config.json).[data-theme] that plays nicely with Angular Material or other libraries.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 the configuration is stored in a JSON file which can be loaded only asynchronously, while for the sake of theme, the config had better be loaded synchronously during startup.
I realize that the prompt presented by the requirements does not explicitly state the need for the config to be loaded as early as possible.
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 would write very little prompts, mostly based on explicit knowledge and visual hints.
Nevertheless, I will add a few more technical requirements when I prompt M365 Copilot to craft code.