diff --git a/README.md b/README.md
index ad2e665..ed1c03a 100644
--- a/README.md
+++ b/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
diff --git a/index.html b/index.html
index 087ca3a..539d267 100644
--- a/index.html
+++ b/index.html
@@ -12,7 +12,10 @@
content="A responsive, offline-capable Pokédex reference powered by PokéAPI."
/>
-
+
+
+
+
Pokédex
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..728b27e
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/pwa-192.png b/public/pwa-192.png
new file mode 100644
index 0000000..b7c4552
Binary files /dev/null and b/public/pwa-192.png differ
diff --git a/public/pwa-512.png b/public/pwa-512.png
new file mode 100644
index 0000000..38df92d
Binary files /dev/null and b/public/pwa-512.png differ
diff --git a/public/pwa-maskable-512.png b/public/pwa-maskable-512.png
new file mode 100644
index 0000000..00e1b7e
Binary files /dev/null and b/public/pwa-maskable-512.png differ
diff --git a/src/lib/install.js b/src/lib/install.js
new file mode 100644
index 0000000..ceff0a5
--- /dev/null
+++ b/src/lib/install.js
@@ -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';
+}
diff --git a/src/main.js b/src/main.js
index 715752b..dde87fd 100644
--- a/src/main.js
+++ b/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() {
diff --git a/src/styles/layout.css b/src/styles/layout.css
index 157d356..7056722 100644
--- a/src/styles/layout.css
+++ b/src/styles/layout.css
@@ -1591,6 +1591,9 @@
.settings__actions {
flex-flow: row wrap;
}
+.settings__install {
+ margin: 10px 0 0;
+}
.field {
display: flex;
flex-direction: column;
diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js
index 51a88e6..bcdc499 100644
--- a/src/views/SettingsView.js
+++ b/src/views/SettingsView.js
@@ -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;
}
diff --git a/vite.config.js b/vite.config.js
index 63fff09..7b3d5a9 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -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' }],
},
],
},