Weather Code Audit — Distinct Rendering Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Make all 50 OpenWeatherMap weather codes visually distinct in the live window, with accurate real-world character.
Architecture: Extend the existing WeatherEffectConfig with 3 new mechanisms (wind levels, atmosphere particles, lightning variants) plus 6 new precip configs. All changes are in WeatherLayer.ts (config + rendering) and live-window.css (animations). No new files needed.
Tech Stack: TypeScript, CSS animations, Vitest
Task 1: Add New Interfaces and Types
Files:
- Modify:
app/src/scripts/live-window/components/sky/WeatherLayer.ts:199-209
Step 1: Add wind type and atmosphere particle interfaces
Add after the existing AtmosphereConfig interface (line ~129):
export type WindLevel = "none" | "light" | "moderate" | "strong";
export interface AtmosphereParticleConfig { count: number; color: string; /** Size range [min, max] in px */ sizeRange: [number, number]; /** Opacity range [min, max] as 0-100 integers */ opacityRange: [number, number]; /** CSS animation duration for particle motion */ speed: string; /** Motion type: float = horizontal drift, swirl = circular, fall = downward */ drift: "float" | "swirl" | "fall";}
export type LightningVariant = "distant" | "standard" | "intense";Step 2: Update WeatherEffectConfig interface
Replace the existing WeatherEffectConfig (line ~199):
export interface WeatherEffectConfig { clouds: CloudDensity; precip: PrecipLayer[]; lightning: LightningVariant | false; atmosphere: AtmosphereConfig | null; wind: WindLevel; atmosphereParticles: AtmosphereParticleConfig | null;}Step 3: Update the fx helper
Replace the existing fx function:
function fx( clouds: WeatherEffectConfig["clouds"], precip: PrecipLayer[], opts?: { lightning?: LightningVariant; atmosphere?: AtmosphereConfig; wind?: WindLevel; atmosphereParticles?: AtmosphereParticleConfig; },): WeatherEffectConfig { return { clouds, precip, lightning: opts?.lightning ?? false, atmosphere: opts?.atmosphere ?? null, wind: opts?.wind ?? "none", atmosphereParticles: opts?.atmosphereParticles ?? null, };}Step 4: Run typecheck to identify all type errors
Run: cd app && npx tsc --noEmit 2>&1 | head -50
Expected: Type errors in WEATHER_EFFECTS entries (lightning was boolean, now LightningVariant | false) and in tests. This confirms the new types are wired up.
Step 5: Fix WEATHER_EFFECTS lightning values temporarily
In the WEATHER_EFFECTS map, replace all { lightning: true } with { lightning: "standard" } so it compiles. We’ll set the correct variants in Task 6.
Step 6: Run typecheck again
Run: cd app && npx tsc --noEmit
Expected: No errors (or only test file errors which we fix later).
Step 7: Commit
git add app/src/scripts/live-window/components/sky/WeatherLayer.tsgit commit -m "feat(weather): add wind, atmosphere particle, and lightning variant types"Task 2: Add New Precip Configs
Files:
- Modify:
app/src/scripts/live-window/components/sky/WeatherLayer.ts:22-123
Step 1: Add 6 new precip configs
Add these entries to PRECIP_CONFIG after the existing showerSnow entry:
drizzleLight: { count: 16, fallSpeed: "10s", shape: "drop", sizeW: [1, 2], aspectRatio: 2.5, color: "#28afff", opacityRange: [30, 55], hasSway: false, }, drizzleHeavy: { count: 28, fallSpeed: "6s", shape: "drop", sizeW: [2, 4], aspectRatio: 2.5, color: "#28afff", opacityRange: [55, 80], hasSway: false, }, showerDrizzle: { count: 25, fallSpeed: "4s", shape: "drop", sizeW: [2, 3], aspectRatio: 3, color: "#28afff", opacityRange: [45, 70], hasSway: false, }, heavyRain: { count: 42, fallSpeed: "1.8s", shape: "drop", sizeW: [4, 5], aspectRatio: 2.5, color: "#28afff", opacityRange: [80, 100], hasSway: false, }, extremeRain: { count: 50, fallSpeed: "1.2s", shape: "drop", sizeW: [4, 6], aspectRatio: 2, color: "#28afff", opacityRange: [85, 100], hasSway: false, }, showerSleet: { count: 35, fallSpeed: "3s", shape: "round", sizeW: [3, 6], aspectRatio: 1, color: "#a0cfff", opacityRange: [55, 90], hasSway: true, },Step 2: Run typecheck
Run: cd app && npx tsc --noEmit
Expected: PASS
Step 3: Commit
git add app/src/scripts/live-window/components/sky/WeatherLayer.tsgit commit -m "feat(weather): add 6 new precip configs for intensity differentiation"Task 3: Add Atmosphere Particle Configs and Fix Sand/Dust
Files:
- Modify:
app/src/scripts/live-window/components/sky/WeatherLayer.ts:131-142
Step 1: Fix sand/dust atmosphere overlays to be distinct
Replace the single dust entry in ATMOSPHERE_CONFIG with two distinct entries and update references:
export const ATMOSPHERE_CONFIG: Record<string, AtmosphereConfig> = { mist: { color: "#c8c8c8", opacity: 0.15, layers: 2 }, fog: { color: "#b0b0b0", opacity: 0.35, layers: 3 }, smoke: { color: "#8b7355", opacity: 0.3, layers: 3 }, haze: { color: "#d4c89a", opacity: 0.2, layers: 2 }, sand: { color: "#c4a050", opacity: 0.3, layers: 3 }, dust: { color: "#8a7560", opacity: 0.22, layers: 2 }, dustWhirls: { color: "#c4a86a", opacity: 0.35, layers: 3 }, volcanicAsh: { color: "#555555", opacity: 0.4, layers: 3 }, squalls: { color: "#888888", opacity: 0.3, layers: 3 }, tornado: { color: "#666666", opacity: 0.45, layers: 3 }, stormDark: { color: "#1a1a2e", opacity: 0.2, layers: 2 },};Note: dust entry changed from { color: "#c4a86a", opacity: 0.25, layers: 2 } to { color: "#8a7560", opacity: 0.22, layers: 2 }. New sand entry added with yellowish color.
Step 2: Update WEATHER_EFFECTS 751 to use sand instead of dust
Change line for code 751:
751: fx("none", [], { atmosphere: ATMOSPHERE_CONFIG.sand }),Keep 761 as:
761: fx("none", [], { atmosphere: ATMOSPHERE_CONFIG.dust }),Step 3: Add atmosphere particle configs
Add after ATMOSPHERE_CONFIG:
export const ATMO_PARTICLE_CONFIG: Record<string, AtmosphereParticleConfig> = { mistWisps: { count: 6, color: "#d0d0d0", sizeRange: [20, 45], opacityRange: [12, 30], speed: "14s", drift: "float", }, fogBanks: { count: 4, color: "#c0c0c0", sizeRange: [35, 70], opacityRange: [25, 50], speed: "18s", drift: "float", }, smokeWisps: { count: 10, color: "#7a6548", sizeRange: [25, 55], opacityRange: [18, 40], speed: "11s", drift: "float", }, dustSwirl: { count: 20, color: "#a08860", sizeRange: [2, 5], opacityRange: [30, 55], speed: "5s", drift: "swirl", }, sandSwirl: { count: 28, color: "#c4a050", sizeRange: [2, 4], opacityRange: [40, 65], speed: "3.5s", drift: "swirl", }, ashFall: { count: 18, color: "#444", sizeRange: [3, 6], opacityRange: [35, 65], speed: "7s", drift: "fall", }, debrisSwirl: { count: 15, color: "#5a5040", sizeRange: [3, 8], opacityRange: [30, 55], speed: "4s", drift: "swirl", }, iceGlint: { count: 12, color: "#e0f0ff", sizeRange: [1, 3], opacityRange: [40, 80], speed: "3s", drift: "float", },};Step 4: Run typecheck
Run: cd app && npx tsc --noEmit
Expected: PASS
Step 5: Commit
git add app/src/scripts/live-window/components/sky/WeatherLayer.tsgit commit -m "feat(weather): add atmosphere particle configs and distinct sand/dust overlays"Task 4: Update All WEATHER_EFFECTS Entries
Files:
- Modify:
app/src/scripts/live-window/components/sky/WeatherLayer.ts:228-290
Step 1: Replace the entire WEATHER_EFFECTS map
export const WEATHER_EFFECTS: Record<number, WeatherEffectConfig> = { // 2xx Thunderstorm 200: fx("heavy", [p("lightRain", 0.6)], { lightning: "distant" }), 201: fx("storm", [p("rain")], { lightning: "standard" }), 202: fx("storm", [p("heavyRain")], { lightning: "intense", atmosphere: ATMOSPHERE_CONFIG.stormDark, wind: "moderate" }), 210: fx("heavy", [], { lightning: "distant" }), 211: fx("storm", [], { lightning: "standard" }), 212: fx("storm", [], { lightning: "intense", atmosphere: ATMOSPHERE_CONFIG.stormDark }), 221: fx("storm", [p("lightRain", 0.4)], { lightning: "standard" }), 230: fx("heavy", [p("drizzle")], { lightning: "standard" }), 231: fx("storm", [p("drizzle", 1.4)], { lightning: "standard" }), 232: fx("storm", [p("drizzle", 1.8)], { lightning: "intense", atmosphere: ATMOSPHERE_CONFIG.stormDark, wind: "moderate" }),
// 3xx Drizzle 300: fx("light", [p("drizzleLight")]), 301: fx("medium", [p("drizzle")]), 302: fx("medium", [p("drizzleHeavy")]), 310: fx("medium", [p("drizzle", 0.6), p("lightRain", 0.4)]), 311: fx("medium", [p("drizzle", 0.7), p("lightRain", 0.7)]), 312: fx("medium", [p("drizzleHeavy", 0.8), p("rain", 0.7)]), 313: fx("medium", [p("showerRain", 0.7), p("drizzle", 0.5)], { wind: "light" }), 314: fx("heavy", [p("showerRain", 1.2), p("drizzleHeavy", 0.6)], { wind: "moderate" }), 321: fx("medium", [p("showerDrizzle")], { wind: "light" }),
// 5xx Rain 500: fx("medium", [p("lightRain", 0.6)]), 501: fx("medium", [p("rain")]), 502: fx("heavy", [p("heavyRain")]), 503: fx("heavy", [p("heavyRain", 1.3)], { wind: "light" }), 504: fx("storm", [p("extremeRain")], { wind: "moderate", atmosphere: ATMOSPHERE_CONFIG.stormDark }), 511: fx("heavy", [p("freezingRain")], { atmosphereParticles: ATMO_PARTICLE_CONFIG.iceGlint }), 520: fx("medium", [p("showerRain", 0.6)], { wind: "light" }), 521: fx("medium", [p("showerRain")], { wind: "light" }), 522: fx("heavy", [p("showerRain", 1.4)], { wind: "moderate" }), 531: fx("medium", [p("showerRain", 0.5), p("lightRain", 0.3)]),
// 6xx Snow 600: fx("medium", [p("lightSnow", 0.6)]), 601: fx("medium", [p("snow")]), 602: fx("heavy", [p("heavySnow", 1.4)]), 611: fx("medium", [p("sleet")]), 612: fx("medium", [p("showerSleet", 0.6)], { wind: "light" }), 613: fx("medium", [p("showerSleet")], { wind: "light" }), 615: fx("medium", [p("lightRain", 0.5), p("lightSnow", 0.5)]), 616: fx("heavy", [p("rain", 0.7), p("snow", 0.7)]), 620: fx("medium", [p("showerSnow", 0.6)], { wind: "light" }), 621: fx("medium", [p("showerSnow")], { wind: "light" }), 622: fx("heavy", [p("showerSnow", 1.4)], { wind: "moderate" }),
// 7xx Atmosphere 701: fx("none", [], { atmosphere: ATMOSPHERE_CONFIG.mist, atmosphereParticles: ATMO_PARTICLE_CONFIG.mistWisps }), 711: fx("none", [], { atmosphere: ATMOSPHERE_CONFIG.smoke, atmosphereParticles: ATMO_PARTICLE_CONFIG.smokeWisps }), 721: fx("none", [], { atmosphere: ATMOSPHERE_CONFIG.haze }), 731: fx("none", [], { atmosphere: ATMOSPHERE_CONFIG.dustWhirls, atmosphereParticles: ATMO_PARTICLE_CONFIG.dustSwirl }), 741: fx("none", [], { atmosphere: ATMOSPHERE_CONFIG.fog, atmosphereParticles: ATMO_PARTICLE_CONFIG.fogBanks }), 751: fx("none", [], { atmosphere: ATMOSPHERE_CONFIG.sand, atmosphereParticles: ATMO_PARTICLE_CONFIG.sandSwirl }), 761: fx("none", [], { atmosphere: ATMOSPHERE_CONFIG.dust, atmosphereParticles: ATMO_PARTICLE_CONFIG.dustSwirl }), 762: fx("none", [], { atmosphere: ATMOSPHERE_CONFIG.volcanicAsh, atmosphereParticles: ATMO_PARTICLE_CONFIG.ashFall }), 771: fx("heavy", [p("heavyRain")], { atmosphere: ATMOSPHERE_CONFIG.squalls, wind: "strong" }), 781: fx("storm", [p("extremeRain")], { atmosphere: ATMOSPHERE_CONFIG.tornado, wind: "strong", atmosphereParticles: ATMO_PARTICLE_CONFIG.debrisSwirl }),
// 800+ Clear/Clouds 800: fx("none", []), 801: fx("light", []), 802: fx("medium", []), 803: fx("heavy", []), 804: fx("storm", []),};Step 2: Run typecheck
Run: cd app && npx tsc --noEmit
Expected: PASS (test files may have errors, that’s OK for now)
Step 3: Commit
git add app/src/scripts/live-window/components/sky/WeatherLayer.tsgit commit -m "feat(weather): update all 50 weather code configs with distinct effects"Task 5: Add Atmosphere Particle Rendering
Files:
- Modify:
app/src/scripts/live-window/components/sky/WeatherLayer.ts
Step 1: Add atmosphereParticleHTML static method
Add after the existing particleHTML method:
/** Generate atmosphere particles (mist wisps, dust, ash, etc.) with deterministic placement. */ static atmosphereParticleHTML(config: AtmosphereParticleConfig): string { const sRange = config.sizeRange[1] - config.sizeRange[0]; const opRange = config.opacityRange[1] - config.opacityRange[0];
let out = ""; for (let i = 0; i < config.count; i++) { const h = ((i + 1) * 2654435761) >>> 0; const left = h % 100; const top = ((h >>> 8) ^ (i * 37)) % 80; // keep in upper 80% for float/swirl const size = config.sizeRange[0] + (h % (sRange + 1)); const opacity = (config.opacityRange[0] + ((h >>> 4) % (opRange + 1))) / 100; const dur = parseFloat(config.speed) + ((h >>> 12) % 30) / 10; const delay = -((h >>> 16) % 80) / 10;
out += `<div class="atmo-particle atmo-${config.drift}" style="left:${left}%;top:${top}%;width:${size}px;height:${size}px;opacity:${opacity};background:${config.color};animation-duration:${dur.toFixed(1)}s;animation-delay:${delay.toFixed(1)}s"></div>`; } return out; }Step 2: Update the update() method to render atmosphere particles
In the update() method, add after the atmosphere overlay rendering block (after the if (config.atmosphere) block):
// Atmosphere particles if (config.atmosphereParticles) { html += WeatherLayer.atmosphereParticleHTML(config.atmosphereParticles); }Step 3: Run typecheck
Run: cd app && npx tsc --noEmit
Expected: PASS
Step 4: Commit
git add app/src/scripts/live-window/components/sky/WeatherLayer.tsgit commit -m "feat(weather): add atmosphere particle rendering for mist/dust/ash/etc"Task 6: Add Wind Support to Precipitation Rendering
Files:
- Modify:
app/src/scripts/live-window/components/sky/WeatherLayer.ts
Step 1: Update precipitation rendering to use wind animation
In the update() method, find the precipitation loop and update it to select the correct animation name based on wind level:
Replace the existing precipitation rendering block:
// Precipitation layers const precipAnim = config.wind === "strong" ? "precipitate-strong-wind" : config.wind === "moderate" ? "precipitate-wind" : config.wind === "light" ? "precipitate-light-wind" : "precipitate";
for (const precipLayer of config.precip) { const precipConfig = PRECIP_CONFIG[precipLayer.type]; if (!precipConfig) continue; const count = Math.round(precipConfig.count * precipLayer.intensityScale); const particles = WeatherLayer.particleHTML(precipConfig, count); html += `<div class="droplets" style="animation-duration:${precipConfig.fallSpeed};animation-name:${precipAnim}">`; html += `<div class="droplets-half">${particles}</div>`; html += `<div class="droplets-half">${particles}</div>`; html += "</div>"; }Step 2: Run typecheck
Run: cd app && npx tsc --noEmit
Expected: PASS
Step 3: Commit
git add app/src/scripts/live-window/components/sky/WeatherLayer.tsgit commit -m "feat(weather): add wind-driven precipitation with angled fall"Task 7: Redesign Lightning Rendering
Files:
- Modify:
app/src/scripts/live-window/components/sky/WeatherLayer.ts
Step 1: Add a lightningHTML static method
Add after atmosphereParticleHTML:
/** Generate lightning bolt HTML based on variant intensity. */ static lightningHTML(variant: LightningVariant): string { switch (variant) { case "distant": return '<div class="lightning lightning-distant"></div>'; case "intense": return '<div class="lightning lightning-intense"></div><div class="lightning lightning-intense lightning-secondary"></div>'; default: // "standard" return '<div class="lightning lightning-standard"></div>'; } }Step 2: Update the update() method to use lightningHTML
Replace the existing lightning rendering:
// Lightning if (config.lightning) { html += WeatherLayer.lightningHTML(config.lightning); }Step 3: Run typecheck
Run: cd app && npx tsc --noEmit
Expected: PASS
Step 4: Commit
git add app/src/scripts/live-window/components/sky/WeatherLayer.tsgit commit -m "feat(weather): redesign lightning with distant/standard/intense variants"Task 8: Add New CSS Animations and Classes
Files:
- Modify:
app/src/scripts/live-window/live-window.css
Step 1: Add wind precipitation keyframes
Add after the existing @keyframes precipitate block:
@keyframes precipitate-light-wind { from { transform: translate3d(-3%, -50%, 0); } to { transform: translate3d(3%, 0%, 0); }}
@keyframes precipitate-wind { from { transform: translate3d(-8%, -50%, 0); } to { transform: translate3d(8%, 0%, 0); }}
@keyframes precipitate-strong-wind { from { transform: translate3d(-15%, -50%, 0); } to { transform: translate3d(15%, 0%, 0); }}Step 2: Add atmosphere particle base class and motion keyframes
Add after the existing atmosphere section:
/* Atmosphere particles */
.atmo-particle { position: absolute; border-radius: 50%; filter: blur(4px); animation-timing-function: ease-in-out; animation-iteration-count: infinite;}
.atmo-float { animation-name: atmo-float; animation-direction: alternate;}
.atmo-swirl { animation-name: atmo-swirl; filter: blur(1px);}
.atmo-fall { animation-name: atmo-fall; animation-timing-function: linear;}
@keyframes atmo-float { 0% { transform: translate(-10%, 5%) scale(1); } 50% { transform: translate(10%, -5%) scale(1.1); } 100% { transform: translate(-10%, 5%) scale(1); }}
@keyframes atmo-swirl { 0% { transform: translate(0, 0) rotate(0deg); } 25% { transform: translate(15px, -10px) rotate(90deg); } 50% { transform: translate(-5px, 5px) rotate(180deg); } 75% { transform: translate(10px, 10px) rotate(270deg); } 100% { transform: translate(0, 0) rotate(360deg); }}
@keyframes atmo-fall { 0% { transform: translate(-5px, -20%); } 100% { transform: translate(5px, 100%); }}Step 3: Redesign lightning CSS
Replace the entire lightning CSS section (.weather .lightning through .weather .lightning::after) with:
/* Lightning */
.weather .lightning { position: absolute; top: 0; left: 50%; width: 3px; height: 100%; pointer-events: none; filter: blur(0.5px);}
/* Bolt shape via clip-path — jagged zigzag */.weather .lightning::before { content: ""; position: absolute; top: 5%; left: 50%; transform: translateX(-50%); background: #fffbe6; clip-path: polygon( 50% 0%, 35% 30%, 55% 32%, 30% 65%, 52% 67%, 25% 100%, 55% 60%, 38% 58%, 60% 28%, 42% 26% );}
/* Glow behind the bolt */.weather .lightning::after { content: ""; position: absolute; top: 5%; left: 50%; transform: translateX(-50%); border-radius: 30%; filter: blur(8px);}
/* Distant: small, dim, slow */.weather .lightning-distant { animation: lightning-flash-distant 4s linear infinite;}
.weather .lightning-distant::before { width: 12px; height: 60px;}
.weather .lightning-distant::after { width: 20px; height: 65px; background: rgba(255, 255, 200, 0.15);}
/* Standard: medium bolt with branch */.weather .lightning-standard { animation: lightning-flash-standard 2.5s linear infinite;}
.weather .lightning-standard::before { width: 18px; height: 100px;}
.weather .lightning-standard::after { width: 30px; height: 105px; background: rgba(255, 255, 200, 0.25);}
/* Intense: large bolt, bright glow, fast double-flash */.weather .lightning-intense { animation: lightning-flash-intense 1.5s linear infinite;}
.weather .lightning-intense::before { width: 24px; height: 130px;}
.weather .lightning-intense::after { width: 40px; height: 135px; background: rgba(255, 255, 200, 0.35);}
/* Secondary bolt offset for intense variant */.weather .lightning-secondary { left: 25%; animation-delay: -0.3s; opacity: 0.7;}
.weather .lightning-secondary::before { width: 14px; height: 80px; top: 15%;}
.weather .lightning-secondary::after { width: 24px; height: 85px; top: 15%;}
/* Lightning flash animations */@keyframes lightning-flash-distant { 0%, 8%, 100% { opacity: 0; } 4% { opacity: 0.6; }}
@keyframes lightning-flash-standard { 0%, 10%, 100% { opacity: 0; } 5% { opacity: 1; }}
@keyframes lightning-flash-intense { 0%, 8%, 16%, 100% { opacity: 0; } 4% { opacity: 1; } 12% { opacity: 0.9; }}Step 4: Commit
git add app/src/scripts/live-window/live-window.cssgit commit -m "feat(weather): add wind, atmosphere particle, and lightning CSS animations"Task 9: Update getSkyDarkenOpacity for New Types
Files:
- Modify:
app/src/scripts/live-window/components/sky/WeatherLayer.ts:296-327
Step 1: Update getSkyDarkenOpacity to handle new lightning type
The existing function checks config.lightning as boolean. Now it’s LightningVariant | false. Update:
function getSkyDarkenOpacity(config: WeatherEffectConfig): number { let opacity = 0;
switch (config.clouds) { case "light": opacity += 0.05; break; case "medium": opacity += 0.12; break; case "heavy": opacity += 0.22; break; case "storm": opacity += 0.28; break; }
for (const layer of config.precip) { opacity += 0.05 * layer.intensityScale; }
if (config.lightning) { opacity += config.lightning === "intense" ? 0.16 : config.lightning === "distant" ? 0.06 : 0.12; }
if (config.atmosphere) { opacity += config.atmosphere.opacity * 0.3; }
return Math.min(opacity, 0.55);}Step 2: Run typecheck
Run: cd app && npx tsc --noEmit
Expected: PASS
Step 3: Commit
git add app/src/scripts/live-window/components/sky/WeatherLayer.tsgit commit -m "feat(weather): update sky darkening for lightning variants"Task 10: Update Tests
Files:
- Modify:
app/src/scripts/live-window/__tests__/layers/weather.test.ts
Step 1: Update the test file
The existing tests check lightning: true/false. Update all lightning assertions to use variant strings. Also add tests for new features. Key changes:
thunderstorm IDs have lightning— changetoBe(true)totoBeTruthy()(since it’s now a string, truthy works)non-thunderstorm IDs do not have lightning— changetoBe(false)stays the sameheavy thunderstorms have dark atmosphere overlay— no change needednon-atmosphere IDs do not have atmosphere configs— update: 511 now has atmosphereParticles (not atmosphere), test should still pass since it checksatmospherenotatmosphereParticles- Add test:
squalls and tornado have heavy clouds— update: 781 is now storm density - Add test:
renders lightning for thunderstorm (201)— check.lightning-standardclass - Add test for new precip configs existing
- Add test for atmosphere particle configs existing
- Add test for wind levels on shower/storm codes
- Add test for 804 being storm density
- Add test for 751 and 761 having distinct atmosphere configs
Replace the full test file with updated assertions:
import { describe, it, expect, beforeEach } from "vitest";import { WeatherLayer, WEATHER_EFFECTS, PRECIP_CONFIG, ATMOSPHERE_CONFIG, ATMO_PARTICLE_CONFIG, CLOUD_CONFIGS,} from "../../components/sky/WeatherLayer";import { makeTestState } from "../helpers";
function makeState(weatherId: number | null) { if (weatherId === null) { return makeTestState({ store: { weather: { current: null } } }); } return makeTestState({ store: { weather: { current: { id: weatherId, main: "Test", description: "test", icon: "01d", temp: 20 }, }, }, });}
describe("WEATHER_EFFECTS", () => { it("covers all thunderstorm IDs (2xx)", () => { for (const id of [200, 201, 202, 210, 211, 212, 221, 230, 231, 232]) { expect(WEATHER_EFFECTS[id]).toBeDefined(); } });
it("covers all drizzle IDs (3xx)", () => { for (const id of [300, 301, 302, 310, 311, 312, 313, 314, 321]) { expect(WEATHER_EFFECTS[id]).toBeDefined(); } });
it("covers all rain IDs (5xx)", () => { for (const id of [500, 501, 502, 503, 504, 511, 520, 521, 522, 531]) { expect(WEATHER_EFFECTS[id]).toBeDefined(); } });
it("covers all snow IDs (6xx)", () => { for (const id of [600, 601, 602, 611, 612, 613, 615, 616, 620, 621, 622]) { expect(WEATHER_EFFECTS[id]).toBeDefined(); } });
it("covers all atmosphere IDs (7xx)", () => { for (const id of [701, 711, 721, 731, 741, 751, 761, 762, 771, 781]) { expect(WEATHER_EFFECTS[id]).toBeDefined(); } });
it("covers all clear/cloud IDs (800+)", () => { for (const id of [800, 801, 802, 803, 804]) { expect(WEATHER_EFFECTS[id]).toBeDefined(); } });
it("thunderstorm IDs have lightning variants", () => { for (const id of [200, 201, 202, 210, 211, 212, 221, 230, 231, 232]) { expect(WEATHER_EFFECTS[id].lightning).toBeTruthy(); } });
it("light thunderstorms use distant lightning", () => { expect(WEATHER_EFFECTS[200].lightning).toBe("distant"); expect(WEATHER_EFFECTS[210].lightning).toBe("distant"); });
it("heavy thunderstorms use intense lightning", () => { expect(WEATHER_EFFECTS[202].lightning).toBe("intense"); expect(WEATHER_EFFECTS[212].lightning).toBe("intense"); expect(WEATHER_EFFECTS[232].lightning).toBe("intense"); });
it("light thunderstorms have fewer clouds than heavy thunderstorms", () => { expect(WEATHER_EFFECTS[200].clouds).toBe("heavy"); expect(WEATHER_EFFECTS[201].clouds).toBe("storm"); expect(WEATHER_EFFECTS[202].clouds).toBe("storm"); expect(WEATHER_EFFECTS[210].clouds).toBe("heavy"); expect(WEATHER_EFFECTS[211].clouds).toBe("storm"); expect(WEATHER_EFFECTS[212].clouds).toBe("storm"); });
it("heavy thunderstorms have dark atmosphere overlay", () => { expect(WEATHER_EFFECTS[202].atmosphere).toBe(ATMOSPHERE_CONFIG.stormDark); expect(WEATHER_EFFECTS[212].atmosphere).toBe(ATMOSPHERE_CONFIG.stormDark); expect(WEATHER_EFFECTS[232].atmosphere).toBe(ATMOSPHERE_CONFIG.stormDark); expect(WEATHER_EFFECTS[200].atmosphere).toBeNull(); expect(WEATHER_EFFECTS[201].atmosphere).toBeNull(); expect(WEATHER_EFFECTS[211].atmosphere).toBeNull(); });
it("non-thunderstorm IDs do not have lightning", () => { for (const id of [300, 500, 600, 701, 800]) { expect(WEATHER_EFFECTS[id].lightning).toBe(false); } });
it("rain+snow (615, 616) have dual precipitation layers", () => { expect(WEATHER_EFFECTS[615].precip.length).toBe(2); expect(WEATHER_EFFECTS[616].precip.length).toBe(2); });
it("drizzle+rain combos (310-314) have dual precipitation layers", () => { for (const id of [310, 311, 312, 313, 314]) { expect(WEATHER_EFFECTS[id].precip.length).toBe(2); } });
it("atmosphere IDs have atmosphere configs", () => { for (const id of [701, 711, 721, 731, 741, 751, 761, 762, 771, 781]) { expect(WEATHER_EFFECTS[id].atmosphere).not.toBeNull(); } });
it("non-atmosphere IDs do not have atmosphere configs", () => { for (const id of [300, 500, 600, 800]) { expect(WEATHER_EFFECTS[id].atmosphere).toBeNull(); } });
it("squalls have heavy clouds and tornado has storm clouds", () => { expect(WEATHER_EFFECTS[771].clouds).toBe("heavy"); expect(WEATHER_EFFECTS[781].clouds).toBe("storm"); });
it("squalls and tornado have precipitation", () => { expect(WEATHER_EFFECTS[771].precip.length).toBeGreaterThan(0); expect(WEATHER_EFFECTS[781].precip.length).toBeGreaterThan(0); });
it("squalls and tornado have strong wind", () => { expect(WEATHER_EFFECTS[771].wind).toBe("strong"); expect(WEATHER_EFFECTS[781].wind).toBe("strong"); });
it("clear sky (800) has no effects", () => { const config = WEATHER_EFFECTS[800]; expect(config.clouds).toBe("none"); expect(config.precip).toEqual([]); expect(config.lightning).toBe(false); expect(config.atmosphere).toBeNull(); expect(config.wind).toBe("none"); expect(config.atmosphereParticles).toBeNull(); });
it("804 (overcast) has storm density, more than 803 (broken)", () => { expect(WEATHER_EFFECTS[803].clouds).toBe("heavy"); expect(WEATHER_EFFECTS[804].clouds).toBe("storm"); });
it("751 (sand) and 761 (dust) have distinct atmosphere configs", () => { expect(WEATHER_EFFECTS[751].atmosphere).not.toBe(WEATHER_EFFECTS[761].atmosphere); expect(WEATHER_EFFECTS[751].atmosphere!.color).not.toBe(WEATHER_EFFECTS[761].atmosphere!.color); });
it("shower variants have wind", () => { for (const id of [520, 521, 522, 620, 621, 622, 612, 613, 313, 314, 321]) { expect(WEATHER_EFFECTS[id].wind).not.toBe("none"); } });
it("non-shower rain/snow/drizzle have no wind", () => { for (const id of [300, 301, 302, 500, 501, 600, 601, 611]) { expect(WEATHER_EFFECTS[id].wind).toBe("none"); } });
it("atmosphere codes have atmosphere particles except haze", () => { for (const id of [701, 711, 731, 741, 751, 761, 762, 781]) { expect(WEATHER_EFFECTS[id].atmosphereParticles).not.toBeNull(); } // Haze is uniform, no particles expect(WEATHER_EFFECTS[721].atmosphereParticles).toBeNull(); });
it("freezing rain (511) has ice glint particles", () => { expect(WEATHER_EFFECTS[511].atmosphereParticles).toBe(ATMO_PARTICLE_CONFIG.iceGlint); });});
describe("PRECIP_CONFIG", () => { it("defines configs for all precipitation types", () => { const expected = [ "lightRain", "rain", "snow", "sleet", "drizzle", "showerRain", "freezingRain", "lightSnow", "heavySnow", "showerSnow", "drizzleLight", "drizzleHeavy", "showerDrizzle", "heavyRain", "extremeRain", "showerSleet", ]; for (const key of expected) { expect(PRECIP_CONFIG[key]).toBeDefined(); } });
it("rain is fastest, snow is slowest", () => { const speed = (s: string) => parseFloat(s); expect(speed(PRECIP_CONFIG.rain.fallSpeed)).toBeLessThan(speed(PRECIP_CONFIG.snow.fallSpeed)); });
it("snow has sway, rain does not", () => { expect(PRECIP_CONFIG.snow.hasSway).toBe(true); expect(PRECIP_CONFIG.rain.hasSway).toBe(false); });
it("drizzle is slower than lightRain", () => { const speed = (s: string) => parseFloat(s); expect(speed(PRECIP_CONFIG.drizzle.fallSpeed)).toBeGreaterThan(speed(PRECIP_CONFIG.lightRain.fallSpeed)); });
it("freezingRain has blue-white color distinct from regular rain", () => { expect(PRECIP_CONFIG.freezingRain.color).not.toBe(PRECIP_CONFIG.rain.color); });
it("drizzleLight is slower and smaller than drizzle", () => { const speed = (s: string) => parseFloat(s); expect(speed(PRECIP_CONFIG.drizzleLight.fallSpeed)).toBeGreaterThan(speed(PRECIP_CONFIG.drizzle.fallSpeed)); expect(PRECIP_CONFIG.drizzleLight.sizeW[1]).toBeLessThanOrEqual(PRECIP_CONFIG.drizzle.sizeW[0]); });
it("heavyRain has bigger drops than rain", () => { expect(PRECIP_CONFIG.heavyRain.sizeW[0]).toBeGreaterThan(PRECIP_CONFIG.rain.sizeW[0]); });
it("extremeRain is fastest and densest", () => { const speed = (s: string) => parseFloat(s); expect(speed(PRECIP_CONFIG.extremeRain.fallSpeed)).toBeLessThan(speed(PRECIP_CONFIG.heavyRain.fallSpeed)); expect(PRECIP_CONFIG.extremeRain.count).toBeGreaterThan(PRECIP_CONFIG.heavyRain.count); });
it("showerSleet is faster than regular sleet", () => { const speed = (s: string) => parseFloat(s); expect(speed(PRECIP_CONFIG.showerSleet.fallSpeed)).toBeLessThan(speed(PRECIP_CONFIG.sleet.fallSpeed)); });});
describe("ATMOSPHERE_CONFIG", () => { it("defines configs for all atmosphere types", () => { expect(ATMOSPHERE_CONFIG.mist).toBeDefined(); expect(ATMOSPHERE_CONFIG.fog).toBeDefined(); expect(ATMOSPHERE_CONFIG.smoke).toBeDefined(); expect(ATMOSPHERE_CONFIG.haze).toBeDefined(); expect(ATMOSPHERE_CONFIG.sand).toBeDefined(); expect(ATMOSPHERE_CONFIG.dust).toBeDefined(); expect(ATMOSPHERE_CONFIG.dustWhirls).toBeDefined(); expect(ATMOSPHERE_CONFIG.volcanicAsh).toBeDefined(); expect(ATMOSPHERE_CONFIG.squalls).toBeDefined(); expect(ATMOSPHERE_CONFIG.tornado).toBeDefined(); expect(ATMOSPHERE_CONFIG.stormDark).toBeDefined(); });
it("fog is denser than mist", () => { expect(ATMOSPHERE_CONFIG.fog.opacity).toBeGreaterThan(ATMOSPHERE_CONFIG.mist.opacity); });
it("smoke has brownish color distinct from mist grey", () => { expect(ATMOSPHERE_CONFIG.smoke.color).not.toBe(ATMOSPHERE_CONFIG.mist.color); });
it("sand and dust have distinct colors", () => { expect(ATMOSPHERE_CONFIG.sand.color).not.toBe(ATMOSPHERE_CONFIG.dust.color); });
it("each config has required fields", () => { for (const key of Object.keys(ATMOSPHERE_CONFIG)) { const config = ATMOSPHERE_CONFIG[key]; expect(config).toHaveProperty("color"); expect(config).toHaveProperty("opacity"); expect(config).toHaveProperty("layers"); expect(config.layers).toBeGreaterThanOrEqual(1); expect(config.layers).toBeLessThanOrEqual(3); } });});
describe("ATMO_PARTICLE_CONFIG", () => { it("defines all atmosphere particle types", () => { const expected = [ "mistWisps", "fogBanks", "smokeWisps", "dustSwirl", "sandSwirl", "ashFall", "debrisSwirl", "iceGlint", ]; for (const key of expected) { expect(ATMO_PARTICLE_CONFIG[key]).toBeDefined(); } });
it("each config has required fields", () => { for (const key of Object.keys(ATMO_PARTICLE_CONFIG)) { const config = ATMO_PARTICLE_CONFIG[key]; expect(config.count).toBeGreaterThan(0); expect(config.sizeRange[0]).toBeLessThan(config.sizeRange[1]); expect(config.opacityRange[0]).toBeLessThan(config.opacityRange[1]); expect(["float", "swirl", "fall"]).toContain(config.drift); } });
it("dust and sand swirl particles have distinct colors", () => { expect(ATMO_PARTICLE_CONFIG.dustSwirl.color).not.toBe(ATMO_PARTICLE_CONFIG.sandSwirl.color); });});
describe("WeatherLayer.cloudHTML", () => { it("generates the configured number of clouds for each density", () => { for (const [density, config] of Object.entries(CLOUD_CONFIGS)) { const html = WeatherLayer.cloudHTML(density as "light" | "medium" | "heavy" | "storm"); const matches = html.match(/class="cloud"/g); expect(matches?.length).toBe(config.count); } });
it("produces deterministic output", () => { const a = WeatherLayer.cloudHTML("heavy"); const b = WeatherLayer.cloudHTML("heavy"); expect(a).toBe(b); });
it("storm has more clouds than heavy", () => { const storm = WeatherLayer.cloudHTML("storm").match(/class="cloud"/g)?.length ?? 0; const heavy = WeatherLayer.cloudHTML("heavy").match(/class="cloud"/g)?.length ?? 0; expect(storm).toBeGreaterThan(heavy); });});
describe("WeatherLayer.particleHTML", () => { it("generates the configured number of particles", () => { const html = WeatherLayer.particleHTML(PRECIP_CONFIG.rain); const matches = html.match(/class="particle"/g); expect(matches?.length).toBe(PRECIP_CONFIG.rain.count); });
it("omits sway animation for non-sway configs", () => { const html = WeatherLayer.particleHTML(PRECIP_CONFIG.rain); expect(html).not.toContain("animation-name:"); });
it("includes sway animation for sway configs", () => { const html = WeatherLayer.particleHTML(PRECIP_CONFIG.snow); expect(html).toContain("animation-name:sway"); });
it("uses the configured color", () => { expect(WeatherLayer.particleHTML(PRECIP_CONFIG.snow)).toContain("background:#fff"); expect(WeatherLayer.particleHTML(PRECIP_CONFIG.sleet)).toContain("background:#a0cfff"); });
it("produces deterministic output", () => { const a = WeatherLayer.particleHTML(PRECIP_CONFIG.snow); const b = WeatherLayer.particleHTML(PRECIP_CONFIG.snow); expect(a).toBe(b); });
it("respects count override", () => { const html = WeatherLayer.particleHTML(PRECIP_CONFIG.rain, 10); const matches = html.match(/class="particle"/g); expect(matches?.length).toBe(10); });});
describe("WeatherLayer.atmosphereParticleHTML", () => { it("generates the configured number of particles", () => { const html = WeatherLayer.atmosphereParticleHTML(ATMO_PARTICLE_CONFIG.dustSwirl); const matches = html.match(/class="atmo-particle/g); expect(matches?.length).toBe(ATMO_PARTICLE_CONFIG.dustSwirl.count); });
it("uses correct drift class", () => { expect(WeatherLayer.atmosphereParticleHTML(ATMO_PARTICLE_CONFIG.mistWisps)).toContain("atmo-float"); expect(WeatherLayer.atmosphereParticleHTML(ATMO_PARTICLE_CONFIG.dustSwirl)).toContain("atmo-swirl"); expect(WeatherLayer.atmosphereParticleHTML(ATMO_PARTICLE_CONFIG.ashFall)).toContain("atmo-fall"); });
it("produces deterministic output", () => { const a = WeatherLayer.atmosphereParticleHTML(ATMO_PARTICLE_CONFIG.fogBanks); const b = WeatherLayer.atmosphereParticleHTML(ATMO_PARTICLE_CONFIG.fogBanks); expect(a).toBe(b); });});
describe("WeatherLayer", () => { let layer: WeatherLayer; let container: HTMLElement;
beforeEach(() => { layer = new WeatherLayer(); container = document.createElement("div"); layer.mount(container); });
it("renders nothing when weather id is null", () => { layer.update(makeState(null)); expect(container.innerHTML).toBe(""); });
it("renders nothing for clear sky (800)", () => { layer.update(makeState(800)); expect(container.innerHTML).toBe(""); });
it("renders procedural clouds for 801 (few clouds)", () => { layer.update(makeState(801)); const clouds = container.querySelectorAll(".cloud"); expect(clouds.length).toBe(CLOUD_CONFIGS.light.count); expect(container.querySelector(".droplets")).toBeFalsy(); });
it("renders more clouds for 802 (scattered) than 801 (few)", () => { layer.update(makeState(802)); const clouds = container.querySelectorAll(".cloud"); expect(clouds.length).toBe(CLOUD_CONFIGS.medium.count); expect(clouds.length).toBeGreaterThan(CLOUD_CONFIGS.light.count); });
it("renders more clouds for 803 (broken) than 802 (scattered)", () => { layer.update(makeState(803)); const clouds = container.querySelectorAll(".cloud"); expect(clouds.length).toBe(CLOUD_CONFIGS.heavy.count); expect(clouds.length).toBeGreaterThan(CLOUD_CONFIGS.medium.count); });
it("renders storm clouds for 804 (overcast), more than 803", () => { layer.update(makeState(804)); const clouds = container.querySelectorAll(".cloud"); expect(clouds.length).toBe(CLOUD_CONFIGS.storm.count); expect(clouds.length).toBeGreaterThan(CLOUD_CONFIGS.heavy.count); });
it("renders rain particles for 501 (moderate rain)", () => { layer.update(makeState(501)); expect(container.querySelector(".droplets")).toBeTruthy(); expect(container.querySelector(".particle")).toBeTruthy(); expect(container.querySelectorAll(".cloud").length).toBe(CLOUD_CONFIGS.medium.count); });
it("renders standard lightning for thunderstorm (201)", () => { layer.update(makeState(201)); expect(container.querySelector(".lightning-standard")).toBeTruthy(); expect(container.querySelector(".droplets")).toBeTruthy(); });
it("renders distant lightning for light thunderstorm (210)", () => { layer.update(makeState(210)); expect(container.querySelector(".lightning-distant")).toBeTruthy(); expect(container.querySelector(".droplets")).toBeFalsy(); const clouds = container.querySelectorAll(".cloud"); expect(clouds.length).toBe(CLOUD_CONFIGS.heavy.count); });
it("renders intense lightning for heavy thunderstorm (212)", () => { layer.update(makeState(212)); expect(container.querySelector(".lightning-intense")).toBeTruthy(); expect(container.querySelector(".lightning-secondary")).toBeTruthy(); expect(container.querySelector(".atmosphere-lg")).toBeTruthy(); });
it("renders storm-density clouds for regular thunderstorm (211)", () => { layer.update(makeState(211)); expect(container.querySelector(".lightning-standard")).toBeTruthy(); const clouds = container.querySelectorAll(".cloud"); expect(clouds.length).toBe(CLOUD_CONFIGS.storm.count); expect(clouds.length).toBeGreaterThan(CLOUD_CONFIGS.heavy.count); });
it("renders atmosphere layers for mist (701) with wisps", () => { layer.update(makeState(701)); expect(container.querySelector(".atmosphere-lg")).toBeTruthy(); expect(container.querySelector(".atmosphere-md")).toBeTruthy(); expect(container.querySelector(".atmosphere-sm")).toBeFalsy(); expect(container.querySelector(".atmo-particle")).toBeTruthy(); expect(container.querySelector(".atmo-float")).toBeTruthy(); });
it("renders 3 atmosphere layers for fog (741) with fog banks", () => { layer.update(makeState(741)); expect(container.querySelector(".atmosphere-lg")).toBeTruthy(); expect(container.querySelector(".atmosphere-md")).toBeTruthy(); expect(container.querySelector(".atmosphere-sm")).toBeTruthy(); expect(container.querySelector(".atmo-float")).toBeTruthy(); });
it("renders atmosphere with correct color for smoke (711)", () => { layer.update(makeState(711)); const el = container.querySelector(".atmosphere-lg") as HTMLElement; expect(el.getAttribute("style")).toContain("#8b7355"); expect(container.querySelector(".atmo-float")).toBeTruthy(); });
it("renders swirling particles for dust whirls (731)", () => { layer.update(makeState(731)); expect(container.querySelector(".atmo-swirl")).toBeTruthy(); });
it("renders falling particles for volcanic ash (762)", () => { layer.update(makeState(762)); expect(container.querySelector(".atmo-fall")).toBeTruthy(); });
it("renders particles for 601 (snow)", () => { layer.update(makeState(601)); expect(container.querySelector(".droplets")).toBeTruthy(); });
it("renders dual precipitation for rain+snow (615)", () => { layer.update(makeState(615)); const droplets = container.querySelectorAll(".droplets"); expect(droplets.length).toBe(2); });
it("renders sleet particles with blue color (611)", () => { layer.update(makeState(611)); const particle = container.querySelector(".particle") as HTMLElement; expect(particle.getAttribute("style")).toContain("background:#a0cfff"); });
it("renders freezing rain with distinct icy-blue color and ice glint (511)", () => { layer.update(makeState(511)); const particle = container.querySelector(".particle") as HTMLElement; expect(particle.getAttribute("style")).toContain("background:#7ec8f0"); expect(container.querySelector(".atmo-float")).toBeTruthy(); });
it("renders wind-driven precipitation for shower rain (521)", () => { layer.update(makeState(521)); const droplets = container.querySelector(".droplets") as HTMLElement; expect(droplets.style.animationName).toBe("precipitate-light-wind"); });
it("renders strong wind for squalls (771)", () => { layer.update(makeState(771)); const droplets = container.querySelector(".droplets") as HTMLElement; expect(droplets.style.animationName).toBe("precipitate-strong-wind"); });
it("sets fall speed inline on droplets container", () => { layer.update(makeState(501)); const droplets = container.querySelector(".droplets") as HTMLElement; expect(droplets.style.animationDuration).toBe(PRECIP_CONFIG.rain.fallSpeed); });
it("cleans up on destroy", () => { layer.update(makeState(501)); layer.destroy(); expect(container.innerHTML).toBe(""); });});Step 2: Run tests
Run: just app::test
Expected: All tests pass
Step 3: Commit
git add app/src/scripts/live-window/__tests__/layers/weather.test.tsgit commit -m "test(weather): update tests for new weather effects (wind, atmo particles, lightning variants)"Task 11: Run Full Verification
Step 1: Run typecheck
Run: just app::typecheck
Expected: PASS
Step 2: Run all tests
Run: just app::test
Expected: All pass
Step 3: Run build
Run: just app::build
Expected: PASS
Step 4: Commit any remaining fixes
If any step above fails, fix the issue and commit.
Task 12: Export New Configs for Playground Testing
Files:
- Modify:
app/src/scripts/live-window/components/sky/WeatherLayer.ts(ensureATMO_PARTICLE_CONFIGis exported)
Step 1: Verify all new configs are exported
Check that the file exports: PRECIP_CONFIG, ATMOSPHERE_CONFIG, ATMO_PARTICLE_CONFIG, CLOUD_CONFIGS, WEATHER_EFFECTS, and all new types.
Step 2: Final commit
git add -Agit commit -m "chore(weather): ensure all new configs are properly exported"