mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
132b9e96dc
commit
bdd64660e7
12 files changed
+196
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
@@ -5,6 +5,16 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>God's Eye View</title>
|
||||
<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="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user