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
git add app/src/scripts/live-window/utils/stars.ts app/src/scripts/live-window/__tests__/stars.test.tsgit 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
git add app/src/scripts/live-window/components/sky/StarsLayer.ts app/src/scripts/live-window/__tests__/components/sky/StarsLayer.test.tsgit 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-layerblock, 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
git add app/src/scripts/live-window/live-window.cssgit 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
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): 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:
git commit -m "fix(live-window): address test/type/build issues from stars layer"