Progressive Web App (PWA) APIs
1. Web App Manifest API for App Installation
| Manifest Property | Description | Example |
|---|---|---|
| name | Full name of the app displayed during installation. | "My Awesome PWA" |
| short_name | Short name for home screen (limit 12 chars). | "MyPWA" |
| start_url | URL to open when app is launched. | "/app?source=pwa" |
| display | Display mode: "standalone", "fullscreen", "minimal-ui", "browser". |
"standalone" |
| background_color | Background color during splash screen. | "#ffffff" |
| theme_color | Theme color for browser UI. | "#4285f4" |
| icons | Array of icon objects with src, sizes, type, purpose. |
[{src: "/icon-192.png", sizes: "192x192"}] |
| orientation | Preferred orientation: "portrait", "landscape", "any". |
"portrait" |
| scope | Navigation scope - URLs outside this scope open in browser. | "/app/" |
| description | Description of the app. | "A powerful task manager" |
Example: Complete Web App Manifest
{
"name": "Task Manager Pro",
"short_name": "TaskPro",
"description": "Professional task management for teams",
"start_url": "/app?source=pwa",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196f3",
"orientation": "portrait-primary",
"scope": "/app/",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/maskable-icon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/screenshots/desktop-1.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile-1.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"categories": ["productivity", "business"],
"lang": "en-US",
"dir": "ltr"
}
Example: Link manifest and check installation
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Manager Pro</title>
<!-- Link to Web App Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Theme color for browser chrome -->
<meta name="theme-color" content="#2196f3">
<!-- Apple-specific tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="TaskPro">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
</head>
<body>
<h1>Task Manager Pro</h1>
<script>
// Check if manifest is linked
if ('manifest' in document.createElement('link')) {
console.log("Web App Manifest supported");
}
// Get manifest programmatically (limited support)
if ('getManifest' in document) {
document.getManifest().then(manifest => {
console.log("Manifest:", manifest);
});
}
</script>
</body>
</html>
Note: Web App Manifest is a JSON file defining PWA metadata. Link with
<link rel="manifest">. Icons should include multiple sizes (72px to 512px). Use "purpose": "maskable" for adaptive icons. Screenshots shown in install prompts on some platforms.
2. App Installation Prompts and beforeinstallprompt
| Event/Method | Description |
|---|---|
| beforeinstallprompt | Event fired when browser is ready to show install prompt. |
| event.prompt() | Shows the install prompt (must be called from user gesture). |
| event.userChoice | Promise resolving to user's choice: "accepted" or "dismissed". |
| appinstalled | Event fired when app is successfully installed. |
| Install Criteria (Chrome/Edge) | Required |
|---|---|
| HTTPS | ✓ Site must be served over HTTPS (or localhost) |
| Web App Manifest | ✓ Valid manifest with name, icons, start_url, display |
| Service Worker | ✓ Registered service worker with fetch event handler |
| User Engagement | ✓ User has visited site at least once |
Example: Custom install button with beforeinstallprompt
// Store the install prompt event
let deferredPrompt;
const installButton = document.getElementById("install-button");
// Hide install button initially
installButton.style.display = "none";
// Listen for beforeinstallprompt event
window.addEventListener("beforeinstallprompt", (event) => {
console.log("beforeinstallprompt fired");
// Prevent the default browser install prompt
event.preventDefault();
// Store the event for later use
deferredPrompt = event;
// Show custom install button
installButton.style.display = "block";
// Track that prompt is available
analytics.track("install_prompt_available");
});
// Handle custom install button click
installButton.addEventListener("click", async () => {
if (!deferredPrompt) {
console.log("Install prompt not available");
return;
}
// Hide the install button
installButton.style.display = "none";
// Show the install prompt
deferredPrompt.prompt();
// Wait for user's choice
const choiceResult = await deferredPrompt.userChoice;
console.log(`User choice: ${choiceResult.outcome}`);
if (choiceResult.outcome === "accepted") {
console.log("User accepted the install prompt");
analytics.track("install_accepted");
} else {
console.log("User dismissed the install prompt");
analytics.track("install_dismissed");
// Show button again after dismissal (optional)
// installButton.style.display = "block";
}
// Clear the deferred prompt
deferredPrompt = null;
});
// Listen for successful installation
window.addEventListener("appinstalled", (event) => {
console.log("PWA installed successfully");
// Hide install button
installButton.style.display = "none";
// Clear deferred prompt
deferredPrompt = null;
// Track installation
analytics.track("app_installed");
// Show thank you message
showNotification("Thanks for installing our app!");
});
Example: Check if already installed and detection
// Check if app is already installed (display mode detection)
function isInstalled() {
// Check if running in standalone mode
if (window.matchMedia("(display-mode: standalone)").matches) {
return true;
}
// Check Safari-specific property
if (window.navigator.standalone === true) {
return true;
}
return false;
}
// Use on page load
if (isInstalled()) {
console.log("App is installed and running in standalone mode");
// Hide install button permanently
document.getElementById("install-button").style.display = "none";
// Show app-specific features
showInstalledUserFeatures();
} else {
console.log("App is running in browser");
// Show install promotion
showInstallPromotion();
}
// Listen for display mode changes
const displayModeMediaQuery = window.matchMedia("(display-mode: standalone)");
displayModeMediaQuery.addEventListener("change", (event) => {
if (event.matches) {
console.log("Switched to standalone mode");
} else {
console.log("Switched to browser mode");
}
});
// Smart install prompt timing
class InstallPromptManager {
constructor() {
this.promptShown = localStorage.getItem("install_prompt_shown") === "true";
this.installDismissed = localStorage.getItem("install_dismissed") === "true";
this.visitCount = parseInt(localStorage.getItem("visit_count") || "0");
}
incrementVisit() {
this.visitCount++;
localStorage.setItem("visit_count", this.visitCount.toString());
}
shouldShowPrompt() {
// Don't show if already shown or dismissed
if (this.promptShown || this.installDismissed) {
return false;
}
// Show after 3 visits
if (this.visitCount < 3) {
return false;
}
// Add more custom logic (e.g., time on site, engagement)
return true;
}
markPromptShown() {
this.promptShown = true;
localStorage.setItem("install_prompt_shown", "true");
}
markDismissed() {
this.installDismissed = true;
localStorage.setItem("install_dismissed", "true");
}
}
// Usage
const promptManager = new InstallPromptManager();
promptManager.incrementVisit();
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredPrompt = event;
if (promptManager.shouldShowPrompt()) {
// Show install UI
showInstallUI();
promptManager.markPromptShown();
}
});
Note:
beforeinstallprompt allows custom install UX. Call event.preventDefault() to prevent default prompt. Store event and call prompt() from user gesture. Check userChoice promise for outcome. Not available on iOS Safari - use custom install instructions instead.
Warning:
beforeinstallprompt only available on Chrome, Edge, and Android browsers. Not supported on iOS/Safari. Install criteria vary by browser. Prompt() can only be called once per event. Must be called from user gesture (click, touch).
3. Window Controls Overlay for Desktop PWAs
| Feature | Description |
|---|---|
| Window Controls Overlay | Allows PWA to use title bar area for custom content on desktop. |
| navigator.windowControlsOverlay | API to interact with window controls overlay. |
| getTitlebarAreaRect() | Returns DOMRect of title bar area. |
| visible | Boolean indicating if overlay is visible. |
| geometrychange | Event fired when title bar geometry changes. |
Example: Enable and use Window Controls Overlay
{
"name": "Desktop PWA",
"display": "standalone",
"display_override": ["window-controls-overlay"],
"theme_color": "#2196f3"
}
Example: Handle title bar area in JavaScript
// Check if Window Controls Overlay is available
if ("windowControlsOverlay" in navigator) {
const windowControls = navigator.windowControlsOverlay;
console.log("Overlay visible:", windowControls.visible);
if (windowControls.visible) {
// Get title bar area dimensions
const titleBarRect = windowControls.getTitlebarAreaRect();
console.log("Title bar area:", {
x: titleBarRect.x,
y: titleBarRect.y,
width: titleBarRect.width,
height: titleBarRect.height
});
// Update custom title bar layout
updateTitleBarLayout(titleBarRect);
}
// Listen for geometry changes (resize, maximize, etc.)
windowControls.addEventListener("geometrychange", (event) => {
console.log("Title bar geometry changed");
const titleBarRect = windowControls.getTitlebarAreaRect();
const isVisible = windowControls.visible;
console.log("New dimensions:", titleBarRect);
console.log("Visible:", isVisible);
// Update layout
updateTitleBarLayout(titleBarRect);
});
}
// Update title bar layout
function updateTitleBarLayout(rect) {
const titleBar = document.getElementById("custom-title-bar");
if (titleBar) {
// Position title bar content avoiding system controls
titleBar.style.position = "fixed";
titleBar.style.top = `${rect.y}px`;
titleBar.style.left = `${rect.x}px`;
titleBar.style.width = `${rect.width}px`;
titleBar.style.height = `${rect.height}px`;
}
}
// CSS for draggable title bar area
const style = document.createElement("style");
style.textContent = `
#custom-title-bar {
app-region: drag; /* Makes area draggable */
display: flex;
align-items: center;
padding: 0 16px;
background: var(--theme-color);
color: white;
}
#custom-title-bar button,
#custom-title-bar a,
#custom-title-bar input {
app-region: no-drag; /* Make interactive elements clickable */
}
`;
document.head.appendChild(style);
Example: CSS for Window Controls Overlay
/* Environment variables for safe areas */
#app-header {
/* Use title bar area when available */
padding-top: env(titlebar-area-height, 0px);
padding-left: env(titlebar-area-x, 0px);
padding-right: env(titlebar-area-width, 100%);
}
/* Draggable region for custom title bar */
.title-bar {
-webkit-app-region: drag;
app-region: drag;
height: env(titlebar-area-height, 40px);
display: flex;
align-items: center;
background: var(--theme-color);
}
/* Make buttons clickable in drag region */
.title-bar button,
.title-bar a,
.title-bar input,
.title-bar select {
-webkit-app-region: no-drag;
app-region: no-drag;
}
/* Adjust layout when overlay is active */
@media (display-mode: window-controls-overlay) {
body {
padding-top: 0;
}
#custom-title-bar {
display: flex;
}
}
Note: Window Controls Overlay enables native-like title bars in desktop PWAs. Set
"display_override": ["window-controls-overlay"] in manifest. Use app-region: drag CSS to make areas draggable. Set app-region: no-drag on interactive elements. Experimental - currently only in Chromium-based browsers.
4. App Shortcuts API for Context Menu Integration
| Shortcut Property | Description |
|---|---|
| name | Name of the shortcut displayed in menu. |
| short_name | Short version of the name (optional). |
| description | Description of what the shortcut does. |
| url | URL to open when shortcut is activated. |
| icons | Array of icon objects for the shortcut. |
Example: Define app shortcuts in manifest
{
"name": "Task Manager Pro",
"short_name": "TaskPro",
"start_url": "/",
"display": "standalone",
"shortcuts": [
{
"name": "New Task",
"short_name": "New",
"description": "Create a new task",
"url": "/new-task?source=shortcut",
"icons": [
{
"src": "/icons/new-task-96x96.png",
"sizes": "96x96",
"type": "image/png"
}
]
},
{
"name": "My Tasks",
"short_name": "Tasks",
"description": "View my tasks",
"url": "/my-tasks?source=shortcut",
"icons": [
{
"src": "/icons/my-tasks-96x96.png",
"sizes": "96x96",
"type": "image/png"
}
]
},
{
"name": "Calendar",
"short_name": "Calendar",
"description": "View calendar",
"url": "/calendar?source=shortcut",
"icons": [
{
"src": "/icons/calendar-96x96.png",
"sizes": "96x96",
"type": "image/png"
}
]
},
{
"name": "Settings",
"short_name": "Settings",
"description": "Open settings",
"url": "/settings?source=shortcut",
"icons": [
{
"src": "/icons/settings-96x96.png",
"sizes": "96x96",
"type": "image/png"
}
]
}
]
}
Example: Handle shortcut navigation
// Detect if app was opened from shortcut
const urlParams = new URLSearchParams(window.location.search);
const source = urlParams.get("source");
if (source === "shortcut") {
console.log("App opened from shortcut");
// Track shortcut usage
const path = window.location.pathname;
analytics.track("shortcut_used", { path });
// Handle specific shortcut actions
if (path === "/new-task") {
// Open new task modal
openNewTaskModal();
} else if (path === "/my-tasks") {
// Navigate to tasks view
navigateToTasks();
} else if (path === "/calendar") {
// Navigate to calendar view
navigateToCalendar();
}
}
// Alternative: Use URL hash for shortcut routing
window.addEventListener("load", () => {
const hash = window.location.hash;
switch (hash) {
case "#new-task":
openNewTaskModal();
break;
case "#my-tasks":
navigateToTasks();
break;
case "#calendar":
navigateToCalendar();
break;
default:
// Show default view
showDashboard();
}
});
Note: App Shortcuts appear in OS context menus (right-click on app icon, taskbar, etc.). Maximum 4 shortcuts recommended. Icons should be 96x96 minimum. URLs can include query parameters to track source. Supported on Windows, macOS, Android.
5. Display Mode Detection and Handling
| Display Mode | Description |
|---|---|
| fullscreen | Full screen without any browser UI. |
| standalone | Standalone app without browser chrome (recommended for PWAs). |
| minimal-ui | Standalone with minimal browser UI (back/forward buttons). |
| browser | Regular browser tab. |
| window-controls-overlay | Desktop PWA with custom title bar. |
Example: Detect and respond to display mode
// Check current display mode
function getDisplayMode() {
// Check each display mode
const modes = [
"fullscreen",
"standalone",
"minimal-ui",
"browser"
];
for (const mode of modes) {
if (window.matchMedia(`(display-mode: ${mode})`).matches) {
return mode;
}
}
return "browser"; // Default
}
// Use on page load
const displayMode = getDisplayMode();
console.log("Display mode:", displayMode);
// Apply mode-specific styles or features
switch (displayMode) {
case "fullscreen":
console.log("Running in fullscreen mode");
hideNavigationControls();
break;
case "standalone":
console.log("Running as installed PWA");
showPWAFeatures();
hideInstallButton();
break;
case "minimal-ui":
console.log("Running with minimal UI");
adjustLayoutForMinimalUI();
break;
case "browser":
console.log("Running in browser");
showInstallPrompt();
break;
}
// Listen for display mode changes
const displayModeQueries = {
fullscreen: window.matchMedia("(display-mode: fullscreen)"),
standalone: window.matchMedia("(display-mode: standalone)"),
minimalUi: window.matchMedia("(display-mode: minimal-ui)"),
browser: window.matchMedia("(display-mode: browser)")
};
Object.keys(displayModeQueries).forEach(mode => {
displayModeQueries[mode].addEventListener("change", (event) => {
if (event.matches) {
console.log(`Display mode changed to: ${mode}`);
handleDisplayModeChange(mode);
}
});
});
function handleDisplayModeChange(mode) {
// Update UI based on new display mode
document.body.dataset.displayMode = mode;
// Trigger layout recalculation
window.dispatchEvent(new Event("displaymodechange"));
}
Example: CSS for different display modes
/* Default browser mode styles */
.app-header {
display: flex;
padding: 1rem;
}
/* Standalone mode (installed PWA) */
@media (display-mode: standalone) {
.app-header {
padding-top: env(safe-area-inset-top);
background: var(--theme-color);
}
.install-button {
display: none; /* Hide install button when installed */
}
.pwa-features {
display: block; /* Show PWA-specific features */
}
}
/* Fullscreen mode */
@media (display-mode: fullscreen) {
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.navigation-controls {
display: flex; /* Show custom nav controls */
}
}
/* Minimal UI mode */
@media (display-mode: minimal-ui) {
.app-header {
padding-top: 0.5rem;
}
}
/* Browser mode */
@media (display-mode: browser) {
.pwa-features {
display: none;
}
.install-promotion {
display: block;
}
}
/* Combine with other media queries */
@media (display-mode: standalone) and (max-width: 768px) {
/* Standalone mobile styles */
.app-header {
flex-direction: column;
}
}
Note: Display mode indicates how PWA is being viewed. Use
matchMedia("(display-mode: MODE)") to detect mode. Apply mode-specific features and styles. Common pattern: hide install button in standalone mode, show custom navigation in fullscreen.
6. Install Events and App Lifecycle Management
| Event | Description |
|---|---|
| beforeinstallprompt | Fired when browser wants to show install prompt. |
| appinstalled | Fired when PWA is successfully installed. |
| DOMContentLoaded | Fired when initial HTML is loaded and parsed. |
| load | Fired when all resources are loaded. |
| visibilitychange | Fired when page visibility changes (app backgrounded/foregrounded). |
| pagehide / pageshow | Fired when page is hidden/shown (mobile app switching). |
Example: Complete PWA lifecycle management
class PWALifecycleManager {
constructor() {
this.isInstalled = this.checkInstalled();
this.installPrompt = null;
this.init();
}
init() {
// Installation events
window.addEventListener("beforeinstallprompt", this.handleBeforeInstall.bind(this));
window.addEventListener("appinstalled", this.handleAppInstalled.bind(this));
// Lifecycle events
document.addEventListener("visibilitychange", this.handleVisibilityChange.bind(this));
window.addEventListener("pageshow", this.handlePageShow.bind(this));
window.addEventListener("pagehide", this.handlePageHide.bind(this));
// Network events
window.addEventListener("online", this.handleOnline.bind(this));
window.addEventListener("offline", this.handleOffline.bind(this));
// Focus events
window.addEventListener("focus", this.handleFocus.bind(this));
window.addEventListener("blur", this.handleBlur.bind(this));
}
checkInstalled() {
// Check if running in standalone mode
return window.matchMedia("(display-mode: standalone)").matches ||
window.navigator.standalone === true;
}
handleBeforeInstall(event) {
console.log("Install prompt available");
event.preventDefault();
this.installPrompt = event;
// Show custom install UI
this.showInstallUI();
// Track
this.trackEvent("install_prompt_shown");
}
async showInstallPrompt() {
if (!this.installPrompt) {
console.log("No install prompt available");
return;
}
// Show prompt
this.installPrompt.prompt();
// Wait for user choice
const result = await this.installPrompt.userChoice;
if (result.outcome === "accepted") {
this.trackEvent("install_accepted");
} else {
this.trackEvent("install_dismissed");
}
this.installPrompt = null;
}
handleAppInstalled(event) {
console.log("App installed successfully");
this.isInstalled = true;
// Hide install UI
this.hideInstallUI();
// Track installation
this.trackEvent("app_installed");
// Show welcome message
this.showWelcomeMessage();
}
handleVisibilityChange() {
if (document.hidden) {
console.log("App went to background");
this.onBackground();
} else {
console.log("App came to foreground");
this.onForeground();
}
}
handlePageShow(event) {
console.log("Page shown");
// Check if restored from cache
if (event.persisted) {
console.log("Page restored from bfcache");
this.onPageRestore();
}
}
handlePageHide(event) {
console.log("Page hidden");
this.saveState();
}
handleOnline() {
console.log("Connection restored");
this.onOnline();
this.showNotification("You're back online");
}
handleOffline() {
console.log("Connection lost");
this.onOffline();
this.showNotification("You're offline. Some features may be limited.");
}
handleFocus() {
console.log("App focused");
this.checkForUpdates();
}
handleBlur() {
console.log("App lost focus");
}
// Lifecycle hooks
onBackground() {
// Pause non-critical operations
this.pauseTimers();
this.trackEvent("app_backgrounded");
}
onForeground() {
// Resume operations
this.resumeTimers();
this.refreshData();
this.trackEvent("app_foregrounded");
}
onPageRestore() {
// Refresh stale data
this.refreshData();
}
onOnline() {
// Sync pending changes
this.syncPendingData();
}
onOffline() {
// Switch to offline mode
this.enableOfflineMode();
}
saveState() {
// Save current state
const state = {
timestamp: Date.now(),
route: window.location.pathname,
scrollPosition: window.scrollY
};
localStorage.setItem("app_state", JSON.stringify(state));
}
restoreState() {
const savedState = localStorage.getItem("app_state");
if (savedState) {
const state = JSON.parse(savedState);
// Restore scroll position, etc.
window.scrollTo(0, state.scrollPosition);
}
}
trackEvent(eventName, data = {}) {
if (window.analytics) {
window.analytics.track(eventName, {
...data,
isInstalled: this.isInstalled,
displayMode: this.getDisplayMode()
});
}
}
getDisplayMode() {
const modes = ["fullscreen", "standalone", "minimal-ui", "browser"];
for (const mode of modes) {
if (window.matchMedia(`(display-mode: ${mode})`).matches) {
return mode;
}
}
return "browser";
}
}
// Initialize
const pwaLifecycle = new PWALifecycleManager();
// Usage
document.getElementById("install-button").addEventListener("click", () => {
pwaLifecycle.showInstallPrompt();
});
Example: Update notification for PWA
// Service worker update detection
let newWorker;
navigator.serviceWorker.register("/sw.js").then(registration => {
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // Check every hour
// Listen for updates
registration.addEventListener("updatefound", () => {
newWorker = registration.installing;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
// New version available
showUpdateNotification();
}
});
});
});
// Show update notification
function showUpdateNotification() {
const notification = document.createElement("div");
notification.className = "update-notification";
notification.innerHTML = `
<p>A new version is available!</p>
<button onclick="updateApp()">Update Now</button>
<button onclick="dismissUpdate()">Later</button>
`;
document.body.appendChild(notification);
}
// Apply update
function updateApp() {
if (newWorker) {
newWorker.postMessage({ type: "SKIP_WAITING" });
}
// Reload page when new worker takes control
navigator.serviceWorker.addEventListener("controllerchange", () => {
window.location.reload();
});
}
// In service worker (sw.js)
self.addEventListener("message", (event) => {
if (event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
Note: PWA lifecycle includes install, background, foreground, and network events. Use
visibilitychange to detect background/foreground. Handle online/offline events for connectivity. Save state on pagehide, restore on pageshow. Check for updates on focus.
Warning: iOS Safari has limited PWA lifecycle events.
beforeinstallprompt not available on iOS. Use visibilitychange instead of pagehide/pageshow on some browsers. Test lifecycle thoroughly on target platforms.
Progressive Web App APIs Best Practices
- Web App Manifest: Include all required fields (name, icons, start_url, display)
- Provide multiple icon sizes (72px to 512px) and maskable icons for Android
- Use HTTPS - required for PWA features and service workers
- Implement custom install flow with beforeinstallprompt for better UX
- Don't show install prompt immediately - wait for user engagement
- Track install metrics: prompt shown, accepted, dismissed, installed
- Detect display mode and adjust UI accordingly (hide install button when installed)
- Window Controls Overlay: Use for native-like desktop experience
- App Shortcuts: Provide quick access to key features (max 4 recommended)
- Handle lifecycle events: visibility change, online/offline, focus/blur
- Implement update notification when new version available
- Save/restore app state on page hide/show for better UX
- Test on all target platforms - iOS Safari has different PWA behavior