Live Window Refactor Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Refactor the monolithic <live-window> web component into focused modules with a SkyLayer interface for extensibility.
Architecture: Extract types, color utilities, state management, API integration, clock, blinds, and sky layers into separate modules. The main live-window.ts becomes a thin orchestrator (~150-200 lines) that composes everything. Sky layers implement a shared SkyLayer interface so adding stars/moon/sun later is just “create a class, register it.”
Tech Stack: TypeScript, Vitest, Web Components (Shadow DOM), Astro
Design doc: docs/plans/2026-03-01-live-window-refactor-design.md
Base path: app/src/scripts/live-window/
Test command: just app::test
Typecheck command: just app::typecheck
Task 1: Create shared types module
Files:
- Create:
app/src/scripts/live-window/types.ts
Step 1: Create types.ts with all shared interfaces
export interface RGB { r: number; g: number; b: number;}
export interface SkyGradient { zenith: RGB; upper: RGB; lower: RGB; horizon: RGB;}
export interface SunPosition { /** Degrees above horizon: 0 at horizon, ~90 at zenith */ altitude: number; /** Compass bearing: 0=N, 90=E, 180=S, 270=W */ azimuth: number; /** Daytime progress: 0=sunrise, 0.5=solar noon, 1=sunset. -1 when nighttime */ progress: number;}
export interface WeatherInfo { icon: string | null; main: string | null; description: string | null; temp: number | null;}
export interface PhaseInfo { now: number; sunrise: number | null; sunset: number | null; phaseIndex: number; nextPhaseIndex: number; /** Interpolation factor 0-1 within the current phase */ t: number; isDaytime: boolean; sun: SunPosition; weather: WeatherInfo;}
export interface SkyLayer { mount(container: HTMLElement): void; update(phase: PhaseInfo): void; destroy(): void;}
export interface WeatherCurrent { main: string; description: string; icon: string; temp: number;}
export interface StoreState { location: { lastFetched: number | null; lat: number | null; lng: number | null; country: string | null; }; weather: { lastFetched: number | null; units: string | null; current: WeatherCurrent | null; sunrise: number | null; sunset: number | null; };}This file defines all shared types. RGB and SkyGradient move here from sky-gradient.ts. StoreState and WeatherCurrent move here from live-window.ts.
Step 2: Run typecheck
Run: just app::typecheck
Expected: PASS (new file has no imports, no consumers yet)
Step 3: Commit
git add app/src/scripts/live-window/types.tsgit commit -m "refactor(live-window): add shared types module"Task 2: Extract color utilities + tests
Files:
- Create:
app/src/scripts/live-window/color.ts - Create:
app/src/scripts/live-window/__tests__/color.test.ts
Step 1: Write failing tests for color utilities
Create app/src/scripts/live-window/__tests__/color.test.ts:
import { describe, it, expect } from "vitest";import { relativeLuminance, contrastRatio, getReadableColor, parseHexColor, parseComputedColor,} from "../color";
describe("parseHexColor", () => { it("parses 6-digit hex with #", () => { expect(parseHexColor("#030a12")).toEqual({ r: 3, g: 10, b: 18 }); });
it("parses 6-digit hex without #", () => { expect(parseHexColor("ff8000")).toEqual({ r: 255, g: 128, b: 0 }); });
it("returns null for invalid hex", () => { expect(parseHexColor("abc")).toBeNull(); expect(parseHexColor("")).toBeNull(); });});
describe("parseComputedColor", () => { it("parses rgb() string", () => { expect(parseComputedColor("rgb(10, 20, 30)")).toEqual({ r: 10, g: 20, b: 30 }); });
it("returns null for non-matching string", () => { expect(parseComputedColor("transparent")).toBeNull(); });});
describe("relativeLuminance", () => { it("returns 0 for black", () => { expect(relativeLuminance({ r: 0, g: 0, b: 0 })).toBe(0); });
it("returns 1 for white", () => { expect(relativeLuminance({ r: 255, g: 255, b: 255 })).toBeCloseTo(1, 4); });});
describe("contrastRatio", () => { it("returns 21 for black on white", () => { const black = { r: 0, g: 0, b: 0 }; const white = { r: 255, g: 255, b: 255 }; expect(contrastRatio(black, white)).toBeCloseTo(21, 0); });
it("returns 1 for same color", () => { const c = { r: 128, g: 128, b: 128 }; expect(contrastRatio(c, c)).toBeCloseTo(1, 4); });});
describe("getReadableColor", () => { it("returns original color when contrast is already sufficient", () => { const white = { r: 255, g: 255, b: 255 }; const black = { r: 0, g: 0, b: 0 }; expect(getReadableColor(white, black)).toEqual(white); });
it("boosts lightness for low-contrast colors on dark background", () => { const darkBlue = { r: 10, g: 15, b: 40 }; const darkBg = { r: 3, g: 10, b: 18 }; const result = getReadableColor(darkBlue, darkBg); // Result should have higher contrast than original expect(contrastRatio(result, darkBg)).toBeGreaterThanOrEqual(4.5); });});Step 2: Run tests to verify they fail
Run: just app::test
Expected: FAIL — ../color module not found
Step 3: Implement color.ts
Create app/src/scripts/live-window/color.ts. Extract the following functions from live-window.ts lines 325-421, converting instance methods to pure functions:
parseHexColor(hex: string): RGB | null— extracted from thebgColorgetter (lines 327-335)parseComputedColor(computed: string): RGB | null— extracted from thebgColorgetter (lines 338-340)relativeLuminance(c: RGB): number— extracted from lines 344-349contrastRatio(a: RGB, b: RGB): number— extracted from lines 352-358getReadableColor(color: RGB, bg: RGB, minContrast?: number): RGB— extracted from lines 360-421, takingbgas a parameter instead of readingthis.bgColor
All functions are pure — no this references. Import RGB from ./types.
Step 4: Run tests to verify they pass
Run: just app::test
Expected: PASS
Step 5: Commit
git add app/src/scripts/live-window/color.ts app/src/scripts/live-window/__tests__/color.test.tsgit commit -m "refactor(live-window): extract color utilities with tests"Task 3: Extract state persistence + tests
Files:
- Create:
app/src/scripts/live-window/state.ts - Create:
app/src/scripts/live-window/__tests__/state.test.ts
Step 1: Write failing tests for state module
Create app/src/scripts/live-window/__tests__/state.test.ts:
import { describe, it, expect, beforeEach } from "vitest";import { loadState, saveState, DEFAULT_STATE, CACHE_VERSION } from "../state";
// Mock localStorageconst store: Record<string, string> = {};const localStorageMock = { getItem: (key: string) => store[key] ?? null, setItem: (key: string, value: string) => { store[key] = value; }, removeItem: (key: string) => { delete store[key]; },};Object.defineProperty(globalThis, "localStorage", { value: localStorageMock });
const STORAGE_KEY = "liveWindowStore";
describe("loadState", () => { beforeEach(() => { delete store[STORAGE_KEY]; });
it("returns DEFAULT_STATE when nothing is stored", () => { expect(loadState()).toEqual(DEFAULT_STATE); });
it("returns stored state when version matches", () => { const saved = { _v: CACHE_VERSION, location: { lastFetched: 1000, lat: 40, lng: -74, country: "US" }, weather: { lastFetched: 2000, units: "imperial", current: null, sunrise: null, sunset: null }, }; store[STORAGE_KEY] = JSON.stringify(saved); const result = loadState(); expect(result.location.lat).toBe(40); expect(result.location.country).toBe("US"); });
it("returns DEFAULT_STATE and clears storage when version mismatches", () => { store[STORAGE_KEY] = JSON.stringify({ _v: 0, location: { lat: 99 } }); const result = loadState(); expect(result).toEqual(DEFAULT_STATE); expect(store[STORAGE_KEY]).toBeUndefined(); });});
describe("saveState", () => { beforeEach(() => { delete store[STORAGE_KEY]; });
it("round-trips through loadState", () => { const state = { ...DEFAULT_STATE, location: { ...DEFAULT_STATE.location, lat: 51.5, lng: -0.1, country: "GB" }, }; saveState(state); const loaded = loadState(); expect(loaded.location.lat).toBe(51.5); expect(loaded.location.country).toBe("GB"); });});Step 2: Run tests to verify they fail
Run: just app::test
Expected: FAIL — ../state module not found
Step 3: Implement state.ts
Create app/src/scripts/live-window/state.ts. Extract from live-window.ts:
CACHE_VERSIONconstant (line 29)STORAGE_KEYconstant (line 30)DEFAULT_STATEconstant (lines 50-53)loadState()function (lines 248-266)saveState(state: StoreState)function (lines 268-274)
Import StoreState from ./types.
Step 4: Run tests to verify they pass
Run: just app::test
Expected: PASS
Step 5: Commit
git add app/src/scripts/live-window/state.ts app/src/scripts/live-window/__tests__/state.test.tsgit commit -m "refactor(live-window): extract state persistence with tests"Task 4: Extract API integration + tests
Files:
- Create:
app/src/scripts/live-window/api.ts - Create:
app/src/scripts/live-window/__tests__/api.test.ts
Step 1: Write failing tests for API module
Create app/src/scripts/live-window/__tests__/api.test.ts:
import { describe, it, expect } from "vitest";import { shouldFetchLocation, shouldFetchWeather, IP_RATE_LIMIT, WEATHER_RATE_LIMIT } from "../api";import { DEFAULT_STATE } from "../state";import type { StoreState } from "../types";
describe("shouldFetchLocation", () => { it("returns true when never fetched", () => { expect(shouldFetchLocation(DEFAULT_STATE)).toBe(true); });
it("returns false when recently fetched", () => { const state: StoreState = { ...DEFAULT_STATE, location: { ...DEFAULT_STATE.location, lastFetched: Date.now() }, }; expect(shouldFetchLocation(state)).toBe(false); });
it("returns true when rate limit exceeded", () => { const state: StoreState = { ...DEFAULT_STATE, location: { ...DEFAULT_STATE.location, lastFetched: Date.now() - IP_RATE_LIMIT - 1 }, }; expect(shouldFetchLocation(state)).toBe(true); });});
describe("shouldFetchWeather", () => { it("returns true when never fetched", () => { expect(shouldFetchWeather(DEFAULT_STATE, "metric")).toBe(true); });
it("returns true when units changed", () => { const state: StoreState = { ...DEFAULT_STATE, weather: { ...DEFAULT_STATE.weather, lastFetched: Date.now(), units: "metric" }, }; expect(shouldFetchWeather(state, "imperial")).toBe(true); });
it("returns false when recently fetched with same units", () => { const state: StoreState = { ...DEFAULT_STATE, weather: { ...DEFAULT_STATE.weather, lastFetched: Date.now(), units: "metric" }, }; expect(shouldFetchWeather(state, "metric")).toBe(false); });});Step 2: Run tests to verify they fail
Run: just app::test
Expected: FAIL — ../api module not found
Step 3: Implement api.ts
Create app/src/scripts/live-window/api.ts. Extract from live-window.ts:
IP_RATE_LIMITandWEATHER_RATE_LIMITconstants (lines 27-28)IMPERIAL_COUNTRIESset (line 11)shouldFetchLocation(state)(lines 624-627)shouldFetchWeather(state, units)(lines 629-634) — takesunitsas parameter instead of readingthis.resolvedUnitsisSameDate(a, b)helper (lines 424-426)fetchLocation(key, state)(lines 636-653) — returns updated state instead of mutatingthis.statefetchWeather(owKey, ipKey, state, units)(lines 655-695) — returns updated state + achangedflagresolveUnits(attr, country)(lines 278-285) — pure function taking attribute value and country codetempSymbol(units)(lines 287-289) — pure function
Import StoreState from ./types.
Step 4: Run tests to verify they pass
Run: just app::test
Expected: PASS
Step 5: Commit
git add app/src/scripts/live-window/api.ts app/src/scripts/live-window/__tests__/api.test.tsgit commit -m "refactor(live-window): extract API integration with tests"Task 5: Extract clock rendering
Files:
- Create:
app/src/scripts/live-window/clock.ts
Step 1: Create clock.ts
Extract from live-window.ts lines 292-321:
export interface ClockElements { hourEl: HTMLSpanElement | null; minuteEl: HTMLSpanElement | null; ampmEl: HTMLSpanElement | null; amEl: HTMLSpanElement | null; pmEl: HTMLSpanElement | null;}
export function renderClock( els: ClockElements, use12Hour: boolean,): { hour: number; minute: number } { const now = new Date(); const raw = now.getHours(); let h = raw; const m = now.getMinutes();
if (use12Hour) { h = raw % 12 || 12; }
if (els.hourEl) els.hourEl.textContent = `${h < 10 ? "0" : ""}${h}`; if (els.minuteEl) els.minuteEl.textContent = `${m < 10 ? "0" : ""}${m}`;
if (els.ampmEl) { els.ampmEl.hidden = !use12Hour; if (use12Hour) { const isPm = raw >= 12; els.amEl?.classList.toggle("active", !isPm); els.pmEl?.classList.toggle("active", isPm); } }
return { hour: raw, minute: m };}Step 2: Run typecheck
Run: just app::typecheck
Expected: PASS
Step 3: Commit
git add app/src/scripts/live-window/clock.tsgit commit -m "refactor(live-window): extract clock rendering"Task 6: Extract blinds animation
Files:
- Create:
app/src/scripts/live-window/blinds.ts
Step 1: Create blinds.ts
Extract from live-window.ts lines 511-619. Move:
AnimatableProptype (line 24)NUM_BLINDSconstant (line 26)BlindsStateinterface (matches the shape ofblindsSettingson lines 82-87)getSkewAndRotateTransform(blindIndex, state, numBlinds)— pure functiongetSkewOnlyTransform(state)— pure functionrenderBlinds(el, state, numBlinds)— takes element and state, returns voidstepAnimation(state, renderFn, targets, speedMs)— takes a render callbackrunBlindsAnimation(state, renderFn, numBlinds)— orchestrates the two-phase animationupdateStrings(shadow, stringLeftEl, stringRightEl)— takes element refs
All functions take their dependencies as parameters instead of reading from this.
Step 2: Run typecheck
Run: just app::typecheck
Expected: PASS
Step 3: Commit
git add app/src/scripts/live-window/blinds.tsgit commit -m "refactor(live-window): extract blinds animation"Task 7: Move sky-gradient.ts into layers/gradient.ts
Files:
- Create:
app/src/scripts/live-window/layers/directory - Move:
app/src/scripts/live-window/sky-gradient.ts→app/src/scripts/live-window/layers/gradient.ts - Modify:
app/src/scripts/live-window/__tests__/sky-gradient.test.ts(update import path)
Step 1: Create the layers directory and move the file
mkdir -p app/src/scripts/live-window/layersgit mv app/src/scripts/live-window/sky-gradient.ts app/src/scripts/live-window/layers/gradient.tsStep 2: Update gradient.ts imports
In app/src/scripts/live-window/layers/gradient.ts:
- Remove the
RGBandSkyGradientinterface/export declarations (they now live intypes.ts) - Add import:
import type { RGB, SkyGradient, SkyLayer, PhaseInfo } from "../types"; - Keep
SkyPhaseas a local interface (not exported from types — it’s gradient-internal) - Re-export
RGBandSkyGradientfrom types for backward compatibility of the test file - Keep all pure functions exported:
SKY_PHASES,getDefaultSunTimes,calculatePhaseTimestamps,blendGradient,getCurrentSkyGradient
Add the GradientLayer class at the bottom:
export class GradientLayer implements SkyLayer { public currentGradient: SkyGradient | null = null; private el: HTMLElement | null = null;
mount(container: HTMLElement): void { this.el = container; this.el.className = "sky-layer sky-color"; this.el.style.position = "absolute"; this.el.style.top = "0"; this.el.style.left = "0"; this.el.style.width = "100%"; this.el.style.height = "100%"; }
update(phase: PhaseInfo): void { this.currentGradient = getCurrentSkyGradient(phase.now, phase.sunrise, phase.sunset); if (!this.el || !this.currentGradient) return; const { zenith, upper, lower, horizon } = this.currentGradient; this.el.style.background = `linear-gradient(180deg, rgb(${zenith.r},${zenith.g},${zenith.b}), rgb(${upper.r},${upper.g},${upper.b}), rgb(${lower.r},${lower.g},${lower.b}), rgb(${horizon.r},${horizon.g},${horizon.b}))`; }
destroy(): void { if (this.el) this.el.innerHTML = ""; this.el = null; this.currentGradient = null; }}Step 3: Update test imports
In app/src/scripts/live-window/__tests__/sky-gradient.test.ts, change:
// Oldimport { SKY_PHASES, calculatePhaseTimestamps, getDefaultSunTimes, blendGradient, getCurrentSkyGradient } from "../sky-gradient";import type { SkyGradient } from "../sky-gradient";
// Newimport { SKY_PHASES, calculatePhaseTimestamps, getDefaultSunTimes, blendGradient, getCurrentSkyGradient } from "../layers/gradient";import type { SkyGradient } from "../types";Step 4: Run tests
Run: just app::test
Expected: All 24 existing sky-gradient tests PASS
Step 5: Commit
git add -A app/src/scripts/live-window/layers/ app/src/scripts/live-window/__tests__/sky-gradient.test.tsgit commit -m "refactor(live-window): move sky-gradient into layers/gradient with SkyLayer class"Task 8: Create phase-info module + tests
Files:
- Create:
app/src/scripts/live-window/phase-info.ts - Create:
app/src/scripts/live-window/__tests__/phase-info.test.ts
Step 1: Write failing tests for buildPhaseInfo and calculateSunPosition
Create app/src/scripts/live-window/__tests__/phase-info.test.ts:
import { describe, it, expect } from "vitest";import { buildPhaseInfo, calculateSunPosition } from "../phase-info";import { DEFAULT_STATE } from "../state";import type { StoreState } from "../types";
describe("calculateSunPosition", () => { const today = new Date(); today.setHours(7, 0, 0, 0); const sunrise = today.getTime(); today.setHours(19, 0, 0, 0); const sunset = today.getTime();
it("returns progress=0 at sunrise", () => { const pos = calculateSunPosition(sunrise, sunrise, sunset); expect(pos.progress).toBeCloseTo(0, 4); expect(pos.altitude).toBeCloseTo(0, 0); });
it("returns progress=0.5 at solar noon", () => { const noon = (sunrise + sunset) / 2; const pos = calculateSunPosition(noon, sunrise, sunset); expect(pos.progress).toBeCloseTo(0.5, 4); expect(pos.altitude).toBeGreaterThan(0); });
it("returns progress=1 at sunset", () => { const pos = calculateSunPosition(sunset, sunrise, sunset); expect(pos.progress).toBeCloseTo(1, 4); expect(pos.altitude).toBeCloseTo(0, 0); });
it("returns progress=-1 at night", () => { const midnight = new Date(sunrise); midnight.setHours(2, 0, 0, 0); const pos = calculateSunPosition(midnight.getTime(), sunrise, sunset); expect(pos.progress).toBe(-1); });
it("azimuth goes from ~90 (east) at sunrise to ~270 (west) at sunset", () => { const srPos = calculateSunPosition(sunrise, sunrise, sunset); const ssPos = calculateSunPosition(sunset, sunrise, sunset); expect(srPos.azimuth).toBeCloseTo(90, 0); expect(ssPos.azimuth).toBeCloseTo(270, 0); });});
describe("buildPhaseInfo", () => { it("returns isDaytime=true during daytime", () => { const today = new Date(); today.setHours(7, 0, 0, 0); const sunrise = today.getTime(); today.setHours(19, 0, 0, 0); const sunset = today.getTime(); today.setHours(12, 0, 0, 0); const noon = today.getTime();
const state: StoreState = { ...DEFAULT_STATE, weather: { ...DEFAULT_STATE.weather, sunrise, sunset }, }; const info = buildPhaseInfo(state, noon); expect(info.isDaytime).toBe(true); expect(info.phaseIndex).toBeGreaterThanOrEqual(0); expect(info.t).toBeGreaterThanOrEqual(0); expect(info.t).toBeLessThanOrEqual(1); });
it("returns isDaytime=false at midnight", () => { const today = new Date(); today.setHours(7, 0, 0, 0); const sunrise = today.getTime(); today.setHours(19, 0, 0, 0); const sunset = today.getTime(); today.setHours(2, 0, 0, 0); const night = today.getTime();
const state: StoreState = { ...DEFAULT_STATE, weather: { ...DEFAULT_STATE.weather, sunrise, sunset }, }; const info = buildPhaseInfo(state, night); expect(info.isDaytime).toBe(false); });
it("uses default sun times when sunrise/sunset are null", () => { const today = new Date(); today.setHours(12, 0, 0, 0); const info = buildPhaseInfo(DEFAULT_STATE, today.getTime()); // Should not throw and should return valid phase info expect(info.phaseIndex).toBeGreaterThanOrEqual(0); expect(info.phaseIndex).toBeLessThan(16); });
it("populates weather info from state", () => { const state: StoreState = { ...DEFAULT_STATE, weather: { ...DEFAULT_STATE.weather, current: { main: "Clouds", description: "scattered clouds", icon: "03d", temp: 22 }, }, }; const info = buildPhaseInfo(state, Date.now()); expect(info.weather.icon).toBe("03d"); expect(info.weather.main).toBe("Clouds"); expect(info.weather.temp).toBe(22); });});Step 2: Run tests to verify they fail
Run: just app::test
Expected: FAIL — ../phase-info module not found
Step 3: Implement phase-info.ts
Create app/src/scripts/live-window/phase-info.ts:
import type { StoreState, PhaseInfo, SunPosition, WeatherInfo } from "./types";import { SKY_PHASES, getDefaultSunTimes, calculatePhaseTimestamps } from "./layers/gradient";
export function calculateSunPosition( now: number, sunrise: number, sunset: number,): SunPosition { const isDaytime = now >= sunrise && now <= sunset;
if (!isDaytime) { return { altitude: 0, azimuth: 0, progress: -1 }; }
const dayDuration = sunset - sunrise; const progress = dayDuration > 0 ? (now - sunrise) / dayDuration : 0;
// Altitude: sine curve peaking at solar noon // At progress=0 (sunrise) and progress=1 (sunset), altitude=0 // At progress=0.5 (noon), altitude peaks const maxAltitude = 90; // simplified; real max depends on latitude const altitude = maxAltitude * Math.sin(progress * Math.PI);
// Azimuth: linear interpolation from 90 (east) to 270 (west) const azimuth = 90 + progress * 180;
return { altitude, azimuth, progress };}
export function buildPhaseInfo(state: StoreState, now: number): PhaseInfo { let sr = state.weather.sunrise; let ss = state.weather.sunset; if (sr == null || ss == null) { const defaults = getDefaultSunTimes(); sr = defaults.sunrise; ss = defaults.sunset; }
const timestamps = calculatePhaseTimestamps(sr, ss);
// Find which two phases bracket the current time let phaseIdx = 0; for (let i = timestamps.length - 1; i >= 0; i--) { if (now >= timestamps[i]) { phaseIdx = i; break; } }
const nextIdx = (phaseIdx + 1) % SKY_PHASES.length; const phaseStart = timestamps[phaseIdx]; const phaseEnd = nextIdx === 0 ? (() => { const eod = new Date(now); eod.setHours(23, 59, 59, 999); return eod.getTime(); })() : timestamps[nextIdx];
const duration = phaseEnd - phaseStart; const t = duration > 0 ? (now - phaseStart) / duration : 0;
const isDaytime = now >= sr && now <= ss; const sun = calculateSunPosition(now, sr, ss);
const current = state.weather.current; const weather: WeatherInfo = { icon: current?.icon ?? null, main: current?.main ?? null, description: current?.description ?? null, temp: current?.temp ?? null, };
return { now, sunrise: state.weather.sunrise, sunset: state.weather.sunset, phaseIndex: phaseIdx, nextPhaseIndex: nextIdx, t, isDaytime, sun, weather, };}Step 4: Run tests to verify they pass
Run: just app::test
Expected: PASS
Step 5: Commit
git add app/src/scripts/live-window/phase-info.ts app/src/scripts/live-window/__tests__/phase-info.test.tsgit commit -m "refactor(live-window): add phase-info module with sun position"Task 9: Create WeatherLayer + tests
Files:
- Create:
app/src/scripts/live-window/layers/weather.ts - Create:
app/src/scripts/live-window/__tests__/layers/weather.test.ts
Step 1: Write failing tests for WeatherLayer
Create app/src/scripts/live-window/__tests__/layers/weather.test.ts:
import { describe, it, expect, beforeEach } from "vitest";import { WeatherLayer, ICON_WEATHER_MAP } from "../../layers/weather";import type { PhaseInfo } from "../../types";
function makePhaseInfo(icon: string | null): PhaseInfo { return { now: Date.now(), sunrise: null, sunset: null, phaseIndex: 8, nextPhaseIndex: 9, t: 0.5, isDaytime: true, sun: { altitude: 45, azimuth: 180, progress: 0.5 }, weather: { icon, main: null, description: null, temp: null }, };}
describe("ICON_WEATHER_MAP", () => { it("maps all expected icon codes", () => { const expectedCodes = ["02d", "02n", "03d", "03n", "04d", "04n", "09d", "09n", "10d", "10n", "11d", "11n", "13d", "13n", "50d", "50n"]; for (const code of expectedCodes) { expect(ICON_WEATHER_MAP[code]).toBeDefined(); } });});
describe("WeatherLayer", () => { let layer: WeatherLayer; let container: HTMLElement;
beforeEach(() => { layer = new WeatherLayer(); container = document.createElement("div"); layer.mount(container); });
it("renders nothing when icon is null", () => { layer.update(makePhaseInfo(null)); expect(container.innerHTML).toBe(""); });
it("renders clouds for partly cloudy (02d)", () => { layer.update(makePhaseInfo("02d")); expect(container.querySelector(".cloud-sm")).toBeTruthy(); });
it("renders droplets for rain (10d)", () => { layer.update(makePhaseInfo("10d")); expect(container.querySelector(".droplets")).toBeTruthy(); expect(container.querySelector(".cloud-lg")).toBeTruthy(); });
it("renders lightning for thunderstorm (11d)", () => { layer.update(makePhaseInfo("11d")); expect(container.querySelector(".lightning")).toBeTruthy(); });
it("renders mist layers for mist (50d)", () => { layer.update(makePhaseInfo("50d")); expect(container.querySelector(".mist-lg")).toBeTruthy(); expect(container.querySelector(".mist-md")).toBeTruthy(); expect(container.querySelector(".mist-sm")).toBeTruthy(); });
it("renders snow mounds for snow (13d)", () => { layer.update(makePhaseInfo("13d")); expect(container.querySelector(".snow-sill")).toBeTruthy(); expect(container.querySelector(".droplets")).toBeTruthy(); });
it("cleans up on destroy", () => { layer.update(makePhaseInfo("10d")); layer.destroy(); expect(container.innerHTML).toBe(""); });});Step 2: Run tests to verify they fail
Run: just app::test
Expected: FAIL — ../../layers/weather module not found
Step 3: Implement layers/weather.ts
Create app/src/scripts/live-window/layers/weather.ts. Extract from live-window.ts:
ICON_WEATHER_MAP(lines 31-48)- The HTML-building logic from
renderWeatherEffects()(lines 450-498)
import type { SkyLayer, PhaseInfo } from "../types";
export const ICON_WEATHER_MAP: Record<string, string[]> = { "02d": ["cloudSm"], "02n": ["cloudSm"], "03d": ["cloudSm", "cloudMd"], "03n": ["cloudSm", "cloudMd"], "04d": ["cloudSm", "cloudMd", "cloudLg"], "04n": ["cloudSm", "cloudMd", "cloudLg"], "09d": ["cloudMd", "lightRain"], "09n": ["cloudMd", "lightRain"], "10d": ["cloudMd", "cloudLg", "rain"], "10n": ["cloudMd", "cloudLg", "rain"], "11d": ["cloudSm", "cloudMd", "cloudLg", "thunderstorm"], "11n": ["cloudSm", "cloudMd", "cloudLg", "thunderstorm"], "13d": ["snow"], "13n": ["snow"], "50d": ["mist"], "50n": ["mist"],};
export class WeatherLayer implements SkyLayer { private el: HTMLElement | null = null;
mount(container: HTMLElement): void { this.el = container; this.el.className = "sky-layer weather"; }
update(phase: PhaseInfo): void { if (!this.el) return; const icon = phase.weather.icon; this.el.className = "sky-layer weather" + (icon ? ` weather-${icon}` : "");
if (!icon) { this.el.innerHTML = ""; return; }
const effects = ICON_WEATHER_MAP[icon] ?? []; const has = (k: string) => effects.includes(k); const showDroplets = has("lightRain") || has("rain") || has("thunderstorm") || has("snow");
let html = ""; if (has("cloudLg")) html += '<div class="cloud cloud-lg"></div>'; if (has("cloudMd")) html += '<div class="cloud cloud-md"></div>'; if (has("thunderstorm")) html += '<div class="lightning"></div>'; if (has("cloudSm")) html += '<div class="cloud cloud-sm"></div>'; if (has("mist")) { html += '<div class="mist mist-lg"></div><div class="mist mist-md"></div><div class="mist mist-sm"></div>'; } if (showDroplets) { html += '<div class="droplets">'; for (let h = 0; h < 2; h++) { html += '<div class="droplets-half">'; for (let i = 0; i < 6; i++) { html += `<div class="droplet-row droplet-row-${i + 1}">`; for (let j = 0; j < 6; j++) { html += `<div class="droplet droplet-${j + 1}"></div>`; } html += "</div>"; } html += "</div>"; } html += "</div>"; } if (has("snow")) { html += '<div class="snow-sill">'; for (let i = 1; i <= 6; i++) { html += `<div class="snow-mound snow-mound-${i}"></div>`; } html += "</div>"; } this.el.innerHTML = html; }
destroy(): void { if (this.el) this.el.innerHTML = ""; this.el = null; }}Step 4: Run tests to verify they pass
Run: just app::test
Expected: PASS
Step 5: Commit
mkdir -p app/src/scripts/live-window/__tests__/layersgit add app/src/scripts/live-window/layers/weather.ts app/src/scripts/live-window/__tests__/layers/weather.test.tsgit commit -m "refactor(live-window): add WeatherLayer implementing SkyLayer"Task 10: Rewrite live-window.ts as slim orchestrator
This is the big one. Rewrite app/src/scripts/live-window/live-window.ts to import from all the extracted modules and become a thin orchestrator.
Files:
- Modify:
app/src/scripts/live-window/live-window.ts
Step 1: Rewrite live-window.ts
The new file should:
- Import from
./types,./state,./api,./color,./clock,./blinds,./phase-info,./layers/gradient,./layers/weather - Keep
LiveWindowElementclass but delegate everything to imported modules - Keep the same HTML structure in
buildDOM()but mount layers via theSkyLayerinterface - DOM structure: the
.skydiv no longer hardcodes.sky-colorand.weatherchildren — layers create their own containers - Keep all the same attributes, events, and public behavior
- Target: ~150-200 lines
Key changes:
constructor: callsloadState()from./state, creates layers array[new GradientLayer(), new WeatherLayer()]buildDOM(): creates the.skycontainer, then callslayer.mount(container)for each layer. Clock, blinds, and other static DOM stays inlineupdateSky()replacesupdateGradient(): callsbuildPhaseInfo(), thenlayer.update(phase)for all layers, then readsGradientLayer.currentGradientfor text colorupdateClock()callsrenderClock()from./clockrenderBlinds()calls functions from./blindsfetchWeather()calls functions from./api, thensaveState()- Color utilities call
getReadableColor()from./color
The buildDOM HTML template changes from:
<div class="sky"> <div class="sky-color"></div> <div class="weather"></div></div>to:
<div class="sky"></div>because layers mount their own elements into the sky container.
Update the .sky-color CSS selector to .sky-layer.sky-color and .weather positioning to .sky-layer.weather — OR keep the existing selectors and have the layer classes set the right class names on their containers (the layer code in tasks 7 and 9 already does this).
Step 2: Run tests
Run: just app::test
Expected: All existing tests PASS (sky-gradient, color, state, api, phase-info, weather)
Step 3: Run typecheck
Run: just app::typecheck
Expected: PASS
Step 4: Commit
git add app/src/scripts/live-window/live-window.tsgit commit -m "refactor(live-window): rewrite as slim orchestrator with SkyLayer system"Task 11: Update CSS for layer system
Files:
- Modify:
app/src/scripts/live-window/live-window.css
Step 1: Add sky-layer base styles
Add a .sky-layer rule for the shared positioning that all sky layers need:
.sky-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%;}The existing .sky-color styles (lines 94-100) can be removed since the GradientLayer sets its own inline styles. The .weather styles (lines 104-110) stay — the WeatherLayer sets className = "sky-layer weather" so the existing .weather selectors still match.
Step 2: Verify visually (manual check)
Run: just app::serve
Check that the live window still renders correctly with sky gradient, weather effects, clock, and blinds.
Step 3: Commit
git add app/src/scripts/live-window/live-window.cssgit commit -m "refactor(live-window): add sky-layer CSS base class"Task 12: Update README
Files:
- Modify:
app/src/scripts/live-window/README.md
Step 1: Update the README
Update the “Files” table and “How It Works > Sky Gradient” section to reflect the new module structure:
## Files
| File | Purpose || --------------------- | ------------------------------------------------------ || `live-window.ts` | Web Component orchestrator, lifecycle, intervals || `live-window.css` | All styles (loaded into Shadow DOM via `<link>`) || `types.ts` | Shared interfaces: RGB, SkyGradient, PhaseInfo, SkyLayer || `color.ts` | WCAG contrast, luminance, readable color (pure fns) || `state.ts` | localStorage persistence, cache versioning || `api.ts` | IP geolocation + OpenWeather fetch + rate limiting || `clock.ts` | Clock rendering + time format logic || `blinds.ts` | Blinds animation + rendering || `phase-info.ts` | PhaseInfo builder + sun position calculation || `layers/gradient.ts` | GradientLayer: sky color gradient (16 phases) || `layers/weather.ts` | WeatherLayer: clouds, rain, snow, lightning, mist |Update the “Sky Gradient” section to mention the 16-phase system (the README currently says 8 phases — line 67).
Add a new section about the SkyLayer system and how to add new layers.
Step 2: Commit
git add app/src/scripts/live-window/README.mdgit commit -m "docs(live-window): update README for modular architecture"Task 13: Full verification
Step 1: Run all tests
Run: just app::test
Expected: All tests PASS
Step 2: Run typecheck
Run: just app::typecheck
Expected: PASS
Step 3: Run build
Run: just app::build
Expected: Build succeeds
Step 4: Run local dev server and visually verify
Run: just app::serve
Manually verify:
- Sky gradient renders and transitions
- Clock updates every second
- Blinds animation plays on load
- Weather effects display (if API keys configured)
- Weather text shows below window
- Theme switching works (dark/light/auto)
Step 5: Commit if any fixes were needed
If any issues were found and fixed in previous steps, commit the fixes.