diff --git a/CHANGELOG.md b/CHANGELOG.md
index 945fc08..b41ee6d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,31 @@
All notable changes to OpenProof are documented here.
+## 0.9.3 — Installed PWA App Experience
+
+### PWA App Shell
+- New `/app` route — focused workbench for installed PWA users
+- App splash screen using existing icon assets (light/dark, respects reduced motion)
+- Compact app header with online/offline status, Base Sepolia badge, theme toggle
+- Mobile bottom tab navigation: Create, Verify, History, More
+- App sub-routes: `/app/history` (combined registered + verified), `/app/more` (About, Docs, GitHub)
+- `/app/verify` redirects to existing `/verify` page
+
+### Display-Mode Detection
+- New `usePwaMode()` hook — detects `display-mode: standalone`, `fullscreen`, `minimal-ui`, iOS standalone, and `?source=pwa`/`?mode=pwa` fallback
+- Returns `isPwa`, `displayMode`, `isMobile`, `isDesktop`, `prefersReducedMotion`
+- Safe client-only detection with media query listeners
+
+### Manifest
+- `start_url` changed from `/` to `/app?source=pwa` — installed PWA launches into the workbench
+- Website mode still serves the public marketing site from `/`
+
+### Architecture
+- Route group `src/app/app/` uses its own layout with `PwaShell` (no marketing footer)
+- Public routes (`/`, `/about`, `/privacy`, `/terms`, `/create`, `/verify`, `/proof/[hash]`, `/bundle/[hash]`) continue using the website `AppShell`
+- History system reused — no new backend, no accounts, no sync
+- Existing CSP, wallet compatibility, accessibility preserved
+
## 0.9.2 — GeneratedAssets & PWA Refresh
### Assets
diff --git a/README.md b/README.md
index 103534f..88aa635 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,7 @@ OpenProof is part of a broader direction: software that helps people prove thing
| v0.9.0 | 2026-06-25 | Native Platform Readiness — MSIX packaging, Android (Capacitor), PWA polish, domain migration |
| v0.9.1 | 2026-07-02 | Open Product Family branding — lockup alignment, Kovina Collection footer, dual-theme icon overhaul |
| v0.9.2 | 2026-07-04 | GeneratedAssets replacement, Kovina footer, PWA icon refresh, README overhaul |
+| v0.9.3 | 2026-07-04 | Installed PWA App Experience — `/app` workbench, splash screen, bottom nav, compact header, display-mode detection |
## Quick Links
diff --git a/docs/pwa-mode.md b/docs/pwa-mode.md
new file mode 100644
index 0000000..a48b7ff
--- /dev/null
+++ b/docs/pwa-mode.md
@@ -0,0 +1,53 @@
+# Website Mode vs Installed PWA Mode
+
+OpenProof supports two interaction models sharing the same product identity.
+
+## Website Mode (default)
+
+- Accessed via any browser at `https://proof.kovina.org`
+- Full marketing/editorial pages: Home, About, Privacy, Terms, Docs
+- SEO-optimized metadata and OpenGraph tags
+- Traditional website footer with navigation links
+- Create, Verify, and proof explorer pages with explanatory copy
+- PWA install prompt available but optional
+
+**Routes:**
+- `/` — Marketing homepage
+- `/create` — Create proof (with explanatory copy)
+- `/verify` — Verify proof (with receipt import)
+- `/about`, `/privacy`, `/terms`, `/docs` — Editorial pages
+- `/proof/[hash]` — Public proof page
+- `/bundle/[hash]` — Bundle proof page
+
+## Installed PWA Mode
+
+- Accessed after installing OpenProof as a PWA (Chrome, Safari, Edge)
+- Launches at `/app?source=pwa` (configured in manifest.json)
+- Focused workbench with minimal copy — action-first
+- No marketing footer or editorial framing
+- Compact app header with online/offline status
+- Mobile bottom tab navigation (Create, Verify, History, More)
+- Desktop: same workbench with keyboard-friendly layout
+- App splash screen on first load (light/dark theme-aware)
+
+**Routes:**
+- `/app` — App workbench (quick actions + recent proofs + testnet notice)
+- `/app/verify` — Redirects to `/verify`
+- `/app/history` — Combined registered + verified proof history
+- `/app/more` — About, Docs, GitHub links
+
+## Detection
+
+The app detects PWA mode via `usePwaMode()` which checks:
+- `display-mode: standalone` CSS media query
+- `display-mode: fullscreen` / `minimal-ui`
+- iOS `navigator.standalone`
+- URL parameter `?source=pwa` / `?mode=pwa` (fallback)
+
+## Key Constraints
+
+- No accounts, analytics, telemetry, backend, sync, or notifications
+- All proof history remains in browser localStorage only
+- Same wallet (RainbowKit/wagmi) and chain (Base Sepolia)
+- Same CSP and security headers
+- Static export / Capacitor compatible
diff --git a/package.json b/package.json
index 178c17d..6cb07e1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openproof",
- "version": "0.9.2",
+ "version": "0.9.3",
"private": true,
"license": "AGPL-3.0-only",
"scripts": {
diff --git a/public/manifest.json b/public/manifest.json
index c64487b..58b1004 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -2,7 +2,7 @@
"name": "OpenProof",
"short_name": "OpenProof",
"description": "Privacy-first proof-of-existence for files. Hash files locally, register only the SHA-256 fingerprint on Base Sepolia. No uploads, no accounts, no backend.",
- "start_url": "/",
+ "start_url": "/app?source=pwa",
"display": "standalone",
"display_override": [
"window-controls-overlay",
diff --git a/src/app/app/history/page.tsx b/src/app/app/history/page.tsx
new file mode 100644
index 0000000..cdd7d02
--- /dev/null
+++ b/src/app/app/history/page.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { ProofHistory } from "@/components/proof-history";
+
+export default function AppHistoryPage() {
+ return (
+
+ );
+}
diff --git a/src/app/app/layout.tsx b/src/app/app/layout.tsx
new file mode 100644
index 0000000..1077edd
--- /dev/null
+++ b/src/app/app/layout.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { PwaShell } from "@/components/pwa-shell";
+
+export default function AppLayout({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/src/app/app/more/page.tsx b/src/app/app/more/page.tsx
new file mode 100644
index 0000000..724cfc6
--- /dev/null
+++ b/src/app/app/more/page.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import Link from "next/link";
+import { ExternalLink, ShieldCheck, BookOpen } from "lucide-react";
+
+export default function AppMorePage() {
+ return (
+
+
More
+
+
+
+
+
About OpenProof
+
+
+
+
+
Documentation
+
+
+
+ GitHub
+
+
+
+
+ {/* Testnet notice */}
+
+
+ OpenProof runs on Base Sepolia (testnet).
+ All proofs are for experimental and verification purposes only.
+
+
+
+
+ OpenProof v0.9.3 · AGPL-3.0
+
+
+
+
+ );
+}
diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx
new file mode 100644
index 0000000..3f9eaa6
--- /dev/null
+++ b/src/app/app/page.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import Link from "next/link";
+import { FileUp, Search, ShieldCheck, FileBox } from "lucide-react";
+import { AppSplash } from "@/components/app-splash";
+import { ProofHistory } from "@/components/proof-history";
+
+export default function AppPage() {
+ const [showSplash, setShowSplash] = useState(true);
+
+ const handleSplashDone = useCallback(() => {
+ setShowSplash(false);
+ }, []);
+
+ if (showSplash) {
+ return ;
+ }
+
+ return (
+
+ {/* Quick actions */}
+
+
+
+
+
+
+
Create proof
+
+ Hash a file and register on Base Sepolia
+
+
+
+
+
+
+
+
+
+
Verify proof
+
+ Check a fingerprint against the registry
+
+
+
+
+
+ {/* History */}
+
+
+ {/* Bundle proofs CTA */}
+
+
+
+
+
+
Bundle proofs
+
+ Register multiple files as a single combined proof
+
+
+
+
+ {/* Testnet notice */}
+
+
+
+
+
+ Base Sepolia testnet
+
+
+ OpenProof runs on Base Sepolia — a test network. Proofs here are
+ for experimentation and verification. No real value is involved.
+
+
+
+
+
+ {/* Footer spacer for bottom nav on mobile */}
+
+
+ );
+}
diff --git a/src/app/app/verify/page.tsx b/src/app/app/verify/page.tsx
new file mode 100644
index 0000000..70b9038
--- /dev/null
+++ b/src/app/app/verify/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function AppVerifyRedirect() {
+ redirect("/verify");
+}
diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx
index e4c2e63..67255e5 100644
--- a/src/app/create/page.tsx
+++ b/src/app/create/page.tsx
@@ -261,7 +261,7 @@ export default function CreateProofPage() {
))}
- — OpenProof v0.9.2 —
+ — OpenProof v0.9.3 —
) : null}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e1a5dd2..a8c8805 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist_Mono } from "next/font/google";
import "@fontsource-variable/stack-sans-notch";
import { AppShell } from "@/components/app-shell";
+import { ConditionalShell } from "@/components/conditional-shell";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { ErrorBoundary } from "@/components/error-boundary";
import { OfflineNotice } from "@/components/offline-notice";
@@ -112,9 +113,9 @@ export default function RootLayout({
}}
/>
-
+
{children}
-
+
diff --git a/src/components/app-splash.tsx b/src/components/app-splash.tsx
new file mode 100644
index 0000000..db33a90
--- /dev/null
+++ b/src/components/app-splash.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { usePwaMode } from "@/lib/use-pwa-mode";
+
+const SPLASH_DURATION = 1200; // ms
+
+export function AppSplash({ onDone }: { onDone: () => void }) {
+ const [visible, setVisible] = useState(true);
+ const { prefersReducedMotion } = usePwaMode();
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setVisible(false);
+ // Small delay after fade for React to settle
+ setTimeout(onDone, 300);
+ }, SPLASH_DURATION);
+
+ return () => clearTimeout(timer);
+ }, [onDone]);
+
+ if (!visible) return null;
+
+ return (
+
+
+
+
+

+

+
+
+
+ OpenProof
+
+
+ Proof without surrender.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/conditional-shell.tsx b/src/components/conditional-shell.tsx
new file mode 100644
index 0000000..b10a964
--- /dev/null
+++ b/src/components/conditional-shell.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { AppShell } from "@/components/app-shell";
+
+export function ConditionalShell({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const isPwaRoute = pathname.startsWith("/app");
+
+ // PWA routes have their own shell in src/app/app/layout.tsx
+ if (isPwaRoute) {
+ return <>{children}>;
+ }
+
+ return {children};
+}
diff --git a/src/components/pwa-bottom-nav.tsx b/src/components/pwa-bottom-nav.tsx
new file mode 100644
index 0000000..29960e4
--- /dev/null
+++ b/src/components/pwa-bottom-nav.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { FileUp, Search, Clock, Menu } from "lucide-react";
+
+const tabs = [
+ { href: "/app", label: "Create", icon: FileUp },
+ { href: "/app/verify", label: "Verify", icon: Search },
+ { href: "/app/history", label: "History", icon: Clock },
+ { href: "/app/more", label: "More", icon: Menu },
+] as const;
+
+export function PwaBottomNav() {
+ const pathname = usePathname();
+
+ return (
+
+ );
+}
diff --git a/src/components/pwa-shell.tsx b/src/components/pwa-shell.tsx
new file mode 100644
index 0000000..7f1068a
--- /dev/null
+++ b/src/components/pwa-shell.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import Link from "next/link";
+import { usePwaMode } from "@/lib/use-pwa-mode";
+import { ThemeToggle } from "@/components/providers/theme-provider";
+import { OnlineStatusBadge } from "@/components/pwa-status-badge";
+import { PwaBottomNav } from "@/components/pwa-bottom-nav";
+
+export function PwaShell({ children }: { children: React.ReactNode }) {
+ const { isMobile } = usePwaMode();
+
+ return (
+
+ {/* Compact app header */}
+
+
+
+
+

+

+
+
+ OpenProof
+
+
+
+
+
+
+ Base Sepolia
+
+
+
+
+
+
+ {/* Main content */}
+
+ {children}
+
+
+ {/* Mobile bottom nav */}
+
+
+ );
+}
diff --git a/src/components/pwa-status-badge.tsx b/src/components/pwa-status-badge.tsx
new file mode 100644
index 0000000..9dc3d4a
--- /dev/null
+++ b/src/components/pwa-status-badge.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { useOnlineStatus } from "@/lib/offline";
+
+export function OnlineStatusBadge() {
+ const isOnline = useOnlineStatus();
+
+ return (
+
+
+ {isOnline ? "Online" : "Offline"}
+
+ );
+}
diff --git a/src/lib/use-pwa-mode.ts b/src/lib/use-pwa-mode.ts
new file mode 100644
index 0000000..fce3abe
--- /dev/null
+++ b/src/lib/use-pwa-mode.ts
@@ -0,0 +1,84 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export type DisplayMode = "standalone" | "fullscreen" | "minimal-ui" | "browser";
+
+export type PwaMode = {
+ isPwa: boolean;
+ displayMode: DisplayMode;
+ isMobile: boolean;
+ isDesktop: boolean;
+ prefersReducedMotion: boolean;
+};
+
+function getDisplayMode(): DisplayMode {
+ if (typeof window === "undefined") return "browser";
+
+ // iOS standalone mode
+ if ((navigator as Navigator & { standalone?: boolean }).standalone) {
+ return "standalone";
+ }
+
+ // Standard display-mode media query
+ const mq = window.matchMedia("(display-mode: standalone)");
+ if (mq.matches) return "standalone";
+ const fq = window.matchMedia("(display-mode: fullscreen)");
+ if (fq.matches) return "fullscreen";
+ const mqMin = window.matchMedia("(display-mode: minimal-ui)");
+ if (mqMin.matches) return "minimal-ui";
+
+ return "browser";
+}
+
+function getIsMobile(): boolean {
+ if (typeof window === "undefined") return false;
+ return window.matchMedia("(max-width: 767px)").matches;
+}
+
+export function usePwaMode(): PwaMode {
+ const [mode, setMode] = useState(() => ({
+ isPwa: false,
+ displayMode: "browser",
+ isMobile: false,
+ isDesktop: true,
+ prefersReducedMotion: false,
+ }));
+
+ useEffect(() => {
+ function update() {
+ const displayMode = getDisplayMode();
+ const isMobile = getIsMobile();
+ const prefersReducedMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ ).matches;
+ setMode({
+ isPwa: displayMode !== "browser",
+ displayMode,
+ isMobile,
+ isDesktop: !isMobile,
+ prefersReducedMotion,
+ });
+ }
+
+ update();
+
+ // Listen for display-mode changes (e.g. browser-installed PWA)
+ const mql = window.matchMedia("(display-mode: standalone)");
+ mql.addEventListener("change", update);
+ const mqlFull = window.matchMedia("(display-mode: fullscreen)");
+ mqlFull.addEventListener("change", update);
+
+ // Listen for resize (mobile/desktop switch)
+ const mqlMobile = window.matchMedia("(max-width: 767px)");
+ mqlMobile.addEventListener("change", update);
+
+ return () => {
+ mql.removeEventListener("change", update);
+ mqlFull.removeEventListener("change", update);
+ mqlMobile.removeEventListener("change", update);
+ };
+ }, []);
+
+ return mode;
+}