Info Panel Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace WeatherTextComponent with a combined InfoPanelComponent that displays location name, coordinates/timezone, and weather info inside the live-window shadow DOM with static white text.
Architecture: Rename and expand the existing WeatherTextComponent into an InfoPanelComponent that renders three text sections. The weather API response’s data.name field provides reverse-geocoded city names. An optional label attribute overrides the API name. All info text uses a static white color instead of the dynamic WCAG-contrast neon color.
Tech Stack: TypeScript, Shadow DOM, CSS, Vitest
Task 1: Add name field to StoreState.location and update defaults
Files:
- Modify:
app/src/scripts/live-window/types.ts:50-56 - Modify:
app/src/scripts/live-window/state.ts:8
Step 1: Add name to StoreState.location type
In types.ts, update the location shape inside StoreState:
location: { lastFetched: number | null; lat: number | null; lng: number | null; country: string | null; name: string | null;};Step 2: Add name: null to DEFAULT_STORE
In state.ts, update DEFAULT_STORE:
export const DEFAULT_STORE: StoreState = { location: { lastFetched: null, lat: null, lng: null, country: null, name: null }, weather: { lastFetched: null, units: null, current: null, sunrise: null, sunset: null },};Step 3: Add label to LiveWindowState.attrs
In types.ts, add to the attrs interface inside LiveWindowState:
attrs: { use12Hour: boolean; hideClock: boolean; hideWeatherText: boolean; bgColor: RGB; resolvedUnits: string; timezone: string | null; label: string | null;};Step 4: Update createDefaultState in state.ts
Add label: null to the attrs object:
attrs: { use12Hour: false, hideClock: false, hideWeatherText: false, bgColor: { r: 0, g: 0, b: 0 }, resolvedUnits: "metric", timezone: null, label: null,},Step 5: Run typecheck
Run: just app::typecheck
Expected: Errors in files that reference location without name (api.ts, LiveWindow.ts). This is expected — we’ll fix them in subsequent tasks.
Step 6: Commit
feat(live-window): add name to location state and label to attrsTask 2: Extract location name from API responses
Files:
- Modify:
app/src/scripts/live-window/api.ts:48-70(fetchLocation) - Modify:
app/src/scripts/live-window/api.ts:72-102(fetchWeather)
Step 1: Update fetchLocation to extract city name
In the fetchLocation function, add name from the IP registry response:
return { ...state, location: { lat: data.location.latitude, lng: data.location.longitude, country: data.location.country?.code ?? null, name: data.location.city ?? null, lastFetched: Date.now(), },};Step 2: Update fetchWeather to extract city name
In the fetchWeather function, update the newState to include name from the weather response:
const newState: StoreState = { ...state, location: { ...state.location, name: data.name ?? state.location.name, }, weather: { current: { ...data.weather[0], temp: data.main.temp }, sunrise: data.sys.sunrise * 1000, sunset: data.sys.sunset * 1000, units, lastFetched: Date.now(), },};Step 3: Update doFetchWeather in LiveWindow.ts
In the explicit coords branch, add name: null (preserving existing name if any):
this.state.store = { ...this.state.store, location: { lat: parseFloat(explicitLat), lng: parseFloat(explicitLng), country: null, name: this.state.store.location.name, lastFetched: Date.now(), },};Step 4: Run typecheck
Run: just app::typecheck
Expected: Should pass (or only errors related to InfoPanelComponent which doesn’t exist yet).
Step 5: Run existing API tests
Run: just app::test
Expected: Existing tests pass. Some may need name: null added to location objects in test fixtures.
Step 6: Commit
feat(live-window): extract location name from API responsesTask 3: Create InfoPanelComponent and delete WeatherTextComponent
Files:
- Create:
app/src/scripts/live-window/components/InfoPanelComponent.ts - Delete:
app/src/scripts/live-window/components/WeatherTextComponent.ts
Step 1: Create InfoPanelComponent.ts
import type { SceneComponent, LiveWindowState } from "../types";
export class InfoPanelComponent implements SceneComponent { private containerEl: HTMLElement | null = null; private locationEl: HTMLParagraphElement | null = null; private coordsEl: HTMLParagraphElement | null = null; private weatherEl: HTMLParagraphElement | null = null;
mount(container: HTMLElement): void { this.containerEl = container;
const wrapper = document.createElement("div"); wrapper.className = "info-panel";
const location = document.createElement("p"); location.className = "info-panel-location"; location.hidden = true; wrapper.appendChild(location); this.locationEl = location;
const coords = document.createElement("p"); coords.className = "info-panel-coords"; coords.hidden = true; wrapper.appendChild(coords); this.coordsEl = coords;
const weather = document.createElement("p"); weather.className = "info-panel-weather"; weather.hidden = true; wrapper.appendChild(weather); this.weatherEl = weather;
container.appendChild(wrapper); }
update(state: LiveWindowState): void { this.updateLocation(state); this.updateCoords(state); this.updateWeather(state); }
private updateLocation(state: LiveWindowState): void { if (!this.locationEl) return; const name = state.attrs.label ?? state.store.location.name; if (name) { this.locationEl.textContent = name; this.locationEl.hidden = false; } else { this.locationEl.hidden = true; } }
private updateCoords(state: LiveWindowState): void { if (!this.coordsEl) return; const { lat, lng } = state.store.location; if (lat != null && lng != null) { const parts = [`${lat.toFixed(2)}°, ${lng.toFixed(2)}°`]; if (state.attrs.timezone) { parts.push(state.attrs.timezone); } this.coordsEl.textContent = parts.join(" · "); this.coordsEl.hidden = false; } else { this.coordsEl.hidden = true; } }
private updateWeather(state: LiveWindowState): void { if (!this.weatherEl) return; const current = state.store.weather.current; if (current && !state.attrs.hideWeatherText) { const units = state.attrs.resolvedUnits; const symbol = units === "imperial" ? "°F" : "°C"; this.weatherEl.textContent = `${Math.round(current.temp)}${symbol} · ${current.description}`; this.weatherEl.hidden = false; } else { this.weatherEl.hidden = true; } }
destroy(): void { if (this.containerEl) this.containerEl.innerHTML = ""; this.containerEl = null; this.locationEl = null; this.coordsEl = null; this.weatherEl = null; }}Step 2: Delete WeatherTextComponent.ts
Remove app/src/scripts/live-window/components/WeatherTextComponent.ts.
Step 3: Commit
feat(live-window): replace WeatherTextComponent with InfoPanelComponentTask 4: Wire InfoPanelComponent into LiveWindow.ts
Files:
- Modify:
app/src/scripts/live-window/LiveWindow.ts
Step 1: Update imports
Replace:
import { WeatherTextComponent } from "./components/WeatherTextComponent";With:
import { InfoPanelComponent } from "./components/InfoPanelComponent";Step 2: Update observedAttributes
Add "label" to the observedAttributes array.
Step 3: Update component instantiation
Replace:
private weatherTextComponent = new WeatherTextComponent();With:
private infoPanelComponent = new InfoPanelComponent();Step 4: Update components array in constructor
Replace:
this.components = [this.skyComponent, this.blindsComponent, this.clockComponent, this.weatherTextComponent];With:
this.components = [this.skyComponent, this.blindsComponent, this.clockComponent, this.infoPanelComponent];Step 5: Update attributeChangedCallback
Replace the block that handles hide-weather-text and bg-color:
if (name === "hide-weather-text" || name === "bg-color") { this.refreshAttrs(); this.weatherTextComponent.update(this.state); return;}With:
if (name === "hide-weather-text" || name === "bg-color" || name === "label") { this.refreshAttrs(); this.infoPanelComponent.update(this.state); return;}Step 6: Update refreshAttrs
Add label:
this.state.attrs = { use12Hour: this.getAttribute("time-format") === "12", hideClock: this.hasAttribute("hide-clock"), hideWeatherText: this.hasAttribute("hide-weather-text"), bgColor: this.getBgColor(), resolvedUnits: resolveUnits(this.getAttribute("temp-unit"), this.state.store.location.country), timezone: this.getAttribute("timezone") || null, label: this.getAttribute("label") || null,};Step 7: Update buildDOM
Replace the weather text mount comment and variable name:
// Mount info panel (outside .live-window, inside .scene)const infoPanelContainer = document.createElement("div");scene.appendChild(infoPanelContainer);this.infoPanelComponent.mount(infoPanelContainer);Step 8: Remove unused import
The getReadableColor import (if pulled in via WeatherTextComponent) is no longer needed. Check if parseHexColor and parseComputedColor are still used — they are (in getBgColor), so keep those.
Step 9: Run typecheck
Run: just app::typecheck
Expected: PASS
Step 10: Commit
feat(live-window): wire InfoPanelComponent into LiveWindowTask 5: Update CSS — replace weather text styles with info panel styles
Files:
- Modify:
app/src/scripts/live-window/live-window.css:770-789
Step 1: Replace .current-weather-text styles with .info-panel styles
Remove the entire /* ---- Weather text ---- */ section (lines 770-789) and replace with:
/* ---- Info panel ---- */
.info-panel { display: flex; flex-direction: column; align-items: center; gap: 4px; width: var(--live-window-width); margin: var(--info-panel-margin, 12px 0); font-family: var(--info-panel-font, var(--primary-font)); color: var(--info-panel-color, #ffffff);}
.info-panel-location { font-size: var(--info-panel-location-size, 0.85rem); font-weight: 600; margin: 0; letter-spacing: 0.02em;}
.info-panel-coords { font-size: var(--info-panel-coords-size, 0.65rem); margin: 0; opacity: 0.7; letter-spacing: 0.04em;}
.info-panel-weather { font-size: var(--info-panel-weather-size, 0.7rem); margin: 4px 0 0; letter-spacing: 0.06em; text-transform: var(--info-panel-weather-transform, uppercase);}
.info-panel-location[hidden],.info-panel-coords[hidden],.info-panel-weather[hidden] { display: none;}Step 2: Remove --weather-text-color from :host
In the :host block (line 15), remove:
--weather-text-color: var(--window-sky-color-default);Step 3: Run typecheck and visual check
Run: just app::typecheck
Expected: PASS
Step 4: Commit
feat(live-window): replace weather text CSS with info panel stylesTask 6: Update test page to remove external location display
Files:
- Modify:
app/src/pages/live-window-test.astro
Step 1: Add label attribute and remove external location markup
Update each city’s <live-window> to include the label attribute, and remove the external <div class="text-center"> block:
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-8"> { cities.map((city) => ( <div class="flex flex-col items-center"> <live-window openweather-key={openweatherKey} latitude={String(city.lat)} longitude={String(city.lng)} timezone={city.tz} temp-unit={city.unit} theme="dark" bg-color="#030a12" label={city.name} /> </div> )) }</div>Step 2: Remove the --weather-text-font and --weather-text-color CSS variables
In the <style> block, update the live-window selector to remove weather-text variables (keep the others):
live-window { --window-color: var(--color-midnight); --blinds-color: var(--color-midnight); --clock-text-color: #e74c3c;}Step 3: Commit
feat(live-window): use label attr in test page, remove external locationTask 7: Write tests for InfoPanelComponent
Files:
- Create:
app/src/scripts/live-window/__tests__/components/InfoPanelComponent.test.ts - Delete:
app/src/scripts/live-window/__tests__/components/WeatherTextComponent.test.ts
Step 1: Write the test file
import { describe, it, expect, beforeEach } from "vitest";import { InfoPanelComponent } from "../../components/InfoPanelComponent";import type { LiveWindowState } from "../../types";import { DEFAULT_STATE } from "../../state";import { buildPhaseInfo } from "../../utils/phase";
function makeState(overrides?: { hideWeatherText?: boolean; temp?: number; description?: string; icon?: string; label?: string | null; locationName?: string | null; lat?: number | null; lng?: number | null; timezone?: string | null;}): LiveWindowState { const hasWeather = overrides?.temp != null; const store = { ...DEFAULT_STATE, location: { ...DEFAULT_STATE.location, lat: overrides?.lat ?? null, lng: overrides?.lng ?? null, name: overrides?.locationName ?? null, }, weather: { ...DEFAULT_STATE.weather, units: "metric", current: hasWeather ? { main: "Clouds", description: overrides?.description ?? "scattered clouds", icon: overrides?.icon ?? "03d", temp: overrides?.temp ?? 0, } : null, }, }; return { store, computed: { phase: buildPhaseInfo(store, Date.now()) }, ref: {}, attrs: { use12Hour: false, hideClock: false, hideWeatherText: overrides?.hideWeatherText ?? false, bgColor: { r: 0, g: 0, b: 0 }, resolvedUnits: "metric", timezone: overrides?.timezone ?? null, label: overrides?.label ?? null, }, };}
describe("InfoPanelComponent", () => { let comp: InfoPanelComponent; let container: HTMLElement;
beforeEach(() => { comp = new InfoPanelComponent(); container = document.createElement("div"); comp.mount(container); });
describe("mount", () => { it("creates info panel elements on mount", () => { expect(container.querySelector(".info-panel")).toBeTruthy(); expect(container.querySelector(".info-panel-location")).toBeTruthy(); expect(container.querySelector(".info-panel-coords")).toBeTruthy(); expect(container.querySelector(".info-panel-weather")).toBeTruthy(); }); });
describe("location name", () => { it("shows label attribute when provided", () => { comp.update(makeState({ label: "New York" })); const el = container.querySelector(".info-panel-location") as HTMLElement; expect(el.textContent).toBe("New York"); expect(el.hidden).toBe(false); });
it("shows API location name when no label", () => { comp.update(makeState({ locationName: "Tokyo" })); const el = container.querySelector(".info-panel-location") as HTMLElement; expect(el.textContent).toBe("Tokyo"); expect(el.hidden).toBe(false); });
it("label overrides API name", () => { comp.update(makeState({ label: "NYC", locationName: "New York" })); const el = container.querySelector(".info-panel-location") as HTMLElement; expect(el.textContent).toBe("NYC"); });
it("hides location when neither label nor name available", () => { comp.update(makeState()); const el = container.querySelector(".info-panel-location") as HTMLElement; expect(el.hidden).toBe(true); }); });
describe("coordinates", () => { it("shows lat/lng when available", () => { comp.update(makeState({ lat: 40.7128, lng: -74.006 })); const el = container.querySelector(".info-panel-coords") as HTMLElement; expect(el.textContent).toBe("40.71°, -74.01°"); expect(el.hidden).toBe(false); });
it("shows lat/lng with timezone", () => { comp.update(makeState({ lat: 40.7128, lng: -74.006, timezone: "America/New_York" })); const el = container.querySelector(".info-panel-coords") as HTMLElement; expect(el.textContent).toBe("40.71°, -74.01° · America/New_York"); });
it("hides coords when no lat/lng", () => { comp.update(makeState()); const el = container.querySelector(".info-panel-coords") as HTMLElement; expect(el.hidden).toBe(true); }); });
describe("weather text", () => { it("shows weather text when weather data exists", () => { comp.update(makeState({ temp: 22, description: "clear sky" })); const el = container.querySelector(".info-panel-weather") as HTMLElement; expect(el.textContent).toContain("22"); expect(el.textContent).toContain("clear sky"); expect(el.hidden).toBe(false); });
it("hides weather text when no weather data", () => { comp.update(makeState()); const el = container.querySelector(".info-panel-weather") as HTMLElement; expect(el.hidden).toBe(true); });
it("hides weather text when hideWeatherText is true", () => { comp.update(makeState({ temp: 22, hideWeatherText: true })); const el = container.querySelector(".info-panel-weather") as HTMLElement; expect(el.hidden).toBe(true); });
it("formats with dot separator", () => { comp.update(makeState({ temp: 29, description: "few clouds" })); const el = container.querySelector(".info-panel-weather") as HTMLElement; expect(el.textContent).toBe("29°C · few clouds"); });
it("uses imperial symbol when units are imperial", () => { const state = makeState({ temp: 85, description: "sunny" }); state.attrs.resolvedUnits = "imperial"; comp.update(state); const el = container.querySelector(".info-panel-weather") as HTMLElement; expect(el.textContent).toBe("85°F · sunny"); }); });
describe("destroy", () => { it("cleans up on destroy", () => { comp.destroy(); expect(container.innerHTML).toBe(""); }); });});Step 2: Delete WeatherTextComponent.test.ts
Remove app/src/scripts/live-window/__tests__/components/WeatherTextComponent.test.ts.
Step 3: Run tests
Run: just app::test
Expected: All tests pass.
Step 4: Fix any test fixtures in other test files
If other tests reference DEFAULT_STATE.location without name, they may need updating. Check api.test.ts and state.test.ts — add name: null to any location object literals.
Step 5: Commit
test(live-window): add InfoPanelComponent tests, remove WeatherTextComponent testsTask 8: Final verification
Step 1: Run full test suite
Run: just app::test
Expected: All tests pass.
Step 2: Run typecheck
Run: just app::typecheck
Expected: PASS
Step 3: Build
Run: just app::build
Expected: PASS
Step 4: Commit (if any fixes needed)
fix(live-window): address final verification issues