Stars Layer Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a stars layer to the live window that displays varied, realistic stars at night, fading in/out with the sky phase system.

Architecture: New StarsLayer SceneComponent slotted between GradientLayer and WeatherLayer in SkyComponent. Star positions generated by a seeded PRNG (utils/stars.ts) for daily consistency. Phase-based opacity mapping drives visibility transitions.

Tech Stack: TypeScript, Vitest (with jsdom), CSS animations, Shadow DOM


Task 1: Star Generation Utility (utils/stars.ts)

Files:

  • Create: app/src/scripts/live-window/utils/stars.ts
  • Test: app/src/scripts/live-window/__tests__/stars.test.ts

Step 1: Write the failing tests

Create app/src/scripts/live-window/__tests__/stars.test.ts:

import { describe, it, expect } from "vitest";
import { mulberry32, generateStars, getStarsOpacity } from "../../utils/stars";
import type { Star } from "../../utils/stars";
describe("mulberry32", () => {
it("returns deterministic values for the same seed", () => {
const rng1 = mulberry32(12345);
const rng2 = mulberry32(12345);
expect(rng1()).toBe(rng2());
expect(rng1()).toBe(rng2());
});
it("returns values between 0 and 1", () => {
const rng = mulberry32(42);
for (let i = 0; i < 100; i++) {
const v = rng();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it("returns different values for different seeds", () => {
const a = mulberry32(1)();
const b = mulberry32(2)();
expect(a).not.toBe(b);
});
});
describe("generateStars", () => {
it("generates ~40 stars by default", () => {
const stars = generateStars(20260302);
expect(stars.length).toBeGreaterThanOrEqual(35);
expect(stars.length).toBeLessThanOrEqual(45);
});
it("returns same stars for the same seed", () => {
const a = generateStars(20260302);
const b = generateStars(20260302);
expect(a).toEqual(b);
});
it("returns different stars for different seeds", () => {
const a = generateStars(20260302);
const b = generateStars(20260303);
expect(a).not.toEqual(b);
});
it("constrains star positions within bounds", () => {
const stars = generateStars(20260302);
for (const star of stars) {
expect(star.x).toBeGreaterThanOrEqual(0);
expect(star.x).toBeLessThanOrEqual(100);
expect(star.y).toBeGreaterThanOrEqual(0);
expect(star.y).toBeLessThanOrEqual(70);
expect(star.size).toBeGreaterThan(0);
expect(star.size).toBeLessThanOrEqual(3.5);
expect(star.baseOpacity).toBeGreaterThan(0);
expect(star.baseOpacity).toBeLessThanOrEqual(1);
}
});
it("produces a mix of tiers (dim, medium, bright)", () => {
const stars = generateStars(20260302);
const dims = stars.filter((s) => s.size <= 1.5);
const mediums = stars.filter((s) => s.size > 1.5 && s.size <= 2.5);
const brights = stars.filter((s) => s.size > 2.5);
expect(dims.length).toBeGreaterThan(0);
expect(mediums.length).toBeGreaterThan(0);
expect(brights.length).toBeGreaterThan(0);
});
it("each star has twinkle animation properties", () => {
const stars = generateStars(20260302);
for (const star of stars) {
expect(star.twinkleDuration).toBeGreaterThanOrEqual(2);
expect(star.twinkleDuration).toBeLessThanOrEqual(5);
expect(star.twinkleDelay).toBeGreaterThanOrEqual(0);
expect(star.twinkleDelay).toBeLessThanOrEqual(5);
}
});
it("bright stars have glow properties", () => {
const stars = generateStars(20260302);
const brights = stars.filter((s) => s.size > 2.5);
for (const star of brights) {
expect(star.glowSize).toBeGreaterThan(0);
}
});
});
describe("getStarsOpacity", () => {
it("returns 1.0 for night phase (0)", () => {
expect(getStarsOpacity(0, 0)).toBe(1.0);
});
it("returns 0 for daytime phases (4-12)", () => {
for (let phase = 4; phase <= 12; phase++) {
expect(getStarsOpacity(phase, 0)).toBe(0);
}
});
it("returns decreasing opacity during dawn (phases 1-3)", () => {
const dawn1 = getStarsOpacity(1, 0);
const dawn2 = getStarsOpacity(2, 0);
const dawn3 = getStarsOpacity(3, 0);
expect(dawn1).toBeGreaterThan(dawn2);
expect(dawn2).toBeGreaterThan(dawn3);
expect(dawn3).toBeGreaterThan(0);
});
it("returns increasing opacity during dusk (phases 13-15)", () => {
const dusk13 = getStarsOpacity(13, 0);
const dusk14 = getStarsOpacity(14, 0);
const dusk15 = getStarsOpacity(15, 0);
expect(dusk15).toBeGreaterThan(dusk14);
expect(dusk14).toBeGreaterThan(dusk13);
expect(dusk13).toBeGreaterThan(0);
});
it("interpolates within a phase using t factor", () => {
// Phase 1 (opacity 0.7) → phase 2 (opacity 0.4)
// At t=0.5 we should be between them
const mid = getStarsOpacity(1, 0.5);
const start = getStarsOpacity(1, 0);
const end = getStarsOpacity(2, 0);
expect(mid).toBeLessThan(start);
expect(mid).toBeGreaterThan(end);
});
});

Step 2: Run tests to verify they fail

Run: just app::test -- --run app/src/scripts/live-window/__tests__/stars.test.ts Expected: FAIL — module ../../utils/stars not found

Step 3: Write the implementation

Create app/src/scripts/live-window/utils/stars.ts:

export interface Star {
x: number;
y: number;
size: number;
baseOpacity: number;
twinkleDuration: number;
twinkleDelay: number;
glowSize: number;
}
/**
* Mulberry32 PRNG — deterministic random from a 32-bit seed.
* Returns a function that produces values in [0, 1).
*/
export function mulberry32(seed: number): () => number {
let s = seed | 0;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Generates a star field from a date-based seed.
* Seed should be YYYYMMDD as an integer (e.g. 20260302).
*/
export function generateStars(seed: number, count = 40): Star[] {
const rng = mulberry32(seed);
const stars: Star[] = [];
for (let i = 0; i < count; i++) {
const roll = rng();
let size: number;
let baseOpacity: number;
let glowSize: number;
if (roll < 0.6) {
// Dim (60%)
size = 1 + rng() * 0.5;
baseOpacity = 0.4 + rng() * 0.2;
glowSize = 0;
} else if (roll < 0.9) {
// Medium (30%)
size = 1.5 + rng() * 1;
baseOpacity = 0.6 + rng() * 0.2;
glowSize = 2 + rng() * 2;
} else {
// Bright (10%)
size = 2.5 + rng() * 1;
baseOpacity = 0.8 + rng() * 0.2;
glowSize = 4 + rng() * 4;
}
stars.push({
x: rng() * 100,
y: rng() * 70,
size,
baseOpacity,
twinkleDuration: 2 + rng() * 3,
twinkleDelay: rng() * 5,
glowSize,
});
}
return stars;
}
/** Phase-index to base star opacity (before interpolation). */
const PHASE_OPACITY: Record<number, number> = {
0: 1.0,
1: 0.7,
2: 0.4,
3: 0.1,
13: 0.1,
14: 0.4,
15: 0.7,
};
/**
* Returns the overall stars-layer opacity for the given phase + interpolation factor.
* Smoothly blends between the current phase opacity and the next phase opacity.
*/
export function getStarsOpacity(phaseIndex: number, t: number): number {
const current = PHASE_OPACITY[phaseIndex] ?? 0;
const nextPhase = (phaseIndex + 1) % 16;
const next = PHASE_OPACITY[nextPhase] ?? 0;
return current + (next - current) * t;
}
/**
* Returns today's date seed as YYYYMMDD integer.
*/
export function todaySeed(): number {
const d = new Date();
return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
}

Step 4: Run tests to verify they pass

Run: just app::test -- --run app/src/scripts/live-window/__tests__/stars.test.ts Expected: ALL PASS

Step 5: Commit

Terminal window
git add app/src/scripts/live-window/utils/stars.ts app/src/scripts/live-window/__tests__/stars.test.ts
git commit -m "feat(live-window): add star generation utility with seeded PRNG"

Task 2: StarsLayer SceneComponent

Files:

  • Create: app/src/scripts/live-window/components/sky/StarsLayer.ts
  • Test: app/src/scripts/live-window/__tests__/components/sky/StarsLayer.test.ts

Step 1: Write the failing tests

Create app/src/scripts/live-window/__tests__/components/sky/StarsLayer.test.ts:

import { describe, it, expect, beforeEach } from "vitest";
import { StarsLayer } from "../../../components/sky/StarsLayer";
import type { LiveWindowState } from "../../../types";
import { buildPhaseInfo } from "../../../utils/phase";
import { DEFAULT_STATE } from "../../../state";
function makeState(hours: number): LiveWindowState {
const store = {
...DEFAULT_STATE,
weather: {
...DEFAULT_STATE.weather,
// Use explicit sunrise/sunset for predictable phases
sunrise: new Date().setHours(6, 0, 0, 0),
sunset: new Date().setHours(18, 0, 0, 0),
},
};
const now = new Date();
now.setHours(hours, 0, 0, 0);
return {
store,
computed: { phase: buildPhaseInfo(store, now.getTime()) },
ref: {},
attrs: {
use12Hour: false,
hideClock: false,
hideWeatherText: false,
bgColor: { r: 0, g: 0, b: 0 },
resolvedUnits: "metric",
},
};
}
describe("StarsLayer", () => {
let layer: StarsLayer;
let container: HTMLElement;
beforeEach(() => {
layer = new StarsLayer();
container = document.createElement("div");
layer.mount(container);
});
it("sets sky-layer stars class on mount", () => {
expect(container.className).toBe("sky-layer stars");
});
it("generates star elements on first update", () => {
layer.update(makeState(1)); // 1 AM — night
const stars = container.querySelectorAll(".star");
expect(stars.length).toBeGreaterThanOrEqual(35);
expect(stars.length).toBeLessThanOrEqual(45);
});
it("does not regenerate stars on subsequent updates (same day)", () => {
const nightState = makeState(1);
layer.update(nightState);
const firstHTML = container.innerHTML;
layer.update(nightState);
// innerHTML should be identical — stars not re-rendered
expect(container.innerHTML).toBe(firstHTML);
});
it("sets opacity > 0 at night (1 AM)", () => {
layer.update(makeState(1));
const opacity = parseFloat(container.style.opacity);
expect(opacity).toBeGreaterThan(0);
});
it("sets opacity to 0 during daytime (12 PM)", () => {
layer.update(makeState(12));
expect(container.style.opacity).toBe("0");
});
it("each star has inline animation styles", () => {
layer.update(makeState(1));
const star = container.querySelector(".star") as HTMLElement;
expect(star).toBeTruthy();
expect(star.style.animationDuration).toBeTruthy();
expect(star.style.animationDelay).toBeTruthy();
});
it("clears DOM on destroy", () => {
layer.update(makeState(1));
layer.destroy();
expect(container.innerHTML).toBe("");
});
});

Step 2: Run tests to verify they fail

Run: just app::test -- --run app/src/scripts/live-window/__tests__/components/sky/StarsLayer.test.ts Expected: FAIL — module not found

Step 3: Write the implementation

Create app/src/scripts/live-window/components/sky/StarsLayer.ts:

import type { SceneComponent, LiveWindowState } from "../../types";
import { generateStars, getStarsOpacity, todaySeed } from "../../utils/stars";
export class StarsLayer implements SceneComponent {
private el: HTMLElement | null = null;
private renderedSeed: number | null = null;
mount(container: HTMLElement): void {
this.el = container;
this.el.className = "sky-layer stars";
}
update(state: LiveWindowState): void {
if (!this.el) return;
const seed = todaySeed();
// Only regenerate star DOM if the seed changed (new day)
if (this.renderedSeed !== seed) {
const stars = generateStars(seed);
let html = "";
for (const star of stars) {
const glow =
star.glowSize > 0
? `box-shadow: 0 0 ${star.glowSize}px ${star.glowSize / 2}px rgba(255, 255, 255, ${star.baseOpacity * 0.5});`
: "";
html += `<div class="star" style="left:${star.x}%;top:${star.y}%;width:${star.size}px;height:${star.size}px;opacity:${star.baseOpacity};animation-duration:${star.twinkleDuration.toFixed(2)}s;animation-delay:${star.twinkleDelay.toFixed(2)}s;${glow}"></div>`;
}
this.el.innerHTML = html;
this.renderedSeed = seed;
}
// Update container opacity based on sky phase
const { phaseIndex, t } = state.computed.phase;
const opacity = getStarsOpacity(phaseIndex, t);
this.el.style.opacity = String(opacity);
}
destroy(): void {
if (this.el) this.el.innerHTML = "";
this.el = null;
this.renderedSeed = null;
}
}

Step 4: Run tests to verify they pass

Run: just app::test -- --run app/src/scripts/live-window/__tests__/components/sky/StarsLayer.test.ts Expected: ALL PASS

Step 5: Commit

Terminal window
git add app/src/scripts/live-window/components/sky/StarsLayer.ts app/src/scripts/live-window/__tests__/components/sky/StarsLayer.test.ts
git commit -m "feat(live-window): add StarsLayer scene component"

Task 3: CSS Styles for Stars

Files:

  • Modify: app/src/scripts/live-window/live-window.css (after the .sky-layer block, around line 100)

Step 1: Add star styles and twinkle animation

Add after the .sky-layer block (line 100) and before the /* ---- Weather effects ---- */ comment (line 102):

/* ---- Stars ---- */
.stars {
z-index: 1;
transition: opacity 1s ease;
pointer-events: none;
}
.star {
position: absolute;
border-radius: 50%;
background: #fff;
animation-name: twinkle;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes twinkle {
0%,
100% {
opacity: var(--star-opacity, 1);
}
50% {
opacity: calc(var(--star-opacity, 1) * 0.3);
}
}

Step 2: Run existing tests to ensure nothing broke

Run: just app::test -- --run app/src/scripts/live-window/__tests__/ Expected: ALL PASS

Step 3: Commit

Terminal window
git add app/src/scripts/live-window/live-window.css
git commit -m "style(live-window): add stars layer CSS with twinkle animation"

Task 4: Wire StarsLayer into SkyComponent

Files:

  • Modify: app/src/scripts/live-window/components/SkyComponent.ts
  • Modify: app/src/scripts/live-window/__tests__/components/SkyComponent.test.ts

Step 1: Update the SkyComponent test to expect 3 children

In SkyComponent.test.ts, update the existing test at line 39:

it("mounts child layers as divs inside container", () => {
expect(container.children.length).toBe(3);
});

Step 2: Run the test to verify it fails

Run: just app::test -- --run app/src/scripts/live-window/__tests__/components/SkyComponent.test.ts Expected: FAIL — expected 3 to be 2

Step 3: Add StarsLayer to SkyComponent

In SkyComponent.ts, add the import and insert StarsLayer between GradientLayer and WeatherLayer:

import type { SceneComponent, LiveWindowState } from "../types";
import { GradientLayer } from "./sky/GradientLayer";
import { StarsLayer } from "./sky/StarsLayer";
import { WeatherLayer } from "./sky/WeatherLayer";
export class SkyComponent implements SceneComponent {
private children: SceneComponent[] = [new GradientLayer(), new StarsLayer(), new WeatherLayer()];
// ... rest unchanged
}

Step 4: Run all live-window tests

Run: just app::test -- --run app/src/scripts/live-window/__tests__/ Expected: ALL PASS

Step 5: Commit

Terminal window
git add app/src/scripts/live-window/components/SkyComponent.ts app/src/scripts/live-window/__tests__/components/SkyComponent.test.ts
git commit -m "feat(live-window): wire StarsLayer into SkyComponent"

Task 5: Final Verification

Step 1: Run full test suite

Run: just test Expected: ALL PASS

Step 2: Type-check

Run: just app::typecheck Expected: No errors

Step 3: Build

Run: just app::build Expected: Build succeeds

Step 4: Commit if any fixes were needed

If any fixes were required, commit them:

Terminal window
git commit -m "fix(live-window): address test/type/build issues from stars layer"