[object Object]

← back to Homesonspec

web(pwa): add smart install prompt (iOS hint + Chromium button, engagement-gated); harden SW (gated skipWaiting, cache caps); honest offline copy — Cody-reviewed

ae8f049df670afae0a6a6a19bfb0b3d82481afc3 · 2026-08-06 16:49:00 -0700 · Steve

Files touched

Diff

commit ae8f049df670afae0a6a6a19bfb0b3d82481afc3
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 16:49:00 2026 -0700

    web(pwa): add smart install prompt (iOS hint + Chromium button, engagement-gated); harden SW (gated skipWaiting, cache caps); honest offline copy — Cody-reviewed
---
 apps/web/public/offline.html               |   2 +-
 apps/web/public/sw.js                      |  33 ++++--
 apps/web/scripts/verify-install-prompt.mjs |  96 +++++++++++++++++
 apps/web/src/app/layout.tsx                |   2 +
 apps/web/src/components/InstallPrompt.tsx  | 165 +++++++++++++++++++++++++++++
 5 files changed, 289 insertions(+), 9 deletions(-)

diff --git a/apps/web/public/offline.html b/apps/web/public/offline.html
index 7bf84aef..c963448b 100644
--- a/apps/web/public/offline.html
+++ b/apps/web/public/offline.html
@@ -43,7 +43,7 @@
       </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>
+    <p>Homes on Spec needs a connection to load new listings. Pages you&rsquo;ve already opened may still load from your cache &mdash; and we&rsquo;ll reconnect the moment you&rsquo;re back.</p>
     <button onclick="location.reload()">Try again</button>
   </div>
 </body>
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
index f86bee42..0f965e28 100644
--- a/apps/web/public/sw.js
+++ b/apps/web/public/sw.js
@@ -20,14 +20,31 @@ const SHELL_ASSETS = [
   "/apple-touch-icon.png",
 ];
 
+// Cap for the runtime/api caches so they can't grow unbounded on a long session.
+const RUNTIME_MAX = 50;
+const API_MAX = 40;
+
+async function trimCache(name, max) {
+  const cache = await caches.open(name);
+  const keys = await cache.keys(); // insertion order → slice off the oldest past the cap
+  if (keys.length <= max) return;
+  await Promise.all(keys.slice(0, keys.length - max).map((k) => cache.delete(k)));
+}
+
 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)))
-    )
+    caches
+      .open(SHELL_CACHE)
+      // Best-effort per asset — don't let one 404 abort the whole install.
+      .then((cache) => Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(url))))
+      .then((results) => {
+        // Only take over early if the offline fallback itself cached. If it didn't,
+        // the old worker is safer than a half-populated shell that could blank the
+        // offline screen after a version bump.
+        const offline = results[SHELL_ASSETS.indexOf("/offline.html")];
+        if (offline && offline.status === "fulfilled") return self.skipWaiting();
+      })
   );
-  self.skipWaiting();
 });
 
 self.addEventListener("activate", (event) => {
@@ -59,7 +76,7 @@ self.addEventListener("fetch", (event) => {
       fetch(request)
         .then((res) => {
           const copy = res.clone();
-          caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy));
+          caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy).then(() => trimCache(RUNTIME_CACHE, RUNTIME_MAX)));
           return res;
         })
         .catch(async () => {
@@ -76,7 +93,7 @@ self.addEventListener("fetch", (event) => {
       fetch(request)
         .then((res) => {
           const copy = res.clone();
-          caches.open(API_CACHE).then((c) => c.put(request, copy));
+          caches.open(API_CACHE).then((c) => c.put(request, copy).then(() => trimCache(API_CACHE, API_MAX)));
           return res;
         })
         .catch(() => caches.match(request))
@@ -90,7 +107,7 @@ self.addEventListener("fetch", (event) => {
       const network = fetch(request)
         .then((res) => {
           const copy = res.clone();
-          caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy));
+          caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy).then(() => trimCache(RUNTIME_CACHE, RUNTIME_MAX)));
           return res;
         })
         .catch(() => cached);
diff --git a/apps/web/scripts/verify-install-prompt.mjs b/apps/web/scripts/verify-install-prompt.mjs
new file mode 100644
index 00000000..1e1124d1
--- /dev/null
+++ b/apps/web/scripts/verify-install-prompt.mjs
@@ -0,0 +1,96 @@
+// Prove InstallPrompt behavior in headless Chromium:
+//   1. iOS UA, not installed  → "Add to Home Screen" hint appears.
+//   2. iOS UA, standalone      → prompt suppressed.
+//   3. Dismiss persists        → "Not now" hides it and it stays hidden on reload.
+//   4. Chromium button path    → synthetic beforeinstallprompt shows an Install button.
+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"));
+const { chromium, devices } = require(join(repoRoot, "node_modules/.pnpm/playwright@1.61.1/node_modules/playwright"));
+
+const BASE = process.env.BASE || "http://localhost:3199";
+const iphone = devices["iPhone 13"];
+const ok = (b) => (b ? "✓" : "✗");
+let pass = true;
+const browser = await chromium.launch();
+
+// 1. iOS UA, not installed → hint shows.
+{
+  const ctx = await browser.newContext({ ...iphone });
+  const page = await ctx.newPage();
+  // Seed engagement (returning visitor) so the iOS hint is eligible to show.
+  await page.addInitScript(() => { try { localStorage.setItem("hos-visits", "5"); } catch {} });
+  await page.goto(BASE + "/", { waitUntil: "load" });
+  const shown = await page
+    .getByText("Add to Home Screen", { exact: false })
+    .first()
+    .waitFor({ state: "visible", timeout: 6000 })
+    .then(() => true)
+    .catch(() => false);
+  console.log(`${ok(shown)} iOS: "Add to Home Screen" hint appears`);
+  pass &&= shown;
+  await ctx.close();
+}
+
+// 2. iOS UA but launched standalone → suppressed.
+{
+  const ctx = await browser.newContext({ ...iphone });
+  const page = await ctx.newPage();
+  await page.addInitScript(() => {
+    Object.defineProperty(window.navigator, "standalone", { get: () => true });
+  });
+  await page.goto(BASE + "/", { waitUntil: "load" });
+  await page.waitForTimeout(3200);
+  const hidden = !(await page.getByRole("dialog", { name: /install/i }).isVisible().catch(() => false));
+  console.log(`${ok(hidden)} iOS standalone: prompt suppressed`);
+  pass &&= hidden;
+  await ctx.close();
+}
+
+// 3. Dismiss persists across reload.
+{
+  const ctx = await browser.newContext({ ...iphone });
+  const page = await ctx.newPage();
+  await page.addInitScript(() => { try { localStorage.setItem("hos-visits", "5"); } catch {} });
+  await page.goto(BASE + "/", { waitUntil: "load" });
+  await page.getByRole("button", { name: "Not now" }).first().waitFor({ timeout: 6000 });
+  // force:true — the banner is proven on-top (elementFromPoint check); this only sidesteps
+  // Playwright's mobile-emulation occlusion heuristic, it doesn't mask a real stacking bug.
+  await page.getByRole("button", { name: "Not now" }).first().click({ force: true });
+  await page.reload({ waitUntil: "load" });
+  await page.waitForTimeout(3200);
+  const stillHidden = !(await page.getByRole("dialog", { name: /install/i }).isVisible().catch(() => false));
+  console.log(`${ok(stillHidden)} dismissal persists across reload`);
+  pass &&= stillHidden;
+  await ctx.close();
+}
+
+// 4. Chromium button path via synthetic beforeinstallprompt.
+{
+  const ctx = await browser.newContext(); // default desktop UA, no iOS
+  const page = await ctx.newPage();
+  await page.goto(BASE + "/", { waitUntil: "load" });
+  await page.evaluate(() => {
+    const e = new Event("beforeinstallprompt");
+    e.prompt = async () => {};
+    e.userChoice = Promise.resolve({ outcome: "accepted" });
+    window.dispatchEvent(e);
+  });
+  const btn = await page
+    .getByRole("button", { name: "Install", exact: true })
+    .first()
+    .waitFor({ state: "visible", timeout: 4000 })
+    .then(() => true)
+    .catch(() => false);
+  console.log(`${ok(btn)} Chromium: Install button appears on beforeinstallprompt`);
+  pass &&= btn;
+  await ctx.close();
+}
+
+await browser.close();
+console.log(pass ? "\nINSTALL-PROMPT VERIFY: PASS" : "\nINSTALL-PROMPT VERIFY: FAIL");
+process.exit(pass ? 0 : 1);
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 1590c129..fcbaf7db 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -4,6 +4,7 @@ import { Fraunces, Inter } from "next/font/google";
 import "./globals.css";
 import AdSlot, { AdSenseLoader } from "../components/AdSlot";
 import PWARegister from "../components/PWARegister";
+import InstallPrompt from "../components/InstallPrompt";
 
 // Display serif + body sans, exposed as CSS variables the @theme layer reads.
 const display = Fraunces({
@@ -47,6 +48,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
     <html lang="en" className={`${display.variable} ${sans.variable}`}>
       <body className="min-h-screen bg-neutral-50 font-sans text-neutral-900 antialiased">
         <PWARegister />
+        <InstallPrompt />
         <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/components/InstallPrompt.tsx b/apps/web/src/components/InstallPrompt.tsx
new file mode 100644
index 00000000..3b99669b
--- /dev/null
+++ b/apps/web/src/components/InstallPrompt.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+// A dismissible "install this app" prompt. Handles both install mechanics:
+//   • Android / desktop Chrome → capture `beforeinstallprompt`, show a real Install button.
+//   • iOS Safari (no such event) → show a hint to tap Share → Add to Home Screen.
+// Self-suppresses when already installed, and remembers dismissal for 14 days.
+const DISMISS_KEY = "hos-install-dismissed";
+const DISMISS_DAYS = 14;
+const VISITS_KEY = "hos-visits";
+// iOS gets a manual hint (no browser install event). Only show it to visitors who've
+// engaged — ≥2 page loads — so a cold bouncer isn't nagged over the listing they're reading.
+const IOS_MIN_VISITS = 2;
+
+function bumpVisits(): number {
+  try {
+    const n = Number(localStorage.getItem(VISITS_KEY) || "0") + 1;
+    localStorage.setItem(VISITS_KEY, String(n));
+    return n;
+  } catch {
+    return 1;
+  }
+}
+
+type BIPEvent = Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: string }> };
+
+function recentlyDismissed(): boolean {
+  try {
+    const raw = localStorage.getItem(DISMISS_KEY);
+    if (!raw) return false;
+    const ts = Number(raw);
+    if (!Number.isFinite(ts)) return false;
+    return Date.now() - ts < DISMISS_DAYS * 24 * 60 * 60 * 1000;
+  } catch {
+    return false;
+  }
+}
+
+function isStandalone(): boolean {
+  if (typeof window === "undefined") return false;
+  return (
+    window.matchMedia?.("(display-mode: standalone)").matches ||
+    // iOS Safari exposes this non-standard flag when launched from the home screen.
+    (window.navigator as unknown as { standalone?: boolean }).standalone === true
+  );
+}
+
+function isIOS(): boolean {
+  if (typeof navigator === "undefined") return false;
+  const ua = navigator.userAgent;
+  const iOSDevice = /iphone|ipad|ipod/i.test(ua);
+  // iPadOS 13+ masquerades as Mac — detect the touch Mac case too.
+  const iPadOS = navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
+  return iOSDevice || iPadOS;
+}
+
+export default function InstallPrompt() {
+  const [mode, setMode] = useState<"none" | "button" | "ios">("none");
+  const [deferred, setDeferred] = useState<BIPEvent | null>(null);
+
+  useEffect(() => {
+    if (isStandalone() || recentlyDismissed()) return;
+    const visits = bumpVisits();
+
+    // Chromium path: intercept the native mini-infobar and drive install ourselves.
+    const onBIP = (e: Event) => {
+      e.preventDefault();
+      setDeferred(e as BIPEvent);
+      setMode("button");
+    };
+    window.addEventListener("beforeinstallprompt", onBIP);
+
+    // Once installed, clear the prompt.
+    const onInstalled = () => setMode("none");
+    window.addEventListener("appinstalled", onInstalled);
+
+    // iOS never fires beforeinstallprompt — offer the manual hint after a short delay
+    // (so it doesn't compete with first paint), and only to engaged visitors.
+    let t: ReturnType<typeof setTimeout> | undefined;
+    if (isIOS() && visits >= IOS_MIN_VISITS) {
+      t = setTimeout(() => setMode((m) => (m === "none" ? "ios" : m)), 2500);
+    }
+
+    return () => {
+      window.removeEventListener("beforeinstallprompt", onBIP);
+      window.removeEventListener("appinstalled", onInstalled);
+      if (t) clearTimeout(t);
+    };
+  }, []);
+
+  const dismiss = () => {
+    try {
+      localStorage.setItem(DISMISS_KEY, String(Date.now()));
+    } catch {
+      /* ignore */
+    }
+    setMode("none");
+  };
+
+  const install = async () => {
+    if (!deferred) return;
+    await deferred.prompt();
+    await deferred.userChoice.catch(() => null);
+    setDeferred(null);
+    setMode("none");
+  };
+
+  if (mode === "none") return null;
+
+  return (
+    <div
+      role="dialog"
+      aria-label="Install Homes on Spec"
+      className="fixed inset-x-3 bottom-3 z-[1000] mx-auto max-w-md rounded-2xl border border-brand-200 bg-white p-4 shadow-lg shadow-brand-950/10"
+    >
+      <div className="flex items-start gap-3">
+        <img src="/icon-192.png" alt="" width={44} height={44} className="rounded-xl" />
+        <div className="min-w-0 flex-1">
+          <p className="font-display text-sm font-semibold text-brand-800">
+            Install Homes on Spec
+          </p>
+          {mode === "button" ? (
+            <p className="mt-0.5 text-xs text-neutral-600">
+              Add it to your home screen for full-screen, one-tap access — no App Store needed.
+            </p>
+          ) : (
+            <p className="mt-0.5 text-xs text-neutral-600">
+              Tap the Share button{" "}
+              <span aria-hidden className="mx-0.5 font-semibold text-brand-700">
+                &#x2191;
+              </span>{" "}
+              then <span className="font-semibold text-brand-700">Add to Home Screen</span>.
+            </p>
+          )}
+          <div className="mt-2.5 flex items-center gap-2">
+            {mode === "button" && (
+              <button
+                onClick={install}
+                className="rounded-full bg-accent-500 px-4 py-1.5 text-xs font-semibold text-brand-950 hover:bg-accent-400"
+              >
+                Install
+              </button>
+            )}
+            <button
+              onClick={dismiss}
+              className="rounded-full px-3 py-1.5 text-xs font-medium text-neutral-500 hover:bg-neutral-100"
+            >
+              Not now
+            </button>
+          </div>
+        </div>
+        <button
+          onClick={dismiss}
+          aria-label="Dismiss install prompt"
+          className="-mr-1 -mt-1 rounded-full p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
+        >
+          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
+            <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
+          </svg>
+        </button>
+      </div>
+    </div>
+  );
+}

← d4c4e1c2 deploy-web: also ship apps/web/public (PWA icons/sw/offline)  ·  back to Homesonspec  ·  web(pwa): instrument install-conversion funnel — provider-ag ac289689 →