Directing the Frontend Foundations
Objectives
By the end of this chapter, you should be able to:
- Write a prompt that gets an AI assistant to build a design-token color system on CSS custom properties, instead of scattering Tailwind
dark:variants through every component - Recognize the moment an AI assistant reaches for
dark:anyway, and know exactly why that choice is a trap two lessons from now - Prompt a Zustand store for live aircraft state and confirm the AI wired components to read narrow selectors, not the whole store
- Verify the no-flash theme script actually runs before first paint, instead of a
useEffectthat only looks like it does
💡 Why this matters: Every component built for the rest of this course reads its colors from this token system. Get this lesson wrong and you’re not fixing one file later, you’re fixing every file that touches color.
The Decision That Has to Happen Before Any Component Exists
Here’s the thing about asking an AI assistant for “dark mode support”: it will give you exactly that, and it will be wrong for this app in a way that doesn’t show up for a while. The obvious, most-common-in-training-data way to do dark mode in a Tailwind project is the dark: variant, bg-white dark:bg-slate-900 next to every color class, everywhere. It works. It looks done. And it is completely the wrong foundation for an app that’s going to have four themes by the end of this module and needs every later component, built across five more modules, to never think about theme at all.
The fix is deciding, before any component exists, that color lives in CSS custom properties, var(--phosphor), var(--bg-deep), redefined once per theme in one block, and that Tailwind classes reference those properties through @theme inline rather than branching on a dark: prefix. State that decision in the prompt itself, explicitly, the same way you’d tell a new teammate “we don’t do dark: here, we do tokens.” An AI assistant that isn’t told this will make the other, more common choice, and it won’t be wrong to do so. It just won’t be what this app needs.
The Prompt
What It Built
cd apps/web
npm install zustand
npm install @fontsource/ibm-plex-mono @fontsource/space-grotesk
/* apps/web/src/app/globals.css */
@import "tailwindcss";
/*
* :root holds the light theme and doubles as the fallback. `.dark` overrides
* the same property names for the dark/CRT-phosphor look. Components never
* branch on which theme is active -- they reference these custom properties
* (bg-base, text-fg, border-line, via the @theme inline block below) and the
* cascade resolves it.
*/
: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;
}
.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;
}
@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;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
background: var(--bg-deep);
color: var(--text-main);
font-family: var(--font-mono);
transition: background-color 0.15s ease, color 0.15s ease;
}
@theme inline is Tailwind v4’s mechanism for turning a runtime CSS custom property into a real Tailwind utility name, bg-base, text-fg-dim, border-line all work as ordinary Tailwind classes from here on, resolving through a property the current theme controls. dark: never needs to appear anywhere.
// apps/web/src/store/useThemeStore.ts
import { create } from "zustand";
const STORAGE_KEY = "skywatch-theme";
interface ThemeState {
isDark: boolean;
toggleTheme: () => void;
}
export const useThemeStore = create<ThemeState>((set, get) => ({
isDark: true,
toggleTheme: () => {
const isDark = !get().isDark;
document.documentElement.classList.toggle("dark", isDark);
try {
localStorage.setItem(STORAGE_KEY, isDark ? "dark" : "light");
} catch {
// localStorage can throw in private-browsing/storage-restricted contexts -- theme just won't persist.
}
set({ isDark });
},
}));
The store’s isDark: true initial value only matters for the very first client-side render before anything has hydrated from localStorage, the real starting value is decided before React even runs, by a synchronous script in <head>:
// apps/web/src/app/layout.tsx
import type { Metadata } from "next";
import "@fontsource/ibm-plex-mono/400.css";
import "@fontsource/ibm-plex-mono/500.css";
import "@fontsource/ibm-plex-mono/600.css";
import "@fontsource/ibm-plex-mono/700.css";
import "@fontsource/space-grotesk/400.css";
import "@fontsource/space-grotesk/500.css";
import "@fontsource/space-grotesk/600.css";
import "@fontsource/space-grotesk/700.css";
import "./globals.css";
export const metadata: Metadata = {
title: "SKYWATCH: Live Flight Tracker",
description: "Real-time ADS-B aircraft tracking dashboard",
};
// Runs before first paint so there's no flash of the wrong theme -- a normal
// useEffect-based sync would apply the class after React's first paint,
// producing a visible flash on every load. Reads the same localStorage key
// useThemeStore writes.
const NO_FLASH_THEME_SCRIPT = `
(function () {
try {
var stored = localStorage.getItem('skywatch-theme');
var isDark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', isDark);
} catch (e) {}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="h-full" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: NO_FLASH_THEME_SCRIPT }} />
</head>
<body className="h-full overflow-hidden font-mono antialiased">{children}</body>
</html>
);
}
suppressHydrationWarning on <html> is required here: the script mutates document.documentElement’s class list on the client before React hydrates, so the server-rendered markup and the first client paint legitimately differ. Without this prop, React logs a hydration mismatch warning every load for a difference that’s intentional.
// apps/web/src/store/useAppStore.ts
import { create } from "zustand";
import type { AircraftState } from "@skywatch/shared";
export type ConnectionStatus = "connecting" | "open" | "closed";
interface AppState {
connectionStatus: ConnectionStatus;
/** Flat list of live aircraft, fed by the WebSocket hook the next lesson builds. */
aircraft: AircraftState[];
lastUpdate: Date | null;
selectedIcao: string | null;
filterText: string;
setConnectionStatus: (status: ConnectionStatus) => void;
setPositions: (aircraft: AircraftState[], ts: number) => void;
setSelectedIcao: (icao: string | null) => void;
setFilterText: (text: string) => void;
}
export const useAppStore = create<AppState>((set) => ({
connectionStatus: "connecting",
aircraft: [],
lastUpdate: null,
selectedIcao: null,
filterText: "",
setConnectionStatus: (status) => set({ connectionStatus: status }),
setPositions: (aircraft, ts) => set({ aircraft, lastUpdate: new Date(ts) }),
setSelectedIcao: (icao) => set({ selectedIcao: icao }),
setFilterText: (text) => set({ filterText: text }),
}));
filterText and selectedIcao aren’t consumed by anything yet, they’re here because next lesson’s search box, detail panel, and flight list all need to read and write them, and defining the store shape once, fully, is clearer than growing it field by field across two adjacent lessons.
// apps/web/src/app/page.tsx
"use client";
// Throwaway: proves the token system and both stores work end to end before
// the real Dashboard component exists. Next lesson replaces this entirely.
import { useThemeStore } from "@/store/useThemeStore";
import { useAppStore } from "@/store/useAppStore";
export default function Home() {
const isDark = useThemeStore((s) => s.isDark);
const toggleTheme = useThemeStore((s) => s.toggleTheme);
const aircraftCount = useAppStore((s) => s.aircraft.length);
return (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-base text-fg">
<h1 className="font-display text-2xl font-bold tracking-[0.14em] text-phosphor">SKYWATCH</h1>
<p className="text-sm text-fg-dim">{aircraftCount} aircraft tracked</p>
<button
type="button"
onClick={toggleTheme}
className="rounded-sm border border-line px-3 py-1.5 text-fg-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
>
{isDark ? "☾ DARK" : "☀ LIGHT"}
</button>
</div>
);
}
Review This
Did the color system actually land as custom properties, or did a dark: variant sneak into a component anyway? This is the one to check first, because it’s the whole reason this lesson exists. Skim every .tsx file the AI produced (there’s only layout.tsx and page.tsx at this point, so it’s a thirty-second check) for the literal string dark:. It won’t be there in the code above, but a model that’s seen ten thousand more dark:bg-slate-900 examples than custom-property theme systems in its training data will sometimes “helpfully” add one anyway, especially if you didn’t say “never dark:” explicitly. It compiles, it renders correctly in both themes today, and it looks completely normal sitting next to token-based classes. It only becomes a real problem two lessons from now, when the amber and blue themes need every component to still “just work,” and a dark:-branched component is the one thing that doesn’t. If you find one, the fix is one line: “replace the dark: variant in <file> with the existing bg-base/text-fg token classes, this app doesn’t use Tailwind’s dark: variant anywhere.”
Is the no-flash script actually a synchronous <head> script, or did it become a useEffect? Both approaches read the same localStorage key and both apply the same class, so a quick skim of the file tree won’t tell you which one you got, an AI assistant reaching for the more common React pattern will happily put the theme sync in a useEffect inside a client component instead of an inline script tag in layout.tsx. It type-checks, it runs, dark mode genuinely works once the page settles. The tell is a flash of the wrong theme for one frame on every load, because a useEffect always runs after React’s first paint, never before it. That flash doesn’t show up if you’re staring at fast localhost with an empty cache; it shows up the moment there’s any real load time. Check for the actual <script dangerouslySetInnerHTML> in layout.tsx’s <head>, not a hook. If it’s a useEffect, the follow-up is: “the theme sync needs to run before first paint, not in a useEffect. Move it to a synchronous inline script in layout.tsx’s <head>, and add suppressHydrationWarning to <html>.”
Are components subscribing to the whole store, or to a narrow selector? Look at how page.tsx (or any component reading useAppStore) pulls values out. useAppStore((s) => s.aircraft.length) re-renders only when the aircraft array reference changes. const { aircraft, connectionStatus, lastUpdate } = useAppStore(), called with no selector at all, re-renders on every store update regardless of which field changed, because Zustand’s no-argument form subscribes to the entire store. Both versions render the exact same UI right now, with one aircraft update every few seconds and one component on screen, there’s nothing to notice. It becomes a real cost once the dashboard has a header, a map with dozens of markers, a detail panel, and a flight list all mounted at once, each one re-rendering on every position update whether it reads aircraft data or not. Check every useAppStore(...) and useThemeStore(...) call for a selector function argument. If you find a bare useAppStore(), the fix is: "<component> is subscribing to the whole store with no selector, change it to select only the specific field(s) it actually reads."
Try It
- Run the prompt above against your AI coding assistant of choice, with
apps/webalready scaffolded from Module 1. - Before running anything, read
globals.css,layout.tsx,useThemeStore.ts, anduseAppStore.tsagainst the three checks above. - Run
npm run devand openhttp://localhost:3000. Confirm you see “SKYWATCH”, “0 aircraft tracked”, and a theme toggle button on a dark CRT-green background. - Click the toggle and confirm the whole page flips to the light palette instantly. Reload the page and confirm it remembers your choice, check
localStorage.getItem('skywatch-theme')in the browser console. - Throttle your network in dev tools (Slow 3G is fine) and reload a few times, watching closely for even a one-frame flash of the wrong theme before the page settles. There shouldn’t be one.
- Run
npm run typecheckfrom the repo root and confirm it passes clean across all three packages.
Recap
- The color-system decision, custom properties over
dark:variants, has to be stated explicitly in the prompt, or the AI will reasonably default to the more common pattern that doesn’t scale past two themes. - A
useEffect-based theme sync and a synchronous inline<head>script produce identical-looking dark mode today and a real, visible flash-of-wrong-theme bug the moment page load isn’t instant. The only way to tell them apart is readinglayout.tsx, not clicking the toggle. - Selector granularity on Zustand stores is invisible with one component and one aircraft mounted, and expensive once the real dashboard, map, and list all subscribe to the same store under real polling load.
Next lesson: consuming the live WebSocket feed with reconnect-with-backoff, rendering the first live Leaflet map, and building out the real dashboard shell around it, search, the detail panel, and the flight list.