diff --git a/branding/hyperhive-maskable.svg b/branding/hyperhive-maskable.svg
new file mode 100644
index 00000000..699f4ab6
--- /dev/null
+++ b/branding/hyperhive-maskable.svg
@@ -0,0 +1,13 @@
+
diff --git a/frontend/packages/swarm-ui/build.mjs b/frontend/packages/swarm-ui/build.mjs
index 1a7d0c40..41f8a1c1 100644
--- a/frontend/packages/swarm-ui/build.mjs
+++ b/frontend/packages/swarm-ui/build.mjs
@@ -16,6 +16,17 @@
// dist/static/swarm-ui.css served at /static/swarm-ui.css — just the
// shared base reset now (@import resolved
// from @hive/shared), NOT component styles
+// dist/manifest.webmanifest served at /manifest.webmanifest (PWA)
+// dist/sw.js served at /sw.js (PWA) — plain JS, no
+// esbuild pass needed (see its own
+// top comment for why); copied as-is.
+//
+// PWA icons (icon-192.png/icon-512.png/icon-512-maskable.png) are NOT
+// produced here — like favicon.svg, they're rasterized from
+// branding/*.svg and copied in by nix/packages/swarm-ui.nix's
+// installPhase, outside the npm tree this script builds. A plain `npm
+// run build` here has no `/icon-*.png`, same as it's always had no
+// `/favicon.svg`.
//
// Not yet wired into any Rust binary's `ServeDir` — swarm-controller
// only serves `/health` today (see swarm-controller/README.md); this
@@ -62,5 +73,7 @@ for (const entry of ["colors.css", "theme.css", "swarm-ui.css"]) {
}
copyFileSync(src("index.html"), dist("index.html"));
+copyFileSync(src("manifest.webmanifest"), dist("manifest.webmanifest"));
+copyFileSync(src("sw.js"), dist("sw.js"));
console.log("swarm-ui build ok →", dist(""));
diff --git a/frontend/packages/swarm-ui/src/index.html b/frontend/packages/swarm-ui/src/index.html
index 0959cfea..dfd9d672 100644
--- a/frontend/packages/swarm-ui/src/index.html
+++ b/frontend/packages/swarm-ui/src/index.html
@@ -5,6 +5,19 @@
hyperhive swarm
+
+
+
+
+
+
+
diff --git a/frontend/packages/swarm-ui/src/main.tsx b/frontend/packages/swarm-ui/src/main.tsx
index 5068ce59..6294fc42 100644
--- a/frontend/packages/swarm-ui/src/main.tsx
+++ b/frontend/packages/swarm-ui/src/main.tsx
@@ -5,3 +5,17 @@ const root = document.getElementById("root");
if (root) {
render(, root);
}
+
+// PWA installability — see sw.js's own top comment for
+// what it does and does not cache. Feature-detected: older browsers
+// without service-worker support just don't get the install affordance,
+// nothing here depends on it existing.
+if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker.register("/sw.js").catch((err: unknown) => {
+ // Never surfaces to the operator — a failed SW registration means
+ // "not installable this session," not "the app is broken."
+ console.error("swarm-ui: service worker registration failed", err);
+ });
+ });
+}
diff --git a/frontend/packages/swarm-ui/src/manifest.webmanifest b/frontend/packages/swarm-ui/src/manifest.webmanifest
new file mode 100644
index 00000000..83a6bfc8
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/manifest.webmanifest
@@ -0,0 +1,14 @@
+{
+ "name": "hyperhive swarm",
+ "short_name": "swarm",
+ "description": "Swarm-level operator UI — agent roster, hives, jobs, issues across the swarm.",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#1e1e2e",
+ "theme_color": "#cba6f7",
+ "icons": [
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
+ { "src": "/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}
diff --git a/frontend/packages/swarm-ui/src/sw.js b/frontend/packages/swarm-ui/src/sw.js
new file mode 100644
index 00000000..4728640f
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/sw.js
@@ -0,0 +1,109 @@
+// Minimal service worker — satisfies PWA installability so swarm-ui can
+// actually be "Add to Home Screen"d (design-guide.md's "Layout &
+// viewport" section already asserted this as fact before this service
+// worker existed to make it true). NOT a data cache: scoped to the app
+// shell only (the SPA's own HTML/JS/CSS/manifest), and explicitly never
+// touches `/api/*` — an ops dashboard silently showing stale status from
+// a cached fetch would be worse than showing nothing (mara: "scope looks
+// good" — approving that as a hard rule, not a short-TTL middle ground).
+//
+// Network-first for the shell, not cache-first: `main.js`/`main.css` are
+// unhashed filenames, so a cache-first strategy would risk wedging an
+// operator on yesterday's JS after a deploy until they manually cleared
+// it (a deploy changes the nix store path serving these but not the URL
+// the browser cached against — a separate, already-filed fix). This SW
+// only ever serves its cache on a FAILED fetch (offline/flaky network),
+// never in preference to a successful network response.
+//
+// Plain JS, not TypeScript, deliberately — the DOM lib swarm-ui's own
+// tsconfig uses and the WebWorker lib a service worker's globals
+// (`self`, `ExtendableEvent`, `FetchEvent`, `caches`, ...) need are
+// mutually exclusive in one tsc program, and this file is small/
+// self-contained enough that a second tsconfig just to typecheck it
+// isn't worth the config surface. Same treatment `build.mjs` already
+// gets in this package.
+//
+// CACHE_VERSION is bumped by hand for now — once the filenames above are
+// content-hashed, the cache name can derive from the build itself and
+// this manual step goes away.
+const CACHE_VERSION = "v1";
+const CACHE_NAME = `swarm-ui-shell-${CACHE_VERSION}`;
+
+// Every navigation (any client-side route wouter handles — /agents,
+// /hives, ...) falls back to this single cached document when offline,
+// same as nginx's own `try_files $uri /index.html` does online: the SPA
+// only ever has one real HTML document regardless of path.
+const SHELL_DOCUMENT = "/";
+const SHELL_ASSETS = [
+ SHELL_DOCUMENT,
+ "/static/main.js",
+ "/static/main.css",
+ "/static/colors.css",
+ "/static/theme.css",
+ "/static/swarm-ui.css",
+ "/manifest.webmanifest",
+];
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)),
+ );
+ // Take over from any previous SW as soon as this one's installed — an
+ // operator installing the PWA for the first time (or after a SW
+ // update) shouldn't need a second reload before it's in control.
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((names) =>
+ Promise.all(
+ names
+ .filter((name) => name !== CACHE_NAME)
+ .map((name) => caches.delete(name)),
+ ),
+ )
+ .then(() => self.clients.claim()),
+ );
+});
+
+self.addEventListener("fetch", (event) => {
+ const url = new URL(event.request.url);
+ // Same-origin only, and never `/api/*` — that's swarm-controller's
+ // live data, not the static shell this SW is scoped to. Letting the
+ // event fall through (no `respondWith`) means the browser handles it
+ // exactly as if this SW didn't exist.
+ if (url.origin !== self.location.origin || url.pathname.startsWith("/api/")) {
+ return;
+ }
+
+ // Any navigation (full-page load or wouter's client-side route change
+ // reaching the network) — network-first, falling back to the cached
+ // shell document on failure regardless of which path was requested,
+ // since every route renders the same SPA shell.
+ if (event.request.mode === "navigate") {
+ event.respondWith(
+ fetch(event.request).catch(() => caches.match(SHELL_DOCUMENT)),
+ );
+ return;
+ }
+
+ // A specific shell asset — network-first, cache the fresh response for
+ // next time, fall back to whatever's cached on failure. Anything not
+ // in this list (a future asset this SW doesn't know about yet) passes
+ // straight through untouched rather than silently going uncached.
+ if (!SHELL_ASSETS.includes(url.pathname)) {
+ return;
+ }
+ event.respondWith(
+ fetch(event.request)
+ .then((response) => {
+ const copy = response.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
+ return response;
+ })
+ .catch(() => caches.match(event.request)),
+ );
+});
diff --git a/nix/packages/default.nix b/nix/packages/default.nix
index 38d41522..c0a534a8 100644
--- a/nix/packages/default.nix
+++ b/nix/packages/default.nix
@@ -197,6 +197,7 @@ in
# `packages.default`'s closure.
swarm-ui = pkgs.callPackage ./swarm-ui.nix {
branding-svg = ../../branding/hyperhive.svg;
+ branding-svg-maskable = ../../branding/hyperhive-maskable.svg;
};
# Bundled browser assets — see ./frontend.nix. Output is
diff --git a/nix/packages/swarm-ui.nix b/nix/packages/swarm-ui.nix
index 035dc904..6d1e5d52 100644
--- a/nix/packages/swarm-ui.nix
+++ b/nix/packages/swarm-ui.nix
@@ -1,4 +1,9 @@
-{ buildNpmPackage, branding-svg }:
+{
+ buildNpmPackage,
+ branding-svg,
+ branding-svg-maskable,
+ librsvg,
+}:
# Static build of the swarm-level UI shell (project-bootstrap scope:
# empty start page, no functionality yet — see
@@ -31,6 +36,14 @@
# `./frontend.nix` copies for the dashboard) is likewise copied in
# during install — it lives outside the npm tree.
+# PWA icons: rasterized from the branding SVGs at build time via
+# `librsvg`'s `rsvg-convert` rather than checking in static PNGs, so the
+# SVG stays the one thing to update if the mark ever changes.
+# `branding-svg-maskable` is a separate, padded source (see
+# `branding/hyperhive-maskable.svg`'s own comment) — `hyperhive.svg`'s
+# artwork already fills nearly its whole canvas, so a maskable icon needs
+# real margin or an OS mask crops into it.
+
buildNpmPackage {
pname = "hyperhive-swarm-ui";
version = "0.0.0";
@@ -39,6 +52,8 @@ buildNpmPackage {
# See `./frontend.nix`'s comment on the same line.
npmDepsHash = builtins.readFile ../../frontend/npm-deps-hash;
+ nativeBuildInputs = [ librsvg ];
+
buildPhase = ''
runHook preBuild
npm run build --workspace=packages/swarm-ui
@@ -51,6 +66,18 @@ buildNpmPackage {
mkdir -p $out
cp -r packages/swarm-ui/dist/. $out/
cp ${branding-svg} $out/favicon.svg
+ rsvg-convert -w 192 -h 192 ${branding-svg} -o $out/icon-192.png
+ rsvg-convert -w 512 -h 512 ${branding-svg} -o $out/icon-512.png
+
+ # `branding-svg-maskable` embeds `branding-svg` via a relative
+ # `` reference (see that file's own comment) — each is passed
+ # in as its own single-file store path, so the sibling reference
+ # only resolves once both live together in one directory again.
+ mkdir -p "$TMPDIR/branding"
+ cp ${branding-svg} "$TMPDIR/branding/hyperhive.svg"
+ cp ${branding-svg-maskable} "$TMPDIR/branding/hyperhive-maskable.svg"
+ rsvg-convert -w 512 -h 512 "$TMPDIR/branding/hyperhive-maskable.svg" \
+ -o $out/icon-512-maskable.png
runHook postInstall
'';