From 8464ee07e664f95e807515813868133cb60ce70a Mon Sep 17 00:00:00 2001 From: Sparsh Sam <110058692+sparshsam@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:25:03 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v0.9.3=20=E2=80=94=20Installed=20PWA=20?= =?UTF-8?q?App=20Experience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add usePwaMode() hook for display-mode detection - Add /app workbench route with app splash screen - Add PWA app shell (compact header, no marketing footer) - Add mobile bottom tab navigation (Create, Verify, History, More) - Add app sub-routes: /app/history, /app/more - Add conditional shell routing (website vs PWA shell) - Update manifest to launch at /app?source=pwa - Add docs/pwa-mode.md - Bump version to v0.9.3 across package.json, README, CHANGELOG --- CHANGELOG.md | 25 ++++++++ README.md | 1 + docs/pwa-mode.md | 53 +++++++++++++++ package.json | 2 +- public/manifest.json | 2 +- src/app/app/history/page.tsx | 14 ++++ src/app/app/layout.tsx | 7 ++ src/app/app/more/page.tsx | 54 ++++++++++++++++ src/app/app/page.tsx | 96 ++++++++++++++++++++++++++++ src/app/app/verify/page.tsx | 5 ++ src/app/create/page.tsx | 2 +- src/app/layout.tsx | 5 +- src/components/app-splash.tsx | 77 ++++++++++++++++++++++ src/components/conditional-shell.tsx | 16 +++++ src/components/pwa-bottom-nav.tsx | 44 +++++++++++++ src/components/pwa-shell.tsx | 58 +++++++++++++++++ src/components/pwa-status-badge.tsx | 24 +++++++ src/lib/use-pwa-mode.ts | 84 ++++++++++++++++++++++++ 18 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 docs/pwa-mode.md create mode 100644 src/app/app/history/page.tsx create mode 100644 src/app/app/layout.tsx create mode 100644 src/app/app/more/page.tsx create mode 100644 src/app/app/page.tsx create mode 100644 src/app/app/verify/page.tsx create mode 100644 src/components/app-splash.tsx create mode 100644 src/components/conditional-shell.tsx create mode 100644 src/components/pwa-bottom-nav.tsx create mode 100644 src/components/pwa-shell.tsx create mode 100644 src/components/pwa-status-badge.tsx create mode 100644 src/lib/use-pwa-mode.ts 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; +}