Live Window Class-Based Refactor Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Refactor <live-window> into self-contained SceneComponent classes (sky, blinds, clock, weather text) with a unified LiveWindowState and utils/ folder.
Architecture: Each scene part becomes a class implementing SceneComponent { mount, update, destroy }. A single LiveWindowState object flows through all components with store (persisted), computed (derived), ref (cross-component), and attrs (DOM attributes) sections. The orchestrator is a slim web component that wires components together.
Tech Stack: TypeScript, Vitest, Web Components (Shadow DOM), CSS
Run tests: just app::test
Run typecheck: just app::typecheck
Task 1: Update types.ts — Add SceneComponent, LiveWindowState
Files:
- Modify:
app/src/scripts/live-window/types.ts
Step 1: Update types.ts
Replace the entire contents of types.ts with:
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 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; };}
export interface LiveWindowState { /** Persisted to localStorage — API cache data */ store: StoreState; /** Recomputed each update cycle from store + current time */ computed: { phase: PhaseInfo; }; /** Written by components during update(), read by downstream components */ ref: { currentGradient?: SkyGradient; }; /** Derived from web component attributes each cycle */ attrs: { use12Hour: boolean; hideClock: boolean; hideWeatherText: boolean; bgColor: RGB; resolvedUnits: string; };}
export interface SceneComponent { /** Create DOM elements inside the provided container */ mount(container: HTMLElement): void; /** Called by the orchestrator at the component's update cadence */ update(state: LiveWindowState): void; /** Tear down DOM and release resources */ destroy(): void;}Note: StoreState is kept as a named interface since it’s used by state.ts, api.ts, and tests. LiveWindowState.store is typed as StoreState. The old SkyLayer interface is removed.
Step 2: Run typecheck to see what breaks
Run: just app::typecheck
Expected: Errors in live-window.ts (references SkyLayer), layers/gradient.ts and layers/weather.ts (import SkyLayer). These will be fixed in later tasks.
Step 3: Commit
git add app/src/scripts/live-window/types.tsgit commit -m "refactor(live-window): add SceneComponent, LiveWindowState types; remove SkyLayer"Task 2: Move utils — color.ts, phase-info.ts, sky-gradient pure functions
Files:
- Move:
app/src/scripts/live-window/color.ts→app/src/scripts/live-window/utils/color.ts - Move:
app/src/scripts/live-window/phase-info.ts→app/src/scripts/live-window/utils/phase.ts - Create:
app/src/scripts/live-window/utils/sky-gradient.ts - Modify:
app/src/scripts/live-window/__tests__/color.test.ts(update import path) - Modify:
app/src/scripts/live-window/__tests__/phase-info.test.ts(update import path) - Modify:
app/src/scripts/live-window/__tests__/sky-gradient.test.ts(update import path)
Step 1: Create utils/ directory and move color.ts
mkdir -p app/src/scripts/live-window/utilsgit mv app/src/scripts/live-window/color.ts app/src/scripts/live-window/utils/color.tsUpdate the import in utils/color.ts — change "./types" to "../types".
Step 2: Move phase-info.ts → utils/phase.ts
git mv app/src/scripts/live-window/phase-info.ts app/src/scripts/live-window/utils/phase.tsUpdate imports in utils/phase.ts:
"./types"→"../types""./layers/gradient"→"./sky-gradient"(since sky-gradient.ts will be in the same utils/ folder)
Step 3: Create utils/sky-gradient.ts
Extract all pure functions from layers/gradient.ts into utils/sky-gradient.ts. This file gets everything EXCEPT the GradientLayer class:
import type { RGB, SkyGradient } from "../types";
export type { RGB, SkyGradient } from "../types";
interface SkyPhase { name: string; gradient: SkyGradient;}
// 16 sky phases ordered chronologically from midnight.// Each defines a 4-stop vertical gradient: zenith (top) → horizon (bottom).export const SKY_PHASES: SkyPhase[] = [ // ... (copy all 16 phases exactly from layers/gradient.ts)];
const MIN30 = 30 * 60_000;const MIN60 = 60 * 60_000;const MIN90 = 90 * 60_000;
export function getDefaultSunTimes(): { sunrise: number; sunset: number } { // ... (copy exactly from layers/gradient.ts)}
export function calculatePhaseTimestamps(sunrise: number, sunset: number): number[] { // ... (copy exactly from layers/gradient.ts)}
function blendChannel(a: number, b: number, t: number): number { return Math.round(a + (b - a) * t);}
function blendColor(a: RGB, b: RGB, t: number): RGB { return { r: blendChannel(a.r, b.r, t), g: blendChannel(a.g, b.g, t), b: blendChannel(a.b, b.b, t), };}
export function blendGradient(a: SkyGradient, b: SkyGradient, t: number): SkyGradient { // ... (copy exactly from layers/gradient.ts)}
export function getCurrentSkyGradient(now: number, sunrise: number | null, sunset: number | null): SkyGradient { // ... (copy exactly from layers/gradient.ts)}Step 4: Update test imports
In __tests__/color.test.ts, change:
// OLDimport { relativeLuminance, contrastRatio, getReadableColor, parseHexColor, parseComputedColor } from "../color";// NEWimport { relativeLuminance, contrastRatio, getReadableColor, parseHexColor, parseComputedColor } from "../utils/color";In __tests__/phase-info.test.ts, change:
// OLDimport { buildPhaseInfo, calculateSunPosition } from "../phase-info";// NEWimport { buildPhaseInfo, calculateSunPosition } from "../utils/phase";In __tests__/sky-gradient.test.ts, change:
// OLDimport { SKY_PHASES, calculatePhaseTimestamps, getDefaultSunTimes, blendGradient, getCurrentSkyGradient,} from "../layers/gradient";// NEWimport { SKY_PHASES, calculatePhaseTimestamps, getDefaultSunTimes, blendGradient, getCurrentSkyGradient,} from "../utils/sky-gradient";Step 5: Run tests
Run: just app::test
Expected: All existing tests pass (color, phase-info, sky-gradient, state, api tests).
Step 6: Commit
git add app/src/scripts/live-window/utils/ app/src/scripts/live-window/__tests__/git commit -m "refactor(live-window): move color, phase, sky-gradient to utils/"Task 3: Create GradientLayer component
Files:
- Create:
app/src/scripts/live-window/components/sky/GradientLayer.ts - Delete:
app/src/scripts/live-window/layers/gradient.ts(after new file is created)
Step 1: Write the GradientLayer test
Create app/src/scripts/live-window/__tests__/components/sky/GradientLayer.test.ts:
import { describe, it, expect, beforeEach } from "vitest";import { GradientLayer } from "../../../components/sky/GradientLayer";import type { LiveWindowState } from "../../../types";import { buildPhaseInfo } from "../../../utils/phase";import { DEFAULT_STATE } from "../../../state";
function makeState(overrides?: Partial<LiveWindowState>): LiveWindowState { const store = overrides?.store ?? DEFAULT_STATE; const today = new Date(); today.setHours(12, 0, 0, 0); return { store, computed: { phase: buildPhaseInfo(store, today.getTime()) }, ref: {}, attrs: { use12Hour: false, hideClock: false, hideWeatherText: false, bgColor: { r: 0, g: 0, b: 0 }, resolvedUnits: "metric", }, ...overrides, };}
describe("GradientLayer", () => { let layer: GradientLayer; let container: HTMLElement;
beforeEach(() => { layer = new GradientLayer(); container = document.createElement("div"); layer.mount(container); });
it("sets sky-layer class on mount", () => { expect(container.className).toBe("sky-layer"); });
it("sets background gradient on update", () => { const state = makeState(); layer.update(state); expect(container.style.background).toContain("linear-gradient"); });
it("writes currentGradient to state.ref", () => { const state = makeState(); layer.update(state); expect(state.ref.currentGradient).toBeDefined(); expect(state.ref.currentGradient!.zenith).toBeDefined(); expect(state.ref.currentGradient!.horizon).toBeDefined(); });
it("clears element on destroy", () => { const state = makeState(); layer.update(state); layer.destroy(); expect(container.innerHTML).toBe(""); });});Step 2: Run test to verify it fails
Run: just app::test
Expected: FAIL — cannot resolve ../../../components/sky/GradientLayer
Step 3: Write GradientLayer implementation
Create app/src/scripts/live-window/components/sky/GradientLayer.ts:
import type { SceneComponent, LiveWindowState, SkyGradient } from "../../types";import { getCurrentSkyGradient } from "../../utils/sky-gradient";
export class GradientLayer implements SceneComponent { private el: HTMLElement | null = null;
mount(container: HTMLElement): void { this.el = container; this.el.className = "sky-layer"; }
update(state: LiveWindowState): void { const { phase } = state.computed; const gradient = getCurrentSkyGradient(phase.now, phase.sunrise, phase.sunset); state.ref.currentGradient = gradient;
if (!this.el) return; const { zenith, upper, lower, horizon } = gradient; 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; }}Step 4: Run tests
Run: just app::test
Expected: New GradientLayer tests pass.
Step 5: Commit
git add app/src/scripts/live-window/components/sky/GradientLayer.ts app/src/scripts/live-window/__tests__/components/sky/GradientLayer.test.tsgit commit -m "feat(live-window): add GradientLayer SceneComponent"Task 4: Create WeatherLayer component
Files:
- Create:
app/src/scripts/live-window/components/sky/WeatherLayer.ts - Modify:
app/src/scripts/live-window/__tests__/layers/weather.test.ts(update to use SceneComponent)
Step 1: Write WeatherLayer implementation
Create app/src/scripts/live-window/components/sky/WeatherLayer.ts:
import type { SceneComponent, LiveWindowState } 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 SceneComponent { private el: HTMLElement | null = null;
mount(container: HTMLElement): void { this.el = container; this.el.className = "sky-layer weather"; }
update(state: LiveWindowState): void { if (!this.el) return; const icon = state.computed.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 2: Update weather test to use new component
Update __tests__/layers/weather.test.ts. Change the imports and the makePhaseInfo helper to produce a LiveWindowState instead:
import { describe, it, expect, beforeEach } from "vitest";import { WeatherLayer, ICON_WEATHER_MAP } from "../../components/sky/WeatherLayer";import type { LiveWindowState } from "../../types";import { DEFAULT_STATE } from "../../state";import { buildPhaseInfo } from "../../utils/phase";
function makeState(icon: string | null): LiveWindowState { const store = { ...DEFAULT_STATE, weather: { ...DEFAULT_STATE.weather, current: icon ? { main: "Test", description: "test", icon, temp: 20 } : null, }, }; return { store, computed: { phase: buildPhaseInfo(store, Date.now()) }, ref: {}, attrs: { use12Hour: false, hideClock: false, hideWeatherText: false, bgColor: { r: 0, g: 0, b: 0 }, resolvedUnits: "metric", }, };}
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(makeState(null)); expect(container.innerHTML).toBe(""); });
it("renders clouds for partly cloudy (02d)", () => { layer.update(makeState("02d")); expect(container.querySelector(".cloud-sm")).toBeTruthy(); });
it("renders droplets for rain (10d)", () => { layer.update(makeState("10d")); expect(container.querySelector(".droplets")).toBeTruthy(); expect(container.querySelector(".cloud-lg")).toBeTruthy(); });
it("renders lightning for thunderstorm (11d)", () => { layer.update(makeState("11d")); expect(container.querySelector(".lightning")).toBeTruthy(); });
it("renders mist layers for mist (50d)", () => { layer.update(makeState("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(makeState("13d")); expect(container.querySelector(".snow-sill")).toBeTruthy(); expect(container.querySelector(".droplets")).toBeTruthy(); });
it("cleans up on destroy", () => { layer.update(makeState("10d")); layer.destroy(); expect(container.innerHTML).toBe(""); });});Step 3: Run tests
Run: just app::test
Expected: All tests pass.
Step 4: Commit
git add app/src/scripts/live-window/components/sky/WeatherLayer.ts app/src/scripts/live-window/__tests__/layers/weather.test.tsgit commit -m "feat(live-window): add WeatherLayer SceneComponent"Task 5: Create SkyComponent composite
Files:
- Create:
app/src/scripts/live-window/components/SkyComponent.ts
Step 1: Write SkyComponent test
Create app/src/scripts/live-window/__tests__/components/SkyComponent.test.ts:
import { describe, it, expect, beforeEach } from "vitest";import { SkyComponent } from "../../components/SkyComponent";import type { LiveWindowState } from "../../types";import { DEFAULT_STATE } from "../../state";import { buildPhaseInfo } from "../../utils/phase";
function makeState(): LiveWindowState { const today = new Date(); today.setHours(12, 0, 0, 0); return { store: DEFAULT_STATE, computed: { phase: buildPhaseInfo(DEFAULT_STATE, today.getTime()) }, ref: {}, attrs: { use12Hour: false, hideClock: false, hideWeatherText: false, bgColor: { r: 0, g: 0, b: 0 }, resolvedUnits: "metric", }, };}
describe("SkyComponent", () => { let sky: SkyComponent; let container: HTMLElement;
beforeEach(() => { sky = new SkyComponent(); container = document.createElement("div"); sky.mount(container); });
it("sets sky class on container", () => { expect(container.className).toBe("sky"); });
it("mounts child layers as divs inside container", () => { expect(container.children.length).toBe(2); });
it("populates state.ref.currentGradient after update", () => { const state = makeState(); sky.update(state); expect(state.ref.currentGradient).toBeDefined(); });
it("cleans up children on destroy", () => { const state = makeState(); sky.update(state); sky.destroy(); // After destroy, child layers have cleared their elements for (const child of container.children) { expect((child as HTMLElement).innerHTML).toBe(""); } });});Step 2: Run test to verify it fails
Run: just app::test
Expected: FAIL — cannot resolve ../../components/SkyComponent
Step 3: Write SkyComponent implementation
Create app/src/scripts/live-window/components/SkyComponent.ts:
import type { SceneComponent, LiveWindowState } from "../types";import { GradientLayer } from "./sky/GradientLayer";import { WeatherLayer } from "./sky/WeatherLayer";
export class SkyComponent implements SceneComponent { private children: SceneComponent[] = [new GradientLayer(), new WeatherLayer()];
mount(container: HTMLElement): void { container.className = "sky"; for (const child of this.children) { const div = document.createElement("div"); container.appendChild(div); child.mount(div); } }
update(state: LiveWindowState): void { for (const child of this.children) child.update(state); }
destroy(): void { for (const child of this.children) child.destroy(); }}Step 4: Run tests
Run: just app::test
Expected: All tests pass.
Step 5: Commit
git add app/src/scripts/live-window/components/SkyComponent.ts app/src/scripts/live-window/__tests__/components/SkyComponent.test.tsgit commit -m "feat(live-window): add SkyComponent composite"Task 6: Create ClockComponent
Files:
- Create:
app/src/scripts/live-window/components/ClockComponent.ts - Delete (later):
app/src/scripts/live-window/clock.ts
Step 1: Write ClockComponent test
Create app/src/scripts/live-window/__tests__/components/ClockComponent.test.ts:
import { describe, it, expect, beforeEach } from "vitest";import { ClockComponent } from "../../components/ClockComponent";import type { LiveWindowState } from "../../types";import { DEFAULT_STATE } from "../../state";import { buildPhaseInfo } from "../../utils/phase";
function makeState(use12Hour = false): LiveWindowState { return { store: DEFAULT_STATE, computed: { phase: buildPhaseInfo(DEFAULT_STATE, Date.now()) }, ref: {}, attrs: { use12Hour, hideClock: false, hideWeatherText: false, bgColor: { r: 0, g: 0, b: 0 }, resolvedUnits: "metric", }, };}
describe("ClockComponent", () => { let clock: ClockComponent; let container: HTMLElement;
beforeEach(() => { clock = new ClockComponent(); container = document.createElement("div"); clock.mount(container); });
it("creates clock HTML structure on mount", () => { expect(container.querySelector(".clock")).toBeTruthy(); expect(container.querySelector(".clock-hour")).toBeTruthy(); expect(container.querySelector(".clock-minute")).toBeTruthy(); expect(container.querySelector(".clock-ampm")).toBeTruthy(); });
it("updates time display on update", () => { clock.update(makeState()); const hourEl = container.querySelector(".clock-hour") as HTMLElement; const minuteEl = container.querySelector(".clock-minute") as HTMLElement; expect(hourEl.textContent).toBeTruthy(); expect(minuteEl.textContent).toBeTruthy(); });
it("hides ampm when in 24-hour mode", () => { clock.update(makeState(false)); const ampmEl = container.querySelector(".clock-ampm") as HTMLElement; expect(ampmEl.hidden).toBe(true); });
it("shows ampm when in 12-hour mode", () => { clock.update(makeState(true)); const ampmEl = container.querySelector(".clock-ampm") as HTMLElement; expect(ampmEl.hidden).toBe(false); });
it("respects hideClock attr", () => { const state = makeState(); state.attrs.hideClock = true; clock.update(state); const clockEl = container.querySelector(".clock") as HTMLElement; expect(clockEl.hidden).toBe(true); });
it("cleans up on destroy", () => { clock.update(makeState()); clock.destroy(); expect(container.innerHTML).toBe(""); });
it("returns hour and minute from lastTick after update", () => { clock.update(makeState()); const tick = clock.lastTick; expect(tick).toBeDefined(); expect(tick!.hour).toBeGreaterThanOrEqual(0); expect(tick!.hour).toBeLessThan(24); expect(tick!.minute).toBeGreaterThanOrEqual(0); expect(tick!.minute).toBeLessThan(60); });});Step 2: Run test to verify it fails
Run: just app::test
Expected: FAIL — cannot resolve ../../components/ClockComponent
Step 3: Write ClockComponent implementation
Create app/src/scripts/live-window/components/ClockComponent.ts:
import type { SceneComponent, LiveWindowState } from "../types";
export class ClockComponent implements SceneComponent { private clockEl: HTMLDivElement | null = null; private hourEl: HTMLSpanElement | null = null; private minuteEl: HTMLSpanElement | null = null; private ampmEl: HTMLSpanElement | null = null; private amEl: HTMLSpanElement | null = null; private pmEl: HTMLSpanElement | null = null;
/** Exposed so the orchestrator can read the last rendered time for events */ public lastTick: { hour: number; minute: number } | null = null;
mount(container: HTMLElement): void { const clock = document.createElement("div"); clock.className = "clock"; clock.innerHTML = ` <span class="clock-hour"></span> <span class="separator">:</span> <span class="clock-minute"></span> <span class="clock-ampm" hidden> <span class="ampm-am">AM</span> <span class="ampm-pm">PM</span> </span> `; container.appendChild(clock);
this.clockEl = clock; this.hourEl = clock.querySelector(".clock-hour"); this.minuteEl = clock.querySelector(".clock-minute"); this.ampmEl = clock.querySelector(".clock-ampm"); this.amEl = clock.querySelector(".ampm-am"); this.pmEl = clock.querySelector(".ampm-pm"); }
update(state: LiveWindowState): void { if (!this.clockEl) return;
this.clockEl.hidden = state.attrs.hideClock;
const now = new Date(); const raw = now.getHours(); let h = raw; const m = now.getMinutes(); const use12Hour = state.attrs.use12Hour;
if (use12Hour) { h = raw % 12 || 12; }
if (this.hourEl) this.hourEl.textContent = `${h < 10 ? "0" : ""}${h}`; if (this.minuteEl) this.minuteEl.textContent = `${m < 10 ? "0" : ""}${m}`;
if (this.ampmEl) { this.ampmEl.hidden = !use12Hour; if (use12Hour) { const isPm = raw >= 12; this.amEl?.classList.toggle("active", !isPm); this.pmEl?.classList.toggle("active", isPm); } }
this.lastTick = { hour: raw, minute: m }; }
destroy(): void { if (this.clockEl?.parentElement) { this.clockEl.parentElement.innerHTML = ""; } this.clockEl = null; this.hourEl = null; this.minuteEl = null; this.ampmEl = null; this.amEl = null; this.pmEl = null; this.lastTick = null; }}Step 4: Run tests
Run: just app::test
Expected: All tests pass.
Step 5: Commit
git add app/src/scripts/live-window/components/ClockComponent.ts app/src/scripts/live-window/__tests__/components/ClockComponent.test.tsgit commit -m "feat(live-window): add ClockComponent"Task 7: Create BlindsComponent
Files:
- Create:
app/src/scripts/live-window/components/BlindsComponent.ts - Delete (later):
app/src/scripts/live-window/blinds.ts
Step 1: Write BlindsComponent test
Create app/src/scripts/live-window/__tests__/components/BlindsComponent.test.ts:
import { describe, it, expect, beforeEach } from "vitest";import { BlindsComponent } from "../../components/BlindsComponent";import type { LiveWindowState } from "../../types";import { DEFAULT_STATE } from "../../state";import { buildPhaseInfo } from "../../utils/phase";
function makeState(): LiveWindowState { return { store: DEFAULT_STATE, computed: { phase: buildPhaseInfo(DEFAULT_STATE, Date.now()) }, ref: {}, attrs: { use12Hour: false, hideClock: false, hideWeatherText: false, bgColor: { r: 0, g: 0, b: 0 }, resolvedUnits: "metric", }, };}
describe("BlindsComponent", () => { let blinds: BlindsComponent; let container: HTMLElement;
beforeEach(() => { blinds = new BlindsComponent(); container = document.createElement("div"); blinds.mount(container); });
it("creates blinds HTML structure on mount", () => { expect(container.querySelector(".blinds")).toBeTruthy(); expect(container.querySelector(".blinds-string-left")).toBeTruthy(); expect(container.querySelector(".blinds-string-right")).toBeTruthy(); });
it("renders slats on mount", () => { expect(container.querySelector(".slats")).toBeTruthy(); expect(container.querySelector(".slat")).toBeTruthy(); });
it("cleans up on destroy", () => { blinds.destroy(); expect(container.innerHTML).toBe(""); });});Step 2: Run test to verify it fails
Run: just app::test
Expected: FAIL — cannot resolve ../../components/BlindsComponent
Step 3: Write BlindsComponent implementation
Create app/src/scripts/live-window/components/BlindsComponent.ts:
import type { SceneComponent, LiveWindowState } from "../types";
const NUM_BLINDS = 20;
interface BlindsState { numBlindsCollapsed: number; blindsOpenDeg: number; blindsSkewDeg: number; skewDirection: number;}
type AnimatableProp = keyof BlindsState;
export class BlindsComponent implements SceneComponent { private blindsEl: HTMLDivElement | null = null; private stringLeftEl: HTMLDivElement | null = null; private stringRightEl: HTMLDivElement | null = null; private containerEl: HTMLElement | null = null;
private blindsState: BlindsState = { numBlindsCollapsed: 0, blindsOpenDeg: 20, blindsSkewDeg: 0, skewDirection: 0, };
private animationStarted = false;
mount(container: HTMLElement): void { this.containerEl = container; container.innerHTML = ` <div class="blinds" style="--live-window-num-blinds: ${NUM_BLINDS}"></div> <div class="blinds-string blinds-string-left"></div> <div class="blinds-string blinds-string-right"></div> `;
this.blindsEl = container.querySelector(".blinds"); this.stringLeftEl = container.querySelector(".blinds-string-left"); this.stringRightEl = container.querySelector(".blinds-string-right");
this.renderBlinds(); }
update(_state: LiveWindowState): void { if (!this.animationStarted) { this.animationStarted = true; this.runAnimation(); } }
destroy(): void { if (this.containerEl) this.containerEl.innerHTML = ""; this.containerEl = null; this.blindsEl = null; this.stringLeftEl = null; this.stringRightEl = null; }
// -- Rendering --------------------------------------------------------------
private renderBlinds(): void { if (!this.blindsEl) return; const state = this.blindsState; const numOpen = NUM_BLINDS - Math.round(state.numBlindsCollapsed); const numCollapsed = Math.round(state.numBlindsCollapsed);
let slats = ""; for (let i = 0; i < numOpen; i++) { slats += `<div class="slat slat-${i + 1}" style="transform:${this.getSkewAndRotateTransform(i)}"></div>`; }
const skew = this.getSkewOnlyTransform(); slats += `<div class="slat-collapse-group" style="transform:${skew}">`; for (let i = 0; i < numCollapsed; i++) { slats += '<div class="slat collapse"></div>'; } slats += "</div>";
slats += `<div class="slat-bar" style="transform:${skew}">`; slats += '<span class="string-marker string-marker-left"></span>'; slats += '<span class="string-marker string-marker-right"></span>'; slats += "</div>";
this.blindsEl.innerHTML = ` <div class="slats">${slats}</div> <div class="rod"></div> `;
requestAnimationFrame(() => requestAnimationFrame(() => this.updateStrings()), ); }
private updateStrings(): void { if (!this.containerEl) return; const win = this.containerEl.closest(".live-window"); const ml = this.containerEl.querySelector(".string-marker-left"); const mr = this.containerEl.querySelector(".string-marker-right"); if (!win || !ml || !mr) return;
const winTop = win.getBoundingClientRect().top; if (this.stringLeftEl) this.stringLeftEl.style.height = `${ml.getBoundingClientRect().top - winTop}px`; if (this.stringRightEl) this.stringRightEl.style.height = `${mr.getBoundingClientRect().top - winTop}px`; }
// -- Animation --------------------------------------------------------------
private runAnimation(): void { this.stepAnimation( { blindsOpenDeg: { targetValue: 75, step: 5 } }, 150, ).then(() => { this.stepAnimation({ blindsOpenDeg: { targetValue: 80, step: 1 }, numBlindsCollapsed: { targetValue: NUM_BLINDS * 0.7, step: 1 }, blindsSkewDeg: { targetValue: 5, step: 1 }, skewDirection: { targetValue: -1, step: 1 }, }); }); }
private stepAnimation( targets: Partial<Record<AnimatableProp, { targetValue: number; step: number }>>, speedMs = 100, ): Promise<void> { const remaining = new Map( Object.entries(targets) as [string, { targetValue: number; step: number }][], ); return new Promise<void>((resolve) => { const interval = window.setInterval(() => { const size = remaining.size; let finished = 0;
for (const [prop, anim] of remaining) { const cur = (this.blindsState as unknown as Record<string, number>)[prop]; const dir = cur < anim.targetValue ? 1 : -1; const next = cur + anim.step * dir; (this.blindsState as unknown as Record<string, number>)[prop] = next;
const reached = dir === -1 ? next <= anim.targetValue : next >= anim.targetValue; if (reached) { finished++; remaining.delete(prop); } }
this.renderBlinds();
if (finished >= size) { clearInterval(interval); resolve(); } }, speedMs); }); }
// -- Transform helpers ------------------------------------------------------
private getSkewAndRotateTransform(blindIndex: number): string { const state = this.blindsState; const currBlind = NUM_BLINDS - blindIndex; const numOpen = NUM_BLINDS - state.numBlindsCollapsed; const skewSteps = numOpen > 0 ? state.blindsSkewDeg / numOpen : 0;
let skewDeg = 0; if (state.skewDirection !== 0 && state.blindsSkewDeg >= 0) { skewDeg = state.blindsSkewDeg - (currBlind - state.numBlindsCollapsed - 1) * skewSteps; }
const rot = state.blindsOpenDeg; return `rotateX(${rot}deg) skewY(${skewDeg * state.skewDirection}deg)`; }
private getSkewOnlyTransform(): string { const state = this.blindsState; let skewDeg = 0; if (state.skewDirection !== 0 && state.blindsSkewDeg >= 0) { skewDeg = state.blindsSkewDeg / 2; } return `skewY(${skewDeg * state.skewDirection}deg)`; }}Step 4: Run tests
Run: just app::test
Expected: All tests pass.
Step 5: Commit
git add app/src/scripts/live-window/components/BlindsComponent.ts app/src/scripts/live-window/__tests__/components/BlindsComponent.test.tsgit commit -m "feat(live-window): add BlindsComponent"Task 8: Create WeatherTextComponent
Files:
- Create:
app/src/scripts/live-window/components/WeatherTextComponent.ts
Step 1: Write WeatherTextComponent test
Create app/src/scripts/live-window/__tests__/components/WeatherTextComponent.test.ts:
import { describe, it, expect, beforeEach } from "vitest";import { WeatherTextComponent } from "../../components/WeatherTextComponent";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;}): LiveWindowState { const hasWeather = overrides?.temp != null; const store = { ...DEFAULT_STATE, weather: { ...DEFAULT_STATE.weather, units: "metric", current: hasWeather ? { main: "Clouds", description: overrides?.description ?? "scattered clouds", icon: overrides?.icon ?? "03d", temp: overrides!.temp!, } : null, }, }; return { store, computed: { phase: buildPhaseInfo(store, Date.now()) }, ref: { currentGradient: { zenith: { r: 65, g: 150, b: 240 }, upper: { r: 110, g: 180, b: 245 }, lower: { r: 160, g: 210, b: 250 }, horizon: { r: 200, g: 225, b: 245 }, }, }, attrs: { use12Hour: false, hideClock: false, hideWeatherText: overrides?.hideWeatherText ?? false, bgColor: { r: 0, g: 0, b: 0 }, resolvedUnits: "metric", }, };}
describe("WeatherTextComponent", () => { let comp: WeatherTextComponent; let container: HTMLElement;
beforeEach(() => { comp = new WeatherTextComponent(); container = document.createElement("div"); comp.mount(container); });
it("creates weather text element on mount", () => { expect(container.querySelector(".current-weather-text")).toBeTruthy(); });
it("shows weather text when weather data exists", () => { comp.update(makeState({ temp: 22, description: "clear sky" })); const el = container.querySelector(".current-weather-text") 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(".current-weather-text") 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(".current-weather-text") as HTMLElement; expect(el.hidden).toBe(true); });
it("cleans up on destroy", () => { comp.destroy(); expect(container.innerHTML).toBe(""); });});Step 2: Run test to verify it fails
Run: just app::test
Expected: FAIL — cannot resolve ../../components/WeatherTextComponent
Step 3: Write WeatherTextComponent implementation
Create app/src/scripts/live-window/components/WeatherTextComponent.ts:
import type { SceneComponent, LiveWindowState } from "../types";import { getReadableColor } from "../utils/color";
export class WeatherTextComponent implements SceneComponent { private el: HTMLParagraphElement | null = null; private containerEl: HTMLElement | null = null;
mount(container: HTMLElement): void { this.containerEl = container; const p = document.createElement("p"); p.className = "current-weather-text"; p.hidden = true; container.appendChild(p); this.el = p; }
update(state: LiveWindowState): void { if (!this.el) return;
const current = state.store.weather.current; if (current) { const units = state.attrs.resolvedUnits; const symbol = units === "imperial" ? "°F" : "°C"; this.el.textContent = `It\u2019s ${Math.round(current.temp)}${symbol} with ${current.description}`; this.el.hidden = state.attrs.hideWeatherText; } else { this.el.hidden = true; }
// Update text color for contrast against background const gradient = state.ref.currentGradient; if (gradient && this.containerEl) { const tc = getReadableColor(gradient.horizon, state.attrs.bgColor); this.containerEl.style.setProperty( "--weather-text-color", `rgb(${tc.r},${tc.g},${tc.b})`, ); } }
destroy(): void { if (this.containerEl) this.containerEl.innerHTML = ""; this.containerEl = null; this.el = null; }}Step 4: Run tests
Run: just app::test
Expected: All tests pass.
Step 5: Commit
git add app/src/scripts/live-window/components/WeatherTextComponent.ts app/src/scripts/live-window/__tests__/components/WeatherTextComponent.test.tsgit commit -m "feat(live-window): add WeatherTextComponent"Task 9: Update state.ts for LiveWindowState
Files:
- Modify:
app/src/scripts/live-window/state.ts
Step 1: Update state.ts
The state module needs to work with LiveWindowState. loadState() returns a full LiveWindowState with default computed/ref/attrs. saveState() only persists the store portion.
import type { StoreState, LiveWindowState } from "./types";import { buildPhaseInfo } from "./utils/phase";
export const CACHE_VERSION = 2;const STORAGE_KEY = "liveWindowStore";
export const DEFAULT_STORE: StoreState = { location: { lastFetched: null, lat: null, lng: null, country: null }, weather: { lastFetched: null, units: null, current: null, sunrise: null, sunset: null },};
/** Keep DEFAULT_STATE as alias for backward compat with existing tests */export const DEFAULT_STATE = DEFAULT_STORE;
export function createDefaultState(store?: StoreState): LiveWindowState { const s = store ?? DEFAULT_STORE; return { store: s, computed: { phase: buildPhaseInfo(s, Date.now()) }, ref: {}, attrs: { use12Hour: false, hideClock: false, hideWeatherText: false, bgColor: { r: 0, g: 0, b: 0 }, resolvedUnits: "metric", }, };}
export function loadState(): LiveWindowState { let store = JSON.parse(JSON.stringify(DEFAULT_STORE)) as StoreState; try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { const data = JSON.parse(raw); if (data._v !== CACHE_VERSION) { localStorage.removeItem(STORAGE_KEY); } else { store = { location: { ...DEFAULT_STORE.location, ...data.location }, weather: { ...DEFAULT_STORE.weather, ...data.weather }, }; } } } catch { /* ignore */ } return createDefaultState(store);}
export function saveState(state: LiveWindowState): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ _v: CACHE_VERSION, ...state.store })); } catch { /* ignore */ }}Step 2: Update state.test.ts
Update __tests__/state.test.ts to work with LiveWindowState:
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) => { Reflect.deleteProperty(store, key); },};Object.defineProperty(globalThis, "localStorage", { value: localStorageMock });
const STORAGE_KEY = "liveWindowStore";
describe("loadState", () => { beforeEach(() => { Reflect.deleteProperty(store, STORAGE_KEY); });
it("returns default store when nothing is stored", () => { const result = loadState(); expect(result.store).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.store.location.lat).toBe(40); expect(result.store.location.country).toBe("US"); });
it("returns default store and clears storage when version mismatches", () => { store[STORAGE_KEY] = JSON.stringify({ _v: 0, location: { lat: 99 } }); const result = loadState(); expect(result.store).toEqual(DEFAULT_STATE); expect(store[STORAGE_KEY]).toBeUndefined(); });
it("has computed, ref, and attrs sections", () => { const result = loadState(); expect(result.computed).toBeDefined(); expect(result.computed.phase).toBeDefined(); expect(result.ref).toBeDefined(); expect(result.attrs).toBeDefined(); });});
describe("saveState", () => { beforeEach(() => { Reflect.deleteProperty(store, STORAGE_KEY); });
it("round-trips through loadState", () => { const state = loadState(); state.store.location = { ...state.store.location, lat: 51.5, lng: -0.1, country: "GB" }; saveState(state); const loaded = loadState(); expect(loaded.store.location.lat).toBe(51.5); expect(loaded.store.location.country).toBe("GB"); });
it("does not persist computed or ref data", () => { const state = loadState(); state.ref.currentGradient = { zenith: { r: 1, g: 2, b: 3 }, upper: { r: 4, g: 5, b: 6 }, lower: { r: 7, g: 8, b: 9 }, horizon: { r: 10, g: 11, b: 12 }, }; saveState(state); const raw = JSON.parse(store[STORAGE_KEY]); expect(raw.currentGradient).toBeUndefined(); expect(raw.ref).toBeUndefined(); expect(raw.computed).toBeUndefined(); });});Step 3: Run tests
Run: just app::test
Expected: All tests pass.
Step 4: Commit
git add app/src/scripts/live-window/state.ts app/src/scripts/live-window/__tests__/state.test.tsgit commit -m "refactor(live-window): update state.ts for LiveWindowState"Task 10: Update api.ts for LiveWindowState
Files:
- Modify:
app/src/scripts/live-window/api.ts - Modify:
app/src/scripts/live-window/__tests__/api.test.ts
Step 1: Update api.ts
The API functions work with StoreState (which is LiveWindowState['store']). The functions themselves don’t change — they already take/return StoreState. The orchestrator will be responsible for wrapping/unwrapping. No functional changes needed, just verify existing tests still pass.
Check that api.ts imports StoreState from "./types" (it already does). No changes needed to api.ts itself.
Step 2: Update api.test.ts imports
The test references DEFAULT_STATE from "../state" and StoreState from "../types". Since DEFAULT_STATE is kept as an alias for DEFAULT_STORE, existing tests continue to work without changes. Verify by running tests.
Step 3: Run tests
Run: just app::test
Expected: All tests pass.
Step 4: Commit (if any changes were needed)
If no changes were needed, skip this commit.
Task 11: Rewrite LiveWindow.ts — Slim Orchestrator
Files:
- Create:
app/src/scripts/live-window/LiveWindow.ts - Delete (later):
app/src/scripts/live-window/live-window.ts
Step 1: Write the new LiveWindow orchestrator
Create app/src/scripts/live-window/LiveWindow.ts:
import type { SceneComponent, LiveWindowState, RGB } from "./types";import { loadState, saveState } from "./state";import { resolveUnits, shouldFetchWeather, fetchLocation, fetchWeather } from "./api";import { parseHexColor, parseComputedColor } from "./utils/color";import { buildPhaseInfo } from "./utils/phase";import { SkyComponent } from "./components/SkyComponent";import { BlindsComponent } from "./components/BlindsComponent";import { ClockComponent } from "./components/ClockComponent";import { WeatherTextComponent } from "./components/WeatherTextComponent";
import STYLES_URL from "./live-window.css?url";
class LiveWindowElement extends HTMLElement { static observedAttributes = [ "openweather-key", "ipregistry-key", "time-format", "hide-clock", "hide-weather-text", "temp-unit", "theme", "bg-color", ];
private shadow: ShadowRoot; private state: LiveWindowState;
private skyComponent = new SkyComponent(); private blindsComponent = new BlindsComponent(); private clockComponent = new ClockComponent(); private weatherTextComponent = new WeatherTextComponent();
private components: SceneComponent[];
private clockInterval: number | null = null; private skyInterval: number | null = null; private weatherInterval: number | null = null;
constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); this.state = loadState(); this.components = [ this.skyComponent, this.blindsComponent, this.clockComponent, this.weatherTextComponent, ]; }
// -- Lifecycle --------------------------------------------------------------
connectedCallback() { if (!document.querySelector("link[data-live-window-font]")) { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = "https://fonts.googleapis.com/css2?family=Squada+One&display=swap"; link.setAttribute("data-live-window-font", ""); document.head.appendChild(link); }
if (!this.shadow.querySelector(".scene")) { this.buildDOM(); } this.startUpdates(); }
disconnectedCallback() { this.stopUpdates(); for (const c of this.components) c.destroy(); }
attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) { if (oldVal === newVal) return;
if (name === "time-format" || name === "hide-clock") { this.refreshAttrs(); this.clockComponent.update(this.state); return; } if (name === "hide-weather-text" || name === "bg-color") { this.refreshAttrs(); this.weatherTextComponent.update(this.state); return; } if (name === "temp-unit") { this.refreshAttrs(); this.doFetchWeather(); return; } if (this.getAttribute("openweather-key") && this.getAttribute("ipregistry-key") && !this.weatherInterval) { this.startWeatherPolling(); } }
// -- DOM --------------------------------------------------------------------
private buildDOM() { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = STYLES_URL; this.shadow.appendChild(link);
const scene = document.createElement("div"); scene.className = "scene";
const liveWindow = document.createElement("div"); liveWindow.className = "live-window"; scene.appendChild(liveWindow);
// Mount sky const skyContainer = document.createElement("div"); liveWindow.appendChild(skyContainer); this.skyComponent.mount(skyContainer);
// Horizontal bar const bar = document.createElement("div"); bar.className = "horizontal-bar"; liveWindow.appendChild(bar);
// Mount blinds const blindsContainer = document.createElement("div"); liveWindow.appendChild(blindsContainer); this.blindsComponent.mount(blindsContainer);
// Mount clock const clockContainer = document.createElement("div"); liveWindow.appendChild(clockContainer); this.clockComponent.mount(clockContainer);
// Mount weather text (outside .live-window, inside .scene) const weatherTextContainer = document.createElement("div"); scene.appendChild(weatherTextContainer); this.weatherTextComponent.mount(weatherTextContainer);
this.shadow.appendChild(scene); }
// -- State ------------------------------------------------------------------
private refreshAttrs() { 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), }; }
private refreshComputed() { this.state.computed.phase = buildPhaseInfo(this.state.store, Date.now()); }
private getBgColor(): RGB { const attr = this.getAttribute("bg-color"); if (attr) { const parsed = parseHexColor(attr); if (parsed) return parsed; } const computed = getComputedStyle(this).backgroundColor; return parseComputedColor(computed) ?? { r: 0, g: 0, b: 0 }; }
// -- Updates ----------------------------------------------------------------
private startUpdates() { this.refreshAttrs(); this.updateAll();
this.clockInterval = window.setInterval(() => this.updateClock(), 1000); this.skyInterval = window.setInterval(() => this.updateAll(), 15 * 60 * 1000);
if (this.getAttribute("openweather-key") && this.getAttribute("ipregistry-key")) { this.startWeatherPolling(); } }
private startWeatherPolling() { this.doFetchWeather(); this.weatherInterval = window.setInterval(() => this.doFetchWeather(), 60 * 60 * 1000); }
private stopUpdates() { if (this.clockInterval != null) clearInterval(this.clockInterval); if (this.skyInterval != null) clearInterval(this.skyInterval); if (this.weatherInterval != null) clearInterval(this.weatherInterval); this.clockInterval = null; this.skyInterval = null; this.weatherInterval = null; }
private updateClock() { this.refreshAttrs(); this.clockComponent.update(this.state); const tick = this.clockComponent.lastTick; if (tick) { this.dispatchEvent(new CustomEvent("live-window:clock-update", { detail: tick })); } }
private updateAll() { this.refreshAttrs(); this.refreshComputed(); for (const c of this.components) c.update(this.state); }
// -- API --------------------------------------------------------------------
private async doFetchWeather(): Promise<void> { const owKey = this.getAttribute("openweather-key"); const ipKey = this.getAttribute("ipregistry-key"); if (!owKey || !ipKey) return;
const units = this.state.attrs.resolvedUnits; if (!shouldFetchWeather(this.state.store, units)) { this.updateAll(); return; }
this.state.store = await fetchLocation(ipKey, this.state.store); saveState(this.state);
const result = await fetchWeather(owKey, this.state.store, units); this.state.store = result.state; saveState(this.state);
if (result.changed) { this.updateAll(); this.dispatchEvent( new CustomEvent("live-window:weather-update", { detail: { weather: this.state.store.weather }, }), ); } }}
customElements.define("live-window", LiveWindowElement);Step 2: Run typecheck
Run: just app::typecheck
Expected: Pass (or errors only from old files that will be deleted in the next task).
Step 3: Run tests
Run: just app::test
Expected: All tests pass.
Step 4: Commit
git add app/src/scripts/live-window/LiveWindow.tsgit commit -m "feat(live-window): add slim LiveWindow orchestrator"Task 12: Delete old files and update entry point
Files:
- Delete:
app/src/scripts/live-window/live-window.ts - Delete:
app/src/scripts/live-window/clock.ts - Delete:
app/src/scripts/live-window/blinds.ts - Delete:
app/src/scripts/live-window/layers/gradient.ts - Delete:
app/src/scripts/live-window/layers/weather.ts - Delete:
app/src/scripts/live-window/color.ts(already moved, verify it’s gone) - Delete:
app/src/scripts/live-window/phase-info.ts(already moved, verify it’s gone) - Modify: whatever file imports
live-window.ts— update to importLiveWindow.ts
Step 1: Find the entry point import
Search the codebase for where live-window is imported:
grep -r "live-window/live-window" app/src/ --include="*.ts" --include="*.astro" --include="*.html"Update the import path from ./scripts/live-window/live-window (or similar) to ./scripts/live-window/LiveWindow.
Step 2: Delete old files
git rm app/src/scripts/live-window/live-window.tsgit rm app/src/scripts/live-window/clock.tsgit rm app/src/scripts/live-window/blinds.tsgit rm app/src/scripts/live-window/layers/gradient.tsgit rm app/src/scripts/live-window/layers/weather.tsAlso remove the layers/ directory if it’s now empty:
rmdir app/src/scripts/live-window/layers 2>/dev/null || trueVerify that color.ts and phase-info.ts were already moved by git mv in Task 2. If they still exist at the old paths, delete them.
Step 3: Run typecheck
Run: just app::typecheck
Expected: Pass — no references to deleted files remain.
Step 4: Run tests
Run: just app::test
Expected: All tests pass.
Step 5: Commit
git add -A app/src/scripts/live-window/git commit -m "refactor(live-window): remove old files, update entry point"Task 13: Final verification and cleanup
Step 1: Run full test suite
Run: just app::test
Expected: All tests pass.
Step 2: Run typecheck
Run: just app::typecheck
Expected: No errors.
Step 3: Run build
Run: just app::build
Expected: Build succeeds.
Step 4: Verify final file structure
find app/src/scripts/live-window -type f | sortExpected output:
app/src/scripts/live-window/LiveWindow.tsapp/src/scripts/live-window/__tests__/api.test.tsapp/src/scripts/live-window/__tests__/color.test.tsapp/src/scripts/live-window/__tests__/components/BlindsComponent.test.tsapp/src/scripts/live-window/__tests__/components/ClockComponent.test.tsapp/src/scripts/live-window/__tests__/components/SkyComponent.test.tsapp/src/scripts/live-window/__tests__/components/WeatherTextComponent.test.tsapp/src/scripts/live-window/__tests__/components/sky/GradientLayer.test.tsapp/src/scripts/live-window/__tests__/layers/weather.test.tsapp/src/scripts/live-window/__tests__/phase-info.test.tsapp/src/scripts/live-window/__tests__/sky-gradient.test.tsapp/src/scripts/live-window/__tests__/state.test.tsapp/src/scripts/live-window/api.tsapp/src/scripts/live-window/components/BlindsComponent.tsapp/src/scripts/live-window/components/ClockComponent.tsapp/src/scripts/live-window/components/SkyComponent.tsapp/src/scripts/live-window/components/WeatherTextComponent.tsapp/src/scripts/live-window/components/sky/GradientLayer.tsapp/src/scripts/live-window/components/sky/WeatherLayer.tsapp/src/scripts/live-window/live-window.cssapp/src/scripts/live-window/state.tsapp/src/scripts/live-window/types.tsapp/src/scripts/live-window/utils/color.tsapp/src/scripts/live-window/utils/phase.tsapp/src/scripts/live-window/utils/sky-gradient.tsStep 5: Commit any final cleanup
git add -A && git commit -m "refactor(live-window): final cleanup after class-based refactor"