diff --git a/.gitignore b/.gitignore
index bc15c90..feb5611 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,7 @@ frontend/dist/
# mai worker state
.m/
+
+# Playwright MCP scratch (screenshots + console logs from local verification)
+/.playwright-mcp/
+/paliad-*.png
diff --git a/frontend/build.ts b/frontend/build.ts
index 8844d50..87041f4 100644
--- a/frontend/build.ts
+++ b/frontend/build.ts
@@ -41,6 +41,10 @@ async function build() {
// Bundle client-side JS
const result = await Bun.build({
entrypoints: [
+ // app.ts is loaded on every page (SW registration + bottom-nav init +
+ // install prompt). Keep it ahead of per-page bundles so name collisions
+ // surface fast.
+ join(import.meta.dir, "src/client/app.ts"),
join(import.meta.dir, "src/client/index.ts"),
join(import.meta.dir, "src/client/login.ts"),
join(import.meta.dir, "src/client/kostenrechner.ts"),
@@ -91,6 +95,15 @@ async function build() {
join(DIST, "assets/global.css"),
);
+ // Copy public/ → dist/ (manifest.json, sw.js, icons/) — served at the
+ // application root so the service worker can claim scope=/ and so the
+ // manifest is reachable at /manifest.json without a sub-path rewrite.
+ await cp(
+ join(import.meta.dir, "public"),
+ DIST,
+ { recursive: true },
+ );
+
// Render HTML pages
await Bun.write(join(DIST, "index.html"), renderIndex());
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
diff --git a/frontend/icons-src/icon-maskable.svg b/frontend/icons-src/icon-maskable.svg
new file mode 100644
index 0000000..811fb09
--- /dev/null
+++ b/frontend/icons-src/icon-maskable.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/frontend/icons-src/icon.svg b/frontend/icons-src/icon.svg
new file mode 100644
index 0000000..93788d4
--- /dev/null
+++ b/frontend/icons-src/icon.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..6667e9c
Binary files /dev/null and b/frontend/public/icons/apple-touch-icon.png differ
diff --git a/frontend/public/icons/favicon-32.png b/frontend/public/icons/favicon-32.png
new file mode 100644
index 0000000..3adb6d0
Binary files /dev/null and b/frontend/public/icons/favicon-32.png differ
diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png
new file mode 100644
index 0000000..024abf0
Binary files /dev/null and b/frontend/public/icons/icon-192.png differ
diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png
new file mode 100644
index 0000000..69bc03e
Binary files /dev/null and b/frontend/public/icons/icon-512.png differ
diff --git a/frontend/public/icons/icon-maskable-192.png b/frontend/public/icons/icon-maskable-192.png
new file mode 100644
index 0000000..569cef3
Binary files /dev/null and b/frontend/public/icons/icon-maskable-192.png differ
diff --git a/frontend/public/icons/icon-maskable-512.png b/frontend/public/icons/icon-maskable-512.png
new file mode 100644
index 0000000..12da5ba
Binary files /dev/null and b/frontend/public/icons/icon-maskable-512.png differ
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
new file mode 100644
index 0000000..9f181ca
--- /dev/null
+++ b/frontend/public/manifest.json
@@ -0,0 +1,41 @@
+{
+ "name": "Paliad",
+ "short_name": "Paliad",
+ "description": "Patentwissen und Aktenverwaltung für das HLC-Patent-Team.",
+ "start_url": "/dashboard",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "theme_color": "#65a30d",
+ "background_color": "#ffffff",
+ "lang": "de",
+ "dir": "ltr",
+ "id": "paliad",
+ "categories": ["productivity", "business"],
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-maskable-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/icons/icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 0000000..7735cbb
--- /dev/null
+++ b/frontend/public/sw.js
@@ -0,0 +1,70 @@
+// Paliad service worker — minimal cache-first for /assets/* and /icons/*,
+// network-first for /api/*, network passthrough for everything else.
+// Bumping CACHE_VERSION purges the previous cache on activation.
+const CACHE_VERSION = "paliad-v1";
+const STATIC_CACHE = `${CACHE_VERSION}-static`;
+
+self.addEventListener("install", (event) => {
+ // Activate the new SW as soon as it is installed; matters when
+ // CACHE_VERSION changes so users don't keep stale assets.
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ (async () => {
+ const keys = await caches.keys();
+ await Promise.all(
+ keys
+ .filter((k) => k !== STATIC_CACHE && k.startsWith("paliad-"))
+ .map((k) => caches.delete(k)),
+ );
+ await self.clients.claim();
+ })(),
+ );
+});
+
+self.addEventListener("fetch", (event) => {
+ const req = event.request;
+ if (req.method !== "GET") return;
+
+ const url = new URL(req.url);
+ if (url.origin !== self.location.origin) return;
+
+ if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/icons/")) {
+ event.respondWith(cacheFirst(req));
+ return;
+ }
+
+ if (url.pathname.startsWith("/api/")) {
+ event.respondWith(networkFirst(req));
+ return;
+ }
+
+ // HTML navigations + everything else: pass through to the network.
+});
+
+async function cacheFirst(req) {
+ const cache = await caches.open(STATIC_CACHE);
+ const cached = await cache.match(req);
+ if (cached) return cached;
+ try {
+ const res = await fetch(req);
+ if (res && res.ok) cache.put(req, res.clone());
+ return res;
+ } catch (err) {
+ if (cached) return cached;
+ throw err;
+ }
+}
+
+async function networkFirst(req) {
+ try {
+ return await fetch(req);
+ } catch (err) {
+ const cache = await caches.open(STATIC_CACHE);
+ const cached = await cache.match(req);
+ if (cached) return cached;
+ throw err;
+ }
+}
diff --git a/frontend/src/agenda.tsx b/frontend/src/agenda.tsx
index b006f61..3ff170a 100644
--- a/frontend/src/agenda.tsx
+++ b/frontend/src/agenda.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
// The /*__PALIAD_AGENDA_DATA__*/ token is replaced at request time by the Go
// handler (internal/handlers/agenda_shell.go) with a JSON payload assigned
@@ -17,6 +18,7 @@ export function renderAgenda(): string {
+
Agenda — Paliad
diff --git a/frontend/src/appointments-calendar.tsx b/frontend/src/appointments-calendar.tsx
index de2592c..102ec09 100644
--- a/frontend/src/appointments-calendar.tsx
+++ b/frontend/src/appointments-calendar.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderAppointmentsCalendar(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderAppointmentsCalendar(): string {
+
Terminkalender — Paliad
diff --git a/frontend/src/appointments-detail.tsx b/frontend/src/appointments-detail.tsx
index b43f0e4..e84dfb9 100644
--- a/frontend/src/appointments-detail.tsx
+++ b/frontend/src/appointments-detail.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderAppointmentsDetail(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderAppointmentsDetail(): string {
+
Termin — Paliad
diff --git a/frontend/src/appointments-new.tsx b/frontend/src/appointments-new.tsx
index 4a07f4f..f6bd16d 100644
--- a/frontend/src/appointments-new.tsx
+++ b/frontend/src/appointments-new.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderAppointmentsNew(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderAppointmentsNew(): string {
+
Neuer Termin — Paliad
diff --git a/frontend/src/appointments.tsx b/frontend/src/appointments.tsx
index f9cacbf..6ecfbdb 100644
--- a/frontend/src/appointments.tsx
+++ b/frontend/src/appointments.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderAppointments(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderAppointments(): string {
+
Termine — Paliad
diff --git a/frontend/src/changelog.tsx b/frontend/src/changelog.tsx
index c9fd871..fa0d2b4 100644
--- a/frontend/src/changelog.tsx
+++ b/frontend/src/changelog.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderChangelog(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderChangelog(): string {
+
Neuigkeiten — Paliad
diff --git a/frontend/src/checklists-detail.tsx b/frontend/src/checklists-detail.tsx
index 60ba011..6204894 100644
--- a/frontend/src/checklists-detail.tsx
+++ b/frontend/src/checklists-detail.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
// Template detail page. Shows template metadata + list of existing
// instances + CTA to create a new instance. Clicking an instance takes
@@ -16,6 +17,7 @@ export function renderChecklistsDetail(): string {
+
Checkliste — Paliad
diff --git a/frontend/src/checklists-instance.tsx b/frontend/src/checklists-instance.tsx
index d52c77f..712a362 100644
--- a/frontend/src/checklists-instance.tsx
+++ b/frontend/src/checklists-instance.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
// Interactive instance page. Loads template + instance JSON, renders
// checkboxes, PATCHes /api/checklist-instances/{id} on every toggle.
@@ -15,6 +16,7 @@ export function renderChecklistsInstance(): string {
+
Checklisten-Instanz — Paliad
diff --git a/frontend/src/checklists.tsx b/frontend/src/checklists.tsx
index a0813b4..bdd3020 100644
--- a/frontend/src/checklists.tsx
+++ b/frontend/src/checklists.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderChecklists(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderChecklists(): string {
+
Checklisten — Paliad
diff --git a/frontend/src/client/app.ts b/frontend/src/client/app.ts
new file mode 100644
index 0000000..f070290
--- /dev/null
+++ b/frontend/src/client/app.ts
@@ -0,0 +1,43 @@
+// app.ts — universal client bundle injected on every page. Three jobs:
+// 1. Wire the BottomNav (was previously written but never bundled — m
+// reproduced the broken [+] and Menü buttons in production).
+// 2. Register the service worker so the site qualifies for PWA install.
+// 3. Surface the install prompt (Chromium banner / iOS share-sheet hint).
+//
+// Per-page bundles still register their own behaviour; this script is
+// orthogonal and only touches DOM nodes it owns.
+
+import { initBottomNav } from "./bottom-nav";
+import { initInstallPrompt } from "./pwa-install";
+
+function registerServiceWorker(): void {
+ if (!("serviceWorker" in navigator)) return;
+ // Don't bother in non-secure contexts (localhost is a secure context, so
+ // local dev still registers).
+ if (!window.isSecureContext) return;
+
+ navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((err) => {
+ // Surface registration failures in the console — do not throw, since the
+ // site has to keep working without the SW.
+ console.warn("paliad: service worker registration failed", err);
+ });
+}
+
+function boot(): void {
+ initBottomNav();
+ initInstallPrompt();
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", boot);
+} else {
+ boot();
+}
+
+// Register the SW after the page has had a chance to paint so it never
+// competes with critical resources on first paint.
+if (document.readyState === "complete") {
+ registerServiceWorker();
+} else {
+ window.addEventListener("load", registerServiceWorker);
+}
diff --git a/frontend/src/client/pwa-install.ts b/frontend/src/client/pwa-install.ts
new file mode 100644
index 0000000..7cf5eec
--- /dev/null
+++ b/frontend/src/client/pwa-install.ts
@@ -0,0 +1,144 @@
+// PWA install prompt UI. Two flows:
+// - Chromium / Android: catch beforeinstallprompt, defer it, and surface a
+// small dismissible banner that triggers .prompt() on user action.
+// - iOS Safari (which never fires beforeinstallprompt): one-time hint
+// showing the native share-sheet → "Zum Home-Bildschirm" instructions.
+//
+// Dismissals are persisted in localStorage so users don't see the banner on
+// every visit. The same key is checked at the top of init() so opening a
+// cached page after a dismissal stays clean.
+
+const DISMISS_KEY = "paliad-install-dismissed";
+const SHOWN_KEY = "paliad-install-ios-shown";
+const SHOW_AFTER_MS = 1500;
+
+type BeforeInstallPromptEvent = Event & {
+ prompt: () => Promise;
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
+};
+
+let deferredPrompt: BeforeInstallPromptEvent | null = null;
+
+export function initInstallPrompt(): void {
+ if (localStorage.getItem(DISMISS_KEY) === "1") return;
+
+ // Skip when already running as an installed PWA.
+ if (window.matchMedia("(display-mode: standalone)").matches) return;
+ // Safari iOS exposes navigator.standalone for the same signal.
+ if ((navigator as { standalone?: boolean }).standalone === true) return;
+
+ window.addEventListener("beforeinstallprompt", (e) => {
+ e.preventDefault();
+ deferredPrompt = e as BeforeInstallPromptEvent;
+ // Defer banner reveal so it never competes with the page's first paint.
+ window.setTimeout(() => showAndroidBanner(), SHOW_AFTER_MS);
+ });
+
+ window.addEventListener("appinstalled", () => {
+ deferredPrompt = null;
+ persistDismissal();
+ removeBanner();
+ });
+
+ if (isIOSSafari() && localStorage.getItem(SHOWN_KEY) !== "1") {
+ window.setTimeout(() => showIOSHint(), SHOW_AFTER_MS);
+ }
+}
+
+function isIOSSafari(): boolean {
+ const ua = navigator.userAgent;
+ const isIOS = /iPad|iPhone|iPod/.test(ua) ||
+ (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
+ if (!isIOS) return false;
+ // Exclude Chrome/Firefox on iOS — they can't install PWAs and the share
+ // sheet hint would be misleading.
+ if (/CriOS|FxiOS|EdgiOS/.test(ua)) return false;
+ return true;
+}
+
+function getLang(): "de" | "en" {
+ const stored = localStorage.getItem("paliad-lang");
+ if (stored === "de" || stored === "en") return stored;
+ const html = document.documentElement.lang;
+ return html === "en" ? "en" : "de";
+}
+
+function showAndroidBanner(): void {
+ if (!deferredPrompt) return;
+ if (document.getElementById("pwa-install-banner")) return;
+
+ const lang = getLang();
+ const banner = document.createElement("div");
+ banner.id = "pwa-install-banner";
+ banner.className = "pwa-install-banner";
+ banner.setAttribute("role", "dialog");
+ banner.setAttribute("aria-live", "polite");
+ banner.innerHTML = `
+ p
+
+
${lang === "en" ? "Install Paliad" : "Paliad installieren"}
+
${lang === "en" ? "Add to your home screen for quick access." : "Zum Home-Bildschirm hinzufügen für schnellen Zugriff."}
+
+
+
+ `;
+ document.body.appendChild(banner);
+
+ banner.querySelector("#pwa-install-cta")?.addEventListener("click", async () => {
+ if (!deferredPrompt) return;
+ try {
+ await deferredPrompt.prompt();
+ const choice = await deferredPrompt.userChoice;
+ if (choice.outcome === "accepted") {
+ persistDismissal();
+ }
+ } finally {
+ deferredPrompt = null;
+ removeBanner();
+ }
+ });
+
+ banner.querySelector("#pwa-install-dismiss")?.addEventListener("click", () => {
+ persistDismissal();
+ removeBanner();
+ });
+}
+
+function showIOSHint(): void {
+ if (document.getElementById("pwa-install-banner")) return;
+
+ const lang = getLang();
+ const banner = document.createElement("div");
+ banner.id = "pwa-install-banner";
+ banner.className = "pwa-install-banner pwa-install-ios";
+ banner.setAttribute("role", "dialog");
+ banner.setAttribute("aria-live", "polite");
+ const shareIcon =
+ '';
+ banner.innerHTML = `
+ p
+
+
${lang === "en" ? "Install Paliad" : "Paliad installieren"}
+
${lang === "en"
+ ? "Tap " + shareIcon + " then “Add to Home Screen”."
+ : "Tippen Sie auf " + shareIcon + " und dann auf „Zum Home-Bildschirm“."}
+
+
+ `;
+ document.body.appendChild(banner);
+
+ banner.querySelector("#pwa-install-dismiss")?.addEventListener("click", () => {
+ localStorage.setItem(SHOWN_KEY, "1");
+ removeBanner();
+ });
+}
+
+function persistDismissal(): void {
+ localStorage.setItem(DISMISS_KEY, "1");
+ localStorage.setItem(SHOWN_KEY, "1");
+}
+
+function removeBanner(): void {
+ document.getElementById("pwa-install-banner")?.remove();
+}
diff --git a/frontend/src/components/PWAHead.tsx b/frontend/src/components/PWAHead.tsx
new file mode 100644
index 0000000..8315d70
--- /dev/null
+++ b/frontend/src/components/PWAHead.tsx
@@ -0,0 +1,23 @@
+import { h, Fragment } from "../jsx";
+
+// PWAHead emits the head fragment that turns Paliad into an installable PWA.
+// Add it to every page's alongside the existing viewport / theme-color
+// metas. The
+
+ );
+}
diff --git a/frontend/src/courts.tsx b/frontend/src/courts.tsx
index 8dd6e60..45eda3e 100644
--- a/frontend/src/courts.tsx
+++ b/frontend/src/courts.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderCourts(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderCourts(): string {
+
Gerichtsverzeichnis — Paliad
diff --git a/frontend/src/dashboard.tsx b/frontend/src/dashboard.tsx
index f3d71b2..70ec1e1 100644
--- a/frontend/src/dashboard.tsx
+++ b/frontend/src/dashboard.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
// by the Go handler (internal/handlers/dashboard_shell.go) with a JSON blob
@@ -19,6 +20,7 @@ export function renderDashboard(): string {
+
Dashboard — Paliad
diff --git a/frontend/src/deadlines-calendar.tsx b/frontend/src/deadlines-calendar.tsx
index 5a276c5..9019af4 100644
--- a/frontend/src/deadlines-calendar.tsx
+++ b/frontend/src/deadlines-calendar.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderDeadlinesCalendar(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderDeadlinesCalendar(): string {
+
Fristenkalender — Paliad
diff --git a/frontend/src/deadlines-detail.tsx b/frontend/src/deadlines-detail.tsx
index e72fcc5..72b8114 100644
--- a/frontend/src/deadlines-detail.tsx
+++ b/frontend/src/deadlines-detail.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderDeadlinesDetail(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderDeadlinesDetail(): string {
+
Frist — Paliad
diff --git a/frontend/src/deadlines-new.tsx b/frontend/src/deadlines-new.tsx
index a71d836..bfde9e8 100644
--- a/frontend/src/deadlines-new.tsx
+++ b/frontend/src/deadlines-new.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderDeadlinesNew(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderDeadlinesNew(): string {
+
Neue Frist — Paliad
diff --git a/frontend/src/deadlines.tsx b/frontend/src/deadlines.tsx
index 6c6fc8e..654678c 100644
--- a/frontend/src/deadlines.tsx
+++ b/frontend/src/deadlines.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderDeadlines(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderDeadlines(): string {
+
Fristen — Paliad
diff --git a/frontend/src/downloads.tsx b/frontend/src/downloads.tsx
index 6f4d6c5..3be81b5 100644
--- a/frontend/src/downloads.tsx
+++ b/frontend/src/downloads.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
const ICON_WORD = '';
@@ -34,6 +35,7 @@ export function renderDownloads(): string {
+
Downloads — Paliad
diff --git a/frontend/src/fristenrechner.tsx b/frontend/src/fristenrechner.tsx
index d3640f6..312bcda 100644
--- a/frontend/src/fristenrechner.tsx
+++ b/frontend/src/fristenrechner.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
interface ProceedingDef {
code: string;
@@ -46,6 +47,7 @@ export function renderFristenrechner(): string {
+
Fristenrechner — Paliad
diff --git a/frontend/src/gebuehrentabellen.tsx b/frontend/src/gebuehrentabellen.tsx
index ede65f6..2b0f858 100644
--- a/frontend/src/gebuehrentabellen.tsx
+++ b/frontend/src/gebuehrentabellen.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderGebuehrentabellen(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderGebuehrentabellen(): string {
+
Gebührentabellen — Paliad
diff --git a/frontend/src/glossary.tsx b/frontend/src/glossary.tsx
index 5b1bb66..6c814ba 100644
--- a/frontend/src/glossary.tsx
+++ b/frontend/src/glossary.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderGlossary(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderGlossary(): string {
+
Patentglossar — Paliad
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx
index 6d82d0c..cdc42da 100644
--- a/frontend/src/index.tsx
+++ b/frontend/src/index.tsx
@@ -1,6 +1,7 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
const ICON_BOOK = '';
const ICON_FILE = '';
@@ -22,6 +23,7 @@ export function renderIndex(): string {
+
Paliad — Patentwissen für Hogan Lovells
diff --git a/frontend/src/kostenrechner.tsx b/frontend/src/kostenrechner.tsx
index 40ff6d4..038e405 100644
--- a/frontend/src/kostenrechner.tsx
+++ b/frontend/src/kostenrechner.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
const ICON_CALC = '';
@@ -97,6 +98,7 @@ export function renderKostenrechner(): string {
+
Prozesskostenrechner — Paliad
diff --git a/frontend/src/links.tsx b/frontend/src/links.tsx
index fcaf51b..bde3cc2 100644
--- a/frontend/src/links.tsx
+++ b/frontend/src/links.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderLinks(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderLinks(): string {
+
Links — Paliad
diff --git a/frontend/src/login.tsx b/frontend/src/login.tsx
index 8d7ba45..5132222 100644
--- a/frontend/src/login.tsx
+++ b/frontend/src/login.tsx
@@ -1,6 +1,7 @@
import { h } from "./jsx";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderLogin(loginJs: string): string {
return "" + (
@@ -11,6 +12,7 @@ export function renderLogin(loginJs: string): string {
+
Anmelden — Paliad
diff --git a/frontend/src/notfound.tsx b/frontend/src/notfound.tsx
index dc9967a..7e8b807 100644
--- a/frontend/src/notfound.tsx
+++ b/frontend/src/notfound.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
// renderNotFound is the chromed 404 page served for any unknown
// authenticated path. Anonymous visitors are redirected to /login by the
@@ -16,6 +17,7 @@ export function renderNotFound(): string {
+
Seite nicht gefunden — Paliad
diff --git a/frontend/src/onboarding.tsx b/frontend/src/onboarding.tsx
index 762eac8..8040e2a 100644
--- a/frontend/src/onboarding.tsx
+++ b/frontend/src/onboarding.tsx
@@ -1,6 +1,7 @@
import { h } from "./jsx";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderOnboarding(): string {
return "" + (
@@ -11,6 +12,7 @@ export function renderOnboarding(): string {
+
Willkommen — Paliad
diff --git a/frontend/src/projects-detail.tsx b/frontend/src/projects-detail.tsx
index cd57d57..2cfd4a3 100644
--- a/frontend/src/projects-detail.tsx
+++ b/frontend/src/projects-detail.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
// Project detail shell (v2). DOM IDs use the English `project-*` /
// `parties-*` / `deadlines-*` / `appointments-*` / `notes-*` / `checklists-*`
@@ -16,6 +17,7 @@ export function renderProjectsDetail(): string {
+
Projekt — Paliad
diff --git a/frontend/src/projects-new.tsx b/frontend/src/projects-new.tsx
index 4a457b6..218eb02 100644
--- a/frontend/src/projects-new.tsx
+++ b/frontend/src/projects-new.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
// "Neues Projekt" form (v2). Rendered at /projekte/neu. Supports five types;
// fields show/hide based on the selected type via client TS.
@@ -14,6 +15,7 @@ export function renderProjectsNew(): string {
+
Neues Projekt — Paliad
diff --git a/frontend/src/projects.tsx b/frontend/src/projects.tsx
index 8a53298..26058ff 100644
--- a/frontend/src/projects.tsx
+++ b/frontend/src/projects.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
// Renders the /projekte list page. File + export name stays `Akten` for build
// pipeline compatibility; labels + data bindings are v2 (t-paliad-024).
@@ -14,6 +15,7 @@ export function renderProjects(): string {
+
Projekte — Paliad
diff --git a/frontend/src/settings.tsx b/frontend/src/settings.tsx
index c9d5acf..bdb2f3a 100644
--- a/frontend/src/settings.tsx
+++ b/frontend/src/settings.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
// Unified settings page. Three tabs today (Profil / Benachrichtigungen / CalDAV)
// — keep the structure additive so future sections (keys, API tokens, etc.)
@@ -16,6 +17,7 @@ export function renderSettings(): string {
+
Einstellungen — Paliad
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 6047b8a..95e2417 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -6583,3 +6583,125 @@ dialog.quick-add-sheet::backdrop {
padding-bottom: calc(var(--bottom-nav-height) + 1rem + env(safe-area-inset-bottom));
}
}
+
+/* --- PWA install banner ---------------------------------------------------
+ Slides in from the bottom on mobile, pinned bottom-right on desktop.
+ Both Chromium beforeinstallprompt and the iOS Safari hint share this UI;
+ pwa-install.ts toggles content + behaviour. */
+
+.pwa-install-banner {
+ position: fixed;
+ left: 1rem;
+ right: 1rem;
+ bottom: calc(env(safe-area-inset-bottom) + 1rem);
+ z-index: 9000;
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ padding: 0.85rem 1rem;
+ background: var(--color-surface, #ffffff);
+ color: var(--color-text, #111111);
+ border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
+ border-radius: 14px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15), 0 2px 6px rgba(0, 0, 0, 0.08);
+ animation: pwa-banner-in 220ms ease-out;
+}
+
+@media (max-width: 767px) {
+ /* Sit above the BottomNav so it never covers it. */
+ .pwa-install-banner {
+ bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom) + 0.75rem);
+ }
+}
+
+@media (min-width: 768px) {
+ .pwa-install-banner {
+ left: auto;
+ right: 1.5rem;
+ bottom: 1.5rem;
+ max-width: 22rem;
+ }
+}
+
+.pwa-install-icon {
+ flex: 0 0 auto;
+ width: 38px;
+ height: 38px;
+ border-radius: 10px;
+ background: #65a30d;
+ color: #ffffff;
+ display: grid;
+ place-items: center;
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+ font-weight: 700;
+ font-size: 1.4rem;
+ line-height: 1;
+}
+
+.pwa-install-text {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.pwa-install-title {
+ font-weight: 600;
+ font-size: 0.95rem;
+ line-height: 1.2;
+}
+
+.pwa-install-sub {
+ margin-top: 0.15rem;
+ font-size: 0.8rem;
+ color: var(--color-text-muted, rgba(0, 0, 0, 0.62));
+ line-height: 1.35;
+}
+
+.pwa-install-share {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ vertical-align: -4px;
+ margin: 0 0.15rem;
+ color: var(--color-accent, #65a30d);
+}
+
+.pwa-install-cta {
+ flex: 0 0 auto;
+ padding: 0.55rem 0.95rem;
+ border: none;
+ border-radius: 10px;
+ background: #65a30d;
+ color: #ffffff;
+ font-family: var(--font-sans);
+ font-weight: 600;
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+
+.pwa-install-cta:hover {
+ background: #4d7c0f;
+}
+
+.pwa-install-dismiss {
+ flex: 0 0 auto;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--color-text-muted, rgba(0, 0, 0, 0.5));
+ font-size: 1.4rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.pwa-install-dismiss:hover {
+ background: rgba(0, 0, 0, 0.06);
+ color: var(--color-text, #111111);
+}
+
+@keyframes pwa-banner-in {
+ from { transform: translateY(20px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
diff --git a/frontend/src/team.tsx b/frontend/src/team.tsx
index a377bf3..ff36d84 100644
--- a/frontend/src/team.tsx
+++ b/frontend/src/team.tsx
@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
+import { PWAHead } from "./components/PWAHead";
export function renderTeam(): string {
return "" + (
@@ -12,6 +13,7 @@ export function renderTeam(): string {
+
Team — Paliad
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index f0232ad..fea499c 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -74,6 +74,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// Static assets (public)
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
+ // PWA static surface (public): manifest, icons, service worker. The SW
+ // must be served from the application origin; its scope is /, so the file
+ // has to live at /sw.js (a SW served from /assets/sw.js could only claim
+ // /assets/* by default).
+ mux.HandleFunc("GET /manifest.json", servePWAManifest)
+ mux.Handle("GET /icons/", http.StripPrefix("/icons/", http.FileServer(http.Dir("dist/icons"))))
+ mux.HandleFunc("GET /sw.js", servePWAServiceWorker)
+
// Protected routes
protected := http.NewServeMux()
protected.HandleFunc("GET /tools/kostenrechner", handleKostenrechnerPage)
diff --git a/internal/handlers/pwa.go b/internal/handlers/pwa.go
new file mode 100644
index 0000000..cfe5f57
--- /dev/null
+++ b/internal/handlers/pwa.go
@@ -0,0 +1,24 @@
+package handlers
+
+import (
+ "net/http"
+)
+
+// servePWAManifest serves the web app manifest with the spec-mandated
+// content type. Browsers will accept application/json but the Lighthouse
+// PWA audit and some installability checks demand application/manifest+json.
+func servePWAManifest(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/manifest+json; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=300")
+ http.ServeFile(w, r, "dist/manifest.json")
+}
+
+// servePWAServiceWorker serves /sw.js with strict no-cache headers so that
+// service-worker updates take effect on the next page load. The SW itself
+// caches /assets/* — caching the SW file would invert that.
+func servePWAServiceWorker(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ w.Header().Set("Service-Worker-Allowed", "/")
+ http.ServeFile(w, r, "dist/sw.js")
+}