diff --git a/docs/user_guide.md b/docs/user_guide.md
index a344604..b5666cc 100644
--- a/docs/user_guide.md
+++ b/docs/user_guide.md
@@ -1268,6 +1268,15 @@ device names, available metrics, settings, and time ranges depend on your
installation. The screenshots show selected views, not every available option.
Tap an image to open it at full size.
+On Android, open the dashboard in Chrome, open the browser menu, and choose
+**Add to Home screen** (or **Install app**, when offered). Confirm the name and
+icon before adding it. Sensorius supplies its logo in standard and adaptive
+Android icon formats. If an existing shortcut still shows an old or generic
+icon, remove that shortcut and add it again after reloading the dashboard.
+Installation options depend on the browser and connection: a local HTTP hub
+may be offered as a shortcut rather than a standalone installed app. This does
+not add offline operation; the phone still needs access to the running hub.
+
### Mobile Dashboard
diff --git a/docs/user_guide.pdf b/docs/user_guide.pdf
index 8eec3ee..605e9d7 100644
Binary files a/docs/user_guide.pdf and b/docs/user_guide.pdf differ
diff --git a/scripts/build_webapp_icons.mjs b/scripts/build_webapp_icons.mjs
new file mode 100644
index 0000000..c433a70
--- /dev/null
+++ b/scripts/build_webapp_icons.mjs
@@ -0,0 +1,34 @@
+/** Rasterize the existing Sensorius SVG for web-app launchers.
+ * The maskable variant keeps the logo inside Android's circular safe zone.
+ */
+import { readFile, writeFile } from 'node:fs/promises';
+import { chromium } from 'playwright';
+
+const root = new URL('../', import.meta.url);
+const svg = await readFile(new URL('ui_static/favicon.svg', root), 'utf8');
+const browser = await chromium.launch({ headless: true });
+try {
+ const page = await browser.newPage();
+ for (const [name, size, scale] of [
+ ['sensorius-192', 192, 1],
+ ['sensorius-512', 512, 1],
+ ['sensorius-maskable-512', 512, 0.66],
+ ]) {
+ const data = await page.evaluate(async ({ svg, size, scale }) => {
+ const img = new Image();
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+ await img.decode();
+ const canvas = document.createElement('canvas');
+ canvas.width = canvas.height = size;
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, size, size);
+ const inset = size * (1 - scale) / 2;
+ ctx.drawImage(img, inset, inset, size * scale, size * scale);
+ return canvas.toDataURL('image/png').split(',')[1];
+ }, { svg, size, scale });
+ await writeFile(new URL(`ui_static/icons/${name}.png`, root), Buffer.from(data, 'base64'));
+ }
+} finally {
+ await browser.close();
+}
diff --git a/sensorius/__init__.py b/sensorius/__init__.py
index fcaee48..2cc197c 100644
--- a/sensorius/__init__.py
+++ b/sensorius/__init__.py
@@ -4,4 +4,4 @@
release notes, and supporting tooling can report a consistent build identity.
"""
-__version__ = "v0.26.252.4"
+__version__ = "v0.26.252.5"
diff --git a/sensorius/saiHtml.py b/sensorius/saiHtml.py
index 3cce294..c151869 100644
--- a/sensorius/saiHtml.py
+++ b/sensorius/saiHtml.py
@@ -1193,6 +1193,8 @@ def _db_label_map_for_switch(sw_id: str) -> dict[str, str]:
yield ""
yield f"
{APP_NAME_LONG} "
yield " "
+ yield " "
+ yield " "
yield " "
yield f" "
yield ""
diff --git a/tests/webapp-manifest.spec.js b/tests/webapp-manifest.spec.js
new file mode 100644
index 0000000..fd8b52e
--- /dev/null
+++ b/tests/webapp-manifest.spec.js
@@ -0,0 +1,32 @@
+// @ts-check
+import { test, expect } from '@playwright/test';
+
+test('dashboard exposes a valid launcher manifest and loadable Android icons', async ({ page }) => {
+ await page.route('https://cdn.jsdelivr.net/**', route => route.fulfill({ body: '' }));
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+ const manifestURL = await page.locator('link[rel="manifest"]').evaluate(link => link.href);
+ const response = await page.request.get(manifestURL);
+ expect(response.ok()).toBeTruthy();
+ expect(response.headers()['content-type']).toContain('application/manifest+json');
+ const manifest = await response.json();
+ const origin = new URL(page.url()).origin;
+ expect(new URL(manifest.start_url, manifestURL).href).toBe(`${origin}/`);
+ expect(new URL(manifest.scope, manifestURL).href).toBe(`${origin}/`);
+ expect(manifest.display).toBe('standalone');
+ expect(manifest.icons.filter(icon => icon.purpose === 'any').map(icon => icon.sizes)).toEqual(['192x192', '512x512']);
+ expect(manifest.icons.some(icon => icon.purpose === 'maskable')).toBeTruthy();
+ for (const icon of manifest.icons) {
+ const info = await page.evaluate(async ({ icon, manifestURL }) => {
+ const image = new Image();
+ image.src = new URL(icon.src, manifestURL).href;
+ await image.decode();
+ return `${image.naturalWidth}x${image.naturalHeight}`;
+ }, { icon, manifestURL });
+ expect(info).toBe(icon.sizes);
+ }
+ const session = await page.context().newCDPSession(page);
+ const parsed = await session.send('Page.getAppManifest');
+ expect(parsed.errors).toEqual([]);
+ expect(parsed.url).toBe(manifestURL);
+ await expect(page.locator('link[rel="apple-touch-icon"]')).toHaveCount(1);
+});
diff --git a/ui_static/icons/sensorius-192.png b/ui_static/icons/sensorius-192.png
new file mode 100644
index 0000000..2aef1bf
Binary files /dev/null and b/ui_static/icons/sensorius-192.png differ
diff --git a/ui_static/icons/sensorius-512.png b/ui_static/icons/sensorius-512.png
new file mode 100644
index 0000000..db3eb81
Binary files /dev/null and b/ui_static/icons/sensorius-512.png differ
diff --git a/ui_static/icons/sensorius-maskable-512.png b/ui_static/icons/sensorius-maskable-512.png
new file mode 100644
index 0000000..782ddcb
Binary files /dev/null and b/ui_static/icons/sensorius-maskable-512.png differ
diff --git a/ui_static/manifest.webmanifest b/ui_static/manifest.webmanifest
new file mode 100644
index 0000000..9cc4586
--- /dev/null
+++ b/ui_static/manifest.webmanifest
@@ -0,0 +1,30 @@
+{
+ "id": "/",
+ "name": "Sensorius AI",
+ "short_name": "Sensorius",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#ffffff",
+ "icons": [
+ {
+ "src": "/ui_static/icons/sensorius-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/ui_static/icons/sensorius-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/ui_static/icons/sensorius-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/ui_templates/base.html b/ui_templates/base.html
index 0ead52d..87f681d 100644
--- a/ui_templates/base.html
+++ b/ui_templates/base.html
@@ -5,6 +5,8 @@
{% block title %}Sensorius{% endblock %}
+
+