articles

Use Windows Copilot to Craft Web Theme Loader

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.

Requirements

User Story

As a web app user, I want to choose from multiple available themes — sometimes light, other times dark.

Work Order

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.

Functional Requirements

  1. Support both light and dark themes.
  2. Support more than two themes — at least three themes must be available.
  3. Support commonly used prebuilt themes, optionally combined with an app‑specific color stylesheet such as colors.css, with an optional dark‑mode variant like colors-dark.css.
  4. Support dynamic switching between themes at runtime.
  5. When the same web app/site is opened in another browser tab, the explicitly selected theme should be preserved and applied.

Technical Requirements

  1. Reusable across multiple applications.
  2. Minimal API surface to ensure easy customization and easy usage.
  3. Neutral with respect to specific UI design choices.
  4. Must be efficient and avoid visual flicker during startup and theme switching.
  5. Usable in both SPA and PWA.
  6. Fully functional in PWAs, offline usage, and intranet environments.
  7. Adjustable after build, bundling, and deployment. For example, an admin should be able to change the number and order of available themes, and modify app‑specific color files.
  8. Themes may be hosted locally or on a CDN.
  9. Selecting a theme that is already loaded should not trigger a reload of the theme.
  10. Core theme management must be separated from the theme‑picker UI.

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:


1. Core types and configuration

// 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;
}

2. Core ThemeManager implementation

Key goals:

// 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 });
  }
}

3. Example theme configuration (light/dark/extra + app colors)

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:


4. Using ThemeManager in a vanilla SPA/PWA

// 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:


5. Avoiding flicker on first paint

To avoid a flash of wrong theme before JS runs, you can:

  1. Store the last theme id in localStorage (already done by ThemeManager).
  2. Add a tiny inline script in your HTML <head> that:
    • Reads the stored theme id.
    • Sets 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.).


6. Optional Angular integration

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);
  }
}

7. Optional React integration

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:

Analysis

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.