📱 PWA & Mobile Optimizations
Overview
LibreFolio is installable as a Progressive Web App (PWA). This page documents the technical implementation for developers.
Architecture
┌─────────────────────────────────────────────────┐
│ app.html │
│ ├── viewport meta (no-zoom, viewport-fit) │
│ ├── manifest link │
│ ├── Apple meta tags │
│ ├── theme-color │
│ ├── early beforeinstallprompt capture │
│ └── Service Worker registration │
├─────────────────────────────────────────────────┤
│ static/manifest.json │
│ └── display: standalone, icons, colors │
├─────────────────────────────────────────────────┤
│ static/sw.js │
│ └── offline fallback (navigate fail → cache) │
├─────────────────────────────────────────────────┤
│ static/offline.html │
│ └── branded fallback page (i18n, dark mode) │
├─────────────────────────────────────────────────┤
│ static/icons/ │
│ ├── icon-192.png (generated by dev.py) │
│ └── icon-512.png (generated by dev.py) │
├─────────────────────────────────────────────────┤
│ HelpMenu.svelte │
│ └── Install App button + platform detection │
├─────────────────────────────────────────────────┤
│ app.css │
│ └── Mobile CSS (overscroll, touch-action, etc) │
└─────────────────────────────────────────────────┘
Key Files
| File | Purpose |
|---|---|
frontend/src/app.html |
Viewport, manifest link, Apple meta tags, early prompt capture, SW registration |
frontend/src/app.css |
Mobile CSS optimizations |
frontend/static/manifest.json |
PWA manifest (display, icons, colors) |
frontend/static/sw.js |
Service Worker (offline fallback only) |
frontend/static/offline.html |
Offline fallback page (static, i18n, dark mode) |
frontend/static/icons/icon-{192,512}.png |
App icons (generated) |
frontend/src/lib/components/layout/HelpMenu.svelte |
Install button logic |
dev.py → generate_pwa_icons() |
Icon generation from logo_square.png |
Mobile CSS
Applied globally via app.css:
html {
overscroll-behavior-x: none; /* Disable swipe-back on Android */
}
body {
overscroll-behavior: contain; /* No pull-to-refresh */
-webkit-tap-highlight-color: transparent; /* No blue flash on tap */
}
/* Prevent iOS auto-zoom on input focus */
@media (max-width: 768px) {
input, select, textarea {
font-size: 16px !important;
}
}
/* Disable double-tap zoom on interactive elements */
a, button, input, select, textarea, [role="button"] {
touch-action: manipulation;
}
The viewport meta in app.html:
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" name="viewport" />
PWA Manifest
{
"name": "LibreFolio",
"short_name": "LibreFolio",
"display": "standalone",
"theme_color": "#1a4031",
"background_color": "#f5f4ef",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "purpose": "maskable" }
]
}
Icon Generation
Icons are generated via dev.py using PIL:
def generate_pwa_icons():
"""Generate PWA icons (192x192, 512x512) from logo_square.png with white padding."""
# Source: frontend/static/logo_square.png (944×944 RGBA)
# Adds ~12% white padding around the logo before resize
# Output: frontend/static/icons/icon-{192,512}.png
Run with: ./dev.py docs copy-assets (called during build pipeline).
The 12% padding ensures the logo looks good when OS applies circular/squircle masks.
Install Button Logic
The HelpMenu.svelte component handles three platform scenarios:
1. Chrome/Edge/Android (beforeinstallprompt)
app.html (early) → captures event on window.__pwaInstallPrompt
↓
HelpMenu onMount → reads window.__pwaInstallPrompt as fallback
↓
User clicks "Install App" → calls deferredPrompt.prompt()
↓
Chrome shows native install banner
Race Condition
beforeinstallprompt can fire BEFORE Svelte components mount. That's why we capture it early in app.html on window.__pwaInstallPrompt, then read it in onMount.
2. iOS (Safari only)
- Detected via user agent:
/iPad|iPhone|iPod/orMacIntel + maxTouchPoints > 1 - Shows localized instructions: "Tap Share → Add to Home Screen"
- No programmatic install possible on iOS
3. Desktop Fallback
- If
deferredPromptnot captured (e.g. Firefox, or event missed) - Shows hint: "Look for ⊕ in address bar"
Standalone Detection
isStandalone = window.matchMedia('(display-mode: standalone)').matches
|| (window.navigator as any).standalone === true; // iOS property
When isStandalone === true, the Install button is hidden entirely.
Service Worker (Offline Fallback)
LibreFolio uses a minimal Service Worker for offline fallback only — it does NOT cache app assets.
How It Works
User opens PWA → browser checks sw.js for updates (byte-diff)
↓
SW install event → pre-caches /offline.html (~5KB)
↓
User navigates → SW intercepts (mode === 'navigate')
↓
fetch(request) succeeds → normal response (no caching)
fetch(request) fails → serve cached /offline.html
Key Design Decisions
| Decision | Rationale |
|---|---|
| No app caching | Updates are instant — no stale cache issues |
Only navigate intercept |
API calls, CSS, JS pass through untouched |
skipWaiting() + clients.claim() |
New SW activates immediately |
Cache versioning (offline-v1) |
Increment to force offline.html refresh |
| Auto-retry (10s interval) | Page reloads automatically when server returns |
Files
| File | Purpose |
|---|---|
frontend/static/sw.js |
Service Worker (~25 lines) |
frontend/static/offline.html |
Fallback page (static, inline styles, i18n) |
Offline Page Features
- Inline CSS (no external dependencies — works without network)
- Dark mode via
prefers-color-schememedia query - i18n: detects
navigator.language→ EN/IT/FR/ES - Auto-retry: pings server every 10s, reloads on success
- LibreFolio branding (colors, leaf icon)
Updating the Offline Page
- Edit
frontend/static/offline.html - Increment cache version in
sw.js:offline-v1→offline-v2 - Deploy — browser auto-updates SW on next PWA open
iOS Notes
- Service Workers supported since iOS 11.3 (2018)
- Cache eviction possible after 7 days of non-use — irrelevant here (1 file, re-cached on next visit)
- All iOS browsers use WebKit → same SW behavior everywhere
HTTP vs HTTPS Behavior
| Feature | HTTPS | HTTP localhost | HTTP LAN |
|---|---|---|---|
| Manifest loaded | ✅ | ✅ | ✅ |
display: standalone |
✅ | ✅ | ✅ |
| Service Worker | ✅ | ✅ | ✅ |
beforeinstallprompt |
✅ | ✅ | ❌ |
| Auto-install banner | ✅ | ✅ | ❌ |
| Manual "Add to Home" | ✅ | ✅ | ✅ (Android) |
| iOS Add to Home | ✅ | ✅ | ✅ |
Future: Share Target (File Receiving)
Not Implemented
This section documents a potential future feature.
The Web Share Target API would allow LibreFolio to appear in the OS "Share" menu when sharing files (e.g., broker CSV exports).
Requirements: - Service Worker (to handle the POST request) - HTTPS mandatory - Works on: Android Chrome ✅, iOS ❌ (Apple doesn't support share_target)
iOS Alternative: iOS Shortcuts automation — user creates a Shortcut that accepts files from Share Sheet and POSTs them to the LibreFolio API (POST /api/v1/files/upload). See user docs for setup guide.
Updating the PWA
Updates flow automatically:
- Admin updates Docker container (new
sw.jsand/or app code) - User reopens the installed PWA
- Browser detects
sw.jschanged (byte-diff) → installs new SW - New SW activates → pre-caches updated
offline.html - App loads fresh assets from server (no caching layer)
No manual "update app" action needed. If offline.html content changes, increment the cache version in sw.js (offline-v1 → offline-v2) to force a refresh.