Merge pull request #636 from cliph/feat/webapp-icons

feat: ship a web app manifest and home-screen icons
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-15 19:03:40 -07:00
commit f91dfc4b8f
12 files changed
+286

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# 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.
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 - 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 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 the server clock are accepted (previously 5 minutes), so the whole feed is
+22
View File
@@ -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 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.
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 ## 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
+13
View File
@@ -4,7 +4,20 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<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>
<!-- 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" />
<!-- Listed after the PNG: browsers that use the last usable icon keep the SVG
for the tab. -->
<link rel="icon" type="image/svg+xml" href="/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<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="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<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

+22
View File
@@ -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"
}
]
}
+2
View File
@@ -1050,6 +1050,8 @@
"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",
"src/ui/flyToDataSource.js", "src/ui/flyToDataSource.js",
"src/ui/flyToDataSource.test.mjs" "src/ui/flyToDataSource.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)`);
}
+149
View File
@@ -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(
/<link[^>]+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 <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, '.');
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(
/<link[^>]+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'),
`<!doctype html><html><head>${tags.join('')}</head><body></body></html>`,
);
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(/<link[^>]+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(/<link[^>]+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))),
);
}
});