diff --git a/scripts/generate-pwa-icons.mjs b/scripts/generate-pwa-icons.mjs
new file mode 100644
index 0000000..ac1e8dc
--- /dev/null
+++ b/scripts/generate-pwa-icons.mjs
@@ -0,0 +1,34 @@
+// Erzeugt PWA-Icons aus dem echten App-Logo (logo_v3.svg) auf hellem Grund.
+// Läuft manuell: `node scripts/generate-pwa-icons.mjs`
+import sharp from "sharp";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+const staticDir = join(root, "static");
+const BG = "#f0f5f1"; // wald-50 (wie Header-Hintergrund)
+
+const logoSvg = readFileSync(
+ join(root, "..", "cms_content", "windwiderstand", "assets", "logo_v3.svg"),
+ "utf8",
+);
+
+async function makeIcon(size, logoRatio, out) {
+ const logoPx = Math.round(size * logoRatio);
+ const logo = await sharp(Buffer.from(logoSvg), { density: 384 })
+ .resize(logoPx, logoPx, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
+ .png()
+ .toBuffer();
+ const pad = Math.round((size - logoPx) / 2);
+ await sharp({ create: { width: size, height: size, channels: 4, background: BG } })
+ .composite([{ input: logo, top: pad, left: pad }])
+ .png()
+ .toFile(join(staticDir, out));
+ console.log("wrote", out, `${size}px`);
+}
+
+await makeIcon(192, 0.78, "icon-192.png");
+await makeIcon(512, 0.78, "icon-512.png");
+await makeIcon(512, 0.6, "icon-maskable-512.png"); // mehr Rand für Maskable-Safe-Zone
+await makeIcon(180, 0.78, "apple-touch-icon.png");
diff --git a/src/app.html b/src/app.html
index ce39be1..c381126 100644
--- a/src/app.html
+++ b/src/app.html
@@ -3,6 +3,12 @@
+
+
+
+
+
+
diff --git a/src/service-worker.ts b/src/service-worker.ts
new file mode 100644
index 0000000..f09840d
--- /dev/null
+++ b/src/service-worker.ts
@@ -0,0 +1,69 @@
+///
+///
+///
+///
+import { build, files, version } from "$service-worker";
+
+const sw = self as unknown as ServiceWorkerGlobalScope;
+
+const CACHE = `ww-cache-${version}`;
+// Vom Build erzeugte Assets + statische Dateien (Icons, Manifest, Fonts…).
+const PRECACHE = [...build, ...files];
+
+sw.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches
+ .open(CACHE)
+ .then((cache) => cache.addAll(PRECACHE))
+ .then(() => sw.skipWaiting()),
+ );
+});
+
+sw.addEventListener("activate", (event) => {
+ event.waitUntil(
+ (async () => {
+ for (const key of await caches.keys()) {
+ if (key !== CACHE) await caches.delete(key);
+ }
+ await sw.clients.claim();
+ })(),
+ );
+});
+
+sw.addEventListener("fetch", (event) => {
+ const req = event.request;
+ if (req.method !== "GET") return;
+
+ const url = new URL(req.url);
+ // Nur same-origin — CMS-/Cross-Origin-Requests (dynamischer Content) nicht cachen.
+ if (url.origin !== sw.location.origin) return;
+
+ // Precache-Assets → cache-first (unveränderlich pro Version).
+ if (PRECACHE.includes(url.pathname)) {
+ event.respondWith(caches.match(req).then((r) => r ?? fetch(req)));
+ return;
+ }
+
+ // Seiten → network-first, bei Offline aus Cache; sonst Hinweis.
+ if (req.mode === "navigate") {
+ event.respondWith(
+ (async () => {
+ try {
+ const res = await fetch(req);
+ const cache = await caches.open(CACHE);
+ cache.put(req, res.clone());
+ return res;
+ } catch {
+ const cached = await caches.match(req);
+ return (
+ cached ??
+ new Response("Du bist offline. Diese Seite wurde noch nicht geladen.", {
+ status: 503,
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ })
+ );
+ }
+ })(),
+ );
+ }
+});
diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png
new file mode 100644
index 0000000..fbbccf7
Binary files /dev/null and b/static/apple-touch-icon.png differ
diff --git a/static/icon-192.png b/static/icon-192.png
new file mode 100644
index 0000000..a80d640
Binary files /dev/null and b/static/icon-192.png differ
diff --git a/static/icon-512.png b/static/icon-512.png
new file mode 100644
index 0000000..d237ba8
Binary files /dev/null and b/static/icon-512.png differ
diff --git a/static/icon-maskable-512.png b/static/icon-maskable-512.png
new file mode 100644
index 0000000..126625e
Binary files /dev/null and b/static/icon-maskable-512.png differ
diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest
new file mode 100644
index 0000000..6c43ee9
--- /dev/null
+++ b/static/manifest.webmanifest
@@ -0,0 +1,17 @@
+{
+ "name": "Windwiderstand Thüringen",
+ "short_name": "Windwiderstand",
+ "description": "Bürgerinitiativen gegen überdimensionierte Windkraftplanung in Südwestthüringen. Termine, Stellungnahmen, Hintergründe.",
+ "lang": "de",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait-primary",
+ "background_color": "#f0f5f1",
+ "theme_color": "#236335",
+ "icons": [
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
+ { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}