diff --git a/CHANGELOG.md b/CHANGELOG.md
index 032c6fe2a..eb61ad1db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# 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.
+
- GBFS v3 station names use English translations when available, then the first
usable translation or short name. Empty or malformed names no longer appear as
`[object Object]` or interrupt station loading; legacy string names remain
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 0d3ef31e0..f8e6bff3a 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1131,6 +1131,22 @@ 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.
+
+`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.
+
## 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 2e630c4a8..31db11b06 100644
--- a/index.html
+++ b/index.html
@@ -5,6 +5,16 @@
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..574b90faf
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,21 @@
+{
+ "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 662d33a6b..d910536a2 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -981,5 +981,7 @@
"src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
- "scripts/qa-gbfs-names.mjs"
+ "scripts/qa-gbfs-names.mjs",
+ "scripts/generate-icons.mjs",
+ "src/tooling/webAppIcons.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..2267872fe
--- /dev/null
+++ b/src/tooling/webAppIcons.test.mjs
@@ -0,0 +1,70 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+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 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 a manifest 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];
+
+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, '/');
+ 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(publicFile(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`);
+ }
+});