Prompting Snapshot Sharing & the Mobile Layout
Objectives
By the end of this chapter, you should be able to:
- Prompt the one-line
crossOrigin="anonymous"fixTileLayerneeds for canvas capture, and know why an AI assistant will very plausibly skip it and still hand you code that “works” - Direct a shareable-view URL and verify the AI wrote real presence checks, not just a
Number()coercion that silently breaks the default view for every normal visitor - Prompt a mobile bottom sheet that reuses existing components instead of rebuilding them, and confirm it actually fills the gap Module 3 left open
💡 Why this matters: This is the last feature lesson in the module, and it’s a useful contrast to the four before it: nothing here touches the server. That also means nothing here gets caught by a type error or a failed request – a broken snapshot still downloads a PNG, a broken share link still looks like a URL, a broken mobile layout still renders something. Every mistake in this lesson is the kind that only shows up when you actually look at the output, not when you run the code.
The Kind of Bug That Doesn’t Announce Itself
Every prior lesson in this module had some server-side signal you could lean on: a 400 from a bad Zod schema, a constraint violation from Postgres, a console error from a malformed WS message. Nothing here has that. MapPanel’s TileLayer has looked the same since Module 3, and it will keep rendering tiles perfectly on screen whether or not it has the one attribute a canvas-based capture actually needs. That’s worth sitting with before you prompt anything: this lesson is entirely about the class of AI mistake that produces code which runs, looks plausible, and is still wrong, discoverable only by opening the file it actually produced and checking it with your own eyes.
The Prompt
What It Built
The one-line fix:
// apps/web/src/components/MapPanel.tsx (addition to the existing TileLayer)
<TileLayer
key={isDark ? "dark" : "light"}
url={isDark ? DARK_TILES : LIGHT_TILES}
attribution={TILE_ATTRIBUTION}
subdomains="abcd"
maxZoom={19}
crossOrigin="anonymous"
/>
The capture half of SnapshotControl:
npm install html2canvas-pro --workspace apps/web
// apps/web/src/components/SnapshotControl.tsx
"use client";
import { useState } from "react";
import { useMap } from "react-leaflet";
import { useAppStore } from "@/store/useAppStore";
type CaptureStatus = "idle" | "capturing" | "error";
export function SnapshotControl() {
const map = useMap();
const selectedIcao = useAppStore((s) => s.selectedIcao);
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<CaptureStatus>("idle");
const [copied, setCopied] = useState(false);
const downloadImage = async () => {
setStatus("capturing");
try {
const { default: html2canvas } = await import("html2canvas-pro");
const canvas = await html2canvas(map.getContainer(), { useCORS: true, backgroundColor: null });
canvas.toBlob((blob) => {
if (!blob) {
setStatus("error");
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `skywatch-${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setStatus("idle");
}, "image/png");
} catch (err) {
console.error("[snapshot] capture failed:", err);
setStatus("error");
}
};
// copyShareLink continues below
The share-link half:
// apps/web/src/components/SnapshotControl.tsx (continued)
const copyShareLink = async () => {
const center = map.getCenter();
const url = new URL(window.location.origin + window.location.pathname);
url.searchParams.set("lat", center.lat.toFixed(4));
url.searchParams.set("lon", center.lng.toFixed(4));
url.searchParams.set("zoom", String(map.getZoom()));
if (selectedIcao) url.searchParams.set("icao", selectedIcao);
try {
await navigator.clipboard.writeText(url.toString());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("[snapshot] clipboard write failed:", err);
setStatus("error");
}
};
return (
<div className="absolute bottom-16 right-3.5 z-[500]">
{open ? (
<div className="w-[190px] rounded-sm border border-line bg-surface/95 p-2.5 text-[11px] text-fg">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] uppercase tracking-[0.08em] text-fg-dim">Snapshot & share</span>
<button type="button" onClick={() => setOpen(false)} className="text-fg-dim hover:text-fg">
✕
</button>
</div>
<button
type="button"
onClick={downloadImage}
disabled={status === "capturing"}
className="mb-1.5 w-full rounded-sm border border-line px-2 py-1.5 text-left text-[11px] text-fg transition-colors hover:border-phosphor-dim hover:text-phosphor disabled:opacity-50"
>
{status === "capturing" ? "Capturing…" : "↓ Download PNG"}
</button>
<button
type="button"
onClick={copyShareLink}
className="w-full rounded-sm border border-line px-2 py-1.5 text-left text-[11px] text-fg transition-colors hover:border-phosphor-dim hover:text-phosphor"
>
{copied ? "✓ Link copied" : "⛓ Copy share link"}
</button>
{status === "error" && <div className="mt-1.5 text-[10px] text-danger">Something went wrong -- try again.</div>}
</div>
) : (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-sm border border-line bg-surface/90 px-3 py-2 text-[11px] tracking-[0.08em] text-phosphor transition-colors hover:border-phosphor-dim"
>
⎙ SNAPSHOT
</button>
)}
</div>
);
}
The parser, with real presence checks:
// apps/web/src/lib/shareView.ts
export interface SharedView {
center: [number, number];
zoom: number;
icao: string | null;
}
export function parseSharedView(): SharedView | null {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
const latRaw = params.get("lat");
const lonRaw = params.get("lon");
const zoomRaw = params.get("zoom");
// Explicit presence checks, not just Number.isFinite on the parsed
// result -- Number(null) coerces to 0 (a finite number), so on an
// ordinary unshared URL the naive version would silently "parse" a
// bogus {0,0,0} view and clobber the real watch-region default.
if (latRaw === null || lonRaw === null || zoomRaw === null) return null;
const lat = Number(latRaw);
const lon = Number(lonRaw);
const zoom = Number(zoomRaw);
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(zoom)) return null;
return { center: [lat, lon], zoom, icao: params.get("icao") };
}
Wired into MapPanel ahead of the watch-region default:
// apps/web/src/components/MapPanel.tsx (additions)
import { parseSharedView } from "@/lib/shareView";
const sharedView = useMemo(() => parseSharedView(), []);
const initialView = sharedView ?? defaultRegionView ?? { center, zoom };
useEffect(() => {
if (loaded && sharedView?.icao) setSelectedIcao(sharedView.icao);
}, [loaded, sharedView, setSelectedIcao]);
The mobile bottom sheet, reusing DetailPanel and FlightList verbatim:
// apps/web/src/components/MobileBottomSheet.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { fmtNum, altitudeFt, isOnGround } from "@skywatch/shared";
import { useAppStore, selectViewAircraft } from "@/store/useAppStore";
import { DetailPanel } from "./DetailPanel";
import { FlightList } from "./FlightList";
export function MobileBottomSheet() {
const [expanded, setExpanded] = useState(false);
const selectedIcao = useAppStore((s) => s.selectedIcao);
const aircraft = useAppStore(selectViewAircraft);
const prevSelected = useRef<string | null>(null);
useEffect(() => {
if (selectedIcao && selectedIcao !== prevSelected.current) {
setExpanded(true);
}
prevSelected.current = selectedIcao;
}, [selectedIcao]);
const selected = selectedIcao ? (aircraft.find((a) => a.hex === selectedIcao) ?? null) : null;
const summary = selected
? (() => {
const cs = (selected.flight ?? "").trim() || selected.r || selected.hex.toUpperCase();
const altFt = altitudeFt(selected);
const onGround = isOnGround(selected);
return `${cs} · ${altFt != null ? `${fmtNum(altFt)} ft` : onGround ? "ON GROUND" : "N/A"}`;
})()
: `${aircraft.length} aircraft nearby`;
return (
<div
className={`absolute inset-x-0 bottom-0 z-[600] flex flex-col rounded-t-md border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.2)] transition-[max-height] duration-300 ease-out md:hidden ${
expanded ? "max-h-[70vh]" : "max-h-[52px]"
}`}
>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
data-testid="mobile-sheet-toggle"
className="flex shrink-0 flex-col items-center gap-1.5 px-4 pb-2 pt-2.5"
>
<span className="h-1 w-10 rounded-full bg-line" />
<span className="text-[11px] tracking-[0.05em] text-fg-dim">
{expanded ? "▾" : "▴"} {summary}
</span>
</button>
{expanded && (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<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>
);
}
Wired in as a sibling of the desktop sidebar, with the mirror-image breakpoint (md:hidden here, hidden md:flex on the sidebar it replaces below that width).
Review This
Did the AI actually add crossOrigin="anonymous" to TileLayer, or did it put the CORS fix somewhere in the capture code instead? This is the single most likely miss in the whole lesson, because “make the capture handle CORS” and “make the tile requests handle CORS” sound like the same instruction, and only one of them actually works. crossorigin has to be set at the moment the browser requests the image – it’s not something you can apply retroactively to a tile that’s already loaded. An AI assistant might instead add useCORS: true to the html2canvas call and stop there, reasoning that’s “the CORS setting” the task needed. That option is real and necessary, but it’s not sufficient on its own, and the failure mode is brutal specifically because it’s silent: no error, no red console line, just a downloaded PNG with markers floating over a flat background. Check TileLayer in MapPanel.tsx for the crossOrigin="anonymous" prop directly. If it’s missing: “TileLayer itself needs crossOrigin="anonymous" – useCORS: true on the html2canvas call is necessary but not sufficient, since the image request’s own crossorigin attribute is what actually determines whether the browser treats the tile as tainted for canvas reads.”
Does parseSharedView check for null before calling Number(), or does it just check Number.isFinite after the fact? Both versions look identical in the one test everyone runs first: paste a real shared link, confirm it loads the right view. The naive version’s bug only shows up on a URL that was never a shared link at all – an ordinary bookmark, a freshly typed address, a link from search results – because Number(null) is 0, a completely finite number that sails right through an isFinite check with nothing to catch it. Check the function for explicit latRaw === null || lonRaw === null || zoomRaw === null guards that run before any Number() call. If it’s missing: “parseSharedView needs explicit === null presence checks on the raw string params before calling Number() on them, not just Number.isFinite on the result – Number(null) coerces to 0, which would silently override the default view on every ordinary page load, not just real shared links.”
Does the mobile sheet’s auto-expand fire on every position update, or only on an actual new selection? If the comparison is against “is selectedIcao truthy” instead of “did selectedIcao change,” the effect re-runs and re-expands the sheet on every render where an aircraft happens to be selected, which in practice means it fights the user every time they manually collapse it while something’s still selected – they tap it shut, the next poll tick’s re-render pops it back open. This is very easy to miss in a quick test because the poll cycle is slow enough that a rushed check might not catch the sheet re-expanding. Check that the effect compares against a prevSelected ref (or equivalent), not just a truthiness check on the current value. If it’s missing the comparison: “MobileBottomSheet’s auto-expand effect needs to compare selectedIcao against its previous value, not just check that it’s truthy – right now it’ll re-expand the sheet on every unrelated re-render while an aircraft is selected, including right after the user collapses it manually.”
Try It
- Add
crossOrigin="anonymous"toMapPanel’sTileLayer, installhtml2canvas-pro, then drop<SnapshotControl />insideMapContainerand restart the frontend. - Click ⎙ SNAPSHOT, then Download PNG, and actually open the downloaded file. Confirm the basemap tiles are visible in the image, not just markers on a flat background – that flatness with no error anywhere is exactly what a missing
crossOriginlooks like, and it’s the one check in this lesson you cannot skip by reading code alone. - Pan/zoom to a specific view, select an aircraft, click Copy share link, and paste the URL into a new tab. Confirm it loads directly into that view with the same aircraft selected.
- Open an ordinary bookmark or freshly-typed URL with no
?lat=params and confirm the app loads its normal default view, not a bogus{0,0,0}view. - Shrink the browser window below the
mdbreakpoint. Confirm the sidebar is gone, a collapsed strip sits at the bottom instead, and tapping an aircraft marker auto-expands the sheet. Then manually collapse it while an aircraft is still selected and wait through a couple of poll cycles – confirm it stays collapsed instead of popping back open on its own.
Recap
crossOrigin="anonymous"has to live onTileLayeritself, not in the capture call – the two CORS settings solve different halves of the same problem, and only checking the capture code’suseCORSflag will miss the half that actually matters.parseSharedView’s explicit=== nullchecks are the entire difference between a working share feature and one that silently breaks the default view for every normal visitor – verify the checks run before anyNumber()coercion, not just thatNumber.isFiniteshows up somewhere.- A truthiness check and a change-detection check look interchangeable in a five-second test and behave completely differently over a real session – the mobile sheet’s auto-expand needed the latter.
- Nothing in this lesson touched the server, which is exactly why nothing in this lesson would have surfaced as a build error, a type error, or a failed request. Every one of these three mistakes only shows up when you actually look at the output.
Next module: directing an AI assistant to help write tests for the core logic.