diff --git a/app/public/icons/icon.svg b/app/public/icons/icon.svg
new file mode 100644
index 0000000..7481f76
--- /dev/null
+++ b/app/public/icons/icon.svg
@@ -0,0 +1,12 @@
+
diff --git a/app/public/index.html b/app/public/index.html
index 0040086..6e06197 100644
--- a/app/public/index.html
+++ b/app/public/index.html
@@ -2,8 +2,16 @@
-
+
Weather
+
+
+
+
+
+
+
+
@@ -134,5 +142,10 @@
+
diff --git a/app/public/manifest.json b/app/public/manifest.json
new file mode 100644
index 0000000..d402ecc
--- /dev/null
+++ b/app/public/manifest.json
@@ -0,0 +1,20 @@
+{
+ "name": "Weather",
+ "short_name": "Weather",
+ "description": "Self-hosted weather app with animated radar. No API keys required.",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#191510",
+ "theme_color": "#1a1a1a",
+ "orientation": "portrait-primary",
+ "categories": ["weather", "utilities"],
+ "icons": [
+ {
+ "src": "/icons/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/app/public/style.css b/app/public/style.css
index b905f45..295f0bb 100644
--- a/app/public/style.css
+++ b/app/public/style.css
@@ -454,6 +454,38 @@ a:hover { text-decoration: underline; }
padding-top: 0.75rem;
}
+/* ── PWA: safe area insets (iPhone notch / home bar) ── */
+@supports (padding: env(safe-area-inset-bottom)) {
+ .app {
+ padding-left: max(1.5rem, calc(1rem + env(safe-area-inset-left)));
+ padding-right: max(1.5rem, calc(1rem + env(safe-area-inset-right)));
+ padding-bottom: max(4rem, calc(2rem + env(safe-area-inset-bottom)));
+ }
+ .radar-map-full {
+ height: calc(100vh - 280px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
+ }
+}
+
+/* ── PWA: status bar area when running standalone ── */
+@media (display-mode: standalone) {
+ .masthead { padding-top: env(safe-area-inset-top); }
+}
+
+/* ── Touch-friendly tap targets (min 44 × 44 px) ── */
+.tab {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.search-row input,
+.search-row button { min-height: 44px; }
+.radar-btn { width: 44px; height: 44px; font-size: 0.8rem; }
+.tl-tick { min-height: 36px; }
+
+/* iOS momentum scrolling */
+.hourly-scroll { -webkit-overflow-scrolling: touch; }
+
/* ── Leaflet overrides ── */
.leaflet-container { background: #1a1f2e; }
.leaflet-control-zoom a {
@@ -464,7 +496,7 @@ a:hover { text-decoration: underline; }
/* ── Responsive ── */
@media (max-width: 640px) {
- .app { padding: 1.25rem 1rem 3rem; }
+ .app { padding: 1.25rem 1rem max(3rem, calc(1.5rem + env(safe-area-inset-bottom, 0px))); }
.masthead { grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; }
.masthead h1 { font-size: 1.6rem; }
.tagline { display: none; }
diff --git a/app/public/sw.js b/app/public/sw.js
new file mode 100644
index 0000000..8019805
--- /dev/null
+++ b/app/public/sw.js
@@ -0,0 +1,66 @@
+'use strict';
+
+const CACHE = 'weather-v1';
+
+// App shell — cached on install for offline use
+const PRECACHE = [
+ '/',
+ '/app.js',
+ '/style.css',
+ '/manifest.json',
+ '/icons/icon.svg'
+];
+
+self.addEventListener('install', event => {
+ event.waitUntil(
+ caches.open(CACHE)
+ .then(cache => cache.addAll(PRECACHE))
+ .then(() => self.skipWaiting())
+ );
+});
+
+self.addEventListener('activate', event => {
+ event.waitUntil(
+ caches.keys()
+ .then(keys => Promise.all(
+ keys.filter(k => k !== CACHE).map(k => caches.delete(k))
+ ))
+ .then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener('fetch', event => {
+ const { request } = event;
+ const url = new URL(request.url);
+
+ // Skip non-GET, API calls, and map/radar tile requests
+ if (
+ request.method !== 'GET' ||
+ url.pathname.startsWith('/api/') ||
+ url.hostname.includes('rainviewer') ||
+ url.hostname.includes('openstreetmap') ||
+ url.hostname.includes('cartodb') ||
+ url.hostname.includes('basemaps')
+ ) {
+ return;
+ }
+
+ event.respondWith(
+ caches.match(request).then(cached => {
+ if (cached) return cached;
+ return fetch(request).then(response => {
+ // Only cache successful same-origin and trusted CDN responses
+ if (response.ok && (
+ url.origin === self.location.origin ||
+ url.hostname.includes('googleapis') ||
+ url.hostname.includes('gstatic') ||
+ url.hostname.includes('unpkg.com')
+ )) {
+ const clone = response.clone();
+ caches.open(CACHE).then(cache => cache.put(request, clone));
+ }
+ return response;
+ });
+ })
+ );
+});