Public Live Window Playground Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Make the live window playground public at /playground/live-window, merge dev test cities into it, add Nominatim city search, and link from the home page.
Architecture: Move the existing dev playground page to a new /playground/ route, remove the prod guard, add all 10 cities as presets, replace the custom coordinate inputs with a city search field (Nominatim geocoding), and add a subtle link on the home page hero section.
Tech Stack: Astro, TypeScript, Nominatim OpenStreetMap API (client-side fetch)
Task 1: Create the public playground page
Files:
- Create:
app/src/pages/playground/live-window.astro
Step 1: Create the playground directory and page
Copy the content from app/src/pages/dev/live-window-playground.astro to app/src/pages/playground/live-window.astro with these changes:
- Remove the prod redirect guard (lines 5-7:
if (import.meta.env.PROD) { return Astro.redirect("/404"); }) - Update the script import path from
../../scripts/live-window/LiveWindow.tsto../../scripts/live-window/LiveWindow.ts(same relative depth, no change needed) - Update the
@referencepath in the style block from../../layouts/BaseLayout/BaseLayout.cssto../../layouts/BaseLayout/BaseLayout.css(same relative depth, no change needed) - Add
titleanddescriptionprops toBaseLayoutfor SEO - Merge the city presets — add Mumbai and São Paulo from the test page to the location select, keeping all existing presets
The location select should contain these 10 presets (union of playground + test page cities):
<option value="">Auto (IP-based)</option><option value="40.7128,-74.0060" data-tz="America/New_York">New York, US</option><option value="51.5074,-0.1278" data-tz="Europe/London">London, UK</option><option value="35.6762,139.6503" data-tz="Asia/Tokyo">Tokyo, JP</option><option value="-33.8688,151.2093" data-tz="Australia/Sydney">Sydney, AU</option><option value="64.1466,-21.9426" data-tz="Atlantic/Reykjavik">Reykjavik, IS</option><option value="1.3521,103.8198" data-tz="Asia/Singapore">Singapore, SG</option><option value="-22.9068,-43.1729" data-tz="America/Sao_Paulo">Rio de Janeiro, BR</option><option value="30.0444,31.2357" data-tz="Africa/Cairo">Cairo, EG</option><option value="19.076,72.8777" data-tz="Asia/Kolkata">Mumbai, IN</option><option value="-23.5505,-46.6333" data-tz="America/Sao_Paulo">São Paulo, BR</option><option value="custom">Custom...</option>Step 2: Verify the page builds
Run: just app::build
Expected: Build succeeds, /playground/live-window route is generated
Step 3: Commit
git add app/src/pages/playground/live-window.astrogit commit -m "feat: create public live window playground at /playground/live-window"Task 2: Add city search with Nominatim geocoding
Files:
- Modify:
app/src/pages/playground/live-window.astro
Step 1: Add the city search UI
In the Location fieldset, add a city search input above the preset select. Replace the existing custom coords section with a search-first approach:
<fieldset class="control-group"> <legend>Location</legend> <div class="control-row"> <label for="location-select">Preset</label> <select id="location-select"> <!-- all 10 presets + Auto + Custom --> </select> </div> <div id="city-search-section" class="city-search-section" hidden> <div class="control-row"> <label for="city-search-input">Search city</label> <div class="search-wrapper"> <input type="text" id="city-search-input" placeholder="Type a city name..." autocomplete="off" /> <ul id="city-search-results" class="search-results" hidden></ul> </div> </div> <div class="control-row"> <label>Coordinates</label> <span id="custom-coords-display" class="coords-display">—</span> </div> <details class="manual-coords-details"> <summary>Enter coordinates manually</summary> <div class="control-row"> <label for="lat-input">Latitude</label> <input type="number" id="lat-input" step="0.0001" min="-90" max="90" placeholder="40.7128" /> </div> <div class="control-row"> <label for="lng-input">Longitude</label> <input type="number" id="lng-input" step="0.0001" min="-180" max="180" placeholder="-74.0060" /> </div> <div class="control-row"> <label for="tz-input">Timezone (IANA)</label> <input type="text" id="tz-input" placeholder="America/New_York" /> </div> </details> </div></fieldset>Step 2: Add the Nominatim search script
In the <script> block, add the city search logic. Key behavior:
- When location select is set to “Custom…”, show the
city-search-section - Debounce input by 300ms
- Fetch from
https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=5&addressdetails=1 - Use
fetchwith header{ 'User-Agent': 'thalida.com-playground' }— note: browsers may strip this header, but Nominatim also accepts it via query paramemailas an alternative. We’ll include it as best-effort. - Show results in
<ul>dropdown - On result click: set lat/lng/timezone on the live-window, update the coords display, close the dropdown
- Timezone lookup: Nominatim doesn’t return timezone. Use the
Intl.DateTimeFormat().resolvedOptions().timeZoneapproach — actually, we can’t determine timezone from lat/lng client-side without a library. Instead, leave the timezone field blank (the live-window API can resolve it), or let users set it manually if needed.
// City searchconst citySearchSection = document.getElementById("city-search-section") as HTMLDivElement;const citySearchInput = document.getElementById("city-search-input") as HTMLInputElement;const citySearchResults = document.getElementById("city-search-results") as HTMLUListElement;const coordsDisplay = document.getElementById("custom-coords-display") as HTMLSpanElement;
let searchTimeout: ReturnType<typeof setTimeout>;
citySearchInput.addEventListener("input", () => { clearTimeout(searchTimeout); const query = citySearchInput.value.trim(); if (query.length < 2) { citySearchResults.hidden = true; return; } searchTimeout = setTimeout(() => searchCity(query), 300);});
async function searchCity(query: string) { try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5&addressdetails=1`; const res = await fetch(url); const results = await res.json(); renderSearchResults(results); } catch { citySearchResults.innerHTML = '<li class="search-result-item search-result-error">Search failed</li>'; citySearchResults.hidden = false; }}
interface NominatimResult { display_name: string; lat: string; lon: string; address?: { city?: string; town?: string; village?: string; state?: string; country?: string; country_code?: string; };}
function renderSearchResults(results: NominatimResult[]) { if (results.length === 0) { citySearchResults.innerHTML = '<li class="search-result-item search-result-empty">No results found</li>'; citySearchResults.hidden = false; return; }
citySearchResults.innerHTML = results .map((r) => { const name = r.address?.city || r.address?.town || r.address?.village || r.display_name.split(",")[0]; const country = r.address?.country || ""; const label = country ? `${name}, ${country}` : name; return `<li class="search-result-item" data-lat="${r.lat}" data-lng="${r.lon}" data-label="${label}">${label}<span class="search-result-coords">${parseFloat(r.lat).toFixed(4)}, ${parseFloat(r.lon).toFixed(4)}</span></li>`; }) .join(""); citySearchResults.hidden = false;}
citySearchResults.addEventListener("click", (e) => { const item = (e.target as HTMLElement).closest("[data-lat]") as HTMLElement | null; if (!item) return; const lat = item.dataset.lat!; const lng = item.dataset.lng!; const label = item.dataset.label!;
win.setAttribute("latitude", lat); win.setAttribute("longitude", lng); win.setAttribute("label", label); win.removeAttribute("timezone"); // let API resolve it
coordsDisplay.textContent = `${parseFloat(lat).toFixed(4)}, ${parseFloat(lng).toFixed(4)}`; citySearchInput.value = label; citySearchResults.hidden = true;
// Also update manual coord fields latInput.value = lat; lngInput.value = lng; tzInput.value = "";});
// Close search results when clicking outsidedocument.addEventListener("click", (e) => { if (!(e.target as HTMLElement).closest(".search-wrapper")) { citySearchResults.hidden = true; }});Step 3: Update the updateLocation function
Modify the existing updateLocation function so that when “Custom…” is selected, it shows city-search-section instead of the old custom-coords div:
function updateLocation() { const value = locationSelect.value; const option = locationSelect.selectedOptions[0];
if (value === "") { win.removeAttribute("latitude"); win.removeAttribute("longitude"); win.removeAttribute("timezone"); win.removeAttribute("label"); citySearchSection.hidden = true; } else if (value === "custom") { citySearchSection.hidden = false; win.removeAttribute("label"); applyCustomCoords(); } else { citySearchSection.hidden = true; const [lat, lng] = value.split(","); const tz = option?.dataset.tz ?? null; const label = option?.textContent?.trim() ?? null; win.setAttribute("latitude", lat); win.setAttribute("longitude", lng); if (tz) win.setAttribute("timezone", tz); else win.removeAttribute("timezone"); if (label) win.setAttribute("label", label); else win.removeAttribute("label"); }}Step 4: Add CSS for the search UI
.city-search-section { margin-top: 0.75rem;}
.search-wrapper { position: relative; flex: 1; min-width: 0;}
.search-wrapper input[type="text"] { width: 100%; box-sizing: border-box;}
.search-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 100; list-style: none; margin: 0; padding: 0; background: var(--color-surface, #0d1b2a); border: 1px solid var(--color-midnight-light, #1a2a3a); border-top: none; border-radius: 0 0 0.25rem 0.25rem; max-height: 200px; overflow-y: auto;}
.search-result-item { padding: 0.5rem; font-size: 0.75rem; color: var(--color-text); cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;}
.search-result-item:hover { background: var(--color-midnight-light, #1a2a3a);}
.search-result-empty,.search-result-error { cursor: default; color: var(--color-muted); font-style: italic;}
.search-result-coords { font-family: var(--font-mono, monospace); font-size: 0.625rem; color: var(--color-muted);}
.coords-display { font-family: var(--font-mono, monospace); font-size: 0.75rem; color: var(--color-muted);}
.manual-coords-details { margin-top: 0.75rem;}
.manual-coords-details summary { font-size: 0.625rem; color: var(--color-muted); cursor: pointer; margin-bottom: 0.5rem;}
.manual-coords-details summary:hover { color: var(--color-text);}Step 5: Update the reset function
In the reset handler, also clear the city search:
// Reset city searchcitySearchInput.value = "";citySearchResults.hidden = true;coordsDisplay.textContent = "—";Step 6: Verify the page builds
Run: just app::build
Expected: Build succeeds
Step 7: Commit
git add app/src/pages/playground/live-window.astrogit commit -m "feat: add Nominatim city search to live window playground"Task 3: Add home page link to playground
Files:
- Modify:
app/src/pages/index.astro
Step 1: Add the playground link
After the <live-window> element on line 26, add a subtle link:
<live-window api-url={apiBaseUrl} theme="dark" bg-color="#030a12"></live-window><a href="/playground/live-window" class="playground-link font-body text-2xs text-muted hover:text-teal no-underline mt-2"> Experiment with Live Window →</a>Step 2: Verify the page builds
Run: just app::build
Expected: Build succeeds, link visible below live window
Step 3: Commit
git add app/src/pages/index.astrogit commit -m "feat: add playground link below live window on home page"Task 4: Remove old dev pages
Files:
- Delete:
app/src/pages/dev/live-window-playground.astro - Delete:
app/src/pages/dev/live-window-test.astro - Delete:
app/src/pages/dev/(directory, if empty)
Step 1: Delete the dev pages and directory
rm app/src/pages/dev/live-window-playground.astrorm app/src/pages/dev/live-window-test.astrormdir app/src/pages/devStep 2: Verify the build still works
Run: just app::build
Expected: Build succeeds with no broken references
Step 3: Commit
git commit -am "chore: remove dev-only live window pages"Task 5: Final verification
Step 1: Run the full build
Run: just app::build
Expected: Clean build with no errors or warnings
Step 2: Run type checking
Run: just app::typecheck
Expected: No type errors
Step 3: Run tests
Run: just test
Expected: All tests pass