CodingNic

Feature Build-Out

Prompting Snapshot Sharing & the Mobile Layout

Feature Build-Out 25 min read

Prompting Snapshot Sharing & the Mobile Layout

Objectives

By the end of this chapter, you should be able to:

  • Prompt the one-line crossOrigin="anonymous" fix TileLayer needs 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

code
Three features for SKYWATCH, none of them touching the server: 1. crossOrigin="anonymous" on MapPanel's existing TileLayer. This matters because Leaflet renders tiles as plain <img> elements, and a browser decides whether an image is safe to read back out of a <canvas> based on how the image was REQUESTED (whether crossorigin was set at request time), not on whether the server's response headers would have allowed it. Skip this and canvas-based capture doesn't error -- it just silently omits every tile, leaving a flat background where the basemap should be. This has to be a change to the TileLayer prop itself, not something added later in capture code, because once a tile has loaded without crossorigin there's no way to retroactively mark it CORS-clean. 2. SnapshotControl: a floating map panel with two actions. - Download PNG: capture map.getContainer() (not document.body, not a wrapper around the whole MapPanel -- just the actual Leaflet root) using html2canvas-pro specifically, not plain html2canvas -- our Tailwind v4 setup generates opacity-modifier utilities as color-mix(), which upstream html2canvas's CSS parser chokes on. Pass useCORS: true and backgroundColor: null. Import the library dynamically inside the click handler, not at the top of the file, so it doesn't bloat the initial bundle for the (likely majority of) sessions that never click it. - Copy share link: encode the current map center, zoom, and selected aircraft (if any) into URL query params (lat, lon, zoom, icao) and copy the URL to the clipboard. 3. A parseSharedView function (lib/shareView.ts) that reads those same query params back out on load. This needs real presence checks -- check each param is not null BEFORE calling Number() on it, don't just check Number.isFinite on the parsed result. Number(null) coerces to 0, which is a perfectly finite number, so a naive version would "successfully" parse a {lat:0, lon:0, zoom:0} view out of an ordinary URL that never had these params at all, silently breaking the default watch-region view on every normal page load, not just shared links. Return null if lat/lon/zoom aren't all present, so MapPanel can treat a real parsed result as a strict override ahead of the watch-region default, and an absent one as "nothing to override." 4. MobileBottomSheet: fills the gap below the md breakpoint where Dashboard's sidebar currently just disappears (hidden below md, flex at md and up). Reuse the existing DetailPanel and FlightList components completely unchanged -- don't rebuild their content, just re-home them into a sheet anchored to the bottom of the map. Tap-to-expand/collapse, not a drag gesture. Collapsed state shows a one-line summary (selected aircraft's callsign + altitude, or a plain aircraft count if nothing's selected). Auto-expand the first time a NEW aircraft gets selected (compare against the previous selectedIcao, not just "is something selected," so this doesn't re-fire on every unrelated position update) -- without that, tapping a marker on a phone while the sheet is collapsed would select the aircraft with zero visible feedback. Show me every file.

What It Built

The one-line fix:

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

bash
npm install html2canvas-pro --workspace apps/web
tsx
// 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:

tsx
// 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 &amp; 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:

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

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

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

  1. Add crossOrigin="anonymous" to MapPanel’s TileLayer, install html2canvas-pro, then drop <SnapshotControl /> inside MapContainer and restart the frontend.
  2. 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 crossOrigin looks like, and it’s the one check in this lesson you cannot skip by reading code alone.
  3. 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.
  4. 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.
  5. Shrink the browser window below the md breakpoint. 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 on TileLayer itself, not in the capture call – the two CORS settings solve different halves of the same problem, and only checking the capture code’s useCORS flag will miss the half that actually matters.
  • parseSharedView’s explicit === null checks 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 any Number() coercion, not just that Number.isFinite shows 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.