articles

Use M365 Copilot to Craft Web Theme Loader

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 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.
  11. The config settings must be loaded during app startup before any component is rendered.

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.


What you get


1) Core Types

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

2) Core Theme Manager (framework‑agnostic)

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

3) Configuration (editable after deployment)

You can deliver the theme list in any of these ways (choose one):

  1. External JSON file (/theme.config.json) — easy for admins to replace without rebuilding
  2. Inline JSON inside an HTML <script type="application/json" id="theme-config">
  3. Direct object in code (less flexible after deployment)

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


4) Early‑Boot No‑Flicker Snippet (insert in <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 full ThemeManager will load/validate config after your app boots and keep everything in sync.


5) Vanilla Usage (no framework)

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


6) Optional Angular Integration (PWA‑friendly)

Angular Service (wrapper)

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

Ensure config loads before first component renders

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

Simple Angular Picker Component

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

Angular Service Worker (offline) caching of theme CSS

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.


7) Non‑Angular PWA (Workbox) hint

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

8) Design Notes vs Requirements


9) CSS authoring tips (optional but helpful)

/* Disable transitions during theme change (optional) */
html.theme-switching * { transition: none !important; }

And briefly add/remove that class around applyTheme if you like.


10) Security with CDN (SRI)

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.


11) Testing Checklist


12) Next steps

If you’d like, tell me:

I can tailor the code scaffolding to your stack and deliver a small sample repo layout.

Analysis

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:

  1. The config settings must be loaded during app startup before any component is rendered.

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.