PWA polish: PNG/maskable icons, install prompt, manifest shortcuts

- Rasterised the SVG to pwa-192 / pwa-512 / pwa-maskable-512 (logo in the
  safe zone, full-bleed background) and a real apple-touch-icon.png;
  manifest now lists PNG "any" + a dedicated "maskable".
- Manifest shortcuts: Team, Search, Type chart.
- lib/install.js captures beforeinstallprompt so the app can offer
  installation itself — a one-time toast, and an "Install app" button in
  Settings (falls back to a "use your browser menu" hint where the event
  never fires, e.g. iOS).
- index.html: apple-touch-icon PNG + apple-mobile-web-app meta.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-09 18:18:19 -04:00
parent cfa30c987f
commit 490ad9b7f1
11 changed files with 136 additions and 12 deletions

View File

@ -127,12 +127,13 @@ Working app with a type-themed UI:
feedback on catch (Vibration API), which screen to land on at launch,
JSON export/import.
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
cache-first LRU for sprites, in-app update toast.
cache-first LRU for sprites, in-app update toast. Installable: PNG +
maskable icons, `beforeinstallprompt` captured for an in-app "Install"
offer (toast + Settings button), manifest shortcuts to Team / Search /
Type chart.
Not yet done: version-exclusive badges, type/generation filters on the grid,
an interactive region map (PokéAPI has no map imagery or coordinates),
skeleton loaders, install-prompt handling, PNG/maskable raster icons
(currently SVG only), and a Lighthouse PWA pass.
Not yet done: version-exclusive badges, an interactive region map (PokéAPI
has no map imagery or coordinates), and a formal Lighthouse pass.
## Notes

View File

@ -12,7 +12,10 @@
content="A responsive, offline-capable Pokédex reference powered by PokéAPI."
/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<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="Pokédex" />
<title>Pokédex</title>
</head>
<body>

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/pwa-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
public/pwa-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/pwa-maskable-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

48
src/lib/install.js Normal file
View File

@ -0,0 +1,48 @@
/**
* Home-screen install: captures the browser's `beforeinstallprompt` so the
* app can offer installation on its own terms (a toast, a Settings button)
* instead of relying on the browser's mini-infobar.
*
* Import this early the event fires once, soon after load.
*/
let deferred = null;
const listeners = new Set();
const notify = () => {
for (const fn of listeners) fn(!!deferred);
};
if (typeof window !== 'undefined') {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferred = e;
notify();
});
window.addEventListener('appinstalled', () => {
deferred = null;
notify();
});
}
export function canInstall() {
return !!deferred;
}
export function onInstallChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
/** Show the native install dialog. Resolves true if the user accepted. */
export async function promptInstall() {
if (!deferred) return false;
deferred.prompt();
let outcome = 'dismissed';
try {
({ outcome } = await deferred.userChoice);
} catch {
/* ignore */
}
deferred = null;
notify();
return outcome === 'accepted';
}

View File

@ -7,6 +7,7 @@ import { initRouter, rerender } from './router.js';
import { Nav } from './components/Nav.js';
import { settings, applyTheme } from './store/settings.js';
import { ui } from './store/ui.js';
import { onInstallChange, promptInstall } from './lib/install.js';
applyTheme();
@ -58,6 +59,16 @@ const updateSW = registerSW({
},
});
// --- Install prompt: offer it once, unobtrusively ---------------------
let installOffered = false;
onInstallChange((can) => {
if (!can || installOffered) return;
installOffered = true;
showToast('Install this Pokédex for offline, full-screen use?', 'Install', () => {
promptInstall();
});
});
// --- Offline indicator -------------------------------------------------
const netPill = el('div', { class: 'netpill', role: 'status' }, 'Offline');
function syncNet() {

View File

@ -1591,6 +1591,9 @@
.settings__actions {
flex-flow: row wrap;
}
.settings__install {
margin: 10px 0 0;
}
.field {
display: flex;
flex-direction: column;

View File

@ -1,6 +1,7 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection } from '../store/selection.js';
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
export async function SettingsView() {
const view = el('section', { class: 'view settings' });
@ -136,8 +137,36 @@ export async function SettingsView() {
},
});
// Install (only when the browser has offered it and the app isn't
// already installed).
const installRow = el('div', { class: 'settings__install' });
function syncInstall() {
installRow.replaceChildren(
canInstall()
? el(
'button',
{
class: 'button',
type: 'button',
onclick: () => promptInstall(),
},
'Install app',
)
: el(
'p',
{ class: 'settings__note' },
window.matchMedia('(display-mode: standalone)').matches
? 'Running as an installed app.'
: 'Use your browser menu to add this Pokédex to your home screen.',
),
);
}
syncInstall();
const offInstall = onInstallChange(syncInstall);
view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
installRow,
el(
'div',
{ class: 'settings__group' },
@ -196,7 +225,9 @@ export async function SettingsView() {
),
);
onTeardown(view, () => {});
onTeardown(view, () => {
offInstall();
});
return view;
}

View File

@ -21,7 +21,11 @@ export default defineConfig({
// The bundled PokéAPI snapshot can be a few hundred KB.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
},
includeAssets: ['icon.svg', 'favicon.svg'],
includeAssets: [
'icon.svg',
'favicon.svg',
'apple-touch-icon.png',
],
manifest: {
name: 'Pokédex',
short_name: 'Pokédex',
@ -35,11 +39,34 @@ export default defineConfig({
theme_color: '#b3161a',
categories: ['games', 'reference', 'utilities'],
icons: [
{ src: 'icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: 'icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable',
src: 'pwa-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
shortcuts: [
{
name: 'Team builder',
short_name: 'Team',
url: './#/team',
icons: [{ src: 'pwa-192.png', sizes: '192x192' }],
},
{
name: 'Search',
short_name: 'Search',
url: './#/search',
icons: [{ src: 'pwa-192.png', sizes: '192x192' }],
},
{
name: 'Type chart',
short_name: 'Types',
url: './#/types',
icons: [{ src: 'pwa-192.png', sizes: '192x192' }],
},
],
},