articles

Use ChatGPT 5.2 Codex 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, therefore give code snippets for Angular app and React app using this API.

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

Analysis

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:

  1. The AI does not understand that a JSON file is inherently loaded asynchronously, while I have stated “The config settings must be loaded during app startup before any component is rendered”, though I did not give detailed background knowledge of providing startup config.

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: