diff --git a/frontend/public/css/styles.css b/frontend/public/css/styles.css
index 5356d56..ffafd42 100644
--- a/frontend/public/css/styles.css
+++ b/frontend/public/css/styles.css
@@ -514,6 +514,62 @@ body[data-user="cer"] .brand__user[data-brand="cer"] { color: var(--accent-cer);
text-transform: uppercase;
}
+/* ---- Install button ---- */
+
+.install-wrap {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--gap-2);
+}
+
+.install-btn {
+ background: transparent;
+ border: var(--hairline) solid var(--line);
+ color: var(--fg-mute);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ padding: 8px 18px;
+ cursor: pointer;
+ border-radius: var(--radius);
+}
+.install-btn:hover { border-color: var(--fg-mute); color: var(--fg); }
+
+.install-hint {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--fg-mute);
+ letter-spacing: 0.10em;
+ text-transform: uppercase;
+ margin: 0;
+ text-align: center;
+}
+
+/* ---- Active user pill in topbar ---- */
+
+body[data-user="ray"] .brand--sm .brand__user[data-brand="ray"],
+body[data-user="cer"] .brand--sm .brand__user[data-brand="cer"] {
+ background: var(--accent);
+ color: var(--bg);
+ padding: 2px 6px;
+ border-radius: var(--radius);
+ border-bottom-color: transparent;
+}
+
+/* ---- Per-user site frame ---- */
+
+body[data-user="ray"]::after,
+body[data-user="cer"]::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ border: 1px solid var(--accent);
+ pointer-events: none;
+ z-index: 9999;
+}
+
/* ---- Misc ---- */
.sr-only {
diff --git a/frontend/public/index.html b/frontend/public/index.html
index 1a0b88c..31ea106 100644
--- a/frontend/public/index.html
+++ b/frontend/public/index.html
@@ -29,6 +29,10 @@
cer
choose to continue
+
+
+
tap share → "add to home screen"
+
diff --git a/frontend/public/js/app.js b/frontend/public/js/app.js
index 5a9fd62..3fc4986 100644
--- a/frontend/public/js/app.js
+++ b/frontend/public/js/app.js
@@ -4,6 +4,8 @@ import {
startSync,
setCompletion,
onChange,
+ getMeta,
+ setMeta,
} from './db.js';
import { initTheme, cycleTheme, currentTheme } from './theme.js';
import { renderCalendar } from './calendar.js';
@@ -19,6 +21,27 @@ import {
const VALID_USERS = new Set(['ray', 'cer']);
const app = document.getElementById('app');
+/* ---------- PWA install prompt ---------- */
+
+let deferredInstallPrompt = null;
+const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
+const isStandalone =
+ window.matchMedia('(display-mode: standalone)').matches ||
+ ('standalone' in navigator && navigator.standalone === true);
+
+window.addEventListener('beforeinstallprompt', (e) => {
+ e.preventDefault();
+ deferredInstallPrompt = e;
+ const btn = document.querySelector('[data-install-wrap]');
+ if (btn) btn.hidden = false;
+});
+
+window.addEventListener('appinstalled', () => {
+ deferredInstallPrompt = null;
+ const wrap = document.querySelector('[data-install-wrap]');
+ if (wrap) wrap.hidden = true;
+});
+
const state = {
route: '/',
user: '',
@@ -48,6 +71,7 @@ function handleRoute() {
state.user = user;
document.body.dataset.route = route;
document.body.dataset.user = user || '';
+ if (user) setMeta('last_route', route).catch(() => {});
render();
}
@@ -85,6 +109,29 @@ function tpl(id) {
function renderLanding() {
app.innerHTML = '';
app.appendChild(tpl('tpl-landing'));
+
+ if (isStandalone) return;
+
+ const wrap = app.querySelector('[data-install-wrap]');
+ const btn = app.querySelector('[data-install-btn]');
+ const hint = app.querySelector('[data-install-hint]');
+
+ if (deferredInstallPrompt) {
+ wrap.hidden = false;
+ btn.addEventListener('click', async () => {
+ deferredInstallPrompt.prompt();
+ const { outcome } = await deferredInstallPrompt.userChoice;
+ if (outcome === 'accepted') {
+ deferredInstallPrompt = null;
+ wrap.hidden = true;
+ }
+ });
+ } else if (isIOS) {
+ wrap.hidden = false;
+ btn.addEventListener('click', () => {
+ hint.hidden = !hint.hidden;
+ });
+ }
}
/* ---------- Render: route page ---------- */
@@ -343,6 +390,16 @@ function render() {
async function boot() {
await initTheme();
await loadFromDexie();
+
+ // Restore last visited user route if opening at root
+ const curPath = location.pathname;
+ if (curPath === '/' || curPath === '') {
+ const saved = await getMeta('last_route', null);
+ if (saved && VALID_USERS.has(saved.slice(1))) {
+ history.replaceState({}, '', saved);
+ }
+ }
+
handleRoute();
// Hydrate from server (non-blocking) and start sync