diff --git a/index.html b/index.html
index 8c896fc..2a62fa7 100644
--- a/index.html
+++ b/index.html
@@ -5,6 +5,8 @@
The Director
+
+
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..e446860
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,14 @@
+{
+ "name": "The Director",
+ "short_name": "Director",
+ "description": "Offline convention GM shell",
+ "start_url": "/",
+ "display": "standalone",
+ "orientation": "landscape",
+ "background_color": "#0b0e13",
+ "theme_color": "#0b0e13",
+ "icons": [
+ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..c00c30b
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,22 @@
+const CACHE = 'director-v1';
+const SHELL = ['/', '/index.html', '/src/main.js', '/src/styles.css'];
+
+self.addEventListener('install', (e) => {
+ e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
+});
+self.addEventListener('activate', (e) => {
+ e.waitUntil(
+ caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
+ .then(() => self.clients.claim())
+ );
+});
+self.addEventListener('fetch', (e) => {
+ if (e.request.method !== 'GET') return;
+ e.respondWith(
+ caches.match(e.request).then((hit) => hit || fetch(e.request).then((res) => {
+ const copy = res.clone();
+ caches.open(CACHE).then((c) => c.put(e.request, copy));
+ return res;
+ }).catch(() => caches.match('/index.html')))
+ );
+});
diff --git a/src/main.js b/src/main.js
index 4c9e265..0c81ce3 100644
--- a/src/main.js
+++ b/src/main.js
@@ -10,3 +10,9 @@ if (typeof document !== 'undefined' && document.querySelector('gm-shell')) {
shell.loadScenario(afterimage);
});
}
+
+if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js').catch(() => {});
+ });
+}
diff --git a/tests/pwa.test.js b/tests/pwa.test.js
new file mode 100644
index 0000000..110bff9
--- /dev/null
+++ b/tests/pwa.test.js
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+
+describe('PWA wiring', () => {
+ it('links the manifest in index.html', () => {
+ const html = readFileSync('index.html', 'utf8');
+ expect(html).toContain('rel="manifest"');
+ expect(html).toContain('manifest.webmanifest');
+ });
+ it('manifest declares an installable app', () => {
+ const m = JSON.parse(readFileSync('public/manifest.webmanifest', 'utf8'));
+ expect(m.name).toBeTruthy();
+ expect(m.display).toBe('standalone');
+ expect(Array.isArray(m.icons)).toBe(true);
+ });
+ it('main.js registers the service worker', () => {
+ const js = readFileSync('src/main.js', 'utf8');
+ expect(js).toContain('serviceWorker');
+ expect(js).toContain("register('/sw.js')");
+ });
+ it('service worker precaches the shell', () => {
+ const sw = readFileSync('public/sw.js', 'utf8');
+ expect(sw).toContain('addEventListener');
+ expect(sw).toContain('caches');
+ });
+});