CodingNic

Frontend Core & Theming

Directing the Frontend Foundations

Frontend Core & Theming 40 min read

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 useEffect that 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

code
I'm building the frontend for SKYWATCH, a real-time ADS-B flight tracker, in apps/web (already scaffolded: Next.js App Router, TypeScript, Tailwind v4). This is the first frontend lesson, so I only want the app shell and the two pieces of state every later screen depends on: the color system and the live-aircraft store. No map, no WebSocket consumption yet, that's next lesson. Install zustand, @fontsource/ibm-plex-mono, and @fontsource/space-grotesk. Requirements: 1. Color system: every color in this app must be a CSS custom property, defined once in :root (light theme) and once in a .dark class (dark theme), never a Tailwind dark: variant on a component. Use Tailwind v4's @theme inline block to turn the custom properties into real Tailwind utility names (bg-base, text-fg, border-line, text-phosphor, etc.) so components use ordinary Tailwind classes that resolve through the current theme. Tokens I need: bg-deep, bg-panel, bg-panel-raised, line, phosphor, phosphor-dim, phosphor-rgb (as a raw "r, g, b" string for use in rgba()), text-main, text-dim. Body font is IBM Plex Mono, display font is Space Grotesk. 2. Theme store: a small Zustand store (useThemeStore) with an isDark boolean and a toggleTheme action that flips the .dark class on document.documentElement and persists the choice to localStorage under the key "skywatch-theme". Wrap the localStorage write in a try/catch, it can throw in private-browsing contexts. 3. No-flash-of-wrong-theme on load: the theme has to be correct in the very first paint, before React hydrates. A useEffect-based sync happens after first paint and causes a visible flash, so this needs to be a synchronous inline script in <head>, reading the same localStorage key, run before the page renders. Root layout needs suppressHydrationWarning on <html> since this script legitimately makes the server-rendered class list and the first client paint differ. 4. Aircraft store: a second Zustand store (useAppStore) for live state: connectionStatus ("connecting" | "open" | "closed"), a flat aircraft array (type it against AircraftState from @skywatch/shared), lastUpdate (Date | null), plus selectedIcao and filterText for the search/detail-panel work next lesson. Every component that reads from this store later needs to subscribe with a selector function, not destructure the whole store, since aircraft updates on every poll cycle and I don't want unrelated components re-rendering because of it. Show me the full file tree for what changes, and every file's contents.

What It Built

bash
cd apps/web
npm install zustand
npm install @fontsource/ibm-plex-mono @fontsource/space-grotesk
css
/* 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.

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

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

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

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

  1. Run the prompt above against your AI coding assistant of choice, with apps/web already scaffolded from Module 1.
  2. Before running anything, read globals.css, layout.tsx, useThemeStore.ts, and useAppStore.ts against the three checks above.
  3. Run npm run dev and open http://localhost:3000. Confirm you see “SKYWATCH”, “0 aircraft tracked”, and a theme toggle button on a dark CRT-green background.
  4. 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.
  5. 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.
  6. Run npm run typecheck from 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 reading layout.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.