diff --git a/public/manifest-field.json b/public/manifest-field.json
new file mode 100644
index 0000000..3d6743c
--- /dev/null
+++ b/public/manifest-field.json
@@ -0,0 +1,17 @@
+{
+ "name": "Water Log — Tuxedo",
+ "short_name": "Water Log",
+ "description": "Field access for Tuxedo water + time tracking",
+ "start_url": "/water",
+ "scope": "/water",
+ "display": "standalone",
+ "background_color": "#f2f2f7",
+ "theme_color": "#14532d",
+ "orientation": "portrait-primary",
+ "icons": [
+ { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+ { "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ],
+ "categories": ["productivity", "utilities"]
+}
diff --git a/public/sw-field.js b/public/sw-field.js
new file mode 100644
index 0000000..7e99640
--- /dev/null
+++ b/public/sw-field.js
@@ -0,0 +1,129 @@
+// public/sw-field.js — Tuxedo field-worker service worker.
+//
+// Cycle 6 sub-PWA, scoped to /water. Tighter cache strategy than
+// the main shell SW because:
+// - The field app is a thin surface (PIN, headgate list, log form,
+// time tab). Few async chunks; mostly static + the latest data
+// snapshot.
+// - "Offline" here means "still see the cached PIN screen + headgate
+// list" — water entry without connectivity is a degraded but
+// non-blocking experience (the form queue handles drafts).
+//
+// Cache names are deliberately prefixed with `field-` so they never
+// collide with the main `rc-shell-v*` / `rc-data-v*` caches.
+
+const SHELL_CACHE = "field-shell-v1";
+const DATA_CACHE = "field-data-v1";
+const OFFLINE_URL = "/offline.html";
+
+const SHELL_ASSETS = [
+ "/water",
+ "/manifest-field.json",
+ "/favicon.svg",
+ "/og-default.svg",
+ "/offline.html",
+ "/icons/icon-192x192.png",
+ "/icons/icon-512x512.png",
+];
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ // Only manage field-* caches — never touch the main app's caches.
+ event.waitUntil(
+ Promise.all([
+ caches.keys().then((cacheNames) => {
+ const toDelete = [];
+ for (const name of cacheNames) {
+ if (
+ name !== SHELL_CACHE &&
+ name !== DATA_CACHE &&
+ name.startsWith("field-")
+ ) {
+ toDelete.push(caches.delete(name));
+ }
+ }
+ return Promise.all(toDelete);
+ }),
+ 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;
+
+ // Out-of-scope requests: skip (let the main app SW handle them
+ // if it's installed for that scope; fall through to network).
+ if (!url.pathname.startsWith("/water")) return;
+
+ // Navigations → network-first, fall back to cache, fall back to offline page.
+ if (req.mode === "navigate") {
+ event.respondWith(
+ fetch(req)
+ .then((res) => {
+ const clone = res.clone();
+ caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
+ return res;
+ })
+ .catch(() =>
+ caches.match(req).then(
+ (cached) => cached ?? caches.match(OFFLINE_URL),
+ ),
+ ),
+ );
+ return;
+ }
+
+ // Static assets → cache-first
+ if (
+ url.pathname.startsWith("/_next/static/") ||
+ url.pathname.startsWith("/icons/") ||
+ url.pathname.endsWith(".svg") ||
+ url.pathname.endsWith(".woff2")
+ ) {
+ event.respondWith(
+ caches.match(req).then((cached) => {
+ if (cached) return cached;
+ return fetch(req).then((res) => {
+ const clone = res.clone();
+ if (res.status === 200) {
+ caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
+ }
+ return res;
+ });
+ }),
+ );
+ return;
+ }
+
+ // /api/water* GETs → stale-while-revalidate (so the headgate list
+ // and worker availability can hydrate from cache while we re-fetch).
+ if (url.pathname.startsWith("/api/water")) {
+ event.respondWith(
+ caches.open(DATA_CACHE).then((cache) =>
+ cache.match(req).then((cached) => {
+ const network = fetch(req)
+ .then((res) => {
+ if (res.status === 200) cache.put(req, res.clone());
+ return res;
+ })
+ .catch(() => cached);
+ return cached ?? network;
+ }),
+ ),
+ );
+ return;
+ }
+
+ // Default: try network, fall back to cache.
+ event.respondWith(fetch(req).catch(() => caches.match(req)));
+});
diff --git a/src/app/water/layout.tsx b/src/app/water/layout.tsx
new file mode 100644
index 0000000..fa8d6b7
--- /dev/null
+++ b/src/app/water/layout.tsx
@@ -0,0 +1,58 @@
+/**
+ * Cycle 6 — Field-worker sub-PWA layout. Overrides the root layout's
+ * `manifest`, `themeColor`, and `applicationName` so the worker PWA
+ * installs as "Water Log — Tuxedo" rather than "Route Commerce".
+ *
+ * Per Next.js App Router semantics, child layouts can override any
+ * `Metadata` field set by an ancestor — `manifest` and `themeColor`
+ * both qualify. The root layout still provides fonts + providers.
+ */
+import type { Metadata, Viewport } from "next";
+import { FieldSWRegistration } from "@/components/pwa/FieldSWRegistration";
+import { FieldInstallPrompt } from "@/components/pwa/FieldInstallPrompt";
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ maximumScale: 1,
+ userScalable: false,
+ themeColor: "#14532d",
+ viewportFit: "cover",
+};
+
+export const metadata: Metadata = {
+ title: {
+ default: "Water Log · Tuxedo",
+ template: "%s · Water Log",
+ },
+ description:
+ "Field log of Tuxedo water readings + worker time tracking — PIN-protected, mobile-first.",
+ applicationName: "Water Log",
+ manifest: "/manifest-field.json",
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "default",
+ title: "Water Log",
+ },
+ robots: {
+ index: false,
+ follow: false,
+ },
+};
+
+export default function WaterLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+ <>
+ {/* Client-side: register the scoped SW + show the field-
+ branded install prompt. Both are no-op on first paint and
+ only fire under user-instigated conditions. */}
+
+
+ {children}
+ >
+ );
+}
diff --git a/src/components/pwa/FieldInstallPrompt.tsx b/src/components/pwa/FieldInstallPrompt.tsx
new file mode 100644
index 0000000..a274bdc
--- /dev/null
+++ b/src/components/pwa/FieldInstallPrompt.tsx
@@ -0,0 +1,168 @@
+/**
+ * FieldInstallPrompt — install prompt for the worker sub-PWA.
+ *
+ * Cycle 6 sibling of `InstallPrompt` (mounted from the root layout).
+ * This one fires only on /water routes and uses the field-PWA
+ * branding (forest green icon, "Water Log — Tuxedo") so the prompt
+ * that surfaces matches the manifest that will be installed.
+ *
+ * Same `BeforeInstallPromptEvent` capture pattern as InstallPrompt;
+ * split into a separate component file because the worker surface
+ * has its own theming + copy.
+ */
+"use client";
+
+import { useEffect, useSyncExternalStore, useCallback, useReducer } from "react";
+
+interface BeforeInstallPromptEvent extends Event {
+ prompt(): Promise;
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
+}
+
+type PromptState = {
+ deferred: BeforeInstallPromptEvent | null;
+ visible: boolean;
+};
+
+type PromptAction =
+ | { type: "captured"; event: BeforeInstallPromptEvent }
+ | { type: "show" }
+ | { type: "hide" }
+ | { type: "dismissed" }
+ | { type: "reset" };
+
+function promptReducer(state: PromptState, action: PromptAction): PromptState {
+ switch (action.type) {
+ case "captured":
+ return { deferred: action.event, visible: false };
+ case "show":
+ return state.deferred ? { ...state, visible: true } : state;
+ case "hide":
+ return { deferred: null, visible: false };
+ case "dismissed":
+ return { ...state, visible: false };
+ case "reset":
+ return { deferred: null, visible: false };
+ }
+}
+
+const INITIAL_PROMPT_STATE: PromptState = { deferred: null, visible: false };
+
+export function FieldInstallPrompt() {
+ const [prompt, dispatch] = useReducer(promptReducer, INITIAL_PROMPT_STATE);
+ const isInstalled = useSyncExternalStore(
+ () => () => {},
+ () =>
+ typeof window !== "undefined" &&
+ (window.matchMedia("(display-mode: standalone)").matches ||
+ // iOS Safari uses navigator.standalone for the home-screen PWA
+ // launch. Cover both.
+ (typeof (navigator as Navigator & { standalone?: boolean }).standalone ===
+ "boolean" &&
+ (navigator as Navigator & { standalone?: boolean }).standalone === true)),
+ () => false,
+ );
+ const mounted = useSyncExternalStore(
+ () => () => {},
+ () => true,
+ () => false,
+ );
+
+ useEffect(() => {
+ if (isInstalled) return;
+ if (typeof window === "undefined") return;
+
+ const handleBeforeInstall = (e: Event) => {
+ e.preventDefault();
+ dispatch({ type: "captured", event: e as BeforeInstallPromptEvent });
+ // Show the field prompt sooner than the main one — workers
+ // know they want this installed on day one.
+ window.setTimeout(() => {
+ dispatch({ type: "show" });
+ }, 4000);
+ };
+
+ const handleAppInstalled = () => {
+ dispatch({ type: "reset" });
+ };
+
+ window.addEventListener("beforeinstallprompt", handleBeforeInstall);
+ window.addEventListener("appinstalled", handleAppInstalled);
+
+ return () => {
+ window.removeEventListener("beforeinstallprompt", handleBeforeInstall);
+ window.removeEventListener("appinstalled", handleAppInstalled);
+ };
+ }, [isInstalled]);
+
+ const handleInstall = useCallback(async () => {
+ const deferred = prompt.deferred;
+ if (!deferred) return;
+
+ await deferred.prompt();
+ const { outcome } = await deferred.userChoice;
+ void outcome;
+ dispatch({ type: "reset" });
+ }, [prompt.deferred]);
+
+ const handleDismiss = useCallback(() => {
+ dispatch({ type: "dismissed" });
+ try {
+ sessionStorage.setItem("field-pwa-install-dismissed", "true");
+ } catch {
+ // ignore
+ }
+ }, []);
+
+ if (!mounted || !prompt.visible || isInstalled || !prompt.deferred) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ Install Water Log
+
+
+ One tap to a full-screen PIN entry from your home screen.
+
+
+
+
+
+
+
+
+ );
+}
+
+export default FieldInstallPrompt;
diff --git a/src/components/pwa/FieldSWRegistration.tsx b/src/components/pwa/FieldSWRegistration.tsx
new file mode 100644
index 0000000..5518a92
--- /dev/null
+++ b/src/components/pwa/FieldSWRegistration.tsx
@@ -0,0 +1,43 @@
+/**
+ * FieldSWRegistration — registers /sw-field.js with scope=/water.
+ *
+ * Cycle 6 component used by the worker sub-PWA. The component is
+ * mounted from src/app/water/layout.tsx (a thin client boundary
+ * inside the otherwise-server layout) so the registration only
+ * happens on /water routes — never on /admin, /tuxedo, etc.
+ *
+ * No-ops on:
+ * - Server (guards with typeof navigator)
+ * - repeat mounts (uses a module-scoped flag so React strict-mode
+ * double-mount doesn't double-register)
+ *
+ * Same registration pattern as next-pwa — we use the raw
+ * ServiceWorkerContainer API to stay framework-free.
+ */
+"use client";
+
+import { useEffect } from "react";
+
+let registered = false;
+
+export function FieldSWRegistration() {
+ useEffect(() => {
+ if (typeof navigator === "undefined") return;
+ if (!("serviceWorker" in navigator)) return;
+ if (registered) return;
+ registered = true;
+ void navigator.serviceWorker
+ .register("/sw-field.js", { scope: "/water" })
+ .catch((err) => {
+ // Surface to console for ops; never block the UI. The
+ // app works without the SW — it just loses offline shell
+ // caching for /water.
+ console.error("[field-sw] registration failed", err);
+ // Allow a retry on a later mount.
+ registered = false;
+ });
+ }, []);
+ return null;
+}
+
+export default FieldSWRegistration;