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:
parent
cfa30c987f
commit
490ad9b7f1
11
README.md
11
README.md
@ -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
|
||||
|
||||
|
||||
@ -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
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
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
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
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
48
src/lib/install.js
Normal 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';
|
||||
}
|
||||
11
src/main.js
11
src/main.js
@ -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() {
|
||||
|
||||
@ -1591,6 +1591,9 @@
|
||||
.settings__actions {
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
.settings__install {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user