Most weather apps fetch temperature by city name. OpenWeatherMap resolves that to a broad bounding-box average — a station that can be kilometres away from your actual location, off by 5–8°C.
❌ GET /weather?q=Mumbai → city bounding box → 30°C
✅ GET /geo/1.0/direct?q=Mumbai → { lat: 19.07, lon: 72.87 }
GET /weather?lat=19.07&lon=72.87 → nearest station → 37°C
Skye geocodes first, then fetches by exact coordinates. Two API calls instead of one. The difference matches what Google and AccuWeather show.
|
Core Weather
|
UI / UX
|
||||||||||||||||||||||||||||||||||||
|
Infrastructure
|
Honest ❌ (Not in scope)
|
╔══════════════════════════════════════════════════════════════╗
║ SKYE — Client Architecture ║
╠══════════════════════════════════════════════════════════════╣
║ ║
║ main.js ──── entry point, event wiring, search handlers ║
║ │ ║
║ ├──▶ api.js ─────── all network I/O ║
║ │ ├── owmFetch() multi-proxy fetch ║
║ │ ├── geocodeCity() name → lat/lon ║
║ │ ├── fetchWeatherByCity() ║
║ │ ├── fetchWeatherByCoords() ║
║ │ └── fetchNearbyCities() OWM /find endpoint ║
║ │ ║
║ ├──▶ weather.js ── all rendering ║
║ │ ├── paint() full UI repaint ║
║ │ ├── loadNearby() nearby city cards ║
║ │ ├── paintForecast() 5-day forecast row ║
║ │ ├── drawWave() canvas bezier chart ║
║ │ ├── animateCounter() rAF number animation ║
║ │ └── weatherIcon() Lucide icon factory ║
║ │ ║
║ ├──▶ theme.js ──── dark/light + weather accents ║
║ │ ├── applyTimeClasses() auto day/night ║
║ │ ├── applyWeatherTheme() wx-clear / wx-rain… ║
║ │ ├── toggleDark() manual override ║
║ │ ├── scheduleAutoTheme() 60s poll ║
║ │ └── aqFromHumidity() air quality proxy ║
║ │ ║
║ ├──▶ ui.js ──────── non-weather UI ║
║ │ ├── initCursor() spring-physics dot+ring ║
║ │ ├── initClock() live HH:MM ticker ║
║ │ ├── setSpin() loading spinners ║
║ │ └── showToast() error notifications ║
║ │ ║
║ └──▶ config.js ─── single source of truth ║
║ ├── OWM_KEY / OWM_BASE / OWM_GEO ║
║ ├── FALLBACK_CITIES ║
║ ├── WX_CLASS_MAP condition → CSS class ║
║ ├── WX_ICON_MAP condition → Lucide name ║
║ └── DAYS[] ║
║ ║
╚══════════════════════════════════════════════════════════════╝
│ │
▼ ▼
┌─────────────────┐ ┌────────────────────┐
│ OWM Weather │ │ OWM Geocoding │
│ /data/2.5/ │ │ /geo/1.0/direct │
│ weather │ │ city → coords │
│ forecast │ └────────────────────┘
│ find │
└─────────────────┘
Skye runs entirely client-side with no backend. When the browser's CORS policy blocks a direct OWM request, it cascades through three public proxies:
Request
│
├─1─▶ Direct fetch (api.openweathermap.org)
│ timeout: 5s ──▶ ✅ return | ❌ continue
│
├─2─▶ api.allorigins.win/get?url=...
│ timeout: 9s ──▶ ✅ return | ❌ continue
│
├─3─▶ corsproxy.io/?url=...
│ timeout: 9s ──▶ ✅ return | ❌ continue
│
└─────▶ throw Error('NETWORK')
API key errors (401) and not-found errors (404) short-circuit immediately — no wasted proxy hops.
/* One background. Two shadows. Everything emerges from this. */
:root {
--bg: #e8eef5; /* the ONE base color */
--bg-d: #c8d2de; /* darker — bottom-right shadow */
--bg-l: #ffffff; /* lighter — top-left highlight */
}
/* Raised element */
.neu { box-shadow: 6px 6px 14px var(--bg-d), -6px -6px 14px var(--bg-l); }
/* Pressed/inset element */
.neu-in { box-shadow: inset 4px 4px 10px var(--bg-d), inset -4px -4px 10px var(--bg-l); }12 visual modes — 6 weather conditions × 2 themes (dark/light). All transitions are 1.4s cubic-bezier(0.4,0,0.2,1) — slow enough to feel physical, fast enough to feel responsive.
| Weather | Accent (Light) | Accent (Dark) |
|---|---|---|
| Clear | #e8a020 amber |
#e8a020 amber |
| Clouds | #5a7a9a steel |
#7a9aba mist |
| Rain | #2e72b2 blue |
#50a0e0 sky |
| Thunderstorm | #6040a0 violet |
#a080e0 lavender |
| Snow | #5a98c8 ice |
#90c8f0 frost |
| Mist/Fog/Haze | #6a8a9a grey |
#90a8b8 silver |
tests/
├── api.test.js 16 tests
│ ├── owmFetch direct 200, 401, 404, proxy fallthrough, all-fail
│ ├── geocodeCity known city, empty result, network error, non-ok
│ ├── fetchWeatherByCity geocode→coords path, name fallback, NOT_FOUND
│ ├── fetchWeatherByCoords parallel fetch
│ └── fetchNearbyCities list returned, network fail, missing list
│
├── theme.test.js 24 tests
│ ├── isNightTime 21:00, 03:00, 06:00, 14:00, 19:59, 20:00 boundary
│ ├── applyTimeClasses forceDark, forceLight, auto-night, auto-day, cleanup
│ ├── applyWeatherTheme Clear, Rain, Drizzle, Haze, unknown, cleanup
│ └── aqFromHumidity all 4 bands + color format + boundaries (44/45, 79/80)
│
├── weather.test.js 23 tests
│ ├── animateCounter target value, zero target (rAF mock pattern)
│ ├── windDirection all 8 compass points + null/undefined defaults
│ ├── formatTime string output, AM/PM, colon present
│ └── weatherIcon tag structure, 7 conditions, fallback, size param
│
└── ui.test.js 10 tests
├── setSpin show, hide, missing elements
├── showToast text, display, 5.5s auto-hide, timer reset
└── initClock immediate render, date render, 1s tick update
Key patterns:
- Closure-safe mocks — data captured at invocation, not inside
json()callback, to survivePromise.allraces - Direct
requestAnimationFramemock — vitest fake timers don't interceptperformance.now(), so rAF is mocked manually with a controlled timestamp - Boundary testing — every threshold in
aqFromHumiditytested atn−1,n,n+1
┌─────────────────────────────────────────────────────┐
│ Every push to main │
│ │
│ JOB 1 — test │
│ ┌──────────────────────────────────────────────┐ │
│ │ 1. Checkout │ │
│ │ 2. Node 20 setup + npm cache │ │
│ │ 3. npm ci │ │
│ │ 4. ESLint src/js/** │ │
│ │ 5. Vitest --coverage (V8) │ │
│ │ 6. Upload coverage artifact (7 days) │ │
│ └──────────────────────────────────────────────┘ │
│ │ needs: test │
│ JOB 2 — build │
│ ┌──────────────────────────────────────────────┐ │
│ │ 1. Checkout + Node 20 │ │
│ │ 2. npm ci │ │
│ │ 3. vite build → dist/ │ │
│ │ 4. upload-pages-artifact │ │
│ └──────────────────────────────────────────────┘ │
│ │ needs: build (main branch only) │
│ JOB 3 — deploy │
│ ┌──────────────────────────────────────────────┐ │
│ │ actions/deploy-pages@v4 │ │
│ │ → https://shaikhshahnawaz13.github.io/skye/ │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
skye/
│
├── .github/
│ └── workflows/
│ └── ci.yml ← Lint → Test → Build → Deploy
│
├── src/
│ ├── index.html ← Zero inline scripts or styles
│ ├── css/
│ │ └── styles.css ← Neumorphic design system (690 lines)
│ └── js/
│ ├── config.js ← All constants in one place
│ ├── api.js ← Every network call lives here
│ ├── theme.js ← Dark/light & weather accent engine
│ ├── weather.js ← paint(), loadNearby(), drawWave()
│ ├── ui.js ← Cursor, clock, spinner, toast
│ └── main.js ← Entry point — event wiring only
│
├── tests/
│ ├── api.test.js ← 16 tests
│ ├── theme.test.js ← 24 tests
│ ├── weather.test.js ← 23 tests
│ └── ui.test.js ← 10 tests
│
├── public/
│ └── favicon.png
│
├── .eslintrc.json ← ESLint config
├── .gitignore
├── LICENSE ← MIT
├── package.json ← scripts: dev, build, deploy, test, lint
├── vite.config.js ← base: '/skye/' for GitHub Pages
└── vitest.config.js ← jsdom environment
# Clone
git clone https://github.com/shaikhshahnawaz13/skye.git
cd skye
# Install
npm install
# Dev server → http://localhost:3000
npm run dev
# All 73 tests
npm test
# Lint
npm run lint
# Production build → dist/
npm run build
# Build + deploy to GitHub Pages
npm run deployAPI key — replace in src/js/config.js:
export const OWM_KEY = 'your_key_here';Get one free at openweathermap.org. Active within ~10 minutes.