[object Object]

← back to Homesonspec

web: make HomesOnSpec an installable PWA (manifest + service worker + icons + offline fallback)

85d96884b45632d02374a57ee4596dae81b455df · 2026-08-06 16:33:18 -0700 · Steve

Files touched

Diff

commit 85d96884b45632d02374a57ee4596dae81b455df
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 16:33:18 2026 -0700

    web: make HomesOnSpec an installable PWA (manifest + service worker + icons + offline fallback)
---
 apps/web/public/apple-touch-icon.png    | Bin 0 -> 1939 bytes
 apps/web/public/favicon-32.png          | Bin 0 -> 368 bytes
 apps/web/public/icon-192.png            | Bin 0 -> 2374 bytes
 apps/web/public/icon-512.png            | Bin 0 -> 9784 bytes
 apps/web/public/offline.html            |  50 ++++++++++++++++
 apps/web/public/sw.js                   | 100 ++++++++++++++++++++++++++++++++
 apps/web/scripts/gen-pwa-icons.mjs      |  36 ++++++++++++
 apps/web/scripts/verify-pwa.mjs         |  65 +++++++++++++++++++++
 apps/web/src/app/layout.tsx             |  26 ++++++++-
 apps/web/src/app/manifest.ts            |  31 ++++++++++
 apps/web/src/components/PWARegister.tsx |  22 +++++++
 11 files changed, 329 insertions(+), 1 deletion(-)

diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png
new file mode 100644
index 00000000..39271b95
Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ
diff --git a/apps/web/public/favicon-32.png b/apps/web/public/favicon-32.png
new file mode 100644
index 00000000..15c7acaa
Binary files /dev/null and b/apps/web/public/favicon-32.png differ
diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png
new file mode 100644
index 00000000..6c40813a
Binary files /dev/null and b/apps/web/public/icon-192.png differ
diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png
new file mode 100644
index 00000000..58149c78
Binary files /dev/null and b/apps/web/public/icon-512.png differ
diff --git a/apps/web/public/offline.html b/apps/web/public/offline.html
new file mode 100644
index 00000000..7bf84aef
--- /dev/null
+++ b/apps/web/public/offline.html
@@ -0,0 +1,50 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+  <title>Offline — Homes on Spec</title>
+  <link rel="icon" href="/favicon-32.png" sizes="32x32" />
+  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+  <style>
+    :root { color-scheme: light; }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0; min-height: 100vh; display: grid; place-items: center;
+      background: #f0f9f8; color: #123231;
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
+      padding: 24px; text-align: center;
+    }
+    .card { max-width: 340px; }
+    .mark {
+      width: 72px; height: 72px; margin: 0 auto 20px; border-radius: 18px;
+      background: #1b4645; display: grid; place-items: center;
+    }
+    .mark svg { width: 44px; height: 44px; }
+    h1 { font-size: 1.35rem; margin: 0 0 8px; letter-spacing: -0.02em; }
+    p { margin: 0 0 22px; color: #226b69; line-height: 1.5; font-size: 0.95rem; }
+    button {
+      appearance: none; border: 0; cursor: pointer;
+      background: #ed8f16; color: #081e1e; font-weight: 600; font-size: 0.95rem;
+      padding: 12px 22px; border-radius: 999px;
+    }
+    button:active { transform: translateY(1px); }
+    .dot { color: #ed8f16; }
+  </style>
+</head>
+<body>
+  <div class="card">
+    <div class="mark" aria-hidden="true">
+      <svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
+        <path d="M256 132 L372 250 H140 Z" fill="#fff"/>
+        <rect x="168" y="238" width="176" height="142" rx="10" fill="#fff"/>
+        <rect x="238" y="300" width="36" height="80" rx="18" fill="#ed8f16"/>
+        <rect x="238" y="340" width="36" height="40" fill="#ed8f16"/>
+      </svg>
+    </div>
+    <h1>You&rsquo;re offline<span class="dot">.</span></h1>
+    <p>Homes on Spec needs a connection to load new listings. Any homes you already viewed are still available &mdash; and we&rsquo;ll reconnect the moment you&rsquo;re back.</p>
+    <button onclick="location.reload()">Try again</button>
+  </div>
+</body>
+</html>
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
new file mode 100644
index 00000000..f86bee42
--- /dev/null
+++ b/apps/web/public/sw.js
@@ -0,0 +1,100 @@
+// Homes on Spec — service worker.
+// Strategy:
+//   • App shell + icons + offline page → precached on install (instant loads, offline-safe).
+//   • Navigations (HTML)   → network-first, fall back to cache, then the offline page.
+//   • Static assets (_next, images, fonts) → stale-while-revalidate (fast, self-healing).
+//   • Listing/search API   → network-first with a short cache, so a dropped signal on-site
+//                            still shows the last-loaded homes instead of a dead screen.
+// Bump CACHE_VERSION on any change here to force clients onto the new worker.
+const CACHE_VERSION = "hos-v1";
+const SHELL_CACHE = `${CACHE_VERSION}-shell`;
+const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
+const API_CACHE = `${CACHE_VERSION}-api`;
+
+const SHELL_ASSETS = [
+  "/",
+  "/offline.html",
+  "/manifest.webmanifest",
+  "/icon-192.png",
+  "/icon-512.png",
+  "/apple-touch-icon.png",
+];
+
+self.addEventListener("install", (event) => {
+  event.waitUntil(
+    caches.open(SHELL_CACHE).then((cache) =>
+      // addAll is atomic — don't let one 404 abort install; add best-effort.
+      Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(url)))
+    )
+  );
+  self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+  event.waitUntil(
+    caches
+      .keys()
+      .then((keys) =>
+        Promise.all(
+          keys
+            .filter((k) => !k.startsWith(CACHE_VERSION))
+            .map((k) => caches.delete(k))
+        )
+      )
+      .then(() => self.clients.claim())
+  );
+});
+
+// Only handle GET; never interfere with POST/PUT (contact forms, saves, analytics).
+self.addEventListener("fetch", (event) => {
+  const { request } = event;
+  if (request.method !== "GET") return;
+
+  const url = new URL(request.url);
+  if (url.origin !== self.location.origin) return; // let cross-origin (ads, tiles CDN) pass through
+
+  // HTML navigations → network-first, offline fallback.
+  if (request.mode === "navigate") {
+    event.respondWith(
+      fetch(request)
+        .then((res) => {
+          const copy = res.clone();
+          caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy));
+          return res;
+        })
+        .catch(async () => {
+          const cached = await caches.match(request);
+          return cached || caches.match("/offline.html");
+        })
+    );
+    return;
+  }
+
+  // Listing/search API → network-first with a short-lived cache.
+  if (url.pathname.startsWith("/api/")) {
+    event.respondWith(
+      fetch(request)
+        .then((res) => {
+          const copy = res.clone();
+          caches.open(API_CACHE).then((c) => c.put(request, copy));
+          return res;
+        })
+        .catch(() => caches.match(request))
+    );
+    return;
+  }
+
+  // Static assets → stale-while-revalidate.
+  event.respondWith(
+    caches.match(request).then((cached) => {
+      const network = fetch(request)
+        .then((res) => {
+          const copy = res.clone();
+          caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy));
+          return res;
+        })
+        .catch(() => cached);
+      return cached || network;
+    })
+  );
+});
diff --git a/apps/web/scripts/gen-pwa-icons.mjs b/apps/web/scripts/gen-pwa-icons.mjs
new file mode 100644
index 00000000..f5a36237
--- /dev/null
+++ b/apps/web/scripts/gen-pwa-icons.mjs
@@ -0,0 +1,36 @@
+// Rasterize public/icon.svg into the PWA/iOS icon sizes.
+// Uses `sharp` (already in the workspace pnpm store) — no new packages, no network.
+// pnpm doesn't hoist sharp into apps/web, so resolve it by absolute path from the repo root.
+// Run: node scripts/gen-pwa-icons.mjs
+import { readFile, writeFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { createRequire } from "node:module";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const publicDir = join(here, "..", "public");
+const repoRoot = join(here, "..", "..", "..");
+
+// Find sharp in the pnpm store and require it (CJS) by absolute directory path.
+const require = createRequire(join(repoRoot, "package.json"));
+const sharpDir = join(repoRoot, "node_modules/.pnpm/sharp@0.34.5/node_modules/sharp");
+const sharp = require(sharpDir);
+
+const svg = await readFile(join(publicDir, "icon.svg"));
+
+// name, size, background (null = transparent). iOS touch icons must be opaque.
+const targets = [
+  { file: "icon-192.png", size: 192, bg: null },
+  { file: "icon-512.png", size: 512, bg: null },
+  { file: "apple-touch-icon.png", size: 180, bg: "#1b4645" },
+  { file: "favicon-32.png", size: 32, bg: null },
+];
+
+for (const t of targets) {
+  let img = sharp(svg, { density: 384 }).resize(t.size, t.size, { fit: "cover" });
+  if (t.bg) img = img.flatten({ background: t.bg });
+  const out = await img.png().toBuffer();
+  await writeFile(join(publicDir, t.file), out);
+  console.log(`✓ ${t.file} (${t.size}×${t.size}, ${out.length} bytes)`);
+}
+console.log("done");
diff --git a/apps/web/scripts/verify-pwa.mjs b/apps/web/scripts/verify-pwa.mjs
new file mode 100644
index 00000000..0693310f
--- /dev/null
+++ b/apps/web/scripts/verify-pwa.mjs
@@ -0,0 +1,65 @@
+// End-to-end PWA proof in an isolated headless Chromium:
+//   1. Load the app, wait for the service worker to activate.
+//   2. Confirm the shell cache got populated.
+//   3. Go offline, reload a navigation, and confirm the offline fallback renders.
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(here, "..", "..", "..");
+const require = createRequire(join(repoRoot, "package.json"));
+// pnpm doesn't hoist playwright into apps/web — resolve it from the store by absolute path.
+const { chromium } = require(join(repoRoot, "node_modules/.pnpm/playwright@1.61.1/node_modules/playwright"));
+
+const BASE = process.env.BASE || "http://localhost:3199";
+const ok = (b) => (b ? "✓" : "✗");
+let pass = true;
+
+const browser = await chromium.launch();
+const ctx = await browser.newContext();
+const page = await ctx.newPage();
+
+await page.goto(BASE + "/", { waitUntil: "load" });
+
+// 1. Wait for an activated service worker.
+const swActive = await page.evaluate(async () => {
+  if (!("serviceWorker" in navigator)) return false;
+  const reg = await navigator.serviceWorker.ready.catch(() => null);
+  return !!(reg && (reg.active || reg.installing || reg.waiting));
+});
+console.log(`${ok(swActive)} service worker registered + activated`);
+pass &&= swActive;
+
+// Give SWR a beat to populate caches, then inspect them.
+await page.waitForTimeout(1500);
+const caches = await page.evaluate(async () => {
+  const keys = await self.caches.keys();
+  const shell = keys.find((k) => k.endsWith("-shell"));
+  if (!shell) return { keys, shellCount: 0 };
+  const c = await self.caches.open(shell);
+  const reqs = await c.keys();
+  return { keys, shellCount: reqs.length, shellUrls: reqs.map((r) => new URL(r.url).pathname) };
+});
+const cachesOk = caches.shellCount > 0;
+console.log(`${ok(cachesOk)} shell cache populated (${caches.shellCount} entries: ${(caches.shellUrls || []).join(", ")})`);
+console.log(`   cache buckets: ${caches.keys.join(", ")}`);
+pass &&= cachesOk;
+
+// 3. Offline fallback: block the network and force a fresh navigation.
+await ctx.setOffline(true);
+let offlineText = "";
+try {
+  await page.goto(BASE + "/search", { waitUntil: "load", timeout: 8000 });
+  offlineText = await page.evaluate(() => document.body.innerText);
+} catch (e) {
+  offlineText = "NAV-THREW: " + e.message;
+}
+const offlineOk = /offline/i.test(offlineText);
+console.log(`${ok(offlineOk)} offline navigation served fallback (matched "offline": ${offlineOk})`);
+pass &&= offlineOk;
+await ctx.setOffline(false);
+
+await browser.close();
+console.log(pass ? "\nPWA VERIFY: PASS" : "\nPWA VERIFY: FAIL");
+process.exit(pass ? 0 : 1);
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 044df920..1590c129 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,8 +1,9 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
 import Link from "next/link";
 import { Fraunces, Inter } from "next/font/google";
 import "./globals.css";
 import AdSlot, { AdSenseLoader } from "../components/AdSlot";
+import PWARegister from "../components/PWARegister";
 
 // Display serif + body sans, exposed as CSS variables the @theme layer reads.
 const display = Fraunces({
@@ -17,12 +18,35 @@ export const metadata: Metadata = {
   title: "HomesOnSpec — Every new home. Every builder. One search.",
   description:
     "Discover, compare, and contact builders for newly constructed homes. Verified from the source, with live verification labels — new builders and markets added continuously.",
+  applicationName: "Homes on Spec",
+  // Installable-app metadata. Next auto-links the manifest from app/manifest.ts.
+  appleWebApp: {
+    capable: true,
+    title: "HomesOnSpec",
+    statusBarStyle: "default",
+  },
+  icons: {
+    icon: [
+      { url: "/favicon-32.png", sizes: "32x32", type: "image/png" },
+      { url: "/icon.svg", type: "image/svg+xml" },
+    ],
+    apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
+  },
+};
+
+// Next 16 requires theme color / viewport in the `viewport` export (not `metadata`).
+export const viewport: Viewport = {
+  themeColor: "#1b4645",
+  width: "device-width",
+  initialScale: 1,
+  viewportFit: "cover",
 };
 
 export default function RootLayout({ children }: { children: React.ReactNode }) {
   return (
     <html lang="en" className={`${display.variable} ${sans.variable}`}>
       <body className="min-h-screen bg-neutral-50 font-sans text-neutral-900 antialiased">
+        <PWARegister />
         <AdSenseLoader />
         {/* Live inventory only — every listing is real builder data with a verification label. */}
         <div className="bg-brand-950 px-4 py-1.5 text-center text-xs font-medium text-brand-100">
diff --git a/apps/web/src/app/manifest.ts b/apps/web/src/app/manifest.ts
new file mode 100644
index 00000000..9975f3fd
--- /dev/null
+++ b/apps/web/src/app/manifest.ts
@@ -0,0 +1,31 @@
+import type { MetadataRoute } from "next";
+
+// Served automatically by Next at /manifest.webmanifest and linked from <head>.
+// This is what makes the site installable ("Add to Home Screen" → full-screen app).
+export default function manifest(): MetadataRoute.Manifest {
+  return {
+    name: "Homes on Spec",
+    short_name: "HomesOnSpec",
+    description:
+      "Every new home. Every builder. One search. Discover, compare, and contact builders for newly constructed homes.",
+    start_url: "/",
+    scope: "/",
+    display: "standalone",
+    orientation: "portrait",
+    background_color: "#f0f9f8",
+    theme_color: "#1b4645",
+    categories: ["shopping", "lifestyle", "business"],
+    icons: [
+      { src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
+      { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
+      // Full-bleed brand background → safe for maskable crops (Android adaptive icons).
+      { src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
+      { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
+    ],
+    shortcuts: [
+      { name: "Search homes", short_name: "Search", url: "/search" },
+      { name: "Map explorer", short_name: "Map", url: "/map" },
+      { name: "Saved homes", short_name: "Saved", url: "/saved" },
+    ],
+  };
+}
diff --git a/apps/web/src/components/PWARegister.tsx b/apps/web/src/components/PWARegister.tsx
new file mode 100644
index 00000000..97951b18
--- /dev/null
+++ b/apps/web/src/components/PWARegister.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { useEffect } from "react";
+
+// Registers the service worker after the page loads. Renders nothing.
+// Kept out of the critical path (waits for `load`) so it never delays first paint.
+export default function PWARegister() {
+  useEffect(() => {
+    if (typeof window === "undefined") return;
+    if (!("serviceWorker" in navigator)) return;
+    // Skip on localhost http if SW isn't available; SW requires HTTPS or localhost.
+    const register = () => {
+      navigator.serviceWorker
+        .register("/sw.js", { scope: "/" })
+        .catch((err) => console.warn("[pwa] service worker registration failed:", err));
+    };
+    if (document.readyState === "complete") register();
+    else window.addEventListener("load", register, { once: true });
+  }, []);
+
+  return null;
+}

← 23315d37 auto-data-snapshot: 2026-08-06T16:03:07 (1 data files) — app  ·  back to Homesonspec  ·  deploy-web: also ship apps/web/public (PWA icons/sw/offline) d4c4e1c2 →