Directing the Multi-Theme System
Objectives
By the end of this chapter, you should be able to:
- Prompt an AI assistant to migrate a boolean
.dark-class toggle to an attribute-based system that scales past two states - Verify a four-theme system (light, dark, amber, blue) actually touches only token definitions and the toggle, and nothing else
- Catch an AI assistant quietly breaking a derived value (
isDark) that other code still depends on, while “successfully” adding two new themes
💡 Why this matters: The token architecture from two lessons ago was built specifically so this moment would be cheap. This is where that bet gets paid off, or the AI quietly breaks it while technically doing what you asked.
The Real Test of the Token System
Two lessons ago, the whole argument for CSS custom properties over Tailwind’s dark: variant was “the fourth theme should cost nothing extra.” This lesson is where you find out if that was actually true, or if it was just true in theory. A boolean can’t hold four states, so isDark: boolean and a single .dark class both have to go, replaced by a small enum and a data-theme attribute that can hold any of several values. That’s a real, structural change to two files: the theme store and the toggle component.
Everything else in this codebase, the map, the header, the detail panel, the flight list, reads color exclusively through var(--phosphor) and Tailwind classes like bg-base that resolve to it. None of it references .dark or data-theme directly. If the token system was built right, none of those components need to change at all for this lesson, only get two new CSS blocks to render against. That’s the specific thing to verify, not just “does the app now have four themes,” but “did adding them require touching a single component file that isn’t the toggle.”
The Prompt
What It Built
// apps/web/src/lib/theme.ts
/**
* Every theme applies as `<html data-theme="...">` -- CSS in globals.css
* keys off that attribute to redefine the same set of custom properties
* (--bg-deep, --phosphor, etc.), so no component needs to know which theme
* is active beyond the couple of places (map tile choice) that care about
* light vs. dark family. THEME_ORDER is also the cycle order the toggle
* advances through on each click.
*/
export type ThemePreference = "light" | "dark" | "amber" | "blue";
export const THEME_ORDER: ThemePreference[] = ["light", "dark", "amber", "blue"];
export const THEME_LABEL: Record<ThemePreference, string> = {
light: "Light",
dark: "Dark",
amber: "Amber",
blue: "Blue",
};
/** Every theme except "light" uses a dark background -- MapPanel picks the CARTO dark basemap for all of these so it doesn't clash with a light Leaflet tile layer. */
const DARK_FAMILY = new Set<ThemePreference>(["dark", "amber", "blue"]);
export function isDarkFamilyTheme(theme: ThemePreference): boolean {
return DARK_FAMILY.has(theme);
}
export function nextTheme(theme: ThemePreference): ThemePreference {
const idx = THEME_ORDER.indexOf(theme);
return THEME_ORDER[(idx + 1) % THEME_ORDER.length];
}
/** Must match the inline no-FOUC script in layout.tsx exactly. */
export const THEME_STORAGE_KEY = "skywatch-theme";
function isThemePreference(value: string | null): value is ThemePreference {
return value != null && (THEME_ORDER as string[]).includes(value);
}
export function getStoredPreference(): ThemePreference | null {
if (typeof window === "undefined") return null;
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
return isThemePreference(stored) ? stored : null;
}
/** Only ever resolves to light/dark -- amber and blue are opt-in only via the toggle. */
export function systemPrefersDark(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
export function applyThemeAttribute(theme: ThemePreference): void {
document.documentElement.dataset.theme = theme;
}
export function storePreference(theme: ThemePreference): void {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}
Centralizing THEME_ORDER, the storage key, and the DOM-writing functions in one small module, rather than spreading them across the store and the toggle component, is what makes the no-flash script in layout.tsx (which can’t import this module, it runs before any bundle loads) the only other place that needs to independently know the theme list and storage key. Every other piece of code imports from here.
// apps/web/src/store/useThemeStore.ts
import { create } from "zustand";
import type { ThemePreference } from "@/lib/theme";
import { applyThemeAttribute, isDarkFamilyTheme, nextTheme, storePreference } from "@/lib/theme";
interface ThemeState {
theme: ThemePreference;
/** Derived from theme, for the couple of call sites (map tile choice) that only care about light vs. dark family. */
isDark: boolean;
/** Reads the attribute the no-FOUC inline script already applied, so the store starts in sync with the DOM. */
hydrateFromDom: () => void;
cycle: () => void;
}
export const useThemeStore = create<ThemeState>((set, get) => ({
theme: "light",
isDark: false,
hydrateFromDom: () => {
if (typeof document === "undefined") return;
const attr = document.documentElement.dataset.theme as ThemePreference | undefined;
if (attr) set({ theme: attr, isDark: isDarkFamilyTheme(attr) });
},
cycle: () => {
const next = nextTheme(get().theme);
applyThemeAttribute(next);
storePreference(next);
set({ theme: next, isDark: isDarkFamilyTheme(next) });
},
}));
isDark survives as a derived field rather than disappearing along with the old boolean-only model. That’s deliberate: MapPanel’s tile-layer choice, and anything else in this app that only ever needed to know “dark family or not,” keeps working with zero changes, reading isDark exactly as before. Only the toggle itself, and anything that wants to show which specific theme is active, needs the new theme field.
// apps/web/src/components/ThemeToggle.tsx
"use client";
import { useEffect } from "react";
import { useThemeStore } from "@/store/useThemeStore";
import { THEME_LABEL, nextTheme } from "@/lib/theme";
/**
* Cycles through THEME_ORDER on each click (light -> dark -> amber -> blue
* -> light...). The swatch shows the CURRENT theme's own accent color
* (var(--phosphor) resolves against whatever data-theme is active) rather
* than a fixed per-theme icon shape -- with only two themes a sun/moon icon
* reads fine, but that convention doesn't scale past two states as cleanly
* as "this dot IS your accent color" does, and it needs no maintenance if
* a fifth theme is ever added.
*/
export function ThemeToggle() {
const theme = useThemeStore((s) => s.theme);
const cycle = useThemeStore((s) => s.cycle);
const hydrateFromDom = useThemeStore((s) => s.hydrateFromDom);
useEffect(() => {
hydrateFromDom();
}, [hydrateFromDom]);
const upcoming = THEME_LABEL[nextTheme(theme)];
return (
<button
type="button"
onClick={cycle}
aria-label={`Theme: ${THEME_LABEL[theme]} -- click for ${upcoming}`}
title={`Theme: ${THEME_LABEL[theme]} -- click for ${upcoming}`}
className="flex h-7 w-7 items-center justify-center rounded-sm border border-line text-text-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
>
<span
aria-hidden="true"
className="block h-2.5 w-2.5 rounded-full"
style={{ background: "var(--phosphor)", boxShadow: "0 0 6px var(--phosphor), 0 0 2px var(--phosphor)" }}
/>
</button>
);
}
/* apps/web/src/app/globals.css -- replace the :root/.dark pair from the previous lesson */
@import "tailwindcss";
/*
* :root holds the light theme and doubles as the fallback for any
* unrecognized/unset data-theme value. Every other theme is a
* [data-theme="..."] block overriding the same property set -- components
* never branch on which theme is active.
*/
:root {
--bg-deep: #eef2f0;
--bg-panel: #ffffff;
--bg-panel-raised: #f3f6f4;
--line: #d3ddd8;
--phosphor: #0e8a52;
--phosphor-dim: #4f9d78;
--phosphor-rgb: 14, 138, 82;
--text-main: #16211c;
--text-dim: #5c6b64;
}
/* Original dark theme: green phosphor on near-black blue-gray. */
[data-theme="dark"] {
--bg-deep: #0a121c;
--bg-panel: #101a26;
--bg-panel-raised: #16222f;
--line: #26394a;
--phosphor: #4dffab;
--phosphor-dim: #2a8f61;
--phosphor-rgb: 77, 255, 171;
--text-main: #e4eef0;
--text-dim: #85a0aa;
}
/* Classic amber CRT: warm near-black background, amber phosphor. */
[data-theme="amber"] {
--bg-deep: #140d06;
--bg-panel: #1e150a;
--bg-panel-raised: #271b0d;
--line: #4a3419;
--phosphor: #ffb020;
--phosphor-dim: #a3711f;
--phosphor-rgb: 255, 176, 32;
--text-main: #f6e6cf;
--text-dim: #a9906d;
}
/* Cool blue/cyan CRT: near-black blue background, cyan phosphor. */
[data-theme="blue"] {
--bg-deep: #060b14;
--bg-panel: #0b1420;
--bg-panel-raised: #101d2c;
--line: #1f3a56;
--phosphor: #3ec7ff;
--phosphor-dim: #2a7fa3;
--phosphor-rgb: 62, 199, 255;
--text-main: #dbeeff;
--text-dim: #6f93b3;
}
@theme inline {
--color-base: var(--bg-deep);
--color-surface: var(--bg-panel);
--color-surface-raised: var(--bg-panel-raised);
--color-line: var(--line);
--color-phosphor: var(--phosphor);
--color-phosphor-dim: var(--phosphor-dim);
--color-fg: var(--text-main);
--color-fg-dim: var(--text-dim);
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
--font-display: "Space Grotesk", ui-sans-serif, sans-serif;
}
/* body/.plane-icon/.leaflet-container rules from the previous lesson are unchanged */
Notice what didn’t change: the @theme inline block, every Tailwind utility name it defines, and every component that uses bg-base or text-fg-dim. Two new theme blocks, defining the same nine property names with new values, are the entire cost of doubling the theme count from two to four.
// apps/web/src/app/layout.tsx -- replace the previous lesson's script
const NO_FLASH_THEME_SCRIPT = `
(function () {
try {
var stored = localStorage.getItem('skywatch-theme');
var known = ['light', 'dark', 'amber', 'blue'];
var theme = known.indexOf(stored) !== -1
? stored
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
})();
`;
The known array here is hand-duplicated from THEME_ORDER in lib/theme.ts on purpose, not imported, this script runs inline, before any JavaScript bundle has loaded, so it can’t import anything. Keep the two lists in sync by hand whenever a theme is added or removed; there’s no way around that given the constraint that this script must be dependency-free.
Review This
Did the AI actually leave every non-toggle component untouched, or did it “helpfully” go patch things it didn’t need to? This is the whole point of the exercise, so check it first, and check it literally: run a diff or a search across apps/web/src/components for anything that changed outside ThemeToggle.tsx. A model that isn’t confident the token system fully covers every case will sometimes go add a defensive data-theme check into MapPanel or sprinkle a conditional class into Header “just in case,” even though nothing required it. That’s not a crash, it’s not even wrong, necessarily, but it’s a sign the AI didn’t trust the architecture it was told to rely on, and every one of those defensive edits is now a second place a future theme change has to be remembered. If you find one, the fix is: “revert the changes to <component>, it should read colors through the existing token classes with no theme-specific logic, the same way it did before this change.”
Is isDark still there, and does it still reflect dark-family membership correctly? MapPanel picks its tile layer based on useThemeStore((s) => s.isDark), and that line of MapPanel was explicitly off-limits this lesson. Check that the rewritten store still exports isDark, and that it’s computed via isDarkFamilyTheme(theme), true for dark, amber, and blue, false only for light, not some leftover comparison like theme !== "light" copy-pasted from before the refactor that happens to produce the same answer today but would silently be wrong if a fifth, light-background theme were ever added. It’s easy for an AI mid-refactor to drop a field that “isn’t really needed anymore” now that the richer theme field exists, and MapPanel would still compile fine against a store missing isDark, until you actually load it in amber or blue and the tile layer renders using the light basemap under a dark UI. The follow-up prompt: “useThemeStore needs to keep exporting a derived isDark boolean computed from isDarkFamilyTheme(theme), MapPanel reads it directly and wasn’t supposed to change.”
Does hydrateFromDom actually get called, or does the store start out of sync with what the no-flash script already painted? The store’s theme: "light" initial value is a placeholder, same as isDark: true was in the two-state version, the real value lives on the DOM the instant the no-flash script runs, before React mounts. If the AI wrote hydrateFromDom but forgot to call it from ThemeToggle’s useEffect, everything still renders correctly on load, because the CSS is driven by the data-theme attribute directly, not by the store. The bug only shows up on the first click: the toggle cycles from its stale placeholder value ("light") instead of whatever theme is actually showing, so a page that loaded into amber jumps to dark on the first click instead of blue, the correct next theme in the cycle. Confirm the useEffect(() => { hydrateFromDom(); }, [hydrateFromDom]) call is actually present in ThemeToggle.tsx. If it’s missing: “ThemeToggle needs to call hydrateFromDom() once on mount, the store’s initial theme value is just a placeholder until it syncs with whatever the no-flash script already wrote to the DOM.”
Try It
- Run the prompt above against your AI coding assistant of choice, with the two-state toggle from the previous lesson in place.
- Before running anything, get a diff of every changed file and check it against the first review point, nothing outside
lib/theme.ts,useThemeStore.ts,ThemeToggle.tsx,globals.css, and the no-flash script inlayout.tsxshould appear. - Read
useThemeStore.tsandThemeToggle.tsxagainst the second and third review points. - Run
npm run dev, click the toggle four times, and confirm it cycles light → dark → amber → blue → light, with the swatch color changing to match each theme’s own phosphor accent. - Set the theme to
amber, reload the page, and confirm it loads directly into amber with no flash of another theme first, then click once and confirm it advances toblue, not back todark. - Confirm the map’s tile layer is still the dark CARTO basemap in all three of dark, amber, and blue, and switches to the light basemap only in the light theme.
- Open dev tools and confirm
<html>has adata-theme="amber"attribute, not a leftoverclass="dark". - Search the whole
apps/web/srctree for the literal stringdark:, it should return zero matches. If a component ever grows one, that component broke the pattern this module was built to establish.
Recap
- The real test of a token architecture is whether the AI can add two themes and touch only the token definitions and the toggle, checking the diff for untouched components is the single highest-value review step in this lesson.
isDarkhas to survive as a derived field, computed from the new enum, not silently dropped or replaced with a lookalike comparison, because code outside the toggle (the map’s tile choice) still depends on it and wasn’t supposed to change.hydrateFromDomhas to actually be called, or the store starts out of sync with what the no-flash script already painted, a bug that’s invisible on page load and only shows up as a wrong jump on the very first click.
Next module: directing an AI assistant through the watch-regions control panel and the default-region-aware map.