Prompting the Core Dashboard UI
Objectives
By the end of this chapter, you should be able to:
- Prompt an AI assistant to consume the Module 2 WebSocket feed from the browser with real reconnect-with-backoff, not a retry loop that hammers the server
- Get a live Leaflet map rendered without crashing server-side rendering, and catch a marker layer that isn’t actually optimized for repeated updates
- Direct the AI through the rest of the dashboard shell, header, search, detail panel, flight list, and confirm every piece the prompt asked for actually landed
💡 Why this matters: A map with nothing to click and nowhere to search is a demo, not a dashboard. This lesson turns last lesson’s placeholder page into the three-panel layout, map, detail, list, that every later feature module assumes already exists.
One Big Prompt, or Several Small Ones
This lesson covers more ground than the last one: the live feed, the map, and three real components. You could split it into four separate prompts, and there’s a real argument for that, smaller diffs are easier to review. But the map and the store it reads from are tightly coupled, and the header, detail panel, and flight list all read the exact same store fields (selectedIcao, filterText, aircraft), so asking for them together means the AI can see the whole shape of what’s sharing state before it writes any of it. Splitting this into four prompts risks four components that each reinvent how selection state should work.
The tradeoff is that a bigger prompt means a bigger review. Every checklist item below exists because a five-part prompt makes it easy for one part to get half-done while the other four look complete.
The Prompt
What It Built
// apps/web/src/lib/serverUrl.ts
export const SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL ?? "http://localhost:4000";
export function apiUrl(path: string): string {
return `${SERVER_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
export function wsUrl(): string {
const u = new URL(SERVER_URL);
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
u.pathname = "/ws";
return u.toString();
}
// apps/web/src/hooks/useLiveFeed.ts
"use client";
import { useEffect } from "react";
import type { ServerToClientMessage } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
import { wsUrl } from "@/lib/serverUrl";
const MAX_RECONNECT_DELAY_MS = 15_000;
/**
* Opens the live WebSocket connection to apps/server and feeds every
* "positions" message into the store. Reconnects with exponential backoff
* if the connection drops instead of giving up after one failed attempt.
*/
export function useLiveFeed() {
const setConnectionStatus = useAppStore((s) => s.setConnectionStatus);
const setPositions = useAppStore((s) => s.setPositions);
useEffect(() => {
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let unmounted = false;
let attempt = 0;
function connect() {
setConnectionStatus("connecting");
ws = new WebSocket(wsUrl());
ws.onopen = () => {
attempt = 0;
setConnectionStatus("open");
};
ws.onmessage = (evt) => {
let msg: ServerToClientMessage;
try {
msg = JSON.parse(evt.data);
} catch {
console.error("[live-feed] received malformed WS message");
return;
}
if (msg.type === "positions") {
setPositions(msg.aircraft, msg.ts);
}
};
ws.onclose = () => {
setConnectionStatus("closed");
if (unmounted) return;
attempt += 1;
const delay = Math.min(1000 * 2 ** attempt, MAX_RECONNECT_DELAY_MS);
reconnectTimer = setTimeout(connect, delay);
};
ws.onerror = () => {
ws?.close();
};
}
connect();
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
};
}, [setConnectionStatus, setPositions]);
}
The backoff, capped at 15 seconds, matters in practice: without it, a server restart during development causes the browser to hammer /ws with a reconnect attempt every few milliseconds. onerror just closing the socket is deliberate, too, close always fires after error for a WebSocket, so routing both through the same onclose reconnect logic avoids writing the backoff dance twice.
// apps/web/src/lib/aircraftIcon.ts
// Client-only: touches `document` via Leaflet's divIcon. Only ever imported
// from MapPanel, which is reached through a dynamic import with ssr:false.
import L from "leaflet";
function planeIconHtml(track: number): string {
return `
<svg width="24" height="24" viewBox="0 0 24 24" style="transform: rotate(${track}deg)">
<path d="M12 2 L15 11 L22 14 L15 15.5 L14 21 L12 18.5 L10 21 L9 15.5 L2 14 L9 11 Z" fill="var(--phosphor)" stroke="var(--bg-deep)" stroke-width="0.8"/>
</svg>
`;
}
export function makePlaneIcon(track: number): L.DivIcon {
return L.divIcon({
className: "plane-icon",
html: planeIconHtml(track),
iconSize: [24, 24],
iconAnchor: [12, 12],
});
}
// apps/web/src/lib/mapView.ts
/** Fallback view centered on the North Atlantic -- wide enough to be a reasonable "nothing configured yet" default. */
export const DEFAULT_VIEW: { center: [number, number]; zoom: number } = {
center: [40.0, -30.0],
zoom: 3,
};
// apps/web/src/components/MapPanel.tsx
"use client";
import { memo, useMemo } from "react";
import { MapContainer, Marker, TileLayer } from "react-leaflet";
import type { AircraftState } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
import { useThemeStore } from "@/store/useThemeStore";
import { makePlaneIcon } from "@/lib/aircraftIcon";
import { DEFAULT_VIEW } from "@/lib/mapView";
const DARK_TILES = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const LIGHT_TILES = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
const TILE_ATTRIBUTION = "© OpenStreetMap © CARTO";
interface AircraftMarkerProps {
aircraft: AircraftState & { lat: number; lon: number };
}
/**
* Memoized so a store update caused by one aircraft moving doesn't recreate
* every other aircraft's icon too. Leaflet's setIcon() tears down and
* rebuilds a marker's DOM node on every icon change, so a fresh L.divIcon()
* on every render, even with identical content, makes every marker flicker
* on each poll cycle instead of just the ones that moved.
*/
const AircraftMarker = memo(function AircraftMarker({ aircraft }: AircraftMarkerProps) {
const track = aircraft.track ?? aircraft.true_heading ?? aircraft.mag_heading ?? 0;
const icon = useMemo(() => makePlaneIcon(track), [track]);
return <Marker position={[aircraft.lat, aircraft.lon]} icon={icon} />;
});
function AircraftMarkers() {
const aircraft = useAppStore((s) => s.aircraft);
return (
<>
{aircraft
.filter((a): a is typeof a & { lat: number; lon: number } => a.lat != null && a.lon != null)
.map((a) => (
<AircraftMarker key={a.hex} aircraft={a} />
))}
</>
);
}
export function MapPanel() {
const isDark = useThemeStore((s) => s.isDark);
const connectionStatus = useAppStore((s) => s.connectionStatus);
return (
<div className="relative h-full w-full">
<MapContainer
center={DEFAULT_VIEW.center}
zoom={DEFAULT_VIEW.zoom}
minZoom={2}
maxZoom={12}
worldCopyJump
zoomControl
attributionControl
className="h-full w-full"
>
<TileLayer
key={isDark ? "dark" : "light"}
url={isDark ? DARK_TILES : LIGHT_TILES}
attribution={TILE_ATTRIBUTION}
subdomains="abcd"
maxZoom={19}
/>
<AircraftMarkers />
</MapContainer>
{connectionStatus !== "open" && (
<div className="absolute left-3.5 top-3.5 z-[500] rounded-sm border border-line bg-surface/90 px-3 py-2 text-[11px] tracking-[0.08em] text-phosphor">
{connectionStatus === "connecting" ? "CONNECTING TO SKYWATCH SERVER…" : "SIGNAL LOST, reconnecting…"}
</div>
)}
</div>
);
}
key={isDark ? "dark" : "light"} on TileLayer forces React to unmount and remount the layer when the theme flips, Leaflet’s TileLayer doesn’t cleanly support swapping its tile URL template on an existing instance, so a key change is the reliable way to get the new tiles to actually load.
// apps/web/src/components/ThemeToggle.tsx
"use client";
import { useThemeStore } from "@/store/useThemeStore";
/**
* Two states for now, extracted from the placeholder button in last lesson's
* page.tsx into its own component so Header can use it. The multi-theme
* lesson rewrites this file's internals entirely once there are four states
* to cycle through instead of two to flip between.
*/
export function ThemeToggle() {
const isDark = useThemeStore((s) => s.isDark);
const toggleTheme = useThemeStore((s) => s.toggleTheme);
return (
<button
type="button"
onClick={toggleTheme}
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
className="flex h-7 items-center gap-1.5 rounded-sm border border-line px-2 text-[11px] tracking-[0.08em] text-fg-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
>
{isDark ? "☾ DARK" : "☀ LIGHT"}
</button>
);
}
// apps/web/src/components/Header.tsx
"use client";
import { ThemeToggle } from "./ThemeToggle";
export interface HeaderProps {
searchValue: string;
onSearchChange: (value: string) => void;
aircraftCount: number | null;
lastUpdateLabel: string;
}
export function Header({ searchValue, onSearchChange, aircraftCount, lastUpdateLabel }: HeaderProps) {
return (
<header className="z-20 flex h-14 shrink-0 items-center justify-between border-b border-line bg-surface px-4">
<div className="flex items-center gap-2.5">
<span className="brand-dot h-[9px] w-[9px] rounded-full bg-phosphor" />
<div>
<h1 className="font-display text-[17px] font-bold tracking-[0.14em] text-fg">SKYWATCH</h1>
<div className="-mt-0.5 text-[10px] tracking-[0.12em] text-fg-dim">LIVE AIRCRAFT POSITIONS · ADSB.LOL</div>
</div>
</div>
<div className="hidden items-center gap-6 sm:flex">
<div className="flex items-center gap-1.5 rounded-sm border border-line bg-surface-raised px-2.5 py-1.5">
<span className="text-[11px] text-fg-dim">⌕</span>
<input
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
type="text"
placeholder="FILTER CALLSIGN..."
className="w-[140px] bg-transparent font-mono text-xs tracking-[0.05em] text-fg placeholder:text-fg-dim focus:outline-none"
/>
</div>
<div className="text-right">
<div className="text-[9px] uppercase tracking-[0.12em] text-fg-dim">Aircraft in view</div>
<div className="font-mono text-[15px] font-semibold text-phosphor">{aircraftCount ?? "--"}</div>
</div>
<div className="text-right">
<div className="text-[9px] uppercase tracking-[0.12em] text-fg-dim">Last update</div>
<div className="font-mono text-xs text-fg">{lastUpdateLabel}</div>
</div>
<ThemeToggle />
</div>
<div className="flex items-center gap-1.5 sm:hidden">
<ThemeToggle />
</div>
</header>
);
}
// apps/web/src/components/DetailPanel.tsx
"use client";
import { useMemo } from "react";
import { altitudeFt, fmtNum, isOnGround } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
function EmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2.5 border-b border-line p-8 text-center text-fg-dim">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" opacity="0.5">
<path d="M2 16l4.5-1.5L12 3l1.5 1L10 13l6 2 2-2 1.5 1.5L17 17l-5-1-3 3-1.5-1L9 15l-7 1z" />
</svg>
<p className="text-[11px] leading-relaxed tracking-[0.03em]">
SELECT AN AIRCRAFT ON THE MAP
<br />
OR FROM THE LIST BELOW TO
<br />
VIEW ITS FLIGHT DATA
</p>
</div>
);
}
function DataCell({ label, value, tone }: { label: string; value: string; tone?: "accent" | "warn" }) {
return (
<div>
<div className="text-[9px] uppercase tracking-[0.1em] text-fg-dim">{label}</div>
<div className={`text-[15px] font-semibold ${tone === "accent" ? "text-phosphor" : tone === "warn" ? "text-amber" : "text-fg"}`}>
{value}
</div>
</div>
);
}
export function DetailPanel() {
const selectedIcao = useAppStore((s) => s.selectedIcao);
const setSelectedIcao = useAppStore((s) => s.setSelectedIcao);
const aircraft = useAppStore((s) => s.aircraft);
const selected = useMemo(() => aircraft.find((a) => a.hex === selectedIcao) ?? null, [aircraft, selectedIcao]);
if (!selected) return <EmptyState />;
const cs = (selected.flight ?? "").trim() || selected.r || selected.hex.toUpperCase();
const onGround = isOnGround(selected);
const altFt = altitudeFt(selected);
const spdKt = selected.gs ?? null;
const vrate = selected.baro_rate ?? selected.geom_rate ?? null;
const track = selected.track ?? selected.true_heading ?? selected.mag_heading ?? null;
return (
<div className="border-b border-line p-4">
<button
type="button"
onClick={() => setSelectedIcao(null)}
className="float-right rounded-sm border border-line px-2 py-1 text-[10px] tracking-[0.08em] text-fg-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
>
CLOSE ✕
</button>
<div className="font-display text-[26px] font-bold leading-tight text-phosphor">{cs}</div>
<div className="mb-4 text-[10px] tracking-[0.1em] text-fg-dim">{selected.hex.toUpperCase()}</div>
<div className="grid grid-cols-2 gap-x-3.5 gap-y-3">
<DataCell label="Altitude" value={altFt != null ? `${fmtNum(altFt)} ft` : "--"} tone="accent" />
<DataCell label="Ground speed" value={spdKt != null ? `${fmtNum(spdKt)} kt` : "--"} tone="accent" />
<DataCell
label="Vertical rate"
value={vrate != null ? `${vrate > 0 ? "+" : ""}${fmtNum(vrate)} fpm` : "--"}
tone={vrate != null ? (vrate > 100 ? "accent" : vrate < -100 ? "warn" : undefined) : undefined}
/>
<DataCell label="Status" value={onGround ? "ON GROUND" : "AIRBORNE"} tone={onGround ? "warn" : "accent"} />
<DataCell label="Squawk" value={selected.squawk || "--"} />
<DataCell label="Category" value={selected.category || "--"} />
<DataCell label="Latitude" value={selected.lat != null ? `${fmtNum(selected.lat, 3)}°` : "--"} />
<DataCell label="Longitude" value={selected.lon != null ? `${fmtNum(selected.lon, 3)}°` : "--"} />
</div>
<div className="mt-4 flex items-center gap-2.5 border-t border-line pt-4">
<div className="relative h-[46px] w-[46px] shrink-0 rounded-full border border-line">
<div
className="absolute left-1/2 top-1/2 h-[18px] w-0.5 bg-phosphor shadow-[0_0_4px_var(--phosphor)]"
style={{ transform: `translate(-50%, -100%) rotate(${track ?? 0}deg)`, transformOrigin: "bottom center" }}
/>
</div>
<DataCell label="Heading" value={track != null ? `${fmtNum(track)}°` : "--"} />
</div>
</div>
);
}
Everything here comes straight from the store and the aircraft’s own ADS-B fields, no network calls beyond the WebSocket feed already running. A later module upgrades this panel with a reference photo, resolved aircraft type and operator, a route lookup, and distance/bearing from a configured home location, each backed by a small external API. Building the plain-telemetry version first means that upgrade is a clean addition to a panel that already works, not a prerequisite to seeing anything at all.
// apps/web/src/components/FlightList.tsx
"use client";
import { useMemo } from "react";
import { altitudeFt, fmtNum, isOnGround } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
const MAX_ROWS = 60;
export function FlightList() {
const aircraft = useAppStore((s) => s.aircraft);
const filterText = useAppStore((s) => s.filterText);
const selectedIcao = useAppStore((s) => s.selectedIcao);
const setSelectedIcao = useAppStore((s) => s.setSelectedIcao);
const items = useMemo(() => {
const q = filterText.trim().toUpperCase();
let list = aircraft.filter((a) => a.lat != null && a.lon != null);
if (q) {
list = list.filter((a) => (a.flight ?? "").toUpperCase().includes(q) || a.hex.toUpperCase().includes(q));
}
return list.slice(0, MAX_ROWS);
}, [aircraft, filterText]);
return (
<div className="flex flex-1 flex-col overflow-hidden px-4 pb-4">
<div className="flex justify-between border-b border-line pb-2 text-[9px] uppercase tracking-[0.08em] text-fg-dim">
<span className="w-[70px]">Callsign</span>
<span className="w-[60px] text-right">Alt ft</span>
<span className="w-[60px] text-right">Spd kt</span>
</div>
<div className="flex-1 overflow-y-auto">
{items.length === 0 ? (
<div className="py-3.5 text-[11px] text-fg-dim">NO AIRCRAFT MATCH</div>
) : (
items.map((a) => {
const cs = (a.flight ?? "").trim() || a.r || a.hex.toUpperCase();
const onGround = isOnGround(a);
const altFt = altitudeFt(a);
const active = a.hex === selectedIcao;
return (
<div
key={a.hex}
role="button"
tabIndex={0}
onClick={() => setSelectedIcao(a.hex)}
onKeyDown={(e) => e.key === "Enter" && setSelectedIcao(a.hex)}
className={`flex cursor-pointer items-center justify-between border-b border-line/50 py-2 text-[11px] transition-colors ${
active ? "bg-amber/10" : "hover:bg-phosphor/5"
}`}
>
<span className="w-[70px] font-semibold tracking-[0.05em] text-fg">{cs}</span>
<span className="w-[60px] text-right text-fg-dim">
{altFt != null ? fmtNum(altFt) : onGround ? "GND" : "--"}
</span>
<span className="w-[60px] text-right text-fg-dim">{a.gs != null ? fmtNum(a.gs) : "--"}</span>
</div>
);
})
)}
</div>
</div>
);
}
filterText is read here and in Header’s search input, both from the same store field, type in the header, and the list narrows, with no prop drilling between two components that don’t otherwise know about each other. MAX_ROWS = 60 is a deliberate cap: a busy region can have far more than 60 aircraft in view at once, and rendering every single row would be wasted work for a panel where a human is scanning a list, not processing all of them programmatically.
// apps/web/src/components/Dashboard.tsx
"use client";
import dynamic from "next/dynamic";
import { Header } from "./Header";
import { DetailPanel } from "./DetailPanel";
import { FlightList } from "./FlightList";
import { useLiveFeed } from "@/hooks/useLiveFeed";
import { useAppStore } from "@/store/useAppStore";
const MapPanel = dynamic(() => import("./MapPanel").then((m) => m.MapPanel), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center text-xs tracking-[0.1em] text-fg-dim">
LOADING MAP…
</div>
),
});
function formatLastUpdate(date: Date | null): string {
if (!date) return "--";
return date.toLocaleTimeString();
}
export function Dashboard() {
useLiveFeed();
const searchValue = useAppStore((s) => s.filterText);
const setFilterText = useAppStore((s) => s.setFilterText);
const aircraft = useAppStore((s) => s.aircraft);
const lastUpdate = useAppStore((s) => s.lastUpdate);
return (
<div className="flex h-full flex-col bg-base">
<Header
searchValue={searchValue}
onSearchChange={setFilterText}
aircraftCount={aircraft.length}
lastUpdateLabel={formatLastUpdate(lastUpdate)}
/>
<div className="flex min-h-0 flex-1">
<div className="relative min-w-0 flex-1 bg-base">
<MapPanel />
</div>
<div className="hidden w-[380px] shrink-0 flex-col overflow-y-auto border-l border-line bg-surface md:flex">
<DetailPanel />
<div className="flex flex-1 flex-col overflow-hidden">
<h2 className="px-4 pb-3 pt-4 text-[11px] uppercase tracking-[0.14em] text-fg-dim">Nearby traffic</h2>
<FlightList />
</div>
</div>
</div>
</div>
);
}
// apps/web/src/app/page.tsx
import { Dashboard } from "@/components/Dashboard";
export default function Home() {
return <Dashboard />;
}
hidden ... md:flex on the sidebar is the first piece of the responsive layout: below the md breakpoint, the detail panel and flight list disappear entirely rather than squeezing into a phone-sized column, a later module replaces that gap with a purpose-built mobile layout instead of trying to force this desktop layout to shrink.
Review This
Does the reconnect actually back off, or does it retry on a fixed delay, or not at all? Read useLiveFeed.ts’s onclose handler specifically. A fixed setTimeout(connect, 2000) looks completely reasonable, it reconnects, the app recovers, everything works in your local dev loop where the server comes back in a second or two. It only becomes a problem in the exact scenario a backoff exists to handle: the server is down for a while, and now every client is hammering /ws with a new connection attempt every two seconds indefinitely, which is real load on a server that’s already having a bad day. Confirm the delay actually grows with each failed attempt (1000 * 2 ** attempt here) and is capped at a sane maximum, not just present as a fixed number. If it’s flat or missing, the follow-up prompt is: “the WebSocket reconnect in useLiveFeed needs exponential backoff capped at a max delay, not a fixed retry interval, a prolonged outage shouldn’t mean every client retries every two seconds forever.”
Are aircraft markers actually memoized, or is a fresh icon getting built on every store update? This one is easy to miss because it renders correctly either way. Check AircraftMarker in MapPanel.tsx for memo(...) wrapping the component and useMemo(() => makePlaneIcon(track), [track]) around the icon itself. Skip either one and the app still works, aircraft still show up in the right place, still rotate to the right heading, with three or four aircraft on screen you won’t see anything wrong. The cost shows up as a visible flicker across every marker on every poll cycle once a real watch region with real traffic is running, because Leaflet’s setIcon() tears down and rebuilds a marker’s DOM node on every icon change, and an unmemoized component creates a new icon object for every aircraft, including the ones that didn’t move, on every store update. The follow-up prompt, if you catch this: “AircraftMarker needs to be memoized, and the L.divIcon it builds needs to be memoized on the track value, so a position update for one aircraft doesn’t rebuild every other aircraft’s marker icon too.”
Did marker clicks on the map actually get wired to selection, or only the flight list? The prompt asked for both “clicking a marker on the map and clicking a row in the flight list should both select the same aircraft.” Look at the AircraftMarker component in MapPanel.tsx above, it renders a Marker with a position and an icon, and nothing else. There’s no eventHandlers prop calling setSelectedIcao. This is a specific, easy AI failure mode on multi-part prompts: earlier requirements (the marker itself, its icon, its rotation) got built in full, and a requirement mentioned once at the end of a long list got dropped entirely, with nothing about the rest of the app suggesting anything’s missing, FlightList already sets selectedIcao on row click, so the detail panel populates fine from there and the whole dashboard looks finished. The only way to catch this is doing exactly what the prompt asked for: click a marker. The follow-up prompt is: “wire an onClick eventHandler on each AircraftMarker that calls setSelectedIcao with that aircraft’s hex, the flight list already does this, the map should do the same thing.”
Try It
- Run the prompt above against your AI coding assistant of choice, with the token system and both stores in place from the previous lesson.
- Before running anything, read
useLiveFeed.tsandMapPanel.tsxagainst the three checks above. - With Postgres and the server running from Module 2, run
npm run devfrom the repo root and openhttp://localhost:3000. - Confirm the header shows the connecting/live status, a themed map covering the North Atlantic, and, once the poller’s next cycle lands, glowing markers appearing wherever your configured watch region has traffic, each rotated to its actual heading.
- Type a partial callsign into the search box and confirm the flight list narrows to matching rows as you type.
- Click a row in the flight list and confirm the detail panel populates with that aircraft’s telemetry. Then click a marker on the map directly, if nothing happens, that’s the third review point above, not a bug in your setup.
- Click “CLOSE” in the detail panel and confirm it returns to the empty state.
- Stop the server process mid-session and watch the browser console: reconnect attempts should visibly space out, not fire every few milliseconds.
Recap
- A prompt covering several tightly-coupled pieces (map, header, detail panel, list, all reading the same store) is worth writing as one prompt, but it multiplies the surface area you need to review, one dropped requirement out of six doesn’t make the output look broken.
- Reconnect backoff and marker memoization are both invisible under light load and both real problems under real load, the only way to catch either is reading the specific line that handles it, not watching the happy path run.
Header,DetailPanel, andFlightListall read from the same store, no prop drilling required between siblings that need to share selection or filter state, but shared state also means a missing wire-up (map clicks) can hide behind a working alternate path (list clicks).
Next lesson: growing the theme toggle from two states into a full four-theme system.