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,
|
feedback on catch (Vibration API), which screen to land on at launch,
|
||||||
JSON export/import.
|
JSON export/import.
|
||||||
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
|
- **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,
|
Not yet done: version-exclusive badges, an interactive region map (PokéAPI
|
||||||
an interactive region map (PokéAPI has no map imagery or coordinates),
|
has no map imagery or coordinates), and a formal Lighthouse pass.
|
||||||
skeleton loaders, install-prompt handling, PNG/maskable raster icons
|
|
||||||
(currently SVG only), and a Lighthouse PWA pass.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,10 @@
|
|||||||
content="A responsive, offline-capable Pokédex reference powered by PokéAPI."
|
content="A responsive, offline-capable Pokédex reference powered by PokéAPI."
|
||||||
/>
|
/>
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<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>
|
<title>Pokédex</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 { Nav } from './components/Nav.js';
|
||||||
import { settings, applyTheme } from './store/settings.js';
|
import { settings, applyTheme } from './store/settings.js';
|
||||||
import { ui } from './store/ui.js';
|
import { ui } from './store/ui.js';
|
||||||
|
import { onInstallChange, promptInstall } from './lib/install.js';
|
||||||
|
|
||||||
applyTheme();
|
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 -------------------------------------------------
|
// --- Offline indicator -------------------------------------------------
|
||||||
const netPill = el('div', { class: 'netpill', role: 'status' }, 'Offline');
|
const netPill = el('div', { class: 'netpill', role: 'status' }, 'Offline');
|
||||||
function syncNet() {
|
function syncNet() {
|
||||||
|
|||||||
@ -1591,6 +1591,9 @@
|
|||||||
.settings__actions {
|
.settings__actions {
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
}
|
}
|
||||||
|
.settings__install {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { el, onTeardown } from '../lib/dom.js';
|
import { el, onTeardown } from '../lib/dom.js';
|
||||||
import { settings, applyTheme } from '../store/settings.js';
|
import { settings, applyTheme } from '../store/settings.js';
|
||||||
import { selection } from '../store/selection.js';
|
import { selection } from '../store/selection.js';
|
||||||
|
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
|
||||||
|
|
||||||
export async function SettingsView() {
|
export async function SettingsView() {
|
||||||
const view = el('section', { class: 'view settings' });
|
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(
|
view.append(
|
||||||
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
|
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
|
||||||
|
installRow,
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'settings__group' },
|
{ class: 'settings__group' },
|
||||||
@ -196,7 +225,9 @@ export async function SettingsView() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
onTeardown(view, () => {});
|
onTeardown(view, () => {
|
||||||
|
offInstall();
|
||||||
|
});
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,11 @@ export default defineConfig({
|
|||||||
// The bundled PokéAPI snapshot can be a few hundred KB.
|
// The bundled PokéAPI snapshot can be a few hundred KB.
|
||||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
includeAssets: ['icon.svg', 'favicon.svg'],
|
includeAssets: [
|
||||||
|
'icon.svg',
|
||||||
|
'favicon.svg',
|
||||||
|
'apple-touch-icon.png',
|
||||||
|
],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Pokédex',
|
name: 'Pokédex',
|
||||||
short_name: 'Pokédex',
|
short_name: 'Pokédex',
|
||||||
@ -35,11 +39,34 @@ export default defineConfig({
|
|||||||
theme_color: '#b3161a',
|
theme_color: '#b3161a',
|
||||||
categories: ['games', 'reference', 'utilities'],
|
categories: ['games', 'reference', 'utilities'],
|
||||||
icons: [
|
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',
|
src: 'pwa-maskable-512.png',
|
||||||
sizes: 'any',
|
sizes: '512x512',
|
||||||
type: 'image/svg+xml',
|
type: 'image/png',
|
||||||
purpose: 'any maskable',
|
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