diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f39f8d96..c13343cee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+- Ship a web app manifest and raster home-screen icons, so adding the app to a
+ phone home screen or installing it on desktop uses the God's Eye View mark
+ instead of a blank tile. The icons are generated from `public/logo.svg` by
+ `node scripts/generate-icons.mjs` and committed, so a plain `vite build` needs
+ no image toolchain. The existing SVG favicon still serves the browser tab.
+ Manifest paths are relative to the manifest, so a build served under a path
+ prefix installs and launches inside that prefix.
+
- Cyclone advisories no longer go dark while NHC publishes an advisory ahead
of its nominal time: advisory and position times up to 90 minutes ahead of
the server clock are accepted (previously 5 minutes), so the whole feed is
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 0af075a6d..889e26431 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1285,6 +1285,28 @@ services, local voice and standalone key setup. Importing them starts no network
acquisition. Browser layer lifecycle, rendering and voice execution stay in their
existing modules.
+## Web app install icons
+
+`public/manifest.webmanifest` declares the installable app: standalone display,
+`#0a0a0f` background and theme, and 192/512 icons plus a 512 maskable variant.
+`index.html` links the manifest, a 180px `apple-touch-icon` and a 32px PNG
+favicon; the existing SVG favicon still serves the browser tab. Safari ignores an
+SVG apple-touch-icon and substitutes a page screenshot, so the raster is what
+makes an installed icon appear at all.
+
+Manifest `start_url`, `scope` and icon paths are relative to the manifest, and
+Vite rewrites the `index.html` links under `base`, so a build served from a path
+prefix installs and launches inside that prefix.
+
+`node scripts/generate-icons.mjs` renders every raster from `public/logo.svg` and
+writes `public/icons/`. Output is committed, so building needs no image
+toolchain; re-run the script after changing the logo. Ordinary icons inset the
+mark to 78% of the canvas and the maskable variant to 54%, keeping it inside the
+circle Android may crop to. `src/tooling/webAppIcons.test.mjs` checks the links,
+the manifest fields and that each committed PNG is square at its declared size.
+It also builds the install links under `base: '/example/'` and asserts every
+emitted link and manifest URL stays inside that base.
+
## Local build preview
After `npm run build`, `npm run preview` serves the built app with the same data
diff --git a/index.html b/index.html
index 78d6e34ec..8a01415b6 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,20 @@
God's Eye View
+
+
+
+
+
+
+
+
+
+
diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png
new file mode 100644
index 000000000..5655a9e70
Binary files /dev/null and b/public/icons/icon-180.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 000000000..7aed57138
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-32.png b/public/icons/icon-32.png
new file mode 100644
index 000000000..a19279f06
Binary files /dev/null and b/public/icons/icon-32.png differ
diff --git a/public/icons/icon-512-maskable.png b/public/icons/icon-512-maskable.png
new file mode 100644
index 000000000..0b3064f39
Binary files /dev/null and b/public/icons/icon-512-maskable.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 000000000..644a5c8ea
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 000000000..478cd9019
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,22 @@
+{
+ "id": ".",
+ "name": "God's Eye View",
+ "short_name": "God's Eye",
+ "description": "Live spatial intelligence in the browser.",
+ "start_url": ".",
+ "scope": ".",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#0a0a0f",
+ "theme_color": "#0a0a0f",
+ "icons": [
+ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
+ {
+ "src": "icons/icon-512-maskable.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index d956d09e8..56dd54da1 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -1050,6 +1050,8 @@
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
"scripts/qa-gbfs-names.mjs",
+ "scripts/generate-icons.mjs",
+ "src/tooling/webAppIcons.test.mjs",
"src/ui/flyToDataSource.js",
"src/ui/flyToDataSource.test.mjs"
]
diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs
new file mode 100644
index 000000000..3c16eb46e
--- /dev/null
+++ b/scripts/generate-icons.mjs
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+// Renders the home-screen and favicon rasters from `public/logo.svg`.
+//
+// Re-run after changing the logo: `node scripts/generate-icons.mjs`. The output
+// is committed, because the install icons have to exist in a plain `vite build`
+// and contributors should not need an image toolchain to ship the app.
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import sharp from 'sharp';
+
+const REPO_ROOT = path.resolve(
+ fileURLToPath(new URL('.', import.meta.url)),
+ '..',
+);
+const LOGO = path.join(REPO_ROOT, 'public', 'logo.svg');
+const OUT_DIR = path.join(REPO_ROOT, 'public', 'icons');
+
+/** `--bg-dark` from src/ui/styles/foundation.css, so the tile matches the app. */
+const BACKGROUND = '#0a0a0f';
+
+/**
+ * Fraction of the canvas the mark is allowed to occupy.
+ *
+ * A maskable icon may be cropped to a circle inscribed in the middle 80% of the
+ * square, so its mark stays well inside that circle; the other icons are shown
+ * as drawn and only need optical breathing room.
+ */
+const INSET = { normal: 0.78, maskable: 0.54 };
+
+const TARGETS = [
+ { file: 'icon-32.png', size: 32, inset: INSET.normal },
+ { file: 'icon-180.png', size: 180, inset: INSET.normal },
+ { file: 'icon-192.png', size: 192, inset: INSET.normal },
+ { file: 'icon-512.png', size: 512, inset: INSET.normal },
+ { file: 'icon-512-maskable.png', size: 512, inset: INSET.maskable },
+];
+
+/** Fit the logo inside `size * inset` and centre it on an opaque square. */
+async function render(svg, { size, inset }) {
+ const box = Math.round(size * inset);
+ // The logo's viewBox is wider than it is tall, so `contain` bounds it by
+ // width and leaves the vertical padding uneven — centring is done by the
+ // composite below rather than by the resize.
+ const mark = await sharp(svg, { density: 384 })
+ .resize(box, box, {
+ fit: 'contain',
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
+ })
+ .png()
+ .toBuffer();
+ return sharp({
+ create: {
+ width: size,
+ height: size,
+ channels: 4,
+ background: BACKGROUND,
+ },
+ })
+ .composite([{ input: mark, gravity: 'centre' }])
+ .png({ compressionLevel: 9 })
+ .toBuffer();
+}
+
+const svg = await readFile(LOGO);
+await mkdir(OUT_DIR, { recursive: true });
+for (const target of TARGETS) {
+ await writeFile(path.join(OUT_DIR, target.file), await render(svg, target));
+ console.log(`icons: wrote ${target.file} (${target.size}px)`);
+}
diff --git a/src/tooling/webAppIcons.test.mjs b/src/tooling/webAppIcons.test.mjs
new file mode 100644
index 000000000..6a6cdb350
--- /dev/null
+++ b/src/tooling/webAppIcons.test.mjs
@@ -0,0 +1,149 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { rm, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import sharp from 'sharp';
+import { build } from 'vite';
+import { makeFixtureRoot } from './fixtureRoot.mjs';
+
+const REPO_ROOT = path.resolve(
+ fileURLToPath(new URL('.', import.meta.url)),
+ '../..',
+);
+const PUBLIC_ROOT = path.join(REPO_ROOT, 'public');
+const INDEX_HTML = readFileSync(path.join(REPO_ROOT, 'index.html'), 'utf8');
+
+/** `public/` is served at the site root, so an index.html href maps to a file by dropping the leading slash. */
+function publicFile(href) {
+ assert.ok(href.startsWith('/'), `${href} must be a root-relative URL`);
+ return path.join(PUBLIC_ROOT, href.slice(1));
+}
+
+const manifestHref = INDEX_HTML.match(
+ /]+rel="manifest"[^>]+href="([^"]+)"/,
+)?.[1];
+
+/**
+ * Manifest URLs resolve against the manifest, not the page. Keeping them
+ * relative is what lets a build served under a path prefix install inside it.
+ */
+function manifestFile(src) {
+ assert.ok(
+ !src.startsWith('/') && !/^[a-z][a-z\d+.-]*:/i.test(src),
+ `${src} must be relative to the manifest`,
+ );
+ return path.join(path.dirname(publicFile(manifestHref)), src);
+}
+
+test('index.html links a web app manifest', () => {
+ assert.ok(manifestHref, 'no in index.html');
+});
+
+test('iOS gets a raster home-screen icon', () => {
+ // Safari ignores SVG for apple-touch-icon and falls back to a screenshot of
+ // the page, which is the blank tile this whole file exists to prevent. The
+ // existing `rel="icon"` SVG covers the browser tab and nothing else.
+ const href = INDEX_HTML.match(
+ /]+rel="apple-touch-icon"[^>]+href="([^"]+)"/,
+ )?.[1];
+ assert.ok(href, 'no in index.html');
+ assert.match(href, /\.png$/, 'apple-touch-icon must be a PNG');
+ assert.doesNotThrow(() => readFileSync(publicFile(href)));
+});
+
+test('the manifest declares a standalone installable app', () => {
+ const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
+ assert.equal(manifest.display, 'standalone');
+ assert.equal(manifest.start_url, '.');
+ assert.equal(manifest.scope, '.');
+ for (const field of ['name', 'short_name', 'background_color', 'theme_color'])
+ assert.ok(manifest[field], `manifest is missing ${field}`);
+ // Android's install prompt needs both, and uses the 512 for the splash screen.
+ const sizes = new Set(manifest.icons.map((icon) => icon.sizes));
+ for (const required of ['192x192', '512x512'])
+ assert.ok(sizes.has(required), `manifest declares no ${required} icon`);
+ // Without a maskable icon Android pads the square into a circle and clips
+ // the mark; with one it crops to the safe zone instead.
+ assert.ok(
+ manifest.icons.some((icon) => icon.purpose?.includes('maskable')),
+ 'manifest declares no maskable icon',
+ );
+});
+
+test('every declared icon is a square PNG at its declared size', async () => {
+ const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
+ for (const icon of manifest.icons) {
+ const meta = await sharp(manifestFile(icon.src)).metadata();
+ const [width, height] = icon.sizes.split('x').map(Number);
+ assert.equal(meta.format, 'png', `${icon.src} is not a PNG`);
+ // A size the manifest overstates is worse than a missing one: the platform
+ // trusts the declaration and scales whatever it finds.
+ assert.equal(meta.width, width, `${icon.src} width`);
+ assert.equal(meta.height, height, `${icon.src} height`);
+ }
+});
+
+test('install assets stay inside a non-root Vite base', async (t) => {
+ // The real index.html pulls the whole Cesium app into a build, so only its
+ // install links are copied, verbatim, into a fixture page.
+ const tags = INDEX_HTML.match(
+ /]+rel="(?:icon|apple-touch-icon|manifest)"[^>]*>/g,
+ );
+ assert.ok(tags?.length >= 3, 'index.html lost its install links');
+ const root = await makeFixtureRoot('gev-webapp-icons-');
+ t.after(() => rm(root, { recursive: true, force: true }));
+ await writeFile(
+ path.join(root, 'index.html'),
+ `${tags.join('')}`,
+ );
+ const base = '/example/';
+ const origin = 'https://host.test';
+ const result = await build({
+ root,
+ configFile: false,
+ envFile: false,
+ logLevel: 'silent',
+ base,
+ publicDir: PUBLIC_ROOT,
+ build: { write: false },
+ });
+ const html = result.output.find(
+ (item) => item.fileName === 'index.html',
+ ).source;
+ const hrefs = [...html.matchAll(/]+href="([^"]+)"/g)].map(
+ (match) => match[1],
+ );
+ assert.equal(hrefs.length, tags.length);
+ for (const href of hrefs) {
+ assert.ok(href.startsWith(base), `${href} escapes ${base}`);
+ assert.doesNotThrow(() =>
+ readFileSync(path.join(PUBLIC_ROOT, href.slice(base.length))),
+ );
+ }
+
+ // Vite copies public/ only when it writes, and copies it verbatim, so the
+ // deployed manifest is the committed one, served at the emitted href.
+ const manifestUrl = new URL(
+ html.match(/]+rel="manifest"[^>]+href="([^"]+)"/)[1],
+ origin,
+ );
+ const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
+ const inBase = (value) => {
+ const url = new URL(value, manifestUrl);
+ assert.ok(
+ url.href.startsWith(origin + base),
+ `${value} resolves to ${url.href}, outside ${base}`,
+ );
+ return url;
+ };
+ assert.equal(inBase(manifest.start_url).href, origin + base);
+ assert.equal(inBase(manifest.scope).href, origin + base);
+ for (const icon of manifest.icons) {
+ const url = inBase(icon.src);
+ assert.doesNotThrow(() =>
+ readFileSync(path.join(PUBLIC_ROOT, url.pathname.slice(base.length))),
+ );
+ }
+});