Live Window Class-Based Refactor Design

Goal

Refactor the <live-window> web component into a class-based architecture where each scene part (sky, blinds, clock, weather text) is a self-contained class implementing a shared SceneComponent interface. Move utilities (color, phase calculations, sky gradient math) into a utils/ folder. Merge state into a single LiveWindowState with clearly separated sections.

Current State

The previous refactor (2026-03-01-live-window-refactor-design.md) extracted modules from a monolithic class and introduced a SkyLayer interface for sky layers. The codebase now has:

  • live-window.ts — orchestrator (~290 lines), still manages DOM building, element caching, and wiring for clock/blinds/weather text
  • layers/gradient.tsGradientLayer implementing SkyLayer
  • layers/weather.tsWeatherLayer implementing SkyLayer
  • clock.ts — standalone functions, state managed externally
  • blinds.ts — standalone functions + BlindsState, state managed externally
  • color.ts, phase-info.ts, state.ts, api.ts — utility/helper modules at root level

Design Decisions

  1. Unified SceneComponent interface — all scene parts (sky, blinds, clock, weather text) implement the same mount/update/destroy contract. The old SkyLayer interface is removed; GradientLayer and WeatherLayer implement SceneComponent directly.

  2. Merged LiveWindowState — instead of separate StoreState (persistence) and SceneContext (render data), a single LiveWindowState with labeled sections: store (persisted), computed (derived each cycle), ref (written by components), attrs (from DOM attributes).

  3. SkyComponent composite — a SceneComponent that internally manages GradientLayer and WeatherLayer as child SceneComponents. Keeps the sky layer composition encapsulated.

  4. utils/ folder — pure functions (color.ts, phase.ts, sky-gradient.ts) move out of the root into a dedicated folder.

Unified State: LiveWindowState

export interface LiveWindowState {
/** Persisted to localStorage — API cache data */
store: {
location: {
lat: number | null;
lng: number | null;
country: string | null;
lastFetched: number | null;
};
weather: {
current: WeatherCurrent | null;
sunrise: number | null;
sunset: number | null;
units: string | null;
lastFetched: number | null;
};
};
/** 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;
};
}
  • saveState() serializes only state.store to localStorage
  • loadState() restores state.store, initializes computed/ref/attrs to defaults
  • Components receive the full LiveWindowState and read from whichever section they need
  • Components write to state.ref during update() — update order matters

SceneComponent Interface

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;
}

Component Breakdown

ClassFileResponsibilityUpdate Behavior
SkyComponentcomponents/SkyComponent.tsMounts .sky container, creates GradientLayer + WeatherLayer as childrenForwards update() to children in order
GradientLayercomponents/sky/GradientLayer.tsRenders 4-stop CSS gradient based on phaseRecalculates gradient from state.computed.phase, writes to state.ref.currentGradient
WeatherLayercomponents/sky/WeatherLayer.tsRenders clouds/rain/snow/mist HTMLRebuilds HTML from state.computed.phase.weather.icon
BlindsComponentcomponents/BlindsComponent.tsOwns blinds slats + strings, runs open animationAnimates once on first update(), then no-ops
ClockComponentcomponents/ClockComponent.tsOwns clock HTML, updates time displayReads state.attrs.use12Hour, updates text every tick
WeatherTextComponentcomponents/WeatherTextComponent.tsOwns weather description text + contrast colorReads weather from state.store, gradient from state.ref, computes readable color

SkyComponent as Composite

class SkyComponent implements SceneComponent {
private children: SceneComponent[] = [new GradientLayer(), new WeatherLayer()];
mount(container: HTMLElement) {
container.className = "sky";
for (const child of this.children) {
const div = document.createElement("div");
container.appendChild(div);
child.mount(div);
}
}
update(state: LiveWindowState) {
for (const child of this.children) child.update(state);
}
destroy() {
for (const child of this.children) child.destroy();
}
}

Component Update Order

The orchestrator calls update() in this order to ensure state.ref is populated before downstream consumers read it:

  1. SkyComponent (GradientLayer writes state.ref.currentGradient, then WeatherLayer)
  2. BlindsComponent
  3. ClockComponent
  4. WeatherTextComponent (reads state.ref.currentGradient for contrast calculation)

File Structure

live-window/
├── components/
│ ├── sky/
│ │ ├── GradientLayer.ts # SceneComponent, writes state.ref.currentGradient
│ │ └── WeatherLayer.ts # SceneComponent, renders weather effects
│ ├── SkyComponent.ts # composite: mounts .sky, forwards to children
│ ├── BlindsComponent.ts # SceneComponent, owns slats + strings + animation
│ ├── ClockComponent.ts # SceneComponent, owns clock HTML + time display
│ └── WeatherTextComponent.ts # SceneComponent, owns weather description text
├── utils/
│ ├── color.ts # parseHex, luminance, contrastRatio, getReadableColor
│ ├── phase.ts # buildPhaseInfo, calculateSunPosition (renamed from phase-info.ts)
│ └── sky-gradient.ts # SKY_PHASES, calculatePhaseTimestamps, getCurrentSkyGradient, blend*
├── __tests__/ # existing tests, updated for new imports
│ ├── layers/
│ │ └── weather.test.ts
│ ├── api.test.ts
│ ├── color.test.ts
│ ├── phase-info.test.ts
│ ├── sky-gradient.test.ts
│ └── state.test.ts
├── types.ts # RGB, SkyGradient, PhaseInfo, LiveWindowState, SceneComponent, etc.
├── state.ts # loadState, saveState (serializes only state.store)
├── api.ts # fetchLocation, fetchWeather, rate-limit guards
├── LiveWindow.ts # slim orchestrator web component (~100-150 lines)
└── live-window.css # unchanged

Orchestrator (LiveWindow.ts)

The refactored orchestrator handles only:

  • Shadow DOM setup and stylesheet loading
  • Instantiating components: [SkyComponent, BlindsComponent, ClockComponent, WeatherTextComponent]
  • Building LiveWindowState (reading attributes, computing phase)
  • Interval management (clock every 1s, sky every 15min, weather every 60min)
  • Calling update(state) on components in defined order
  • Attribute change handling (delegates to re-computing attrs + triggering updates)
  • Custom event dispatch (live-window:clock-update, live-window:weather-update)
  • API fetch orchestration (calls api.ts, updates state.store, saves to localStorage)

Each component builds its own HTML in mount() — the orchestrator does NOT build any component DOM.

What Moves Where

Current FileDestinationNotes
color.tsutils/color.tsMove as-is
phase-info.tsutils/phase.tsRename
layers/gradient.ts (pure functions)utils/sky-gradient.tsExtract SKY_PHASES, calculatePhaseTimestamps, blendGradient, etc.
layers/gradient.ts (GradientLayer class)components/sky/GradientLayer.tsRewrite to implement SceneComponent
layers/weather.tscomponents/sky/WeatherLayer.tsRewrite to implement SceneComponent
clock.tscomponents/ClockComponent.tsWrap in class, own its HTML
blinds.tscomponents/BlindsComponent.tsWrap in class, own its HTML + animation
Weather text logic (in live-window.ts)components/WeatherTextComponent.tsExtract into new class
live-window.tsLiveWindow.tsSlim down to orchestrator only
state.tsstate.tsUpdate to serialize only state.store portion of LiveWindowState
api.tsapi.tsUpdate types to use LiveWindowState['store']
types.tstypes.tsAdd SceneComponent, LiveWindowState; remove SkyLayer

Test Strategy

Existing tests update import paths but logic stays the same. New component classes can be tested by:

  • Providing a container element + mock LiveWindowState
  • Calling mount(), then update(), then asserting DOM contents
  • No additional test files required in this refactor (existing coverage carries over)