๐ฑ 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 + apple-touch-icon link โ
โ โโโ theme-color โ
โ โโโ early beforeinstallprompt capture โ
โ โโโ Service Worker registration โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ static/manifest.json โ
โ โโโ display: standalone, icons, colors โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ static/sw.js โ
โ โโโ offline fallback (navigate fail โ cache) โ
โ โโโ // build: <md5(offline.html)[:8]> stamp โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ static/offline.html โ
โ โโโ branded fallback page (i18n, dark mode) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ static/icons/ (generated by dev.py) โ
โ โโโ icon-192.png (any, white) โ
โ โโโ icon-512.png (any, white) โ
โ โโโ icon-maskable-192.png (maskable, beige) โ
โ โโโ icon-maskable-512.png (maskable, beige) โ
โ โโโ apple-touch-icon.png (iOS, 180ร180) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ 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, apple-touch-icon link, 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, manifest purpose any (generated) |
frontend/static/icons/icon-maskable-{192,512}.png |
App icons, manifest purpose maskable (generated) |
frontend/static/icons/apple-touch-icon.png |
iOS home-screen icon, 180ร180 (generated) |
frontend/src/lib/components/layout/HelpMenu.svelte |
Install button logic |
dev.py โ generate_pwa_icons() |
Icon generation from logo_square.png |
frontend/generated-icons.json |
Committed record of the source sha256 and recipe each generated icon set was drawn from: an icon set is redrawn only when it no longer matches |
dev.py โ stamp_service_worker() |
Writes the // build: stamp of sw.js from offline.html |
backend/test_scripts/test_utilities/test_pwa_assets.py |
Gate: icons, manifest icon entries, apple-touch-icon link, sw.js stamp |
Mobile CSS
Applied globally via app.css (excerpt):
html {
overscroll-behavior-x: none; /* Disable swipe-back on Android */
scrollbar-gutter: stable; /* A modal's scroll lock never shifts the layout */
}
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; dense toolbar inputs opt out with .zoom-guard-exempt */
@media (max-width: 768px) {
input:not(.zoom-guard-exempt),
select:not(.zoom-guard-exempt),
textarea:not(.zoom-guard-exempt) {
font-size: 16px !important;
}
}
/* Disable double-tap zoom on interactive elements */
a, button, input, select, textarea, [role='button'] {
touch-action: manipulation;
}
/* iOS standalone mode: content below the status bar / notch */
.safe-top {
padding-top: env(safe-area-inset-top, 0px);
}
The other global classes are listed in Styling โ Utility Classes.
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
Excerpt of frontend/static/manifest.json (description, start_url, scope and orientation left out):
{
"name": "LibreFolio",
"short_name": "LibreFolio",
"display": "standalone",
"theme_color": "#1a4031",
"background_color": "#f5f4ef",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
One entry per purpose and size, each naming its own file: no file serves both any and maskable.
Splash and Theme Colours
| Surface | Colour | Set by |
|---|---|---|
| Android splash screen | Beige #f5f4ef, with the app icon |
Manifest background_color |
| Title bar of the installed app | #1a4031; #156534 in dark mode |
Manifest theme_color, and the <meta name="theme-color"> of app.html, which the anti-FOUC script rewrites in dark mode (offline.html does the same) |
| iOS status bar | Translucent, over the page | apple-mobile-web-app-status-bar-style: black-translucent; .safe-top pads content below it |
In-app loading splash (#app-splash) |
Beige #f5f4ef; #0f172a in dark mode |
Inline in app.html: the logo on a white tile and a spinner, faded out and removed by routes/+layout.svelte once the translations are loaded |
The iOS home-screen icon is apple-touch-icon.png (180ร180), linked from app.html.
Icon Generation
dev.py โ generate_pwa_icons() draws five icons with PIL from frontend/static/logo_square.png (944ร944 RGBA) into frontend/static/icons/:
| File | Manifest purpose | Background | Logo |
|---|---|---|---|
icon-192.png, icon-512.png |
any |
White | Side = size / 1.24, a margin of ~12 % of the logo per side |
icon-maskable-192.png, icon-maskable-512.png |
maskable |
Splash beige #f5f4ef (the manifest background_color) |
Scaled so its farthest pixel sits at 0.37 ร size from the centre |
apple-touch-icon.png (180ร180) |
โ (linked from app.html) |
White | As the any icons |
Every icon is saved as opaque RGB, with no alpha channel. The logo is resized premultiplied (RGBa), so its transparent black pixels do not bleed a dark fringe into its edge, then laid on the background with Image.alpha_composite.
The generator runs inside copy_docs_assets(), right before stamp_service_worker(): on ./dev.py mkdocs build, serve and deploy, and when ./dev.py server or ./dev.py docker build rebuilds a stale docs site. ./dev.py front build does not run it. The PNGs are committed, and mirrored into mkdocs_src/docs/static/icons/: after changing logo_square.png, run ./dev.py mkdocs build and commit the regenerated icons together with frontend/generated-icons.json, which records the sha256 of the logo they were drawn from. The generator redraws only when an icon is missing or when that hash, or PWA_ICONS_RECIPE in dev.py, no longer matches the record: Pillow does not write the same PNG bytes on every platform, so the decision never looks at the PNGs themselves. A change to the code that draws the icons must therefore bump PWA_ICONS_RECIPE, or nothing is regenerated.
Icons and the splash screen
- Maskable safe zone: a launcher crops a
maskableicon to its own shape (circle, squircleโฆ), and only a centred circle of radius 0.40 ร size is sure to survive. The logo reaches 0.37 ร size, about 0.38 with its antialiased edge, so the crop never bites into it. Ananyicon is shown whole instead, which is why each purpose has its own files. - Beige: the maskable icons are beige to the edges because
#f5f4efis the colour of the Android splash screen (background_color), so the cropped icon meets it without a seam. Change both or neither. - Opacity: Android's splash screen and iOS paint transparent pixels black, so every icon is opaque RGB โ no alpha channel and no
tRNScolour key. That is what keeps black corners off the Android splash and off the iPhone home-screen icon. - Service-worker stamp:
stamp_service_worker()writes// build: <md5(offline.html)[:8]>as line 2 ofsw.js. Any byte change insw.jsmakes browsers update the worker, so everyoffline.htmledit must be restamped (see Updating the Offline Page).
The gate is backend/test_scripts/test_utilities/test_pwa_assets.py, a pure pytest module. It reads the committed files and checks the icons are opaque RGB at their exact sizes, the maskable logo stays inside 0.40 ร size on the beige, the manifest has the four icon entries, app.html links the 180ร180 apple-touch-icon.png, and the stamp equals md5(offline.html)[:8]. Run it with ./dev.py test utils pwa-assets.
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 its native install dialog
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.
Both listeners โ the early one in app.html and the one HelpMenu.svelte adds in onMount โ call
e.preventDefault(), so the browser keeps the event instead of showing its automatic install
prompt (the mini-infobar of Chrome on Android); the app calls prompt() only from Install App.
A captured prompt is used once (deferredPrompt = null afterwards), and an accepted one hides the
button (isStandalone = true).
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"
- On Android (
/Android/in the user agent) the same fallback showshelp.installAppAndroidinstead: HTTPS required for install. Use Chrome menu โฎ โ "Add to Home Screen" as shortcut โ typically on a plain-HTTP LAN address
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 (~22 KB)
โ
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 |
// build: stamp in sw.js (fixed cache name offline-fallback) |
Editing offline.html changes sw.js, so clients install the new SW, which re-fetches the page |
| Auto-retry (10s interval) | Page reloads automatically when server returns |
Files
| File | Purpose |
|---|---|
frontend/static/sw.js |
Service Worker (~30 lines); line 2 is the // build: stamp |
frontend/static/offline.html |
Fallback page (static, inline styles, i18n) |
backend/test_scripts/test_utilities/test_pwa_assets.py |
Gate: fails when the stamp is not md5(offline.html)[:8] |
Offline Page Features
- Inline CSS and script; the only external resource it loads is
/lf-flags.css(the flag font face, see Fonts), which the service worker does not pre-cache - Dark mode from the app's stored theme (
librefolio-theme), falling back toprefers-color-scheme, with its own theme toggle - i18n: detects
navigator.languageโ EN/IT/FR/ES, with a language menu - Auto-retry: pings server every 10s, reloads on success
- Same animated background as the login page, and a toolbar with Buy Me a Coffee, theme, language and a link to the online documentation
Updating the Offline Page
- Edit
frontend/static/offline.html - Restamp
sw.jswith./dev.py mkdocs build, which runsstamp_service_worker()(./dev.py front builddoes not) - Commit
sw.jstogether with the page: the gatetest_pwa_assets.pyfails while the stamp and the page disagree - 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 | โ | โ | โ |
| Service Worker (offline page) | โ | โ | โ |
beforeinstallprompt |
โ | โ | โ |
| Browser's own automatic install banner | โ suppressed by preventDefault() |
โ suppressed by preventDefault() |
โ no event |
Native install dialog from Install App (prompt()) |
โ | โ | โ a hint instead |
| Manual "Add to Home" | โ | โ | โ (Android) |
| iOS Add to Home | โ | โ | โ |
Service workers and the install prompt need a secure context: HTTPS, or localhost. On a
plain-HTTP LAN address the browser exposes no navigator.serviceWorker, so app.html skips the
registration and there is no offline page.
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 โ a Shortcut that accepts files from the Share Sheet and posts them to the broker-report upload endpoint, POST /api/v1/brokers/import/upload (multipart: file, broker_id), with an authenticated session.
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, restamp sw.js (see Updating the Offline Page) to force a refresh.