feat: ship a web app manifest and home-screen icons

Adding the app to a phone home screen produced a blank tile: index.html
linked only an SVG favicon, and Safari ignores SVG for apple-touch-icon,
substituting a screenshot of the page. There was no manifest at all, so
Android had nothing to install against either.

Generate the rasters from public/logo.svg with sharp, which the repo
already carries. The output is committed so a plain `vite build` needs no
image toolchain; re-run scripts/generate-icons.mjs after changing the
logo. The logo's viewBox is wider than it is tall, so each icon centres
the mark on a square #0a0a0f canvas rather than stretching it, inset to
78% normally and 54% for the maskable variant to survive Android's
circular crop.

The SVG favicon stays as the browser tab icon.
This commit is contained in:
Cliff Flood committed 2026-10-13 18:54:58 -04:00
1 parent 132b9e96dc
commit bdd64660e7
12 files changed
+196 -1

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog # 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 - 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 usable translation or short name. Empty or malformed names no longer appear as
`[object Object]` or interrupt station loading; legacy string names remain `[object Object]` or interrupt station loading; legacy string names remain
+16
View File
@@ -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 acquisition. Browser layer lifecycle, rendering and voice execution stay in their
existing modules. 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 ## Local build preview
After `npm run build`, `npm run preview` serves the built app with the same data After `npm run build`, `npm run preview` serves the built app with the same data
+10
View File
@@ -5,6 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>God's Eye View</title> <title>God's Eye View</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<!-- Raster fallbacks: Safari ignores an SVG apple-touch-icon and falls back to a
screenshot of the page, so an installed app needs a real PNG. Regenerate
these from the logo with `node scripts/generate-icons.mjs`. -->
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0a0a0f" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="God's Eye" />
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+21
View File
@@ -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"
}
]
}
+3 -1
View File
@@ -981,5 +981,7 @@
"src/tools/globePanel.test.mjs", "src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs", "src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.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"
] ]
+70
View File
@@ -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)`);
}
+70
View File
@@ -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(
/<link[^>]+rel="manifest"[^>]+href="([^"]+)"/,
)?.[1];
test('index.html links a web app manifest', () => {
assert.ok(manifestHref, 'no <link rel="manifest"> 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(
/<link[^>]+rel="apple-touch-icon"[^>]+href="([^"]+)"/,
)?.[1];
assert.ok(href, 'no <link rel="apple-touch-icon"> 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`);
}
});